Skip to main content

layover_core/
pipeline.rs

1//! Pipelines: named, triggerable entry points that carry flags.
2//!
3//! A route map says which agents *may* talk to each other. A pipeline says how work *enters* the
4//! mesh: which agent receives it, whether a human starts it or a clock does, and which boolean
5//! flags the run is parameterised by.
6//!
7//! Pipelines are deliberately thin. They do not describe a sequence of steps — agents still decide
8//! where work goes next — so adding one does not turn the permission mesh into a pipeline engine.
9//!
10//! # Safety
11//!
12//! A schedule is the one part of Layover that starts work with no human present, so the floor on
13//! how often it may fire is enforced here rather than left to the author. See
14//! [`Schedule::MIN_INTERVAL_SECS`].
15
16use 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/// The name of a pipeline, as written in `layover.toml`.
27#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
28#[serde(transparent)]
29pub struct PipelineName(String);
30
31impl PipelineName {
32    /// Creates a pipeline name.
33    #[must_use]
34    pub fn new(name: impl Into<String>) -> Self {
35        Self(name.into())
36    }
37
38    /// Returns the name as a string slice.
39    #[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/// When a scheduled pipeline fires.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Schedule {
60    /// A fixed interval between runs.
61    Every(Duration),
62    /// A five-field cron expression, evaluated in the Tower's local time zone.
63    Cron(String),
64}
65
66impl Schedule {
67    /// The shortest interval a schedule may declare.
68    ///
69    /// Every firing is a real, paid CLI invocation, and a schedule runs with nobody watching. A
70    /// minute is the floor because it is the finest granularity a five-field cron expression can
71    /// express, so allowing anything shorter would make `every` and `cron` disagree about what is
72    /// possible.
73    pub const MIN_INTERVAL_SECS: u64 = 60;
74
75    /// Returns the fixed interval, if this schedule is one.
76    #[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    /// The first firing strictly after `now`.
85    ///
86    /// Both forms are computed from the clock rather than from when the last run finished. Adding
87    /// an interval to a finish time makes the period drift by however long the work took, so an
88    /// hourly job slowly becomes a ninety-minute one.
89    ///
90    /// Returns `None` only for a cron expression that never matches — 31 February, say — which
91    /// parses cleanly and is therefore accepted at load. A pipeline that can never fire is better
92    /// left silent than made to fire at some arbitrary substitute time.
93    #[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                // Local time, because somebody writing `0 8 * * *` means eight in the morning
103                // where they are.
104                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    /// Returns a lower bound on the gap between firings, in seconds, when one can be established.
114    ///
115    /// For a fixed interval this is exact. For a cron expression it is read off the minute field,
116    /// and only when that field states the answer unambiguously: `*` fires every minute and
117    /// `*/n` every `n` minutes. Lists and ranges are left alone rather than guessed at, because a
118    /// wrong lower bound here means a warning that is not true, and a validator that cries wolf is
119    /// one people stop reading.
120    #[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/// What starts a pipeline.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum Trigger {
148    /// A human sends the first flight.
149    Manual,
150    /// The Tower sends the first flight on a clock.
151    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    /// Returns the schedule, if this trigger has one.
165    #[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    /// Returns `true` when only a human can start this pipeline.
174    #[must_use]
175    pub fn is_manual(&self) -> bool {
176        matches!(self, Self::Manual)
177    }
178}
179
180/// A boolean parameter a pipeline accepts at trigger time.
181///
182/// Flags are how one factory definition serves several situations without duplicating prompts:
183/// a tester that also runs a remote end-to-end suite is the same agent with one extra paragraph
184/// of instructions. See [`crate::prompt`].
185#[derive(Debug, Clone, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct FlagSpec {
188    /// Value used when the trigger does not set the flag.
189    #[serde(default)]
190    pub default: bool,
191    /// What turning this flag on actually does.
192    #[serde(default)]
193    pub description: Option<String>,
194}
195
196/// How an itinerary's workspace relates to other itineraries'.
197///
198/// A pipeline that can have several instances in flight — one per pull request, say — needs each
199/// to work somewhere of its own, or two `read-write` agents in two unrelated itineraries will
200/// clobber each other in the shared directory.
201#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
202#[serde(rename_all = "kebab-case")]
203pub enum Workspace {
204    /// Every itinerary works in the one shared `work_dir`.
205    ///
206    /// The default, because it is what a single-instance pipeline wants and because a worktree
207    /// per itinerary costs disk and setup time.
208    #[default]
209    Shared,
210    /// Each itinerary gets its own git worktree, named after the itinerary.
211    ///
212    /// This is what makes several instances of one pipeline safe to run at once.
213    PerItinerary,
214}
215
216impl Workspace {
217    /// Returns `true` when each itinerary is isolated from the others.
218    #[must_use]
219    pub fn is_isolated(&self) -> bool {
220        matches!(self, Self::PerItinerary)
221    }
222}
223
224/// What happens when a scheduled pipeline is due and its previous wave has not finished.
225///
226/// The default is to skip. Starting a second copy means paying twice for one result and, where
227/// agents share a workspace, two of them writing to the same files; skipping means being one
228/// interval late. For unattended spending those are not comparable.
229#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
230#[serde(rename_all = "kebab-case")]
231pub enum Overlap {
232    /// Miss this firing and wait for the next one.
233    #[default]
234    Skip,
235    /// Start another instance anyway.
236    ///
237    /// Safe when instances cannot interfere — a `per-itinerary` workspace, or agents that only
238    /// read — and a way to pay twice when they can.
239    Allow,
240}
241
242impl Overlap {
243    /// Returns `true` when a second instance may start.
244    #[must_use]
245    pub fn allows_second_instance(&self) -> bool {
246        matches!(self, Self::Allow)
247    }
248}
249
250/// A named entry point into the mesh.
251#[derive(Debug, Clone, Deserialize)]
252#[serde(deny_unknown_fields)]
253pub struct Pipeline {
254    /// One line saying what this pipeline is for.
255    #[serde(default)]
256    pub description: Option<String>,
257    /// The agent that receives the first flight.
258    pub entry: AgentName,
259    /// What starts it.
260    #[serde(default = "manual_trigger")]
261    pub trigger: Trigger,
262    /// Whether instances of this pipeline share a workspace or get one each.
263    #[serde(default)]
264    pub workspace: Workspace,
265    /// What to do when this pipeline is due again before the last wave has finished.
266    #[serde(default)]
267    pub overlap: Overlap,
268    /// Whether this pipeline picks up booked layovers rather than starting fresh work.
269    ///
270    /// A resuming pipeline does not open an itinerary on every tick. It looks for work that was
271    /// set down and is now due, and opens one seeded with what the earlier chain knew. A tick
272    /// that finds nothing due costs nothing, which is what makes checking every twenty minutes
273    /// affordable.
274    #[serde(default)]
275    pub resumes: bool,
276    /// Boolean parameters this pipeline accepts, keyed by flag name.
277    #[serde(default)]
278    pub flags: BTreeMap<String, FlagSpec>,
279}
280
281impl Pipeline {
282    /// Returns `true` when a second instance may start while the first is still going.
283    #[must_use]
284    pub fn allows_overlap(&self) -> bool {
285        self.overlap.allows_second_instance()
286    }
287
288    /// Resolves the flag values for one run, filling in declared defaults.
289    ///
290    /// # Errors
291    ///
292    /// Returns [`FlagError::Undeclared`] if `overrides` names a flag this pipeline does not
293    /// declare. Silently ignoring it would let a typo at trigger time change nothing while
294    /// appearing to work.
295    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/// The resolved boolean parameters of a single run.
316#[derive(Debug, Clone, Default, PartialEq, Eq)]
317pub struct Flags(BTreeMap<String, bool>);
318
319impl Flags {
320    /// Builds a flag set directly, for tests and for triggers that declare no pipeline.
321    #[must_use]
322    pub fn new(values: BTreeMap<String, bool>) -> Self {
323        Self(values)
324    }
325
326    /// Returns the value of `name`, or `None` when the flag was never declared.
327    ///
328    /// An undeclared flag is deliberately distinct from one that is declared and false: a prompt
329    /// referring to a flag nobody declared is a mistake, not a false condition.
330    #[must_use]
331    pub fn get(&self, name: &str) -> Option<bool> {
332        self.0.get(name).copied()
333    }
334
335    /// Returns every declared flag and its value.
336    pub fn iter(&self) -> impl Iterator<Item = (&str, bool)> {
337        self.0.iter().map(|(name, value)| (name.as_str(), *value))
338    }
339
340    /// Returns `true` when no flags are declared.
341    #[must_use]
342    pub fn is_empty(&self) -> bool {
343        self.0.is_empty()
344    }
345}
346
347/// Why a flag could not be resolved.
348#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
349pub enum FlagError {
350    /// The trigger set a flag the pipeline does not declare.
351    #[error("flag `{flag}` is not declared by this pipeline")]
352    Undeclared {
353        /// The offending flag name.
354        flag: String,
355    },
356}
357
358/// Why a trigger could not be understood.
359#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
360pub enum TriggerError {
361    /// The trigger keyword was not one Layover knows.
362    #[error(
363        "unknown trigger `{found}`; expected `manual`, `{{ every = \"1h\" }}` or `{{ cron = \"0 * * * *\" }}`"
364    )]
365    UnknownKeyword {
366        /// What was written.
367        found: String,
368    },
369    /// An `every` value was not a duration.
370    #[error("could not read `{found}` as a duration; expected a number followed by s, m, h or d")]
371    BadDuration {
372        /// What was written.
373        found: String,
374    },
375    /// An `every` value was shorter than the floor.
376    #[error(
377        "`every = \"{found}\"` fires more often than once every {minimum}s, which is not allowed \
378         for unattended work"
379    )]
380    TooFrequent {
381        /// What was written.
382        found: String,
383        /// The floor that was breached.
384        minimum: u64,
385    },
386    /// A cron expression did not parse.
387    #[error("could not read `{found}` as a cron expression: {reason}")]
388    BadCron {
389        /// What was written.
390        found: String,
391        /// Why the parser rejected it.
392        reason: String,
393    },
394    /// A cron expression carried a seconds field.
395    #[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        /// What was written.
401        found: String,
402        /// How many fields it had.
403        fields: usize,
404    },
405    /// Both `every` and `cron` were given.
406    #[error("a trigger sets both `every` and `cron`; give exactly one")]
407    AmbiguousSchedule,
408    /// A trigger table was empty.
409    #[error("a trigger table sets neither `every` nor `cron`")]
410    EmptySchedule,
411}
412
413const fn manual_trigger() -> Trigger {
414    Trigger::Manual
415}
416
417/// Parses a duration such as `30s`, `15m`, `1h` or `2d`.
418fn 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    // Split on the last *character* rather than the last byte, so a stray multi-byte character
425    // produces an error instead of a panic.
426    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
446/// Validates a cron expression, rejecting anything finer than a minute.
447fn 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
464/// The table form of a trigger, once its fields are known.
465fn 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    /// Accepts `"manual"` or a table with exactly one of `every` and `cron`.
488    ///
489    /// Hand-written rather than an untagged enum: untagged loses the inner error and reports only
490    /// "data did not match any variant", which turns a one-character typo into a puzzle. A
491    /// factory definition should say what is wrong with it while a human is still watching.
492    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        // A one-character typo must say which character. An untagged enum would report only
654        // "data did not match any variant", which is why this is deserialised by hand.
655        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        // A seconds field can schedule work faster than a run can finish, which is a fork bomb
732        // with a clock attached.
733        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        // A typo at trigger time would otherwise change nothing while appearing to work.
800        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}