From 2889ab9ebb077545a91cb616c1f2b1d0b23187f5 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 3 Sep 2026 16:40:47 +0100 Subject: [PATCH] feat(schedule): support weekday and raw cron schedules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46a9d42b-591c-4d80-96d2-1c0962554c7f --- docs/front-matter.md | 2 +- docs/schedule-syntax.md | 58 ++- src/compile/agentic_pipeline.rs | 70 ++- src/compile/mod.rs | 17 +- src/compile/pr_filters.rs | 8 +- src/compile/types.rs | 108 ++++- src/fuzzy_schedule.rs | 744 +++++++++++++++++++++++++++++--- tests/compiler_tests.rs | 73 ++++ 8 files changed, 975 insertions(+), 105 deletions(-) diff --git a/docs/front-matter.md b/docs/front-matter.md index 0d9379c1..9e54d311 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -154,7 +154,7 @@ on: # trigger configuration (unified under on: key) paths: include: ["src/**"] exclude: ["docs/**"] - schedule: daily around 14:00 # fuzzy schedule - see docs/schedule-syntax.md + schedule: daily on weekdays # fuzzy, raw cron, or list - see docs/schedule-syntax.md pipeline: name: "Build Pipeline" # source pipeline name project: "OtherProject" # optional: project name if different diff --git a/docs/schedule-syntax.md b/docs/schedule-syntax.md index 50fa0e2e..62035f9e 100644 --- a/docs/schedule-syntax.md +++ b/docs/schedule-syntax.md @@ -4,7 +4,7 @@ _Part of the [ado-aw documentation](../AGENTS.md)._ ## Schedule Syntax (Fuzzy Schedule Time Syntax) -The `on.schedule` field supports a human-friendly fuzzy schedule syntax that automatically distributes execution times to prevent server load spikes. The syntax is based on the [Fuzzy Schedule Time Syntax Specification](https://github.com/githubnext/gh-aw/blob/main/docs/src/content/docs/reference/fuzzy-schedule-specification.md). +The `on.schedule` field supports a human-friendly fuzzy schedule syntax that automatically distributes execution times to prevent server load spikes. The syntax follows the [gh-aw schedule reference](https://github.github.com/gh-aw/reference/schedule-syntax/) and its fuzzy-schedule parser where the concepts map to Azure Pipelines. The [formal fuzzy schedule specification](https://github.com/githubnext/gh-aw/blob/main/docs/src/content/docs/specs/fuzzy-schedule-specification.md) describes the core grammar but currently trails the reference for weekday modifiers. Schedule is configured under the `on:` key: @@ -22,8 +22,15 @@ schedule: daily around 3pm # 12-hour format supported schedule: daily around midnight # Keywords: midnight, noon schedule: daily between 9:00 and 17:00 # Business hours (9 AM - 5 PM) schedule: daily between 22:00 and 02:00 # Overnight (handles midnight crossing) +schedule: daily on weekdays # Monday-Friday at a scattered time +schedule: daily around 9am on weekdays # Monday-Friday within ±60 minutes +schedule: daily between 9:00 and 17:00 on weekdays ``` +`on weekdays` is supported on daily schedules, including `around` and +`between`. The generated Azure Pipelines cron uses the day-of-week range +`1-5`. + ### Weekly Schedules ```yaml @@ -44,10 +51,15 @@ schedule: every 1h # Equivalent to hourly schedule: every 2h # Every 2 hours at scattered minute schedule: every 2 hours # Long form also supported schedule: every 6h # Every 6 hours at scattered minute +schedule: hourly on weekdays +schedule: every 2h on weekdays ``` Valid hour intervals: 1, 2, 3, 4, 6, 8, 12 (factors of 24 for even distribution) +Weekday filtering is not supported for minute, day, week, bi-weekly, or +tri-weekly intervals. + ### Minute Intervals (Fixed, Not Scattered) ```yaml @@ -84,9 +96,20 @@ schedule: daily around 14:00 utc+9 # 2 PM JST → 5 AM UTC schedule: daily around 3pm utc-5 # 3 PM EST → 8 PM UTC schedule: daily around 09:00 utc # Bare UTC means UTC+0 schedule: daily between 9am utc+05:30 and 5pm utc+05:30 # IST business hours +schedule: daily around 08:00 utc-7 on weekdays ``` -Supported offset formats: `utc`, `utc+9`, `utc-5`, `utc+05:30`, `utc-08:00` +Supported offset formats: `utc`, `utc+9`, `utc-5`, `utc+05:30`, `utc-08:00`. +Keep the offset with the time and put `on weekdays` last. For compatibility +with the syntax requested in #1965, `daily around 08:00 on weekdays utc-7` is +also accepted. + +Azure Pipelines YAML schedules are always evaluated in UTC. IANA timezone names +such as `America/New_York` are not supported because Azure Pipelines has no +schedule timezone field and a single UTC cron cannot preserve local wall-clock +time across daylight-saving transitions. When a fixed UTC offset crosses +midnight, the compiler rotates the cron weekday range so the schedule still +runs Monday-Friday in the requested local offset. ### How Scattering Works @@ -112,3 +135,34 @@ schedule: - main - release/* ``` + +### Raw Cron and Multiple Schedules + +Use a validated five-field Azure Pipelines cron expression when fuzzy syntax +cannot express the required schedule: + +```yaml +on: + schedule: "0 9 * * 1-5" +``` + +The fields are `minute hour day-of-month month day-of-week`. Numeric values, +wildcards, lists, ranges, and steps are supported and validated against the ADO +field ranges. Months and weekdays also accept full English names or their first +three letters, such as `Jan` and `Mon-Fri`. + +Use list form to configure multiple fuzzy and/or raw cron schedules. Each item +may specify its own branch include list; omitted branches default to `main`. + +```yaml +on: + schedule: + - cron: daily on weekdays + - cron: "0 9 * * 1-5" + branches: + - main + - release/* +``` + +List items accept only `cron` and `branches`. IANA `timezone`, display-name, +batching, and branch-exclusion options are not supported. diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index f5e9c941..b8861db8 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -984,21 +984,28 @@ fn build_resources(repos: &[RepoCfg], on: &Option) -> Result, front_matter: &FrontMatter) -> Result { // Schedules — fuzzy schedule parsed once into typed Schedule items. let mut schedules: Vec = Vec::new(); - if let Some(s) = front_matter.schedule() { - let parsed = crate::fuzzy_schedule::parse_fuzzy_schedule(s.expression())?; - let cron = crate::fuzzy_schedule::generate_cron(&parsed, &front_matter.name); - let branches = s.branches(); - let branches_include = if branches.is_empty() { - vec!["main".to_string()] - } else { - branches.to_vec() - }; - schedules.push(Schedule { - cron, - display_name: "Scheduled run".to_string(), - branches_include, - always: true, - }); + if let Some(config) = front_matter.schedule() { + let entries = config.entries(); + if entries.is_empty() { + anyhow::bail!("on.schedule list must contain at least one schedule item"); + } + for schedule in entries { + let cron = crate::fuzzy_schedule::schedule_expression_to_cron( + schedule.expression, + &front_matter.name, + )?; + let branches_include = if schedule.branches.is_empty() { + vec!["main".to_string()] + } else { + schedule.branches.to_vec() + }; + schedules.push(Schedule { + cron, + display_name: "Scheduled run".to_string(), + branches_include, + always: true, + }); + } } // `on:` declares when this pipeline runs, and both keys are ALWAYS @@ -8119,6 +8126,39 @@ safe-outputs: assert_eq!(t.schedules.len(), 1); } + #[test] + fn build_triggers_compiles_mixed_schedule_list() { + let t = triggers_for(&format!( + "{BASE}on:\n schedule:\n - cron: daily on weekdays\n - cron: '0 9 * * 1-5'\n branches: [release/*]\n" + )); + assert_eq!(t.schedules.len(), 2); + assert_eq!( + t.schedules[0] + .cron + .split_whitespace() + .last() + .expect("cron should have a day-of-week field"), + "1-5" + ); + assert_eq!(t.schedules[0].branches_include, vec!["main".to_string()]); + assert_eq!(t.schedules[1].cron, "0 9 * * 1-5"); + assert_eq!( + t.schedules[1].branches_include, + vec!["release/*".to_string()] + ); + } + + #[test] + fn build_triggers_rejects_empty_schedule_list() { + let fm = test_front_matter(&format!("{BASE}on:\n schedule: []\n")); + let error = build_triggers(&fm.on_config, &fm).unwrap_err(); + assert!( + error + .to_string() + .contains("must contain at least one schedule item") + ); + } + #[test] fn build_triggers_explicit_push_wins_over_schedule() { // "Run nightly, and also whenever `main` moves" is a legitimate diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 2f9492aa..d4a1da61 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -1317,8 +1317,9 @@ Body "#; let (fm, _) = parse_markdown(content).unwrap(); let schedule = fm.schedule().unwrap(); - assert_eq!(schedule.expression(), "daily around 14:00"); - assert!(schedule.branches().is_empty()); + let entries = schedule.entries(); + assert_eq!(entries[0].expression, "daily around 14:00"); + assert!(entries[0].branches.is_empty()); } #[test] @@ -1337,13 +1338,14 @@ Body "#; let (fm, _) = parse_markdown(content).unwrap(); let schedule = fm.schedule().unwrap(); - assert_eq!(schedule.expression(), "weekly on friday around 17:00"); - assert_eq!(schedule.branches(), &["main", "release/*"]); + let entries = schedule.entries(); + assert_eq!(entries[0].expression, "weekly on friday around 17:00"); + assert_eq!(entries[0].branches, &["main", "release/*"]); } #[test] fn test_schedule_object_form_no_branches() { - // Object form without a `branches` key: schedule.branches() must default to empty. + // Object form without a `branches` key defaults to an empty branch list. let content = r#"--- name: "Agent" description: "Test" @@ -1355,8 +1357,9 @@ Body "#; let (fm, _) = parse_markdown(content).unwrap(); let schedule = fm.schedule().unwrap(); - assert_eq!(schedule.expression(), "daily around 10:00"); - assert!(schedule.branches().is_empty()); + let entries = schedule.entries(); + assert_eq!(entries[0].expression, "daily around 10:00"); + assert!(entries[0].branches.is_empty()); } #[test] diff --git a/src/compile/pr_filters.rs b/src/compile/pr_filters.rs index 427d0370..71a73694 100644 --- a/src/compile/pr_filters.rs +++ b/src/compile/pr_filters.rs @@ -411,8 +411,9 @@ on: let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let oc: OnConfig = serde_yaml::from_value(val["on"].clone()).unwrap(); assert!(oc.schedule.is_some(), "should have schedule"); + let schedule = oc.schedule.as_ref().unwrap().entries(); assert_eq!( - oc.schedule.unwrap().expression(), + schedule[0].expression, "daily around 14:00", "schedule expression should round-trip" ); @@ -447,9 +448,10 @@ on: let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let oc: OnConfig = serde_yaml::from_value(val["on"].clone()).unwrap(); let schedule = oc.schedule.unwrap(); - assert_eq!(schedule.expression(), "weekly on monday"); + let entries = schedule.entries(); + assert_eq!(entries[0].expression, "weekly on monday"); assert_eq!( - schedule.branches(), + entries[0].branches, &["main"], "schedule branches should round-trip" ); diff --git a/src/compile/types.rs b/src/compile/types.rs index 79de7262..a69e5ae2 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -159,6 +159,15 @@ impl SanitizeConfigTrait for PoolConfig { /// branches: /// - main /// - release/* +/// +/// # Multiple fuzzy or raw cron schedules +/// schedule: +/// - cron: daily on weekdays +/// branches: +/// - main +/// - cron: "0 9 * * 1-5" +/// branches: +/// - release/* /// ``` #[derive(Debug, Deserialize, Clone)] #[serde(untagged)] @@ -167,22 +176,29 @@ pub enum ScheduleConfig { Simple(String), /// Schedule with options (branch filtering) WithOptions(ScheduleOptions), + /// Multiple fuzzy or raw cron schedules + Multiple(Vec), } impl ScheduleConfig { - /// Get the schedule expression string - pub fn expression(&self) -> &str { + /// Return a normalized one-or-many view over the configured schedules. + pub fn entries(&self) -> Vec> { match self { - ScheduleConfig::Simple(s) => s, - ScheduleConfig::WithOptions(opts) => &opts.run, - } - } - - /// Get the branches filter (empty means default to "main" branch) - pub fn branches(&self) -> &[String] { - match self { - ScheduleConfig::Simple(_) => &[], - ScheduleConfig::WithOptions(opts) => &opts.branches, + ScheduleConfig::Simple(expression) => vec![ScheduleEntryRef { + expression, + branches: &[], + }], + ScheduleConfig::WithOptions(options) => vec![ScheduleEntryRef { + expression: &options.run, + branches: &options.branches, + }], + ScheduleConfig::Multiple(items) => items + .iter() + .map(|item| ScheduleEntryRef { + expression: &item.cron, + branches: &item.branches, + }) + .collect(), } } } @@ -192,10 +208,21 @@ impl SanitizeConfigTrait for ScheduleConfig { match self { ScheduleConfig::Simple(s) => *s = crate::sanitize::sanitize_config(s), ScheduleConfig::WithOptions(opts) => opts.sanitize_config_fields(), + ScheduleConfig::Multiple(items) => { + for item in items { + item.sanitize_config_fields(); + } + } } } } +#[derive(Debug, Clone, Copy)] +pub struct ScheduleEntryRef<'a> { + pub expression: &'a str, + pub branches: &'a [String], +} + #[derive(Debug, Deserialize, Clone, SanitizeConfig)] pub struct ScheduleOptions { /// Fuzzy schedule expression (e.g., "daily around 14:00") @@ -205,6 +232,16 @@ pub struct ScheduleOptions { pub branches: Vec, } +#[derive(Debug, Deserialize, Clone, SanitizeConfig)] +#[serde(deny_unknown_fields)] +pub struct ScheduleListItem { + /// Fuzzy schedule expression or validated five-field ADO cron. + pub cron: String, + /// Branches to restrict this schedule to (empty = defaults to "main"). + #[serde(default)] + pub branches: Vec, +} + /// Engine configuration — aligned with gh-aw's engine front matter. /// /// The string form is an engine identifier (e.g., `copilot`). The object form @@ -5253,8 +5290,9 @@ imports: let yaml = "run: hourly"; let opts: ScheduleOptions = serde_yaml::from_str(yaml).unwrap(); let sc = ScheduleConfig::WithOptions(opts); - assert_eq!(sc.expression(), "hourly"); - assert!(sc.branches().is_empty()); + let entries = sc.entries(); + assert_eq!(entries[0].expression, "hourly"); + assert!(entries[0].branches.is_empty()); } #[test] @@ -5262,8 +5300,9 @@ imports: let yaml = "schedule: daily around 14:00"; let fm: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let sc: ScheduleConfig = serde_yaml::from_value(fm["schedule"].clone()).unwrap(); - assert_eq!(sc.expression(), "daily around 14:00"); - assert!(sc.branches().is_empty()); + let entries = sc.entries(); + assert_eq!(entries[0].expression, "daily around 14:00"); + assert!(entries[0].branches.is_empty()); } #[test] @@ -5271,8 +5310,41 @@ imports: let yaml = "schedule:\n run: weekly on friday\n branches:\n - main\n - develop"; let fm: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); let sc: ScheduleConfig = serde_yaml::from_value(fm["schedule"].clone()).unwrap(); - assert_eq!(sc.expression(), "weekly on friday"); - assert_eq!(sc.branches(), &["main", "develop"]); + let entries = sc.entries(); + assert_eq!(entries[0].expression, "weekly on friday"); + assert_eq!(entries[0].branches, &["main", "develop"]); + } + + #[test] + fn test_schedule_config_deserialized_as_list() { + let yaml = r#" +schedule: + - cron: daily on weekdays + - cron: "0 9 * * 1-5" + branches: [main, "release/*"] +"#; + let fm: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let sc: ScheduleConfig = serde_yaml::from_value(fm["schedule"].clone()).unwrap(); + let entries = sc.entries(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].expression, "daily on weekdays"); + assert!(entries[0].branches.is_empty()); + assert_eq!(entries[1].expression, "0 9 * * 1-5"); + assert_eq!(entries[1].branches, &["main", "release/*"]); + } + + #[test] + fn test_schedule_list_rejects_timezone_field() { + let yaml = r#" +schedule: + - cron: "0 9 * * 1-5" + timezone: America/New_York +"#; + let fm: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + assert!( + serde_yaml::from_value::(fm["schedule"].clone()).is_err(), + "IANA timezone fields must not be silently ignored" + ); } // ─── EngineConfig deserialization ──────────────────────────────────────── diff --git a/src/fuzzy_schedule.rs b/src/fuzzy_schedule.rs index cb90f99a..5398e2c1 100644 --- a/src/fuzzy_schedule.rs +++ b/src/fuzzy_schedule.rs @@ -5,6 +5,7 @@ //! //! Supported schedule types: //! - `daily` - Scattered across full day +//! - `daily on weekdays` - Scattered across Monday-Friday //! - `daily around HH:MM` - Within ±60 minute window //! - `daily between HH:MM and HH:MM` - Within specified time range //! - `weekly` - Scattered across full week @@ -12,7 +13,9 @@ //! - `weekly on around HH:MM` - On specific day, within ±60 minute window //! - `weekly on between HH:MM and HH:MM` - On specific day, within range //! - `hourly` - Every hour at scattered minute +//! - `hourly on weekdays` - Every hour Monday-Friday at scattered minute //! - `every Nh` / `every N hours` - Every N hours at scattered minute +//! - `every Nh on weekdays` - Every N hours Monday-Friday at scattered minute //! - `every Nm` / `every N minutes` - Every N minutes (fixed, not scattered) //! - `bi-weekly` - Every 14 days at scattered time //! - `tri-weekly` - Every 21 days at scattered time @@ -112,20 +115,68 @@ pub enum TimeConstraint { Between(TimeSpec, TimeSpec), } +/// Day filter for schedule forms that can run daily. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DayFilter { + EveryDay, + Weekdays { utc_offset_minutes: i32 }, +} + +impl DayFilter { + fn cron_field(self) -> &'static str { + match self { + DayFilter::EveryDay => "*", + DayFilter::Weekdays { + utc_offset_minutes: 0, + } => "1-5", + DayFilter::Weekdays { .. } => { + unreachable!("timezone-aware weekday filters require a generated UTC time") + } + } + } + + fn with_utc_offset(self, utc_offset_minutes: i32) -> Self { + match self { + DayFilter::EveryDay => DayFilter::EveryDay, + DayFilter::Weekdays { .. } => DayFilter::Weekdays { + utc_offset_minutes, + }, + } + } + + fn cron_field_for_utc_time(self, utc_minutes: u32) -> &'static str { + match self { + DayFilter::EveryDay => "*", + DayFilter::Weekdays { utc_offset_minutes } => { + let local_minutes = utc_minutes as i32 + utc_offset_minutes; + match local_minutes.div_euclid(1440) { + -1 => "2-6", + 0 => "1-5", + 1 => "0-4", + shift => unreachable!("UTC offset produced unsupported day shift {shift}"), + } + } + } + } +} + /// Parsed schedule expression #[derive(Debug, Clone, PartialEq, Eq)] pub enum FuzzySchedule { /// Daily schedule with optional time constraint - Daily(TimeConstraint), + Daily { + constraint: TimeConstraint, + days: DayFilter, + }, /// Weekly schedule with optional day and time constraint Weekly { day: Option, constraint: TimeConstraint, }, /// Hourly schedule (scattered minute) - Hourly, + Hourly { days: DayFilter }, /// Every N hours (scattered minute) - EveryHours(u8), + EveryHours { interval: u8, days: DayFilter }, /// Every N minutes (fixed, not scattered) EveryMinutes(u8), /// Every N days (scattered time) @@ -268,12 +319,8 @@ pub fn parse_fuzzy_schedule(input: &str) -> Result { "daily" => parse_daily_schedule(&tokens[1..]), "weekly" => parse_weekly_schedule(&tokens[1..]), "hourly" => { - if tokens.len() > 1 { - bail!( - "'hourly' does not accept additional parameters. Use 'every Nh' for interval schedules." - ); - } - Ok(FuzzySchedule::Hourly) + let days = parse_hourly_day_filter(&tokens[1..])?; + Ok(FuzzySchedule::Hourly { days }) } "every" => parse_interval_schedule(&tokens[1..]), "bi-weekly" | "biweekly" => { @@ -295,9 +342,217 @@ pub fn parse_fuzzy_schedule(input: &str) -> Result { } } +/// Parse either a fuzzy schedule expression or a validated five-field ADO cron. +pub fn schedule_expression_to_cron(input: &str, workflow_id: &str) -> Result { + let input = input.trim(); + if looks_like_raw_cron(input) { + validate_raw_cron(input)?; + return Ok(input.to_string()); + } + + let schedule = parse_fuzzy_schedule(input)?; + Ok(generate_cron(&schedule, workflow_id)) +} + +fn looks_like_raw_cron(input: &str) -> bool { + let fields = input.split_whitespace().collect::>(); + let Some(first) = fields.first() else { + return false; + }; + if matches!( + *first, + "daily" | "weekly" | "hourly" | "every" | "bi-weekly" | "biweekly" + | "tri-weekly" | "triweekly" + ) { + return false; + } + fields.len() == 5 + || first.starts_with('*') + || first.starts_with('$') + || first.chars().any(|ch| ch.is_ascii_digit()) +} + +fn validate_raw_cron(input: &str) -> Result<()> { + let fields = input.split_whitespace().collect::>(); + if fields.len() != 5 { + bail!( + "ADO cron expressions require exactly 5 fields (minute hour day-of-month month day-of-week), got {} in '{}'", + fields.len(), + input + ); + } + + let definitions = [ + ("minute", 0, 59, CronValueKind::Numeric), + ("hour", 0, 23, CronValueKind::Numeric), + ("day-of-month", 1, 31, CronValueKind::Numeric), + ("month", 1, 12, CronValueKind::Month), + ("day-of-week", 0, 6, CronValueKind::Weekday), + ]; + for (field, (name, min, max, kind)) in fields.iter().zip(definitions) { + validate_raw_cron_field(field, name, min, max, kind)?; + } + + Ok(()) +} + +#[derive(Clone, Copy)] +enum CronValueKind { + Numeric, + Month, + Weekday, +} + +fn validate_raw_cron_field( + field: &str, + name: &str, + min: u8, + max: u8, + kind: CronValueKind, +) -> Result<()> { + if field.is_empty() { + bail!("ADO cron {name} field cannot be empty"); + } + if !field + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '*' | ',' | '-' | '/')) + { + bail!( + "ADO cron {name} field '{}' contains unsupported characters", + field + ); + } + + for item in field.split(',') { + if item.is_empty() { + bail!("ADO cron {name} field '{}' contains an empty list item", field); + } + + let mut step_parts = item.split('/'); + let base = step_parts.next().unwrap_or_default(); + let step = step_parts.next(); + if step_parts.next().is_some() { + bail!( + "ADO cron {name} field '{}' contains more than one step separator", + field + ); + } + if let Some(step) = step { + let step = parse_cron_number(step, name, field)?; + if step == 0 { + bail!("ADO cron {name} field '{}' has a zero step", field); + } + } + + if base == "*" { + continue; + } + if base.is_empty() { + bail!("ADO cron {name} field '{}' is missing a value", field); + } + + if let Some((start, end)) = base.split_once('-') { + if end.contains('-') { + bail!("ADO cron {name} field '{}' contains an invalid range", field); + } + let start = parse_cron_value(start, name, field, kind)?; + let end = parse_cron_value(end, name, field, kind)?; + validate_cron_value(start, name, field, min, max)?; + validate_cron_value(end, name, field, min, max)?; + if start > end { + bail!( + "ADO cron {name} field '{}' has a reversed range {}-{}", + field, + start, + end + ); + } + } else { + let value = parse_cron_value(base, name, field, kind)?; + validate_cron_value(value, name, field, min, max)?; + } + } + + Ok(()) +} + +fn parse_cron_number(value: &str, name: &str, field: &str) -> Result { + if value.is_empty() { + bail!("ADO cron {name} field '{}' contains a missing number", field); + } + value.parse::().with_context(|| { + format!( + "ADO cron {name} field '{}' contains invalid number '{}'", + field, value + ) + }) +} + +fn parse_cron_value(value: &str, name: &str, field: &str, kind: CronValueKind) -> Result { + if let Ok(value) = value.parse::() { + return Ok(value); + } + + let normalized = value.to_ascii_lowercase(); + let named_value = match kind { + CronValueKind::Numeric => None, + CronValueKind::Month => match normalized.as_str() { + "jan" | "january" => Some(1), + "feb" | "february" => Some(2), + "mar" | "march" => Some(3), + "apr" | "april" => Some(4), + "may" => Some(5), + "jun" | "june" => Some(6), + "jul" | "july" => Some(7), + "aug" | "august" => Some(8), + "sep" | "september" => Some(9), + "oct" | "october" => Some(10), + "nov" | "november" => Some(11), + "dec" | "december" => Some(12), + _ => None, + }, + CronValueKind::Weekday => match normalized.as_str() { + "sun" | "sunday" => Some(0), + "mon" | "monday" => Some(1), + "tue" | "tuesday" => Some(2), + "wed" | "wednesday" => Some(3), + "thu" | "thursday" => Some(4), + "fri" | "friday" => Some(5), + "sat" | "saturday" => Some(6), + _ => None, + }, + }; + + named_value.ok_or_else(|| { + anyhow::anyhow!( + "ADO cron {name} field '{}' contains unsupported value '{}'", + field, + value + ) + }) +} + +fn validate_cron_value(value: u8, name: &str, field: &str, min: u8, max: u8) -> Result<()> { + if !(min..=max).contains(&value) { + bail!( + "ADO cron {name} field '{}' contains value {}; expected {}-{}", + field, + value, + min, + max + ); + } + Ok(()) +} + fn parse_daily_schedule(tokens: &[&str]) -> Result { + let (tokens, days) = extract_daily_day_filter(tokens)?; + if tokens.is_empty() { - return Ok(FuzzySchedule::Daily(TimeConstraint::None)); + return Ok(FuzzySchedule::Daily { + constraint: TimeConstraint::None, + days, + }); } match tokens[0] { @@ -305,8 +560,11 @@ fn parse_daily_schedule(tokens: &[&str]) -> Result { if tokens.len() < 2 { bail!("'around' requires a time specification. Example: daily around 14:00"); } - let (time, _offset) = parse_time_with_offset(&tokens[1..])?; - Ok(FuzzySchedule::Daily(TimeConstraint::Around(time))) + let (time, offset) = parse_time_with_offset(&tokens[1..])?; + Ok(FuzzySchedule::Daily { + constraint: TimeConstraint::Around(time), + days: days.with_utc_offset(offset), + }) } "between" => { // Format: between and @@ -319,23 +577,96 @@ fn parse_daily_schedule(tokens: &[&str]) -> Result { bail!("'between' requires format: between and "); } - let (start_time, _) = parse_time_with_offset(&tokens[1..and_pos])?; - let (end_time, _) = parse_time_with_offset(&tokens[and_pos + 1..])?; + let (start_time, start_offset) = + parse_time_with_offset(&tokens[1..and_pos])?; + let (end_time, end_offset) = + parse_time_with_offset(&tokens[and_pos + 1..])?; + if matches!(days, DayFilter::Weekdays { .. }) && start_offset != end_offset { + bail!("weekday 'between' schedules require the same UTC offset on both times"); + } - Ok(FuzzySchedule::Daily(TimeConstraint::Between( - start_time, end_time, - ))) + Ok(FuzzySchedule::Daily { + constraint: TimeConstraint::Between(start_time, end_time), + days: days.with_utc_offset(start_offset), + }) } "at" => bail!( "'daily at