1pub mod audit;
2pub mod condition;
3pub mod dispatch;
4pub mod engine;
5pub mod metrics;
6pub mod types;
7
8pub use audit::SopAuditLogger;
9pub use engine::SopEngine;
10pub use metrics::SopMetricsCollector;
11#[allow(unused_imports)]
12pub use types::{
13 DeterministicRunState, DeterministicSavings, Sop, SopEvent, SopExecutionMode, SopPriority,
14 SopRun, SopRunAction, SopRunStatus, SopStep, SopStepKind, SopStepResult, SopStepStatus,
15 SopTrigger, SopTriggerSource, StepSchema,
16};
17
18use anyhow::Result;
19use std::path::{Path, PathBuf};
20use tracing::warn;
21
22use types::{SopManifest, SopMeta};
23
24pub fn parse_execution_mode(s: &str) -> SopExecutionMode {
27 match s.trim().to_lowercase().as_str() {
28 "auto" => SopExecutionMode::Auto,
29 "step_by_step" => SopExecutionMode::StepByStep,
30 "priority_based" => SopExecutionMode::PriorityBased,
31 "deterministic" => SopExecutionMode::Deterministic,
32 _ => SopExecutionMode::Supervised,
34 }
35}
36
37fn sops_dir(workspace_dir: &Path) -> PathBuf {
41 workspace_dir.join("sops")
42}
43
44pub fn resolve_sops_dir(workspace_dir: &Path, config_dir: Option<&str>) -> PathBuf {
46 match config_dir {
47 Some(dir) if !dir.is_empty() => {
48 let expanded = shellexpand::tilde(dir);
49 PathBuf::from(expanded.as_ref())
50 }
51 _ => sops_dir(workspace_dir),
52 }
53}
54
55pub fn load_sops(
59 workspace_dir: &Path,
60 config_dir: Option<&str>,
61 default_execution_mode: SopExecutionMode,
62) -> Vec<Sop> {
63 let dir = resolve_sops_dir(workspace_dir, config_dir);
64 load_sops_from_directory(&dir, default_execution_mode)
65}
66
67fn load_sops_from_directory(sops_dir: &Path, default_execution_mode: SopExecutionMode) -> Vec<Sop> {
70 if !sops_dir.exists() {
71 return Vec::new();
72 }
73
74 let mut sops = Vec::new();
75
76 let Ok(entries) = std::fs::read_dir(sops_dir) else {
77 return sops;
78 };
79
80 for entry in entries.flatten() {
81 let path = entry.path();
82 if !path.is_dir() {
83 continue;
84 }
85
86 let toml_path = path.join("SOP.toml");
87 if !toml_path.exists() {
88 continue;
89 }
90
91 match load_sop(&path, default_execution_mode) {
92 Ok(sop) => sops.push(sop),
93 Err(e) => {
94 warn!("Failed to load SOP from {}: {e}", path.display());
95 }
96 }
97 }
98
99 sops.sort_by(|a, b| a.name.cmp(&b.name));
100 sops
101}
102
103fn load_sop(sop_dir: &Path, default_execution_mode: SopExecutionMode) -> Result<Sop> {
105 let toml_path = sop_dir.join("SOP.toml");
106 let toml_content = std::fs::read_to_string(&toml_path)?;
107 let manifest: SopManifest = toml::from_str(&toml_content)?;
108
109 let md_path = sop_dir.join("SOP.md");
110 let steps = if md_path.exists() {
111 let md_content = std::fs::read_to_string(&md_path)?;
112 parse_steps(&md_content)
113 } else {
114 Vec::new()
115 };
116
117 let SopMeta {
118 name,
119 description,
120 version,
121 priority,
122 execution_mode,
123 cooldown_secs,
124 max_concurrent,
125 deterministic,
126 } = manifest.sop;
127
128 let effective_mode = if deterministic {
130 SopExecutionMode::Deterministic
131 } else {
132 execution_mode.unwrap_or(default_execution_mode)
133 };
134
135 Ok(Sop {
136 name,
137 description,
138 version,
139 priority,
140 execution_mode: effective_mode,
141 triggers: manifest.triggers,
142 steps,
143 cooldown_secs,
144 max_concurrent,
145 location: Some(sop_dir.to_path_buf()),
146 deterministic,
147 })
148}
149
150pub fn parse_steps(md: &str) -> Vec<SopStep> {
158 let mut steps = Vec::new();
159 let mut in_steps_section = false;
160 let mut current_number: Option<u32> = None;
161 let mut current_title = String::new();
162 let mut current_body = String::new();
163 let mut current_tools: Vec<String> = Vec::new();
164 let mut current_requires_confirmation = false;
165 let mut current_kind = SopStepKind::Execute;
166
167 for line in md.lines() {
168 let trimmed = line.trim();
169
170 if trimmed.starts_with("## ") {
172 if trimmed.eq_ignore_ascii_case("## steps") || trimmed.eq_ignore_ascii_case("## Steps")
173 {
174 in_steps_section = true;
175 continue;
176 }
177 if in_steps_section {
179 flush_step(
181 &mut steps,
182 &mut current_number,
183 &mut current_title,
184 &mut current_body,
185 &mut current_tools,
186 &mut current_requires_confirmation,
187 &mut current_kind,
188 );
189 in_steps_section = false;
190 }
191 continue;
192 }
193
194 if !in_steps_section {
195 continue;
196 }
197
198 if let Some(rest) = parse_numbered_item(trimmed) {
200 flush_step(
202 &mut steps,
203 &mut current_number,
204 &mut current_title,
205 &mut current_body,
206 &mut current_tools,
207 &mut current_requires_confirmation,
208 &mut current_kind,
209 );
210
211 let step_num = u32::try_from(steps.len())
212 .unwrap_or(u32::MAX)
213 .saturating_add(1);
214 current_number = Some(step_num);
215
216 if let Some((title, body)) = extract_bold_title(rest) {
218 current_title = title;
219 current_body = body;
220 } else {
221 current_title = rest.to_string();
222 current_body = String::new();
223 }
224 current_tools = Vec::new();
225 current_requires_confirmation = false;
226 continue;
227 }
228
229 if current_number.is_some() && trimmed.starts_with("- ") {
231 let bullet = trimmed.trim_start_matches("- ").trim();
232 if let Some(tools_str) = bullet.strip_prefix("tools:") {
233 current_tools = tools_str
234 .split(',')
235 .map(|t| t.trim().to_string())
236 .filter(|t| !t.is_empty())
237 .collect();
238 } else if bullet.starts_with("requires_confirmation:") {
239 if let Some(val) = bullet.strip_prefix("requires_confirmation:") {
240 current_requires_confirmation = val.trim().eq_ignore_ascii_case("true");
241 }
242 } else if bullet.starts_with("kind:") {
243 if let Some(val) = bullet.strip_prefix("kind:") {
244 let val = val.trim();
245 if val.eq_ignore_ascii_case("checkpoint") {
246 current_kind = SopStepKind::Checkpoint;
247 } else {
248 current_kind = SopStepKind::Execute;
249 }
250 }
251 } else {
252 if !current_body.is_empty() {
254 current_body.push('\n');
255 }
256 current_body.push_str(trimmed);
257 }
258 continue;
259 }
260
261 if current_number.is_some() && !trimmed.is_empty() {
263 if !current_body.is_empty() {
264 current_body.push('\n');
265 }
266 current_body.push_str(trimmed);
267 }
268 }
269
270 flush_step(
272 &mut steps,
273 &mut current_number,
274 &mut current_title,
275 &mut current_body,
276 &mut current_tools,
277 &mut current_requires_confirmation,
278 &mut current_kind,
279 );
280
281 steps
282}
283
284fn flush_step(
286 steps: &mut Vec<SopStep>,
287 number: &mut Option<u32>,
288 title: &mut String,
289 body: &mut String,
290 tools: &mut Vec<String>,
291 requires_confirmation: &mut bool,
292 kind: &mut SopStepKind,
293) {
294 if let Some(n) = number.take() {
295 steps.push(SopStep {
296 number: n,
297 title: std::mem::take(title),
298 body: body.trim().to_string(),
299 suggested_tools: std::mem::take(tools),
300 requires_confirmation: *requires_confirmation,
301 kind: *kind,
302 schema: None,
303 });
304 *body = String::new();
305 *requires_confirmation = false;
306 *kind = SopStepKind::Execute;
307 }
308}
309
310fn parse_numbered_item(line: &str) -> Option<&str> {
312 let dot_pos = line.find(". ")?;
313 let prefix = &line[..dot_pos];
314 if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
315 Some(line[dot_pos + 2..].trim())
316 } else {
317 None
318 }
319}
320
321fn extract_bold_title(text: &str) -> Option<(String, String)> {
323 let start = text.find("**")?;
324 let after_start = start + 2;
325 let end = text[after_start..].find("**")?;
326 let title = text[after_start..after_start + end].to_string();
327
328 let rest_start = after_start + end + 2;
330 let rest = text[rest_start..].trim();
331 let rest = rest
332 .strip_prefix("—")
333 .or_else(|| rest.strip_prefix("–"))
334 .or_else(|| rest.strip_prefix("-"))
335 .unwrap_or(rest)
336 .trim();
337
338 Some((title, rest.to_string()))
339}
340
341pub fn validate_sop(sop: &Sop) -> Vec<String> {
345 let mut warnings = Vec::new();
346
347 if sop.name.is_empty() {
348 warnings.push("SOP name is empty".into());
349 }
350 if sop.description.is_empty() {
351 warnings.push("SOP description is empty".into());
352 }
353 if sop.triggers.is_empty() {
354 warnings.push("SOP has no triggers defined".into());
355 }
356 if sop.steps.is_empty() {
357 warnings.push("SOP has no steps (missing or empty SOP.md)".into());
358 }
359
360 for (i, step) in sop.steps.iter().enumerate() {
362 let expected = u32::try_from(i).unwrap_or(u32::MAX).saturating_add(1);
363 if step.number != expected {
364 warnings.push(format!(
365 "Step numbering gap: expected {expected}, got {}",
366 step.number
367 ));
368 }
369 if step.title.is_empty() {
370 warnings.push(format!("Step {} has an empty title", step.number));
371 }
372 }
373
374 warnings
375}
376
377pub fn handle_command(command: crate::SopCommands, config: &crate::config::Config) -> Result<()> {
381 let sops_dir_override = config.sop.sops_dir.as_deref();
382
383 match command {
384 crate::SopCommands::List => {
385 let sops = load_sops(
386 &config.workspace_dir,
387 sops_dir_override,
388 parse_execution_mode(&config.sop.default_execution_mode),
389 );
390 if sops.is_empty() {
391 println!("No SOPs found.");
392 println!();
393 println!(" Create one: mkdir -p ~/.construct/workspace/sops/my-sop");
394 println!(" # Add SOP.toml and SOP.md");
395 println!();
396 println!(
397 " SOPs directory: {}",
398 resolve_sops_dir(&config.workspace_dir, sops_dir_override).display()
399 );
400 } else {
401 println!("SOPs ({}):", sops.len());
402 println!();
403 for sop in &sops {
404 let triggers: Vec<String> =
405 sop.triggers.iter().map(ToString::to_string).collect();
406 println!(
407 " {} {} [{}] — {}",
408 console::style(&sop.name).white().bold(),
409 console::style(format!("v{}", sop.version)).dim(),
410 console::style(&sop.priority).cyan(),
411 sop.description
412 );
413 println!(
414 " Mode: {} Steps: {} Triggers: {}",
415 sop.execution_mode,
416 sop.steps.len(),
417 triggers.join(", ")
418 );
419 if sop.cooldown_secs > 0 {
420 println!(" Cooldown: {}s", sop.cooldown_secs);
421 }
422 }
423 }
424 println!();
425 Ok(())
426 }
427
428 crate::SopCommands::Validate { name } => {
429 let sops = load_sops(
430 &config.workspace_dir,
431 sops_dir_override,
432 parse_execution_mode(&config.sop.default_execution_mode),
433 );
434 let matching: Vec<&Sop> = if let Some(ref name) = name {
435 sops.iter().filter(|s| s.name == *name).collect()
436 } else {
437 sops.iter().collect()
438 };
439
440 if matching.is_empty() {
441 if let Some(name) = name {
442 anyhow::bail!("SOP not found: {name}");
443 }
444 println!("No SOPs to validate.");
445 return Ok(());
446 }
447
448 let mut any_warnings = false;
449 for sop in &matching {
450 let warnings = validate_sop(sop);
451 if warnings.is_empty() {
452 println!(
453 " {} {} — valid",
454 console::style("✓").green().bold(),
455 sop.name
456 );
457 } else {
458 any_warnings = true;
459 println!(
460 " {} {} — {} warning(s):",
461 console::style("!").yellow().bold(),
462 sop.name,
463 warnings.len()
464 );
465 for w in &warnings {
466 println!(" {w}");
467 }
468 }
469 }
470 println!();
471
472 if any_warnings {
473 anyhow::bail!("Validation completed with warnings");
474 }
475 Ok(())
476 }
477
478 crate::SopCommands::Show { name } => {
479 let sops = load_sops(
480 &config.workspace_dir,
481 sops_dir_override,
482 parse_execution_mode(&config.sop.default_execution_mode),
483 );
484 let sop = sops
485 .iter()
486 .find(|s| s.name == name)
487 .ok_or_else(|| anyhow::anyhow!("SOP not found: {name}"))?;
488
489 println!(
490 "{} v{}",
491 console::style(&sop.name).white().bold(),
492 sop.version
493 );
494 println!("{}", sop.description);
495 println!();
496 println!("Priority: {}", sop.priority);
497 println!("Execution mode: {}", sop.execution_mode);
498 println!("Cooldown: {}s", sop.cooldown_secs);
499 println!("Max concurrent: {}", sop.max_concurrent);
500 println!();
501
502 if !sop.triggers.is_empty() {
503 println!("Triggers:");
504 for trigger in &sop.triggers {
505 println!(" - {trigger}");
506 }
507 println!();
508 }
509
510 if !sop.steps.is_empty() {
511 println!("Steps:");
512 for step in &sop.steps {
513 let mut tags = Vec::new();
514 if step.requires_confirmation {
515 tags.push("requires confirmation");
516 }
517 if step.kind == SopStepKind::Checkpoint {
518 tags.push("checkpoint");
519 }
520 let tag_str = if tags.is_empty() {
521 String::new()
522 } else {
523 format!(" [{}]", tags.join(", "))
524 };
525 println!(
526 " {}. {}{}",
527 step.number,
528 console::style(&step.title).bold(),
529 tag_str
530 );
531 if !step.body.is_empty() {
532 for line in step.body.lines() {
533 println!(" {line}");
534 }
535 }
536 if !step.suggested_tools.is_empty() {
537 println!(" Tools: {}", step.suggested_tools.join(", "));
538 }
539 }
540 }
541 println!();
542 Ok(())
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use std::fs;
551
552 #[test]
553 fn parse_steps_basic() {
554 let md = r#"# Test SOP
555
556## Conditions
557Some conditions here.
558
559## Steps
560
5611. **Check readings** — Read sensor data and confirm.
562 - tools: gpio_read, memory_store
563
5642. **Close valve** — Set GPIO pin 5 LOW.
565 - tools: gpio_write, gpio_read
566 - requires_confirmation: true
567
5683. **Notify operator** — Send alert.
569 - tools: pushover
570"#;
571
572 let steps = parse_steps(md);
573 assert_eq!(steps.len(), 3);
574
575 assert_eq!(steps[0].number, 1);
576 assert_eq!(steps[0].title, "Check readings");
577 assert!(steps[0].body.contains("Read sensor data"));
578 assert_eq!(steps[0].suggested_tools, vec!["gpio_read", "memory_store"]);
579 assert!(!steps[0].requires_confirmation);
580
581 assert_eq!(steps[1].number, 2);
582 assert_eq!(steps[1].title, "Close valve");
583 assert!(steps[1].requires_confirmation);
584 assert_eq!(steps[1].suggested_tools, vec!["gpio_write", "gpio_read"]);
585
586 assert_eq!(steps[2].number, 3);
587 assert_eq!(steps[2].title, "Notify operator");
588 }
589
590 #[test]
591 fn parse_steps_empty_md() {
592 let steps = parse_steps("# Nothing here\n\nNo steps section.");
593 assert!(steps.is_empty());
594 }
595
596 #[test]
597 fn parse_steps_no_bold_title() {
598 let md = "## Steps\n\n1. Just a plain step without bold.\n";
599 let steps = parse_steps(md);
600 assert_eq!(steps.len(), 1);
601 assert_eq!(steps[0].title, "Just a plain step without bold.");
602 }
603
604 #[test]
605 fn parse_steps_multiline_body() {
606 let md = r#"## Steps
607
6081. **Do thing** — First line of body.
609 Second line of body.
610 Third line of body.
611 - tools: shell
612"#;
613 let steps = parse_steps(md);
614 assert_eq!(steps.len(), 1);
615 assert!(steps[0].body.contains("First line"));
616 assert!(steps[0].body.contains("Second line"));
617 assert!(steps[0].body.contains("Third line"));
618 }
619
620 #[test]
621 fn load_sop_from_directory() {
622 let dir = tempfile::tempdir().unwrap();
623 let sop_dir = dir.path().join("test-sop");
624 fs::create_dir_all(&sop_dir).unwrap();
625
626 fs::write(
627 sop_dir.join("SOP.toml"),
628 r#"
629[sop]
630name = "test-sop"
631description = "A test SOP"
632version = "1.0.0"
633priority = "high"
634execution_mode = "auto"
635cooldown_secs = 60
636
637[[triggers]]
638type = "manual"
639
640[[triggers]]
641type = "webhook"
642path = "/sop/test"
643"#,
644 )
645 .unwrap();
646
647 fs::write(
648 sop_dir.join("SOP.md"),
649 r#"# Test SOP
650
651## Steps
652
6531. **Step one** — Do something.
654 - tools: shell
655
6562. **Step two** — Do something else.
657 - requires_confirmation: true
658"#,
659 )
660 .unwrap();
661
662 let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Supervised);
663 assert_eq!(sops.len(), 1);
664
665 let sop = &sops[0];
666 assert_eq!(sop.name, "test-sop");
667 assert_eq!(sop.priority, SopPriority::High);
668 assert_eq!(sop.execution_mode, SopExecutionMode::Auto);
669 assert_eq!(sop.cooldown_secs, 60);
670 assert_eq!(sop.triggers.len(), 2);
671 assert_eq!(sop.steps.len(), 2);
672 assert!(sop.steps[1].requires_confirmation);
673 assert!(sop.location.is_some());
674 }
675
676 #[test]
677 fn load_sops_empty_dir() {
678 let dir = tempfile::tempdir().unwrap();
679 let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Supervised);
680 assert!(sops.is_empty());
681 }
682
683 #[test]
684 fn load_sops_nonexistent_dir() {
685 let sops =
686 load_sops_from_directory(Path::new("/nonexistent/path"), SopExecutionMode::Supervised);
687 assert!(sops.is_empty());
688 }
689
690 #[test]
691 fn load_sop_toml_only_no_md() {
692 let dir = tempfile::tempdir().unwrap();
693 let sop_dir = dir.path().join("no-steps");
694 fs::create_dir_all(&sop_dir).unwrap();
695
696 fs::write(
697 sop_dir.join("SOP.toml"),
698 r#"
699[sop]
700name = "no-steps"
701description = "SOP without steps"
702
703[[triggers]]
704type = "manual"
705"#,
706 )
707 .unwrap();
708
709 let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Supervised);
710 assert_eq!(sops.len(), 1);
711 assert!(sops[0].steps.is_empty());
712 }
713
714 #[test]
715 fn load_sop_uses_config_default_execution_mode_when_omitted() {
716 let dir = tempfile::tempdir().unwrap();
717 let sop_dir = dir.path().join("default-mode");
718 fs::create_dir_all(&sop_dir).unwrap();
719
720 fs::write(
721 sop_dir.join("SOP.toml"),
722 r#"
723[sop]
724name = "default-mode"
725description = "SOP without explicit execution mode"
726
727[[triggers]]
728type = "manual"
729"#,
730 )
731 .unwrap();
732
733 let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Auto);
734 assert_eq!(sops.len(), 1);
735 assert_eq!(sops[0].execution_mode, SopExecutionMode::Auto);
736 }
737
738 #[test]
739 fn validate_sop_warnings() {
740 let sop = Sop {
741 name: String::new(),
742 description: String::new(),
743 version: "1.0.0".into(),
744 priority: SopPriority::Normal,
745 execution_mode: SopExecutionMode::Supervised,
746 triggers: Vec::new(),
747 steps: Vec::new(),
748 cooldown_secs: 0,
749 max_concurrent: 1,
750 location: None,
751 deterministic: false,
752 };
753
754 let warnings = validate_sop(&sop);
755 assert!(warnings.iter().any(|w| w.contains("name is empty")));
756 assert!(warnings.iter().any(|w| w.contains("description is empty")));
757 assert!(warnings.iter().any(|w| w.contains("no triggers")));
758 assert!(warnings.iter().any(|w| w.contains("no steps")));
759 }
760
761 #[test]
762 fn validate_sop_clean() {
763 let sop = Sop {
764 name: "valid-sop".into(),
765 description: "A valid SOP".into(),
766 version: "1.0.0".into(),
767 priority: SopPriority::High,
768 execution_mode: SopExecutionMode::Auto,
769 triggers: vec![SopTrigger::Manual],
770 steps: vec![SopStep {
771 number: 1,
772 title: "Do thing".into(),
773 body: "Do the thing".into(),
774 suggested_tools: vec!["shell".into()],
775 requires_confirmation: false,
776 kind: SopStepKind::default(),
777 schema: None,
778 }],
779 cooldown_secs: 0,
780 max_concurrent: 1,
781 location: None,
782 deterministic: false,
783 };
784
785 let warnings = validate_sop(&sop);
786 assert!(warnings.is_empty());
787 }
788
789 #[test]
790 fn resolve_sops_dir_default() {
791 let ws = Path::new("/home/user/.construct/workspace");
792 let dir = resolve_sops_dir(ws, None);
793 assert_eq!(dir, ws.join("sops"));
794 }
795
796 #[test]
797 fn resolve_sops_dir_override() {
798 let ws = Path::new("/home/user/.construct/workspace");
799 let dir = resolve_sops_dir(ws, Some("/custom/sops"));
800 assert_eq!(dir, PathBuf::from("/custom/sops"));
801 }
802
803 #[test]
804 fn extract_bold_title_with_dash() {
805 let (title, body) = extract_bold_title("**Close valve** — Set GPIO pin LOW.").unwrap();
806 assert_eq!(title, "Close valve");
807 assert_eq!(body, "Set GPIO pin LOW.");
808 }
809
810 #[test]
811 fn extract_bold_title_no_separator() {
812 let (title, body) = extract_bold_title("**Close valve** Set pin LOW.").unwrap();
813 assert_eq!(title, "Close valve");
814 assert_eq!(body, "Set pin LOW.");
815 }
816
817 #[test]
818 fn extract_bold_title_none() {
819 assert!(extract_bold_title("No bold here").is_none());
820 }
821
822 #[test]
823 fn parse_all_trigger_types() {
824 let toml_str = r#"
825[sop]
826name = "multi-trigger"
827description = "SOP with all trigger types"
828
829[[triggers]]
830type = "mqtt"
831topic = "sensors/temp"
832condition = "$.value > 90"
833
834[[triggers]]
835type = "webhook"
836path = "/sop/test"
837
838[[triggers]]
839type = "cron"
840expression = "0 */5 * * *"
841
842[[triggers]]
843type = "peripheral"
844board = "nucleo-f401re-0"
845signal = "pin_3"
846condition = "> 0"
847
848[[triggers]]
849type = "manual"
850"#;
851 let manifest: SopManifest = toml::from_str(toml_str).unwrap();
852 assert_eq!(manifest.triggers.len(), 5);
853
854 assert!(matches!(manifest.triggers[0], SopTrigger::Mqtt { .. }));
855 assert!(matches!(manifest.triggers[1], SopTrigger::Webhook { .. }));
856 assert!(matches!(manifest.triggers[2], SopTrigger::Cron { .. }));
857 assert!(matches!(
858 manifest.triggers[3],
859 SopTrigger::Peripheral { .. }
860 ));
861 assert!(matches!(manifest.triggers[4], SopTrigger::Manual));
862 }
863
864 #[test]
865 fn deterministic_flag_overrides_execution_mode() {
866 let dir = tempfile::tempdir().unwrap();
867 let sop_dir = dir.path().join("det-sop");
868 fs::create_dir_all(&sop_dir).unwrap();
869
870 fs::write(
871 sop_dir.join("SOP.toml"),
872 r#"
873[sop]
874name = "det-sop"
875description = "A deterministic SOP"
876deterministic = true
877
878[[triggers]]
879type = "manual"
880"#,
881 )
882 .unwrap();
883
884 fs::write(
885 sop_dir.join("SOP.md"),
886 r#"# Det SOP
887
888## Steps
889
8901. **Step one** — First step.
891 - kind: execute
892
8932. **Checkpoint** — Pause for approval.
894 - kind: checkpoint
895
8963. **Step three** — Final step.
897"#,
898 )
899 .unwrap();
900
901 let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Supervised);
902 assert_eq!(sops.len(), 1);
903
904 let sop = &sops[0];
905 assert_eq!(sop.name, "det-sop");
906 assert_eq!(sop.execution_mode, SopExecutionMode::Deterministic);
907 assert!(sop.deterministic);
908 assert_eq!(sop.steps.len(), 3);
909 assert_eq!(sop.steps[0].kind, SopStepKind::Execute);
910 assert_eq!(sop.steps[1].kind, SopStepKind::Checkpoint);
911 assert_eq!(sop.steps[2].kind, SopStepKind::Execute);
912 }
913
914 #[test]
915 fn parse_steps_with_checkpoint_kind() {
916 let md = r#"## Steps
917
9181. **Read data** — Read from sensor.
919 - tools: gpio_read
920 - kind: execute
921
9222. **Review** — Human review checkpoint.
923 - kind: checkpoint
924
9253. **Apply** — Apply changes.
926"#;
927 let steps = parse_steps(md);
928 assert_eq!(steps.len(), 3);
929 assert_eq!(steps[0].kind, SopStepKind::Execute);
930 assert_eq!(steps[1].kind, SopStepKind::Checkpoint);
931 assert_eq!(steps[2].kind, SopStepKind::Execute);
933 }
934}