///|
/// Cron expression normalizer and optimizer.
/// Converts expressions to canonical form, simplifies redundant patterns,
/// and provides expression-level utilities.
///|
/// Normalize a cron expression string to its canonical form.
/// This simplifies the expression while preserving its meaning.
pub fn normalize_expression(raw : String) -> Result[String, CronError] {
let fields = match parse_cron_expression(raw) {
Ok(f) => f
Err(e) => return Err(e)
}
Ok(normalize_fields(fields))
}
///|
/// Normalize an already-parsed field array into a canonical string.
pub fn normalize_fields(fields : Array[CronField]) -> String {
let mut result = ""
for i = 0; i < fields.length(); i = i + 1 {
if i > 0 {
result = result + " "
}
result = result + normalize_single_field(fields[i])
}
result
}
///|
/// Normalize a single field to its most compact representation.
fn normalize_single_field(field : CronField) -> String {
let values = field.values
let (min, max) = field_range(field.field_type)
let total = max - min + 1
// If matches all values, use "*"
if values.length() == total {
let mut is_all = true
for i = 0; i < total && is_all; i = i + 1 {
if values[i] != min + i {
is_all = false
}
}
if is_all {
return "*"
}
}
// If empty, return "*" as default
if values.length() == 0 {
return "*"
}
// Check for step pattern: */N
if values.length() > 1 {
let step = values[1] - values[0]
if step > 1 {
let mut is_step = true
let mut expected = values[0]
for i = 0; i < values.length() && is_step; i = i + 1 {
if values[i] != expected {
is_step = false
}
expected = expected + step
}
if is_step && values[0] == min && values[values.length() - 1] + step > max {
return "*/" + step.to_string()
}
if is_step {
return values[0].to_string() +
"-" +
values[values.length() - 1].to_string() +
"/" +
step.to_string()
}
}
}
// Check for consecutive range (2 or more values)
if values.length() >= 2 {
let mut is_consec = true
for i = 1; i < values.length() && is_consec; i = i + 1 {
if values[i] != values[i - 1] + 1 {
is_consec = false
}
}
if is_consec {
return values[0].to_string() +
"-" +
values[values.length() - 1].to_string()
}
}
// Check for step pattern within a range
if values.length() >= 3 {
let step = values[1] - values[0]
if step > 1 {
let mut is_step = true
for i = 1; i < values.length() && is_step; i = i + 1 {
if values[i] - values[i - 1] != step {
is_step = false
}
}
if is_step {
return values[0].to_string() +
"-" +
values[values.length() - 1].to_string() +
"/" +
step.to_string()
}
}
}
// Try to represent as contiguous ranges
let result = compact_to_ranges(values)
if result.length() < values.length() * 2 {
// Ranges representation is shorter than listing all values
return join_parts(result)
}
// Fall back to comma-separated list
let mut s = ""
for i = 0; i < values.length(); i = i + 1 {
if i > 0 {
s = s + ","
}
s = s + values[i].to_string()
}
s
}
///|
/// Compact a sorted array of values into range strings.
/// e.g., [1,2,3,5,6,7,10] -> ["1-3","5-7","10"]
pub fn compact_to_ranges(values : Array[Int]) -> Array[String] {
if values.length() == 0 {
return []
}
let result : Array[String] = []
let mut range_start = values[0]
let mut range_end = values[0]
for i = 1; i < values.length(); i = i + 1 {
if values[i] == range_end + 1 {
range_end = values[i]
} else {
// End of a range
if range_start == range_end {
result.push(range_start.to_string())
} else if range_end == range_start + 1 {
result.push(range_start.to_string())
result.push(range_end.to_string())
} else {
result.push(range_start.to_string() + "-" + range_end.to_string())
}
range_start = values[i]
range_end = values[i]
}
}
// Don't forget the last range
if range_start == range_end {
result.push(range_start.to_string())
} else if range_end == range_start + 1 {
result.push(range_start.to_string())
result.push(range_end.to_string())
} else {
result.push(range_start.to_string() + "-" + range_end.to_string())
}
result
}
///|
/// Join parts with commas.
pub fn join_parts(parts : Array[String]) -> String {
let mut result = ""
for i = 0; i < parts.length(); i = i + 1 {
if i > 0 {
result = result + ","
}
result = result + parts[i]
}
result
}
///|
/// Check if two cron expressions are semantically equivalent.
/// This compares the expanded field values rather than the raw strings.
pub fn are_equivalent(a : Array[CronField], b : Array[CronField]) -> Bool {
if a.length() != b.length() {
return false
}
for i = 0; i < a.length(); i = i + 1 {
if !fields_equal(a[i], b[i]) {
return false
}
}
true
}
///|
/// Compare two fields for value equality.
fn fields_equal(a : CronField, b : CronField) -> Bool {
if a.values.length() != b.values.length() {
return false
}
for i = 0; i < a.values.length(); i = i + 1 {
if a.values[i] != b.values[i] {
return false
}
}
true
}
///|
/// Find the intersection of two cron fields (values present in both).
pub fn field_intersection(a : CronField, b : CronField) -> Array[Int] {
let result : Array[Int] = []
let mut i = 0
let mut j = 0
while i < a.values.length() && j < b.values.length() {
if a.values[i] == b.values[j] {
result.push(a.values[i])
i = i + 1
j = j + 1
} else if a.values[i] < b.values[j] {
i = i + 1
} else {
j = j + 1
}
}
result
}
///|
/// Find the union of two cron fields (all values from both).
pub fn field_union(a : CronField, b : CronField) -> Array[Int] {
let result : Array[Int] = []
let mut i = 0
let mut j = 0
while i < a.values.length() && j < b.values.length() {
if a.values[i] == b.values[j] {
result.push(a.values[i])
i = i + 1
j = j + 1
} else if a.values[i] < b.values[j] {
result.push(a.values[i])
i = i + 1
} else {
result.push(b.values[j])
j = j + 1
}
}
while i < a.values.length() {
result.push(a.values[i])
i = i + 1
}
while j < b.values.length() {
result.push(b.values[j])
j = j + 1
}
result
}
///|
/// Count the number of distinct execution times per field.
pub fn field_cardinality(field : CronField) -> Int {
field.values.length()
}
///|
/// Get the effective range (min..max) of a field's values.
pub fn field_effective_range(field : CronField) -> (Int, Int) {
if field.values.length() == 0 {
let (min, max) = field_range(field.field_type)
(min, max)
} else {
(field.values[0], field.values[field.values.length() - 1])
}
}