Skip to main content

construct/sop/
mod.rs

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
24/// Parse an execution mode string into `SopExecutionMode`, falling back to
25/// `Supervised` for unknown values.
26pub 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        // "supervised" and any unknown value
33        _ => SopExecutionMode::Supervised,
34    }
35}
36
37// ── SOP directory helpers ───────────────────────────────────────
38
39/// Return the default SOPs directory: `<workspace>/sops`.
40fn sops_dir(workspace_dir: &Path) -> PathBuf {
41    workspace_dir.join("sops")
42}
43
44/// Resolve the SOPs directory from config, falling back to workspace default.
45pub 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
55// ── SOP loading ─────────────────────────────────────────────────
56
57/// Load all SOPs from the configured directory.
58pub 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
67/// Load SOPs from a specific directory. Each subdirectory may contain
68/// `SOP.toml` (metadata + triggers) and `SOP.md` (procedure steps).
69fn 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
103/// Load a single SOP from a directory containing SOP.toml and optionally SOP.md.
104fn 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    // When deterministic=true, override execution_mode to Deterministic
129    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
150// ── Markdown step parser ────────────────────────────────────────
151
152/// Parse procedure steps from SOP.md content.
153///
154/// Expects a `## Steps` heading followed by numbered items (`1.`, `2.`, …).
155/// Each item's first bold text (`**...**`) is the step title; the rest is body.
156/// Sub-bullets `- tools:` and `- requires_confirmation: true` are parsed.
157pub 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        // Detect ## Steps heading
171        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            // Any other ## heading ends the steps section
178            if in_steps_section {
179                // Flush pending step
180                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        // Check for numbered item: `1.`, `2.`, etc.
199        if let Some(rest) = parse_numbered_item(trimmed) {
200            // Flush previous step
201            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            // Extract title from bold text: **title** — body
217            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        // Sub-bullet parsing (only when inside a step)
230        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                // Continuation body line
253                if !current_body.is_empty() {
254                    current_body.push('\n');
255                }
256                current_body.push_str(trimmed);
257            }
258            continue;
259        }
260
261        // Continuation line for step body
262        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 final step
271    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
284/// Flush accumulated step state into the steps vector.
285fn 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
310/// Try to parse `N. rest` from a line, returning `rest` if successful.
311fn 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
321/// Extract `**title**` from the beginning of text, returning (title, rest).
322fn 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    // Rest is everything after the closing ** and any separator (— or -)
329    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
341// ── Validation ──────────────────────────────────────────────────
342
343/// Validate a loaded SOP and return a list of warnings.
344pub 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    // Check step numbering continuity
361    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
377// ── CLI handler ─────────────────────────────────────────────────
378
379/// Handle the `sop` CLI subcommand.
380pub 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        // Default kind should be Execute
932        assert_eq!(steps[2].kind, SopStepKind::Execute);
933    }
934}