Skip to content

Commit 1c39e76

Browse files
committed
refactor: migrate from cron to croner for standard 5-field cron support
Replace the `cron` crate (7-field format) with `croner` (standard 5-field). This removes the need for format conversion and day-of-week normalization. - Remove normalize_cron, normalize_dow_* functions (~65 lines) - Remove 5-to-6 field conversion in parse_job - Update Schedule type to Cron throughout - Update is_job_due to use find_next_occurrence API
1 parent ae29b67 commit 1c39e76

File tree

5 files changed

+131
-151
lines changed

5 files changed

+131
-151
lines changed

Cargo.lock

Lines changed: 105 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ edition = "2024"
55

66
[dependencies]
77
tokio = { version = "1", features = ["full"] }
8-
cron = "0.15"
8+
croner = "3"
99
chrono = "0.4"
1010
chrono-tz = "0.10"
1111
anyhow = "1"

src/config.rs

Lines changed: 3 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use anyhow::{anyhow, Result};
22
use chrono_tz::Tz;
3-
use cron::Schedule;
3+
use croner::Cron;
44
use serde::Deserialize;
55
use std::collections::HashMap;
66
use std::str::FromStr;
@@ -145,7 +145,7 @@ fn default_log_max_size() -> String {
145145
pub struct Job {
146146
pub id: String,
147147
pub name: String,
148-
pub schedule: Schedule,
148+
pub schedule: Cron,
149149
pub command: String,
150150
pub timeout: Duration,
151151
pub concurrency: Concurrency,
@@ -168,72 +168,6 @@ pub struct RetryConfig {
168168
pub jitter: Option<Duration>,
169169
}
170170

171-
/// Normalize 5-field cron expression for compatibility with cron crate.
172-
/// Converts day-of-week 0 (Sunday in POSIX) to 7 (Sunday in cron crate).
173-
fn normalize_cron(cron: &str) -> String {
174-
let fields: Vec<&str> = cron.split_whitespace().collect();
175-
if fields.len() != 5 {
176-
return cron.to_string();
177-
}
178-
179-
// Day-of-week is the 5th field (index 4)
180-
// Parse each element properly to avoid false replacements (e.g., "10" -> "17")
181-
let dow = normalize_dow_field(fields[4]);
182-
183-
format!(
184-
"{} {} {} {} {}",
185-
fields[0], fields[1], fields[2], fields[3], dow
186-
)
187-
}
188-
189-
/// Normalize a single day-of-week field, converting 0 (POSIX Sunday) to 7 (cron crate Sunday).
190-
fn normalize_dow_field(field: &str) -> String {
191-
// Handle special cases
192-
if field == "*" || field == "?" {
193-
return field.to_string();
194-
}
195-
196-
// Split by comma for lists (e.g., "0,3,5")
197-
field
198-
.split(',')
199-
.map(|part| normalize_dow_part(part))
200-
.collect::<Vec<_>>()
201-
.join(",")
202-
}
203-
204-
/// Normalize a single part of a day-of-week field (handles ranges like "0-6" and steps like "0/2").
205-
fn normalize_dow_part(part: &str) -> String {
206-
// Handle step values (e.g., "0/2" or "0-6/2")
207-
if let Some((range_part, step)) = part.split_once('/') {
208-
let normalized_range = normalize_dow_range(range_part);
209-
return format!("{}/{}", normalized_range, step);
210-
}
211-
212-
normalize_dow_range(part)
213-
}
214-
215-
/// Normalize a range or single value (e.g., "0", "0-6").
216-
fn normalize_dow_range(part: &str) -> String {
217-
// Handle ranges (e.g., "0-6")
218-
if let Some((start, end)) = part.split_once('-') {
219-
let start_normalized = normalize_dow_value(start);
220-
let end_normalized = normalize_dow_value(end);
221-
return format!("{}-{}", start_normalized, end_normalized);
222-
}
223-
224-
// Single value
225-
normalize_dow_value(part)
226-
}
227-
228-
/// Normalize a single day-of-week value: "0" -> "7", others unchanged.
229-
fn normalize_dow_value(val: &str) -> String {
230-
if val == "0" {
231-
"7".to_string()
232-
} else {
233-
val.to_string()
234-
}
235-
}
236-
237171
pub fn parse_config(content: &str) -> Result<(RunnerConfig, Vec<Job>)> {
238172
let config: Config =
239173
serde_yaml::from_str(content).map_err(|e| anyhow!("Failed to parse YAML: {}", e))?;
@@ -281,21 +215,7 @@ fn parse_job(
281215
) -> Result<Job> {
282216
validate_job_id(id)?;
283217

284-
// Validate 5-field cron format
285-
let cron_fields: Vec<&str> = job.schedule.cron.split_whitespace().collect();
286-
if cron_fields.len() != 5 {
287-
anyhow::bail!(
288-
"Invalid cron '{}': expected 5 fields (minute hour day month weekday), got {}",
289-
job.schedule.cron,
290-
cron_fields.len()
291-
);
292-
}
293-
294-
// Convert 5-field cron to 6-field by prepending seconds (0)
295-
// Also normalize day-of-week: 0 → 7 (cron crate uses 1-7, not 0-6)
296-
let normalized_cron = normalize_cron(&job.schedule.cron);
297-
let schedule_str = format!("0 {}", normalized_cron);
298-
let schedule = Schedule::from_str(&schedule_str)
218+
let schedule = Cron::from_str(&job.schedule.cron)
299219
.map_err(|e| anyhow!("Invalid cron '{}': {}", job.schedule.cron, e))?;
300220

301221
let timeout = parse_duration(&job.timeout)
@@ -1053,38 +973,4 @@ jobs:
1053973
assert_eq!(jobs[0].webhook.len(), 3);
1054974
}
1055975

1056-
#[test]
1057-
fn test_normalize_dow_field() {
1058-
// Standalone 0 -> 7
1059-
assert_eq!(normalize_dow_field("0"), "7");
1060-
// Other values unchanged
1061-
assert_eq!(normalize_dow_field("1"), "1");
1062-
assert_eq!(normalize_dow_field("*"), "*");
1063-
assert_eq!(normalize_dow_field("?"), "?");
1064-
// List with 0
1065-
assert_eq!(normalize_dow_field("0,3,5"), "7,3,5");
1066-
assert_eq!(normalize_dow_field("1,0,3"), "1,7,3");
1067-
// Range starting with 0
1068-
assert_eq!(normalize_dow_field("0-6"), "7-6");
1069-
// Step value
1070-
assert_eq!(normalize_dow_field("0/2"), "7/2");
1071-
assert_eq!(normalize_dow_field("0-6/2"), "7-6/2");
1072-
// Values that should NOT be affected (regression test for "10" -> "17" bug)
1073-
assert_eq!(normalize_dow_field("10"), "10"); // Invalid but should not be mangled
1074-
assert_eq!(normalize_dow_field("1-5"), "1-5");
1075-
assert_eq!(normalize_dow_field("20"), "20"); // Invalid but should not be mangled
1076-
}
1077-
1078-
#[test]
1079-
fn skip_6_field_cron() {
1080-
let yaml = r#"
1081-
jobs:
1082-
test:
1083-
schedule:
1084-
cron: "0 0 7 * * 7"
1085-
run: echo test
1086-
"#;
1087-
let (_, jobs) = parse_config(yaml).unwrap();
1088-
assert!(jobs.is_empty()); // Invalid job is skipped
1089-
}
1090976
}

src/scheduler/executor.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,15 +340,15 @@ fn handle_result(job: &Job, result: &CommandResult, log_file: Option<&mut File>)
340340
mod tests {
341341
use super::*;
342342
use crate::config::{Concurrency, RetryConfig, TimezoneConfig};
343-
use cron::Schedule;
343+
use croner::Cron;
344344
use std::str::FromStr;
345345
use tempfile::tempdir;
346346

347347
fn make_job(cmd: &str, timeout_secs: u64) -> Job {
348348
Job {
349349
id: "test".to_string(),
350350
name: "Test Job".to_string(),
351-
schedule: Schedule::from_str("* * * * * *").unwrap(),
351+
schedule: Cron::from_str("* * * * *").unwrap(),
352352
command: cmd.to_string(),
353353
timeout: Duration::from_secs(timeout_secs),
354354
concurrency: Concurrency::Skip,

0 commit comments

Comments
 (0)