Skip to main content

bootroom_core/
config.rs

1//! `bootroom.toml` schema + load-time validation.
2//!
3//! This module is the single source of truth for the Phase-3 config
4//! surface (Pitfall #5: schema drift). Every downstream consumer —
5//! `/api/config`, `bootroom check`, the live-reload watcher, and the
6//! Phase-4 scenario engine — projects from these types.
7//!
8//! Pure: no I/O, no async, no panic on operator input. All errors
9//! surface through [`LoadError`] with optional 1-based `line`/`col`
10//! span coordinates suitable for UI underlining.
11//!
12//! See `.planning/phases/03-config-buttons-watcher/03-CONTEXT.md`
13//! decision D-01 for the canonical schema shape.
14//!
15//! ## Phase 4 extensions: load-time assertion validation
16//!
17//! Two checks run at `LoadedConfig::from_config` time, after the
18//! existing scenario cross-validation loop:
19//!
20//! 1. Assertions with `kind = "regex"` have their `pattern` compiled
21//!    by the Rust `regex` crate. The supported regex feature subset
22//!    is the intersection of Rust `regex` and ECMAScript `RegExp`:
23//!    no backreferences, no lookaround. Rust is the stricter engine
24//!    — anything Rust accepts here will compile as a JS `RegExp` in
25//!    the browser scenario engine. See 04-RESEARCH Pitfall #1 for
26//!    the feature-intersection table.
27//!
28//! 2. Every assertion's `after` value must resolve to either the
29//!    literal `"any"` (universal-buffer evaluation, Pitfall #5) OR
30//!    a label that appears in THIS scenario's `actions` Vec. A
31//!    typo like `after = "rebot"` when `actions = ["reboot"]`
32//!    would silently never-match in the browser engine; rejecting
33//!    at load surfaces the typo in `bootroom check` instead.
34
35use std::collections::HashMap;
36use std::fmt;
37
38use serde::{Deserialize, Serialize};
39
40use crate::escape::decode_bytes_escape;
41
42// --- TOML schema (validated by serde with deny_unknown_fields) -----------
43
44/// Root `bootroom.toml` document.
45///
46/// `schema_version` is required (no `#[serde(default)]`) and locked to
47/// the integer `1` for the Phase-3 surface; incompatible versions
48/// produce a typed [`LoadError`] via [`LoadedConfig::load_from_str`].
49#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
50#[serde(deny_unknown_fields)]
51pub struct Config {
52    pub schema_version: u32,
53    #[serde(default, rename = "action")]
54    pub actions: Vec<Action>,
55    #[serde(default, rename = "scenario")]
56    pub scenarios: Vec<Scenario>,
57}
58
59#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
60#[serde(deny_unknown_fields)]
61pub struct Action {
62    pub label: String,
63    pub bytes: String,
64    #[serde(default)]
65    pub group: Option<String>,
66    #[serde(default)]
67    pub description: Option<String>,
68}
69
70#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
71#[serde(deny_unknown_fields)]
72pub struct Scenario {
73    pub name: String,
74    pub actions: Vec<String>,
75    #[serde(default, rename = "assert")]
76    pub assertions: Vec<Assertion>,
77    #[serde(default = "default_scenario_timeout")]
78    pub timeout_ms: u64,
79}
80
81#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
82#[serde(deny_unknown_fields)]
83pub struct Assertion {
84    pub kind: AssertionKind,
85    pub pattern: String,
86    pub after: String,
87    #[serde(default = "default_assertion_timeout")]
88    pub timeout_ms: u64,
89}
90
91#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
92#[serde(rename_all = "lowercase")]
93pub enum AssertionKind {
94    Contains,
95    Regex,
96}
97
98fn default_scenario_timeout() -> u64 {
99    30_000
100}
101
102fn default_assertion_timeout() -> u64 {
103    5_000
104}
105
106// --- Load errors --------------------------------------------------------
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109enum LoadErrorKind {
110    Parse,
111    SchemaMismatch { actual: u32 },
112    DecodeBytes,
113    UnknownActionRef,
114    DuplicateAction,
115    InvalidRegex,
116    UnresolvableAfter,
117}
118
119/// Failure raised while parsing or validating a `bootroom.toml`.
120///
121/// `line` and `col` are 1-based; both are `None` for errors that don't
122/// originate at a specific TOML span (cross-validation errors, CLI
123/// override duplicates, etc.).
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct LoadError {
126    pub message: String,
127    pub line: Option<u32>,
128    pub col: Option<u32>,
129    kind: LoadErrorKind,
130}
131
132impl LoadError {
133    fn schema_mismatch(actual: u32) -> Self {
134        LoadError {
135            message: format!(
136                "schema_version {actual} not supported; bootroom requires schema_version = 1"
137            ),
138            line: None,
139            col: None,
140            kind: LoadErrorKind::SchemaMismatch { actual },
141        }
142    }
143
144    fn duplicate_action(label: &str) -> Self {
145        LoadError {
146            message: format!("duplicate action label '{label}'"),
147            line: None,
148            col: None,
149            kind: LoadErrorKind::DuplicateAction,
150        }
151    }
152
153    fn unknown_action_ref(scenario: &str, action: &str) -> Self {
154        LoadError {
155            message: format!(
156                "scenario '{scenario}' references unknown action '{action}'"
157            ),
158            line: None,
159            col: None,
160            kind: LoadErrorKind::UnknownActionRef,
161        }
162    }
163
164    fn invalid_regex(
165        scenario: &str,
166        after: &str,
167        pattern: &str,
168        err: &regex::Error,
169    ) -> Self {
170        LoadError {
171            message: format!(
172                "scenario '{scenario}' assertion (after = '{after}'): \
173                 invalid regex {pattern:?}: {err}"
174            ),
175            line: None,
176            col: None,
177            kind: LoadErrorKind::InvalidRegex,
178        }
179    }
180
181    fn unresolvable_after(scenario: &str, after: &str, legal: &[String]) -> Self {
182        // Sort legal for stable error output across runs.
183        let mut sorted = legal.to_vec();
184        sorted.sort();
185        LoadError {
186            message: format!(
187                "scenario '{scenario}' assertion: `after = {after:?}` does not \
188                 resolve. Legal values are \"any\" or one of this scenario's \
189                 actions: {sorted:?}"
190            ),
191            line: None,
192            col: None,
193            kind: LoadErrorKind::UnresolvableAfter,
194        }
195    }
196
197    /// True when the failure was a `schema_version` mismatch.
198    #[must_use]
199    pub fn is_schema_version_mismatch(&self) -> bool {
200        matches!(self.kind, LoadErrorKind::SchemaMismatch { .. })
201    }
202
203    /// The actual `schema_version` value encountered, when this error is
204    /// a schema mismatch. `None` for all other variants.
205    #[must_use]
206    pub fn actual_version(&self) -> Option<u32> {
207        match self.kind {
208            LoadErrorKind::SchemaMismatch { actual } => Some(actual),
209            _ => None,
210        }
211    }
212
213    /// True when the failure was a regex compile failure on a
214    /// `kind = "regex"` assertion pattern.
215    #[must_use]
216    pub fn is_invalid_regex(&self) -> bool {
217        matches!(self.kind, LoadErrorKind::InvalidRegex)
218    }
219
220    /// True when the failure was an `Assertion.after` that did not
221    /// resolve to either `"any"` or a label in the containing
222    /// `Scenario.actions` Vec.
223    #[must_use]
224    pub fn is_unresolvable_after(&self) -> bool {
225        matches!(self.kind, LoadErrorKind::UnresolvableAfter)
226    }
227}
228
229impl fmt::Display for LoadError {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        match (self.line, self.col) {
232            (Some(l), Some(c)) => write!(f, "{} (line {l}, col {c})", self.message),
233            _ => f.write_str(&self.message),
234        }
235    }
236}
237
238impl std::error::Error for LoadError {}
239
240// --- Parser primitives --------------------------------------------------
241
242/// Parse a `bootroom.toml` string into the raw [`Config`] AST.
243///
244/// Performs only TOML syntax and `deny_unknown_fields` validation;
245/// schema-version checking, byte-escape decoding, scenario cross-validation,
246/// and CLI override merging live in [`LoadedConfig::load_from_str`] /
247/// [`LoadedConfig::load_from_str_with_overrides`].
248///
249/// # Errors
250///
251/// Returns [`LoadError`] for any TOML syntax error or unknown field; the
252/// `line` / `col` fields carry 1-based span coordinates when the underlying
253/// `toml` crate reports them.
254pub fn parse_str(input: &str) -> Result<Config, LoadError> {
255    match toml::from_str::<Config>(input) {
256        Ok(cfg) => Ok(cfg),
257        Err(e) => {
258            let (line, col) = e
259                .span()
260                .and_then(|range| offset_to_line_col(input, range.start))
261                .map_or((None, None), |(l, c)| (Some(l), Some(c)));
262            Err(LoadError {
263                message: e.message().to_string(),
264                line,
265                col,
266                kind: LoadErrorKind::Parse,
267            })
268        }
269    }
270}
271
272/// Convert a 0-based byte offset within `input` into a 1-based (line, col)
273/// pair suitable for `vim`/`code` editor jump-to-line.
274///
275/// Returns `None` if `byte_off > input.len()`. Columns count Unicode scalars
276/// (`char`s), not bytes, so multi-byte UTF-8 sequences contribute a single
277/// column each.
278#[must_use]
279pub fn offset_to_line_col(input: &str, byte_off: usize) -> Option<(u32, u32)> {
280    if byte_off > input.len() {
281        return None;
282    }
283    let prefix = &input[..byte_off];
284    let line = u32::try_from(prefix.bytes().filter(|&b| b == b'\n').count())
285        .unwrap_or(u32::MAX)
286        .saturating_add(1);
287    // Column = 1 + chars since the last newline (or start of input).
288    let last_nl = prefix.rfind('\n');
289    let col_slice = match last_nl {
290        Some(i) => &prefix[i + 1..],
291        None => prefix,
292    };
293    let col = u32::try_from(col_slice.chars().count())
294        .unwrap_or(u32::MAX)
295        .saturating_add(1);
296    Some((line, col))
297}
298
299// --- Resolved (post-merge, byte-decoded) views --------------------------
300
301/// One Action after byte-escape decoding and CLI-override merging.
302///
303/// `bytes_decoded` is the raw byte sequence written to guest stdin when
304/// the button is clicked; the original TOML `bytes` string is not kept.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct ResolvedAction {
307    pub label: String,
308    pub bytes_decoded: Vec<u8>,
309    pub group: Option<String>,
310    pub description: Option<String>,
311}
312
313/// An ad-hoc Action introduced via `--action label=<bytes>` on the CLI.
314///
315/// `bytes` is post-`decode_bytes_escape` (the CLI parser is responsible
316/// for decoding). No UI metadata: the operator gave us a label and a byte
317/// sequence; group/description default to `None`.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct CliAction {
320    pub label: String,
321    pub bytes: Vec<u8>,
322}
323
324/// A fully-validated `bootroom.toml` ready for projection to `/api/config`,
325/// `bootroom check`, the watcher, or the Phase-4 scenario engine.
326#[derive(Debug, Clone)]
327pub struct LoadedConfig {
328    actions: Vec<ResolvedAction>,
329    scenarios: Vec<Scenario>,
330    actions_by_label: HashMap<String, usize>,
331}
332
333impl LoadedConfig {
334    /// Parse + validate without any CLI overrides.
335    ///
336    /// # Errors
337    ///
338    /// Same as [`LoadedConfig::load_from_str_with_overrides`].
339    pub fn load_from_str(s: &str) -> Result<Self, LoadError> {
340        Self::load_from_str_with_overrides(s, &[])
341    }
342
343    /// Parse + validate, then merge `cli` overrides into the action list.
344    ///
345    /// Override semantics (CONTEXT D-02):
346    /// - Existing label is replaced in place; group/description cleared.
347    /// - New label appends to the end.
348    /// - Among CLI-only collisions, the last `--action label=...` wins.
349    /// - Source-TOML duplicate labels are rejected after the merge.
350    ///
351    /// # Errors
352    ///
353    /// Returns [`LoadError`] for:
354    /// - any error from [`parse_str`]
355    /// - `schema_version != 1`
356    /// - malformed `\xNN` / `\q` etc. in any `Action.bytes`
357    /// - duplicate action labels in the merged set
358    /// - any [`Scenario::actions`] entry that doesn't resolve to a
359    ///   known label
360    pub fn load_from_str_with_overrides(
361        s: &str,
362        cli: &[CliAction],
363    ) -> Result<Self, LoadError> {
364        let cfg = parse_str(s)?;
365        Self::from_config(cfg, cli)
366    }
367
368    fn from_config(cfg: Config, cli: &[CliAction]) -> Result<Self, LoadError> {
369        if cfg.schema_version != 1 {
370            return Err(LoadError::schema_mismatch(cfg.schema_version));
371        }
372
373        // Decode every TOML action's bytes first (preserves insertion order).
374        let mut actions: Vec<ResolvedAction> = Vec::with_capacity(cfg.actions.len());
375        for a in cfg.actions {
376            let bytes_decoded = decode_bytes_escape(&a.bytes).map_err(|e| LoadError {
377                message: format!("action '{}': {e}", a.label),
378                line: None,
379                col: None,
380                kind: LoadErrorKind::DecodeBytes,
381            })?;
382            actions.push(ResolvedAction {
383                label: a.label,
384                bytes_decoded,
385                group: a.group,
386                description: a.description,
387            });
388        }
389
390        // Merge CLI overrides. Dedupe-replace by label: last `--action` wins
391        // among CLI-only collisions; existing TOML labels are replaced in
392        // place and have their UI metadata (group/description) cleared.
393        for c in cli {
394            if let Some(existing) = actions.iter_mut().find(|x| x.label == c.label) {
395                existing.bytes_decoded.clone_from(&c.bytes);
396                existing.group = None;
397                existing.description = None;
398            } else {
399                actions.push(ResolvedAction {
400                    label: c.label.clone(),
401                    bytes_decoded: c.bytes.clone(),
402                    group: None,
403                    description: None,
404                });
405            }
406        }
407
408        // Post-merge label uniqueness check. This only trips when the source
409        // TOML itself declared duplicate labels; CLI overrides go through
410        // the dedupe-replace branch above and cannot land here.
411        let mut actions_by_label: HashMap<String, usize> = HashMap::new();
412        for (i, a) in actions.iter().enumerate() {
413            if actions_by_label.insert(a.label.clone(), i).is_some() {
414                return Err(LoadError::duplicate_action(&a.label));
415            }
416        }
417
418        // Scenario cross-validation: every referenced action label must
419        // resolve to an entry in the merged action map.
420        for s in &cfg.scenarios {
421            for refed in &s.actions {
422                if !actions_by_label.contains_key(refed) {
423                    return Err(LoadError::unknown_action_ref(&s.name, refed));
424                }
425            }
426        }
427
428        // Phase 4 RUN-04 / RUN-05: per-assertion load-time validation.
429        //
430        // (a) Compile-check every `kind = "regex"` pattern. The browser
431        //     engine (web/scenario.js) uses JS `RegExp` for runtime
432        //     matching; the Rust `regex` crate is the STRICTER engine of
433        //     the two (no backreferences, no lookaround), so anything that
434        //     compiles in Rust here will also compile as JS RegExp. Inverse:
435        //     a pattern that uses backref / lookaround is rejected here,
436        //     which is correct — those features are not portable across the
437        //     two engines and the supported subset is pinned to
438        //     Rust regex ∩ ECMAScript RegExp. See 04-RESEARCH Pitfall #1.
439        //
440        // (b) Resolve every `after` value. Legal values are the literal
441        //     `"any"` (universal-buffer evaluation per Pitfall #5) OR
442        //     one of the labels in THIS scenario's `actions` Vec. A typo
443        //     like `after = "rebot"` when `actions = ["reboot"]` would
444        //     silently never-match in the browser engine; reject here
445        //     instead so `bootroom check` surfaces it loudly.
446        for s in &cfg.scenarios {
447            for a in &s.assertions {
448                // (a) Regex compile-check.
449                if matches!(a.kind, AssertionKind::Regex) {
450                    regex::Regex::new(&a.pattern).map_err(|e| {
451                        LoadError::invalid_regex(&s.name, &a.after, &a.pattern, &e)
452                    })?;
453                }
454                // (b) `after` resolution.
455                if a.after != "any" && !s.actions.iter().any(|act| act == &a.after) {
456                    return Err(LoadError::unresolvable_after(
457                        &s.name,
458                        &a.after,
459                        &s.actions,
460                    ));
461                }
462            }
463        }
464
465        Ok(LoadedConfig {
466            actions,
467            scenarios: cfg.scenarios,
468            actions_by_label,
469        })
470    }
471
472    /// All merged actions in TOML insertion order (new CLI actions appended).
473    #[must_use]
474    pub fn actions(&self) -> &[ResolvedAction] {
475        &self.actions
476    }
477
478    #[must_use]
479    pub fn scenarios(&self) -> &[Scenario] {
480        &self.scenarios
481    }
482
483    #[must_use]
484    pub fn action_by_label(&self, label: &str) -> Option<&ResolvedAction> {
485        self.actions_by_label
486            .get(label)
487            .and_then(|&i| self.actions.get(i))
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    // Note: `bytes` uses a TOML literal string (single-quoted) so the
496    // backslash survives TOML's own escape pass — the byte-level escape
497    // decoding happens inside `decode_bytes_escape`.
498    const VALID_TOML: &str = r#"
499schema_version = 1
500
501[[action]]
502label = "reboot"
503bytes = 'reboot\r'
504group = "system"
505description = "Soft reboot via init"
506
507[[scenario]]
508name = "boot_smoke"
509actions = ["reboot"]
510timeout_ms = 10000
511
512  [[scenario.assert]]
513  kind = "contains"
514  pattern = "Booting"
515  after = "reboot"
516  timeout_ms = 2000
517"#;
518
519    // --- CFG-02: parse a valid TOML round-trip ---------------------------
520
521    #[test]
522    fn actions_roundtrip() {
523        let cfg = parse_str(VALID_TOML).expect("parse VALID_TOML");
524        assert_eq!(cfg.schema_version, 1);
525        assert_eq!(cfg.actions.len(), 1);
526        let a = &cfg.actions[0];
527        assert_eq!(a.label, "reboot");
528        assert_eq!(a.bytes, "reboot\\r", "raw TOML literal string (pre escape-decode)");
529        assert_eq!(a.group.as_deref(), Some("system"));
530        assert_eq!(a.description.as_deref(), Some("Soft reboot via init"));
531
532        let loaded = LoadedConfig::load_from_str(VALID_TOML).expect("load VALID_TOML");
533        let resolved = &loaded.actions()[0];
534        assert_eq!(resolved.label, "reboot");
535        assert_eq!(
536            resolved.bytes_decoded,
537            vec![b'r', b'e', b'b', b'o', b'o', b't', 0x0d]
538        );
539    }
540
541    // --- CFG-03: scenarios parse with assertions ------------------------
542
543    #[test]
544    fn scenarios_parse() {
545        let loaded = LoadedConfig::load_from_str(VALID_TOML).expect("load");
546        let scenarios = loaded.scenarios();
547        assert_eq!(scenarios.len(), 1);
548        let s = &scenarios[0];
549        assert_eq!(s.name, "boot_smoke");
550        assert_eq!(s.actions, vec!["reboot".to_string()]);
551        assert_eq!(s.timeout_ms, 10_000);
552        assert_eq!(s.assertions.len(), 1);
553        let a = &s.assertions[0];
554        assert_eq!(a.kind, AssertionKind::Contains);
555        assert_eq!(a.pattern, "Booting");
556        assert_eq!(a.after, "reboot");
557        assert_eq!(a.timeout_ms, 2_000);
558    }
559
560    // --- CFG-04: schema_version mismatch --------------------------------
561
562    #[test]
563    fn schema_version_rejected() {
564        for bad in [0u32, 2u32, 99u32] {
565            let s = format!("schema_version = {bad}\n");
566            let err = LoadedConfig::load_from_str(&s).expect_err("expected mismatch");
567            assert!(err.is_schema_version_mismatch(), "actual: {err:?}");
568            assert_eq!(err.actual_version(), Some(bad));
569        }
570        // Sanity: schema_version = 1 with no actions/scenarios parses fine.
571        LoadedConfig::load_from_str("schema_version = 1\n").expect("schema_version=1 ok");
572    }
573
574    // --- CFG-05: deny_unknown_fields with span --------------------------
575
576    #[test]
577    fn deny_unknown_fields_with_span() {
578        let bad = "schema_version = 1\n[[action]]\nlable = \"x\"\n";
579        let err = LoadedConfig::load_from_str(bad).expect_err("typo should fail");
580        assert!(
581            err.message.to_lowercase().contains("unknown field")
582                || err.message.contains("lable"),
583            "message did not mention unknown field: {}",
584            err.message
585        );
586        assert_eq!(err.line, Some(3), "line; full err: {err:?}");
587        assert_eq!(err.col, Some(1), "col; full err: {err:?}");
588    }
589
590    // --- CFG-06: scenario references unknown action --------------------
591
592    #[test]
593    fn scenario_unknown_action_ref() {
594        let s = r#"
595schema_version = 1
596
597[[action]]
598label = "reboot"
599bytes = "x"
600
601[[scenario]]
602name = "boot_smoke"
603actions = ["missing_one"]
604"#;
605        let err = LoadedConfig::load_from_str(s).expect_err("unknown ref should fail");
606        assert!(
607            err.message.contains("boot_smoke"),
608            "must name scenario; got: {}",
609            err.message
610        );
611        assert!(
612            err.message.contains("missing_one"),
613            "must name missing action; got: {}",
614            err.message
615        );
616    }
617
618    // --- ACT-03: CLI override merge semantics ---------------------------
619
620    #[test]
621    fn cli_override_replaces_existing_action_bytes() {
622        let cli = vec![CliAction {
623            label: "reboot".into(),
624            bytes: vec![0x03],
625        }];
626        let loaded =
627            LoadedConfig::load_from_str_with_overrides(VALID_TOML, &cli).expect("ok");
628        let actions = loaded.actions();
629        assert_eq!(actions.len(), 1);
630        let a = &actions[0];
631        assert_eq!(a.label, "reboot");
632        assert_eq!(a.bytes_decoded, vec![0x03]);
633        // CLI override clears UI metadata even when replacing existing.
634        assert!(a.group.is_none(), "group should be cleared on override");
635        assert!(
636            a.description.is_none(),
637            "description should be cleared on override"
638        );
639    }
640
641    #[test]
642    fn cli_override_appends_new_action() {
643        let cli = vec![CliAction {
644            label: "newone".into(),
645            bytes: vec![0x41],
646        }];
647        let loaded =
648            LoadedConfig::load_from_str_with_overrides(VALID_TOML, &cli).expect("ok");
649        let actions = loaded.actions();
650        assert_eq!(actions.len(), 2);
651        // Original first, new appended.
652        assert_eq!(actions[0].label, "reboot");
653        assert_eq!(actions[1].label, "newone");
654        assert_eq!(actions[1].bytes_decoded, vec![0x41]);
655        assert!(actions[1].group.is_none());
656        assert!(actions[1].description.is_none());
657    }
658
659    #[test]
660    fn last_cli_action_wins_for_same_label() {
661        // No "x" action in the TOML; two CLI overrides for "x".
662        let toml = "schema_version = 1\n";
663        let cli = vec![
664            CliAction {
665                label: "x".into(),
666                bytes: vec![1],
667            },
668            CliAction {
669                label: "x".into(),
670                bytes: vec![2],
671            },
672        ];
673        let loaded = LoadedConfig::load_from_str_with_overrides(toml, &cli).expect("ok");
674        let actions = loaded.actions();
675        assert_eq!(actions.len(), 1);
676        assert_eq!(actions[0].label, "x");
677        assert_eq!(
678            actions[0].bytes_decoded,
679            vec![2],
680            "last --action x= should win"
681        );
682    }
683
684    // --- CFG-09 prerequisite: insertion order preserved -----------------
685
686    #[test]
687    fn actions_insertion_order_preserved() {
688        let s = r#"
689schema_version = 1
690
691[[action]]
692label = "alpha"
693bytes = "a"
694
695[[action]]
696label = "beta"
697bytes = "b"
698
699[[action]]
700label = "gamma"
701bytes = "c"
702"#;
703        let loaded = LoadedConfig::load_from_str(s).expect("ok");
704        let labels: Vec<&str> = loaded.actions().iter().map(|a| a.label.as_str()).collect();
705        assert_eq!(labels, vec!["alpha", "beta", "gamma"]);
706    }
707
708    // --- offset_to_line_col: ASCII + Unicode ----------------------------
709
710    #[test]
711    fn offset_to_line_col_basic() {
712        assert_eq!(offset_to_line_col("a\nb\nc", 4), Some((3, 1)));
713        assert_eq!(offset_to_line_col("", 0), Some((1, 1)));
714        assert_eq!(offset_to_line_col("abc", 100), None);
715    }
716
717    #[test]
718    fn offset_to_line_col_handles_unicode_columns() {
719        // "aé\nx" — 'é' is 2 bytes (0xc3 0xa9). 'x' starts at byte 4.
720        // That's line 2, col 1. With prefix.len() the col would be wrong.
721        let s = "aé\nx";
722        assert_eq!(offset_to_line_col(s, 4), Some((2, 1)));
723    }
724
725    // --- Source-TOML duplicate labels rejected --------------------------
726
727    // --- Phase 4 RUN-04: regex compile-check at load -------------------
728
729    #[test]
730    fn regex_assertion_valid_pattern_loads_ok() {
731        let s = r#"
732schema_version = 1
733
734[[action]]
735label = "reboot"
736bytes = "x"
737
738[[scenario]]
739name = "boot_smoke"
740actions = ["reboot"]
741
742  [[scenario.assert]]
743  kind = "regex"
744  pattern = 'Booting[a-z]+'
745  after = "reboot"
746"#;
747        LoadedConfig::load_from_str(s).expect("valid regex must load");
748    }
749
750    #[test]
751    fn regex_assertion_invalid_pattern_rejected() {
752        let s = r#"
753schema_version = 1
754
755[[action]]
756label = "reboot"
757bytes = "x"
758
759[[scenario]]
760name = "boot_smoke"
761actions = ["reboot"]
762
763  [[scenario.assert]]
764  kind = "regex"
765  pattern = 'unclosed['
766  after = "reboot"
767"#;
768        let err = LoadedConfig::load_from_str(s).expect_err("unclosed [ must fail");
769        assert!(err.is_invalid_regex(), "is_invalid_regex; full: {err:?}");
770        assert!(
771            err.message.contains("boot_smoke"),
772            "must name scenario; got: {}",
773            err.message
774        );
775        assert!(
776            err.message.contains("reboot"),
777            "must name after-label; got: {}",
778            err.message
779        );
780        assert!(
781            err.message.contains("unclosed["),
782            "must include offending pattern; got: {}",
783            err.message
784        );
785    }
786
787    #[test]
788    fn regex_assertion_backref_rejected() {
789        // Backreferences are JS-only; Rust `regex` rejects them. This is
790        // exactly the intersection-subset policy (Pitfall #1 in 04-RESEARCH).
791        let s = r#"
792schema_version = 1
793
794[[action]]
795label = "reboot"
796bytes = "x"
797
798[[scenario]]
799name = "boot_smoke"
800actions = ["reboot"]
801
802  [[scenario.assert]]
803  kind = "regex"
804  pattern = '(a)\1'
805  after = "reboot"
806"#;
807        let err = LoadedConfig::load_from_str(s).expect_err("backref must fail");
808        assert!(err.is_invalid_regex(), "is_invalid_regex; full: {err:?}");
809    }
810
811    #[test]
812    fn regex_assertion_lookaround_rejected() {
813        // Lookaround is JS-only; Rust `regex` rejects it.
814        let s = r#"
815schema_version = 1
816
817[[action]]
818label = "reboot"
819bytes = "x"
820
821[[scenario]]
822name = "boot_smoke"
823actions = ["reboot"]
824
825  [[scenario.assert]]
826  kind = "regex"
827  pattern = '(?=foo)'
828  after = "reboot"
829"#;
830        let err = LoadedConfig::load_from_str(s).expect_err("lookaround must fail");
831        assert!(err.is_invalid_regex(), "is_invalid_regex; full: {err:?}");
832    }
833
834    #[test]
835    fn contains_assertion_with_bracket_loads_ok() {
836        // `kind = "contains"` is substring; the pattern is a literal,
837        // not a regex. An unclosed `[` is therefore fine.
838        let s = r#"
839schema_version = 1
840
841[[action]]
842label = "reboot"
843bytes = "x"
844
845[[scenario]]
846name = "boot_smoke"
847actions = ["reboot"]
848
849  [[scenario.assert]]
850  kind = "contains"
851  pattern = 'unclosed['
852  after = "reboot"
853"#;
854        LoadedConfig::load_from_str(s).expect("contains assertion must load");
855    }
856
857    // --- Phase 4 RUN-05: `after`-resolution check at load --------------
858
859    #[test]
860    fn assertion_after_resolves_to_scenario_action_loads_ok() {
861        let s = r#"
862schema_version = 1
863
864[[action]]
865label = "reboot"
866bytes = "x"
867
868[[scenario]]
869name = "boot_smoke"
870actions = ["reboot"]
871
872  [[scenario.assert]]
873  kind = "contains"
874  pattern = "login: "
875  after = "reboot"
876"#;
877        LoadedConfig::load_from_str(s).expect("after=reboot is in actions, must load");
878    }
879
880    #[test]
881    fn assertion_after_any_loads_ok() {
882        let s = r#"
883schema_version = 1
884
885[[action]]
886label = "reboot"
887bytes = "x"
888
889[[scenario]]
890name = "boot_smoke"
891actions = ["reboot"]
892
893  [[scenario.assert]]
894  kind = "contains"
895  pattern = "login: "
896  after = "any"
897"#;
898        LoadedConfig::load_from_str(s).expect("after=any is always legal");
899    }
900
901    #[test]
902    fn assertion_after_typo_rejected() {
903        let s = r#"
904schema_version = 1
905
906[[action]]
907label = "reboot"
908bytes = "x"
909
910[[scenario]]
911name = "boot_smoke"
912actions = ["reboot"]
913
914  [[scenario.assert]]
915  kind = "contains"
916  pattern = "login: "
917  after = "rebot"
918"#;
919        let err = LoadedConfig::load_from_str(s).expect_err("typo must fail");
920        assert!(
921            err.is_unresolvable_after(),
922            "is_unresolvable_after; full: {err:?}"
923        );
924        assert!(
925            err.message.contains("boot_smoke"),
926            "must name scenario; got: {}",
927            err.message
928        );
929        assert!(
930            err.message.contains("rebot"),
931            "must surface offending after value; got: {}",
932            err.message
933        );
934        assert!(
935            err.message.contains("reboot"),
936            "must list legal action label; got: {}",
937            err.message
938        );
939        assert!(
940            err.message.contains("any"),
941            "must mention 'any' as a universal-legal value; got: {}",
942            err.message
943        );
944    }
945
946    #[test]
947    fn assertion_after_references_action_not_in_scenario_rejected() {
948        // "ls" is a top-level action label but is NOT in `boot_smoke`'s
949        // `actions` Vec — so the assertion `after = "ls"` is unresolvable
950        // inside this scenario even though `ls` is otherwise a known label.
951        let s = r#"
952schema_version = 1
953
954[[action]]
955label = "reboot"
956bytes = "x"
957
958[[action]]
959label = "ls"
960bytes = "y"
961
962[[scenario]]
963name = "boot_smoke"
964actions = ["reboot"]
965
966  [[scenario.assert]]
967  kind = "contains"
968  pattern = "login: "
969  after = "ls"
970"#;
971        let err = LoadedConfig::load_from_str(s)
972            .expect_err("after=ls must fail; ls is not in this scenario");
973        assert!(
974            err.is_unresolvable_after(),
975            "is_unresolvable_after; full: {err:?}"
976        );
977        assert!(
978            err.message.contains("boot_smoke"),
979            "must name scenario; got: {}",
980            err.message
981        );
982        assert!(
983            err.message.contains("ls"),
984            "must surface offending after value; got: {}",
985            err.message
986        );
987    }
988
989    #[test]
990    fn duplicate_toml_action_labels_rejected() {
991        let s = r#"
992schema_version = 1
993
994[[action]]
995label = "dup"
996bytes = "a"
997
998[[action]]
999label = "dup"
1000bytes = "b"
1001"#;
1002        let err =
1003            LoadedConfig::load_from_str(s).expect_err("duplicate labels must fail");
1004        assert!(
1005            err.message.contains("dup"),
1006            "must name the duplicate label; got: {}",
1007            err.message
1008        );
1009    }
1010}