1use std::collections::HashMap;
36use std::fmt;
37
38use serde::{Deserialize, Serialize};
39
40use crate::escape::decode_bytes_escape;
41
42#[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#[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#[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: ®ex::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 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 #[must_use]
199 pub fn is_schema_version_mismatch(&self) -> bool {
200 matches!(self.kind, LoadErrorKind::SchemaMismatch { .. })
201 }
202
203 #[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 #[must_use]
216 pub fn is_invalid_regex(&self) -> bool {
217 matches!(self.kind, LoadErrorKind::InvalidRegex)
218 }
219
220 #[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
240pub 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#[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 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
319pub struct CliAction {
320 pub label: String,
321 pub bytes: Vec<u8>,
322}
323
324#[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 pub fn load_from_str(s: &str) -> Result<Self, LoadError> {
340 Self::load_from_str_with_overrides(s, &[])
341 }
342
343 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 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 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 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 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 for s in &cfg.scenarios {
447 for a in &s.assertions {
448 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 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 #[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 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 #[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 #[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 #[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 LoadedConfig::load_from_str("schema_version = 1\n").expect("schema_version=1 ok");
572 }
573
574 #[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 #[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 #[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 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 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 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 #[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 #[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 let s = "aé\nx";
722 assert_eq!(offset_to_line_col(s, 4), Some((2, 1)));
723 }
724
725 #[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 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 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 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 #[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 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}