1use std::collections::BTreeMap;
17use std::fmt;
18use std::str::FromStr;
19use std::time::Duration;
20
21use jiff::Timestamp;
22use serde::{Deserialize, Serialize};
23
24use crate::agent::AgentName;
25
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
28#[serde(transparent)]
29pub struct PipelineName(String);
30
31impl PipelineName {
32 #[must_use]
34 pub fn new(name: impl Into<String>) -> Self {
35 Self(name.into())
36 }
37
38 #[must_use]
40 pub fn as_str(&self) -> &str {
41 &self.0
42 }
43}
44
45impl fmt::Display for PipelineName {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 f.write_str(&self.0)
48 }
49}
50
51impl From<&str> for PipelineName {
52 fn from(value: &str) -> Self {
53 Self(value.to_owned())
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Schedule {
60 Every(Duration),
62 Cron(String),
64}
65
66impl Schedule {
67 pub const MIN_INTERVAL_SECS: u64 = 60;
74
75 #[must_use]
77 pub fn interval(&self) -> Option<Duration> {
78 match self {
79 Self::Every(duration) => Some(*duration),
80 Self::Cron(_) => None,
81 }
82 }
83
84 #[must_use]
94 pub fn next_after(&self, now: Timestamp) -> Option<Timestamp> {
95 match self {
96 Self::Every(interval) => {
97 let step = i64::try_from(interval.as_secs()).ok()?.max(1);
98 now.checked_add(jiff::SignedDuration::from_secs(step)).ok()
99 }
100 Self::Cron(expression) => {
101 let cron = croner::Cron::from_str(expression).ok()?;
102 let zoned = now.to_zoned(jiff::tz::TimeZone::system());
105
106 cron.find_next_occurrence(&zoned, false)
107 .ok()
108 .map(|at| at.timestamp())
109 }
110 }
111 }
112
113 #[must_use]
121 pub fn min_gap_secs(&self) -> Option<u64> {
122 match self {
123 Self::Every(duration) => Some(duration.as_secs()),
124 Self::Cron(expression) => {
125 let minutes = expression.split_whitespace().next()?;
126 if minutes == "*" {
127 return Some(60);
128 }
129 let step: u64 = minutes.strip_prefix("*/")?.parse().ok()?;
130 step.checked_mul(60)
131 }
132 }
133 }
134}
135
136impl fmt::Display for Schedule {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 match self {
139 Self::Every(duration) => write!(f, "every {}s", duration.as_secs()),
140 Self::Cron(expression) => write!(f, "cron `{expression}`"),
141 }
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum Trigger {
148 Manual,
150 Scheduled(Schedule),
152}
153
154impl fmt::Display for Trigger {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 match self {
157 Self::Manual => f.write_str("manual"),
158 Self::Scheduled(schedule) => write!(f, "{schedule}"),
159 }
160 }
161}
162
163impl Trigger {
164 #[must_use]
166 pub fn schedule(&self) -> Option<&Schedule> {
167 match self {
168 Self::Manual => None,
169 Self::Scheduled(schedule) => Some(schedule),
170 }
171 }
172
173 #[must_use]
175 pub fn is_manual(&self) -> bool {
176 matches!(self, Self::Manual)
177 }
178}
179
180#[derive(Debug, Clone, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct FlagSpec {
188 #[serde(default)]
190 pub default: bool,
191 #[serde(default)]
193 pub description: Option<String>,
194}
195
196#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
202#[serde(rename_all = "kebab-case")]
203pub enum Workspace {
204 #[default]
209 Shared,
210 PerItinerary,
214}
215
216impl Workspace {
217 #[must_use]
219 pub fn is_isolated(&self) -> bool {
220 matches!(self, Self::PerItinerary)
221 }
222}
223
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
230#[serde(rename_all = "kebab-case")]
231pub enum Overlap {
232 #[default]
234 Skip,
235 Allow,
240}
241
242impl Overlap {
243 #[must_use]
245 pub fn allows_second_instance(&self) -> bool {
246 matches!(self, Self::Allow)
247 }
248}
249
250#[derive(Debug, Clone, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct Pipeline {
254 #[serde(default)]
256 pub description: Option<String>,
257 pub entry: AgentName,
259 #[serde(default = "manual_trigger")]
261 pub trigger: Trigger,
262 #[serde(default)]
264 pub workspace: Workspace,
265 #[serde(default)]
267 pub overlap: Overlap,
268 #[serde(default)]
275 pub resumes: bool,
276 #[serde(default)]
278 pub flags: BTreeMap<String, FlagSpec>,
279}
280
281impl Pipeline {
282 #[must_use]
284 pub fn allows_overlap(&self) -> bool {
285 self.overlap.allows_second_instance()
286 }
287
288 pub fn flags_for_run(&self, overrides: &BTreeMap<String, bool>) -> Result<Flags, FlagError> {
296 if let Some(unknown) = overrides.keys().find(|key| !self.flags.contains_key(*key)) {
297 return Err(FlagError::Undeclared {
298 flag: unknown.clone(),
299 });
300 }
301
302 let values = self
303 .flags
304 .iter()
305 .map(|(name, spec)| {
306 let value = overrides.get(name).copied().unwrap_or(spec.default);
307 (name.clone(), value)
308 })
309 .collect();
310
311 Ok(Flags(values))
312 }
313}
314
315#[derive(Debug, Clone, Default, PartialEq, Eq)]
317pub struct Flags(BTreeMap<String, bool>);
318
319impl Flags {
320 #[must_use]
322 pub fn new(values: BTreeMap<String, bool>) -> Self {
323 Self(values)
324 }
325
326 #[must_use]
331 pub fn get(&self, name: &str) -> Option<bool> {
332 self.0.get(name).copied()
333 }
334
335 pub fn iter(&self) -> impl Iterator<Item = (&str, bool)> {
337 self.0.iter().map(|(name, value)| (name.as_str(), *value))
338 }
339
340 #[must_use]
342 pub fn is_empty(&self) -> bool {
343 self.0.is_empty()
344 }
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
349pub enum FlagError {
350 #[error("flag `{flag}` is not declared by this pipeline")]
352 Undeclared {
353 flag: String,
355 },
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
360pub enum TriggerError {
361 #[error(
363 "unknown trigger `{found}`; expected `manual`, `{{ every = \"1h\" }}` or `{{ cron = \"0 * * * *\" }}`"
364 )]
365 UnknownKeyword {
366 found: String,
368 },
369 #[error("could not read `{found}` as a duration; expected a number followed by s, m, h or d")]
371 BadDuration {
372 found: String,
374 },
375 #[error(
377 "`every = \"{found}\"` fires more often than once every {minimum}s, which is not allowed \
378 for unattended work"
379 )]
380 TooFrequent {
381 found: String,
383 minimum: u64,
385 },
386 #[error("could not read `{found}` as a cron expression: {reason}")]
388 BadCron {
389 found: String,
391 reason: String,
393 },
394 #[error(
396 "cron expression `{found}` has {fields} fields; Layover accepts five-field expressions \
397 only, because a seconds field can schedule work faster than a run can finish"
398 )]
399 SubMinuteCron {
400 found: String,
402 fields: usize,
404 },
405 #[error("a trigger sets both `every` and `cron`; give exactly one")]
407 AmbiguousSchedule,
408 #[error("a trigger table sets neither `every` nor `cron`")]
410 EmptySchedule,
411}
412
413const fn manual_trigger() -> Trigger {
414 Trigger::Manual
415}
416
417fn parse_duration(text: &str) -> Result<Duration, TriggerError> {
419 let trimmed = text.trim();
420 let bad = || TriggerError::BadDuration {
421 found: text.to_owned(),
422 };
423
424 let (digits, unit) = match trimmed.char_indices().next_back() {
427 Some((index, unit)) => (&trimmed[..index], unit),
428 None => return Err(bad()),
429 };
430
431 let multiplier = match unit {
432 's' => 1_u64,
433 'm' => 60,
434 'h' => 60 * 60,
435 'd' => 24 * 60 * 60,
436 _ => return Err(bad()),
437 };
438
439 let amount: u64 = digits.parse().map_err(|_| bad())?;
440 amount
441 .checked_mul(multiplier)
442 .map(Duration::from_secs)
443 .ok_or_else(bad)
444}
445
446fn parse_cron(expression: &str) -> Result<String, TriggerError> {
448 let fields = expression.split_whitespace().count();
449 if fields > 5 {
450 return Err(TriggerError::SubMinuteCron {
451 found: expression.to_owned(),
452 fields,
453 });
454 }
455
456 croner::Cron::from_str(expression).map_err(|error| TriggerError::BadCron {
457 found: expression.to_owned(),
458 reason: error.to_string(),
459 })?;
460
461 Ok(expression.to_owned())
462}
463
464fn trigger_from_parts(
466 every: Option<String>,
467 cron: Option<String>,
468) -> Result<Trigger, TriggerError> {
469 match (every, cron) {
470 (Some(every), None) => {
471 let duration = parse_duration(&every)?;
472 if duration.as_secs() < Schedule::MIN_INTERVAL_SECS {
473 return Err(TriggerError::TooFrequent {
474 found: every,
475 minimum: Schedule::MIN_INTERVAL_SECS,
476 });
477 }
478 Ok(Trigger::Scheduled(Schedule::Every(duration)))
479 }
480 (None, Some(cron)) => Ok(Trigger::Scheduled(Schedule::Cron(parse_cron(&cron)?))),
481 (Some(_), Some(_)) => Err(TriggerError::AmbiguousSchedule),
482 (None, None) => Err(TriggerError::EmptySchedule),
483 }
484}
485
486impl<'de> Deserialize<'de> for Trigger {
487 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
493 where
494 D: serde::Deserializer<'de>,
495 {
496 struct TriggerVisitor;
497
498 impl<'de> serde::de::Visitor<'de> for TriggerVisitor {
499 type Value = Trigger;
500
501 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
502 f.write_str(r#""manual", { every = "1h" } or { cron = "0 * * * *" }"#)
503 }
504
505 fn visit_str<E>(self, value: &str) -> Result<Trigger, E>
506 where
507 E: serde::de::Error,
508 {
509 if value == "manual" {
510 return Ok(Trigger::Manual);
511 }
512 Err(E::custom(TriggerError::UnknownKeyword {
513 found: value.to_owned(),
514 }))
515 }
516
517 fn visit_map<M>(self, mut map: M) -> Result<Trigger, M::Error>
518 where
519 M: serde::de::MapAccess<'de>,
520 {
521 const FIELDS: &[&str] = &["every", "cron"];
522
523 let mut every: Option<String> = None;
524 let mut cron: Option<String> = None;
525
526 while let Some(key) = map.next_key::<String>()? {
527 match key.as_str() {
528 "every" if every.is_some() => {
529 return Err(serde::de::Error::duplicate_field("every"));
530 }
531 "every" => every = Some(map.next_value()?),
532 "cron" if cron.is_some() => {
533 return Err(serde::de::Error::duplicate_field("cron"));
534 }
535 "cron" => cron = Some(map.next_value()?),
536 other => return Err(serde::de::Error::unknown_field(other, FIELDS)),
537 }
538 }
539
540 trigger_from_parts(every, cron).map_err(serde::de::Error::custom)
541 }
542 }
543
544 deserializer.deserialize_any(TriggerVisitor)
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 fn pipeline(body: &str) -> Pipeline {
553 toml::from_str(body).expect("pipeline parses")
554 }
555
556 fn trigger_error(body: &str) -> String {
557 toml::from_str::<Pipeline>(body)
558 .expect_err("trigger must be rejected")
559 .to_string()
560 }
561
562 #[test]
563 fn a_pipeline_defaults_to_manual() {
564 let pipeline = pipeline(r#"entry = "analyst""#);
565
566 assert_eq!(pipeline.trigger, Trigger::Manual);
567 assert!(pipeline.trigger.is_manual());
568 assert!(pipeline.trigger.schedule().is_none());
569 }
570
571 #[test]
572 fn manual_may_be_stated_explicitly() {
573 let pipeline = pipeline(
574 r#"
575 entry = "analyst"
576 trigger = "manual"
577 "#,
578 );
579
580 assert_eq!(pipeline.trigger, Trigger::Manual);
581 }
582
583 #[test]
584 fn an_interval_schedule_is_parsed() {
585 let pipeline = pipeline(
586 r#"
587 entry = "pr_scanner"
588 trigger = { every = "1h" }
589 "#,
590 );
591
592 assert_eq!(
593 pipeline.trigger,
594 Trigger::Scheduled(Schedule::Every(Duration::from_secs(3_600)))
595 );
596 assert_eq!(
597 pipeline.trigger.schedule().and_then(Schedule::interval),
598 Some(Duration::from_secs(3_600))
599 );
600 }
601
602 #[test]
603 fn every_unit_is_understood() {
604 assert_eq!(parse_duration("90s"), Ok(Duration::from_secs(90)));
605 assert_eq!(parse_duration("15m"), Ok(Duration::from_mins(15)));
606 assert_eq!(parse_duration("2h"), Ok(Duration::from_secs(7_200)));
607 assert_eq!(parse_duration("1d"), Ok(Duration::from_hours(24)));
608 }
609
610 #[test]
611 fn a_malformed_duration_is_rejected() {
612 assert!(parse_duration("soon").is_err());
613 assert!(parse_duration("10").is_err());
614 assert!(parse_duration("").is_err());
615 assert!(parse_duration("1w").is_err());
616 assert!(parse_duration("-5m").is_err());
617 assert!(
618 parse_duration("1é").is_err(),
619 "must not panic on a multi-byte tail"
620 );
621 assert!(parse_duration("99999999999999999999d").is_err());
622 }
623
624 #[test]
625 fn a_trigger_setting_both_forms_is_refused() {
626 let message = trigger_error(
627 r#"
628 entry = "pr_scanner"
629 trigger = { every = "1h", cron = "0 * * * *" }
630 "#,
631 );
632
633 assert!(
634 message.contains("both"),
635 "a trigger must not silently pick one of two schedules, got: {message}"
636 );
637 }
638
639 #[test]
640 fn an_empty_trigger_table_is_refused() {
641 let message = trigger_error(
642 r#"
643 entry = "pr_scanner"
644 trigger = {}
645 "#,
646 );
647
648 assert!(message.contains("neither"), "got: {message}");
649 }
650
651 #[test]
652 fn an_unknown_trigger_field_is_refused_by_name() {
653 let message = trigger_error(
656 r#"
657 entry = "pr_scanner"
658 trigger = { evry = "1h" }
659 "#,
660 );
661
662 assert!(
663 message.contains("evry"),
664 "the error must name the offending field, got: {message}"
665 );
666 assert!(
667 message.contains("every") && message.contains("cron"),
668 "the error must list what was expected, got: {message}"
669 );
670 }
671
672 #[test]
673 fn a_duplicate_trigger_field_is_refused() {
674 let message = trigger_error(
675 r#"
676 entry = "pr_scanner"
677 trigger = { every = "1h", every = "2h" }
678 "#,
679 );
680
681 assert!(!message.is_empty(), "got: {message}");
682 }
683
684 #[test]
685 fn a_schedule_faster_than_the_floor_is_refused() {
686 let message = trigger_error(
687 r#"
688 entry = "pr_scanner"
689 trigger = { every = "30s" }
690 "#,
691 );
692
693 assert!(
694 message.contains("fires more often"),
695 "expected a frequency refusal, got: {message}"
696 );
697 }
698
699 #[test]
700 fn a_cron_schedule_is_validated_at_load_time() {
701 let pipeline = pipeline(
702 r#"
703 entry = "pr_scanner"
704 trigger = { cron = "0 * * * *" }
705 "#,
706 );
707
708 assert_eq!(
709 pipeline.trigger,
710 Trigger::Scheduled(Schedule::Cron("0 * * * *".to_owned()))
711 );
712 }
713
714 #[test]
715 fn a_nonsense_cron_expression_is_refused() {
716 let message = trigger_error(
717 r#"
718 entry = "pr_scanner"
719 trigger = { cron = "every hour please" }
720 "#,
721 );
722
723 assert!(
724 message.contains("cron expression"),
725 "expected a cron refusal, got: {message}"
726 );
727 }
728
729 #[test]
730 fn a_six_field_cron_expression_is_refused() {
731 let message = trigger_error(
734 r#"
735 entry = "pr_scanner"
736 trigger = { cron = "*/5 * * * * *" }
737 "#,
738 );
739
740 assert!(
741 message.contains("five-field"),
742 "expected a granularity refusal, got: {message}"
743 );
744 }
745
746 #[test]
747 fn an_unknown_trigger_keyword_is_refused() {
748 let message = trigger_error(
749 r#"
750 entry = "pr_scanner"
751 trigger = "whenever"
752 "#,
753 );
754
755 assert!(message.contains("unknown trigger"));
756 }
757
758 #[test]
759 fn flags_fall_back_to_their_declared_defaults() {
760 let pipeline = pipeline(
761 r#"
762 entry = "analyst"
763
764 [flags]
765 run_e2e = { default = false, description = "Run the remote suite" }
766 verbose = { default = true }
767 "#,
768 );
769
770 let flags = pipeline
771 .flags_for_run(&BTreeMap::new())
772 .expect("no overrides is always valid");
773
774 assert_eq!(flags.get("run_e2e"), Some(false));
775 assert_eq!(flags.get("verbose"), Some(true));
776 assert_eq!(flags.get("undeclared"), None);
777 assert!(!flags.is_empty());
778 }
779
780 #[test]
781 fn a_trigger_override_wins_over_the_default() {
782 let pipeline = pipeline(
783 r#"
784 entry = "analyst"
785
786 [flags]
787 run_e2e = { default = false }
788 "#,
789 );
790
791 let overrides = BTreeMap::from([("run_e2e".to_owned(), true)]);
792 let flags = pipeline.flags_for_run(&overrides).expect("declared flag");
793
794 assert_eq!(flags.get("run_e2e"), Some(true));
795 }
796
797 #[test]
798 fn setting_an_undeclared_flag_is_an_error() {
799 let pipeline = pipeline(r#"entry = "analyst""#);
801 let overrides = BTreeMap::from([("run_e2ee".to_owned(), true)]);
802
803 assert_eq!(
804 pipeline.flags_for_run(&overrides),
805 Err(FlagError::Undeclared {
806 flag: "run_e2ee".to_owned()
807 })
808 );
809 }
810
811 #[test]
812 fn flags_iterate_in_a_stable_order() {
813 let flags = Flags::new(BTreeMap::from([
814 ("b".to_owned(), true),
815 ("a".to_owned(), false),
816 ]));
817
818 assert_eq!(
819 flags.iter().collect::<Vec<_>>(),
820 [("a", false), ("b", true)]
821 );
822 }
823}