///|
/// Cron expression validation.
/// Validates field values, ranges, and syntactic correctness of cron expressions.
///|
/// Validate a raw cron expression string.
/// Returns Ok(()) if valid, or an error with details.
pub fn validate_expression(raw : String) -> Result[Unit, CronError] {
let expression = raw.trim().to_owned()
if expression == "" {
return Err({ kind: EmptyExpression, expression: "" })
}
// First try alias resolution
match resolve_alias_v(expression) {
Some(expanded) =>
match parse_cron_expression(expanded) {
Ok(_) => Ok(())
Err(err) => Err({ ..err, expression, })
}
None => validate_fields_detailed(expression)
}
}
///|
/// Detailed field validation with clear error messages.
fn validate_fields_detailed(expression : String) -> Result[Unit, CronError] {
let fields = split_fields_v(expression)
let count = fields.length()
if count != 5 && count != 6 {
return Err({ kind: WrongFieldCount(count), expression })
}
let field_types = match count {
5 => [Minute, Hour, DayOfMonth, Month, DayOfWeek]
6 => [Second, Minute, Hour, DayOfMonth, Month, DayOfWeek]
_ => [Minute, Hour, DayOfMonth, Month, DayOfWeek]
}
for i = 0; i < count; i = i + 1 {
let raw = fields[i].trim().to_owned()
if raw == "" {
return Err({
kind: InvalidFieldValue(field_key(field_types[i])),
expression,
})
}
match validate_single_field(field_types[i], raw) {
Err(err) => return Err({ ..err, expression, })
Ok(_) => ()
}
}
// Also verify the full parse works
match parse_cron_expression(expression) {
Ok(_) => Ok(())
Err(err) => Err(err)
}
}
///|
/// Validate a single field value.
fn validate_single_field(
field_type : FieldType,
raw : String,
) -> Result[Unit, CronError] {
let (min, max) = field_range(field_type)
let name = field_key(field_type)
// Validate characters first
for i = 0; i < raw.length(); i = i + 1 {
let code = raw[i].to_int()
if !is_valid_field_code(code) {
return Err({ kind: InvalidCharacter(name), expression: "" })
}
}
// Check for common syntax errors
if raw.contains("--") {
return Err({ kind: InvalidRange(name), expression: "" })
}
if raw.contains(",,") {
return Err({ kind: InvalidSyntax(name), expression: "" })
}
if raw.contains("//") {
return Err({ kind: InvalidSyntax(name), expression: "" })
}
// Validate step values
if raw.contains("/") {
let step_val = extract_step(raw)
match step_val {
Some(0) => return Err({ kind: StepCannotBeZero(name), expression: "" })
_ => ()
}
}
// Validate comma-separated items
if raw.contains(",") {
let items = split_comma_v(raw)
for i = 0; i < items.length(); i = i + 1 {
match validate_field_item(items[i], name, min, max) {
Ok(_) => ()
Err(err) => return Err(err)
}
}
return Ok(())
}
validate_field_item(raw, name, min, max)
}
///|
/// Validate a single field item (not containing commas).
fn validate_field_item(
item : String,
name : String,
min : Int,
max : Int,
) -> Result[Unit, CronError] {
if item == "*" {
return Ok(())
}
// Handle step: */5 or 10-50/5
if item.contains("/") {
let parts = split_slash_v(item)
let base = parts.0
let step_str = parts.1
if base == "*" {
// Check that step is a valid number
match parse_int_v(step_str) {
Some(_) => return Ok(())
None => return Err({ kind: InvalidSyntax(name), expression: "" })
}
}
if base.contains("-") {
return validate_range(base, name, min, max)
}
// N/step: validate N is in range
match parse_int_v(base) {
Some(v) => {
if v < min || v > max {
return Err({
kind: FieldValueOutOfRange(name, v, min, max),
expression: "",
})
}
Ok(())
}
None => Err({ kind: InvalidSyntax(name), expression: "" })
}
} else if item.contains("-") {
validate_range(item, name, min, max)
} else {
// Simple number
match parse_int_v(item) {
Some(v) => {
if v < min || v > max {
return Err({
kind: FieldValueOutOfRange(name, v, min, max),
expression: "",
})
}
Ok(())
}
None => Err({ kind: InvalidSyntax(name), expression: "" })
}
}
}
///|
/// Validate a range expression.
fn validate_range(
raw : String,
name : String,
min : Int,
max : Int,
) -> Result[Unit, CronError] {
let parts = split_dash_v(raw)
let start_str = parts.0
let end_str = parts.1
match (parse_int_v(start_str), parse_int_v(end_str)) {
(Some(start), Some(end)) => {
if start < min || start > max {
return Err({
kind: FieldValueOutOfRange(name, start, min, max),
expression: "",
})
}
if end < min || end > max {
return Err({
kind: FieldValueOutOfRange(name, end, min, max),
expression: "",
})
}
if start > end {
return Err({ kind: InvalidRange(name), expression: "" })
}
Ok(())
}
_ => Err({ kind: InvalidRange(name), expression: "" })
}
}
///|
/// Extract the step value from a "*/N" or "a-b/N" expression.
fn extract_step(raw : String) -> Int? {
let pos = find_char_v(raw, 47)
if pos >= 0 && pos + 1 < raw.length() {
return parse_int_v(raw[pos + 1:raw.length()].to_owned())
}
None
}
///|
/// Parse an integer, returning None on failure.
fn parse_int_v(s : String) -> Int? {
if s == "" {
return None
}
let mut value = 0
for i = 0; i < s.length(); i = i + 1 {
let code = s[i].to_int()
if code < 48 || code > 57 {
return None
}
value = value * 10 + (code - 48)
}
Some(value)
}
///|
/// Check if an integer character code is valid in a cron field.
fn is_valid_field_code(code : Int) -> Bool {
(code >= 48 && code <= 57) || // digits
code == 42 || // *
code == 44 || // ,
code == 45 || // -
code == 47 // /
}
///|
/// Find the first occurrence of a character code in a string, return index or -1.
fn find_char_v(s : String, needle_code : Int) -> Int {
for i = 0; i < s.length(); i = i + 1 {
if s[i].to_int() == needle_code {
return i
}
}
-1
}
///|
/// Split a string by comma.
fn split_comma_v(s : String) -> Array[String] {
let result : Array[String] = []
let mut start = 0
let mut i = 0
while i < s.length() {
if s[i].to_int() == 44 {
result.push(s[start:i].to_owned())
start = i + 1
}
i = i + 1
}
result.push(s[start:s.length()].to_owned())
result
}
///|
/// Split a string on the first slash.
fn split_slash_v(s : String) -> (String, String) {
for i = 0; i < s.length(); i = i + 1 {
if s[i].to_int() == 47 {
return (s[0:i].to_owned(), s[i + 1:s.length()].to_owned())
}
}
(s, "")
}
///|
/// Split a string on the first dash.
fn split_dash_v(s : String) -> (String, String) {
for i = 0; i < s.length(); i = i + 1 {
if s[i].to_int() == 45 {
return (s[0:i].to_owned(), s[i + 1:s.length()].to_owned())
}
}
(s, "")
}
///|
/// Split whitespace-separated fields.
fn split_fields_v(s : String) -> Array[String] {
let result : Array[String] = []
let mut start = 0
let mut i = 0
// Skip leading whitespace
while i < s.length() && is_ws_code(s[i].to_int()) {
i = i + 1
}
start = i
while i < s.length() {
if is_ws_code(s[i].to_int()) {
if start < i {
result.push(s[start:i].to_owned())
}
while i < s.length() && is_ws_code(s[i].to_int()) {
i = i + 1
}
start = i
} else {
i = i + 1
}
}
if start < s.length() {
result.push(s[start:s.length()].to_owned())
}
result
}
///|
/// Check if integer code is whitespace.
fn is_ws_code(code : Int) -> Bool {
code == 32 || code == 9 || code == 13 || code == 10
}
///|
/// Direct alias resolution.
fn resolve_alias_v(raw : String) -> String? {
match raw {
"@hourly" => Some("0 * * * *")
"@daily" | "@midnight" => Some("0 0 * * *")
"@weekly" => Some("0 0 * * 0")
"@monthly" => Some("0 0 1 * *")
"@yearly" | "@annually" => Some("0 0 1 1 *")
_ => None
}
}