Skip to main content

zeroclaw/sop/
mod.rs

1#[allow(unused_imports)]
2pub use zeroclaw_runtime::sop::*;
3
4use anyhow::Result;
5
6pub fn handle_command(command: crate::SopCommands, config: &crate::config::Config) -> Result<()> {
7    let workspace_dir = &config.data_dir;
8    let default_mode = parse_execution_mode(&config.sop.default_execution_mode);
9    let sops = load_sops(workspace_dir, config.sop.sops_dir.as_deref(), default_mode);
10
11    match command {
12        crate::SopCommands::List => {
13            if sops.is_empty() {
14                println!("No SOPs found.");
15                println!();
16                println!("  Create one: mkdir -p <workspace>/sops/my-sop");
17                println!("              then add SOP.toml and SOP.md");
18            } else {
19                println!("Loaded SOPs ({}):", sops.len());
20                println!();
21                for sop in &sops {
22                    println!(
23                        "  {} v{} [{}] — {}",
24                        console::style(&sop.name).white().bold(),
25                        sop.version,
26                        sop.priority,
27                        sop.description,
28                    );
29                    println!(
30                        "    Mode: {}  Steps: {}  Triggers: {}",
31                        sop.execution_mode,
32                        sop.steps.len(),
33                        sop.triggers
34                            .iter()
35                            .map(ToString::to_string)
36                            .collect::<Vec<_>>()
37                            .join(", "),
38                    );
39                }
40            }
41            println!();
42            Ok(())
43        }
44        crate::SopCommands::Validate { name } => {
45            let targets: Vec<_> = match &name {
46                Some(n) => sops.iter().filter(|s| s.name == *n).collect(),
47                None => sops.iter().collect(),
48            };
49
50            if targets.is_empty() {
51                if let Some(n) = &name {
52                    anyhow::bail!("SOP not found: {n}");
53                }
54                println!("No SOPs found to validate.");
55                return Ok(());
56            }
57
58            let mut any_warnings = false;
59            for sop in &targets {
60                let warnings = validate_sop(sop);
61                if warnings.is_empty() {
62                    println!("  ✅ {} — valid", sop.name);
63                } else {
64                    any_warnings = true;
65                    println!("  ⚠️  {} — {} warning(s):", sop.name, warnings.len());
66                    for w in &warnings {
67                        println!("       - {w}");
68                    }
69                }
70            }
71            if !any_warnings {
72                println!();
73                println!("All SOPs passed validation.");
74            }
75            Ok(())
76        }
77        crate::SopCommands::Show { name } => {
78            let sop = sops.iter().find(|s| s.name == name).ok_or_else(|| {
79                ::zeroclaw_log::record!(
80                    WARN,
81                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
82                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
83                        .with_attrs(::serde_json::json!({"sop": name})),
84                    "sop show: name not found in loaded SOPs"
85                );
86                anyhow::Error::msg(format!("SOP not found: {name}"))
87            })?;
88
89            println!(
90                "{} v{}",
91                console::style(&sop.name).white().bold(),
92                sop.version
93            );
94            println!("  {}", sop.description);
95            println!();
96            println!("  Priority:       {}", sop.priority);
97            println!("  Execution mode: {}", sop.execution_mode);
98            println!("  Deterministic:  {}", sop.deterministic);
99            println!("  Cooldown:       {}s", sop.cooldown_secs);
100            println!("  Max concurrent: {}", sop.max_concurrent);
101            if let Some(loc) = &sop.location {
102                println!("  Location:       {}", loc.display());
103            }
104            println!();
105            println!("  Triggers:");
106            for trigger in &sop.triggers {
107                println!("    - {trigger}");
108            }
109
110            if !sop.steps.is_empty() {
111                println!();
112                println!("  Steps:");
113                for step in &sop.steps {
114                    let confirm = if step.requires_confirmation {
115                        " [confirmation required]"
116                    } else {
117                        ""
118                    };
119                    println!(
120                        "    {}. {}{}",
121                        step.number,
122                        console::style(&step.title).bold(),
123                        confirm,
124                    );
125                    if !step.body.is_empty() {
126                        println!("       {}", step.body);
127                    }
128                    if !step.suggested_tools.is_empty() {
129                        println!("       Tools: {}", step.suggested_tools.join(", "));
130                    }
131                }
132            }
133            println!();
134            Ok(())
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::sop::types::SopManifest;
143    use std::fs;
144    use std::path::{Path, PathBuf};
145
146    #[test]
147    fn parse_steps_basic() {
148        let md = r#"# Test SOP
149
150## Conditions
151Some conditions here.
152
153## Steps
154
1551. **Check readings** — Read sensor data and confirm.
156   - tools: gpio_read, memory_store
157
1582. **Close valve** — Set GPIO pin 5 LOW.
159   - tools: gpio_write, gpio_read
160   - requires_confirmation: true
161
1623. **Notify operator** — Send alert.
163   - tools: pushover
164"#;
165
166        let steps = parse_steps(md);
167        assert_eq!(steps.len(), 3);
168
169        assert_eq!(steps[0].number, 1);
170        assert_eq!(steps[0].title, "Check readings");
171        assert!(steps[0].body.contains("Read sensor data"));
172        assert_eq!(steps[0].suggested_tools, vec!["gpio_read", "memory_store"]);
173        assert!(!steps[0].requires_confirmation);
174
175        assert_eq!(steps[1].number, 2);
176        assert_eq!(steps[1].title, "Close valve");
177        assert!(steps[1].requires_confirmation);
178        assert_eq!(steps[1].suggested_tools, vec!["gpio_write", "gpio_read"]);
179
180        assert_eq!(steps[2].number, 3);
181        assert_eq!(steps[2].title, "Notify operator");
182    }
183
184    #[test]
185    fn parse_steps_empty_md() {
186        let steps = parse_steps("# Nothing here\n\nNo steps section.");
187        assert!(steps.is_empty());
188    }
189
190    #[test]
191    fn parse_steps_no_bold_title() {
192        let md = "## Steps\n\n1. Just a plain step without bold.\n";
193        let steps = parse_steps(md);
194        assert_eq!(steps.len(), 1);
195        assert_eq!(steps[0].title, "Just a plain step without bold.");
196    }
197
198    #[test]
199    fn parse_steps_multiline_body() {
200        let md = r#"## Steps
201
2021. **Do thing** — First line of body.
203   Second line of body.
204   Third line of body.
205   - tools: shell
206"#;
207        let steps = parse_steps(md);
208        assert_eq!(steps.len(), 1);
209        assert!(steps[0].body.contains("First line"));
210        assert!(steps[0].body.contains("Second line"));
211        assert!(steps[0].body.contains("Third line"));
212    }
213
214    #[test]
215    fn load_sop_from_directory() {
216        let dir = tempfile::tempdir().unwrap();
217        let sop_dir = dir.path().join("test-sop");
218        fs::create_dir_all(&sop_dir).unwrap();
219
220        fs::write(
221            sop_dir.join("SOP.toml"),
222            r#"
223[sop]
224name = "test-sop"
225description = "A test SOP"
226version = "1.0.0"
227priority = "high"
228execution_mode = "auto"
229cooldown_secs = 60
230
231[[triggers]]
232type = "manual"
233
234[[triggers]]
235type = "webhook"
236path = "/sop/test"
237"#,
238        )
239        .unwrap();
240
241        fs::write(
242            sop_dir.join("SOP.md"),
243            r#"# Test SOP
244
245## Steps
246
2471. **Step one** — Do something.
248   - tools: shell
249
2502. **Step two** — Do something else.
251   - requires_confirmation: true
252"#,
253        )
254        .unwrap();
255
256        let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Supervised);
257        assert_eq!(sops.len(), 1);
258
259        let sop = &sops[0];
260        assert_eq!(sop.name, "test-sop");
261        assert_eq!(sop.priority, SopPriority::High);
262        assert_eq!(sop.execution_mode, SopExecutionMode::Auto);
263        assert_eq!(sop.cooldown_secs, 60);
264        assert_eq!(sop.triggers.len(), 2);
265        assert_eq!(sop.steps.len(), 2);
266        assert!(sop.steps[1].requires_confirmation);
267        assert!(sop.location.is_some());
268    }
269
270    #[test]
271    fn load_sops_empty_dir() {
272        let dir = tempfile::tempdir().unwrap();
273        let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Supervised);
274        assert!(sops.is_empty());
275    }
276
277    #[test]
278    fn load_sops_nonexistent_dir() {
279        let sops =
280            load_sops_from_directory(Path::new("/nonexistent/path"), SopExecutionMode::Supervised);
281        assert!(sops.is_empty());
282    }
283
284    #[test]
285    fn load_sop_toml_only_no_md() {
286        let dir = tempfile::tempdir().unwrap();
287        let sop_dir = dir.path().join("no-steps");
288        fs::create_dir_all(&sop_dir).unwrap();
289
290        fs::write(
291            sop_dir.join("SOP.toml"),
292            r#"
293[sop]
294name = "no-steps"
295description = "SOP without steps"
296
297[[triggers]]
298type = "manual"
299"#,
300        )
301        .unwrap();
302
303        let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Supervised);
304        assert_eq!(sops.len(), 1);
305        assert!(sops[0].steps.is_empty());
306    }
307
308    #[test]
309    fn load_sop_uses_config_default_execution_mode_when_omitted() {
310        let dir = tempfile::tempdir().unwrap();
311        let sop_dir = dir.path().join("default-mode");
312        fs::create_dir_all(&sop_dir).unwrap();
313
314        fs::write(
315            sop_dir.join("SOP.toml"),
316            r#"
317[sop]
318name = "default-mode"
319description = "SOP without explicit execution mode"
320
321[[triggers]]
322type = "manual"
323"#,
324        )
325        .unwrap();
326
327        let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Auto);
328        assert_eq!(sops.len(), 1);
329        assert_eq!(sops[0].execution_mode, SopExecutionMode::Auto);
330    }
331
332    #[test]
333    fn validate_sop_warnings() {
334        let sop = Sop {
335            name: String::new(),
336            description: String::new(),
337            version: "1.0.0".into(),
338            priority: SopPriority::Normal,
339            execution_mode: SopExecutionMode::Supervised,
340            triggers: Vec::new(),
341            steps: Vec::new(),
342            cooldown_secs: 0,
343            max_concurrent: 1,
344            location: None,
345            deterministic: false,
346        };
347
348        let warnings = validate_sop(&sop);
349        assert!(warnings.iter().any(|w| w.contains("name is empty")));
350        assert!(warnings.iter().any(|w| w.contains("description is empty")));
351        assert!(warnings.iter().any(|w| w.contains("no triggers")));
352        assert!(warnings.iter().any(|w| w.contains("no steps")));
353    }
354
355    #[test]
356    fn validate_sop_clean() {
357        let sop = Sop {
358            name: "valid-sop".into(),
359            description: "A valid SOP".into(),
360            version: "1.0.0".into(),
361            priority: SopPriority::High,
362            execution_mode: SopExecutionMode::Auto,
363            triggers: vec![SopTrigger::Manual],
364            steps: vec![SopStep {
365                number: 1,
366                title: "Do thing".into(),
367                body: "Do the thing".into(),
368                suggested_tools: vec!["shell".into()],
369                requires_confirmation: false,
370                kind: SopStepKind::default(),
371                schema: None,
372            }],
373            cooldown_secs: 0,
374            max_concurrent: 1,
375            location: None,
376            deterministic: false,
377        };
378
379        let warnings = validate_sop(&sop);
380        assert!(warnings.is_empty());
381    }
382
383    #[test]
384    fn resolve_sops_dir_default() {
385        let ws = Path::new("/home/user/.zeroclaw/workspace");
386        let dir = resolve_sops_dir(ws, None);
387        assert_eq!(dir, ws.join("sops"));
388    }
389
390    #[test]
391    fn resolve_sops_dir_override() {
392        let ws = Path::new("/home/user/.zeroclaw/workspace");
393        let dir = resolve_sops_dir(ws, Some("/custom/sops"));
394        assert_eq!(dir, PathBuf::from("/custom/sops"));
395    }
396
397    #[test]
398    fn extract_bold_title_with_dash() {
399        let (title, body) = extract_bold_title("**Close valve** — Set GPIO pin LOW.").unwrap();
400        assert_eq!(title, "Close valve");
401        assert_eq!(body, "Set GPIO pin LOW.");
402    }
403
404    #[test]
405    fn extract_bold_title_no_separator() {
406        let (title, body) = extract_bold_title("**Close valve** Set pin LOW.").unwrap();
407        assert_eq!(title, "Close valve");
408        assert_eq!(body, "Set pin LOW.");
409    }
410
411    #[test]
412    fn extract_bold_title_none() {
413        assert!(extract_bold_title("No bold here").is_none());
414    }
415
416    #[test]
417    fn parse_all_trigger_types() {
418        let toml_str = r#"
419[sop]
420name = "multi-trigger"
421description = "SOP with all trigger types"
422
423[[triggers]]
424type = "mqtt"
425topic = "sensors/temp"
426condition = "$.value > 90"
427
428[[triggers]]
429type = "webhook"
430path = "/sop/test"
431
432[[triggers]]
433type = "cron"
434expression = "0 */5 * * *"
435
436[[triggers]]
437type = "peripheral"
438board = "nucleo-f401re-0"
439signal = "pin_3"
440condition = "> 0"
441
442[[triggers]]
443type = "manual"
444"#;
445        let manifest: SopManifest = toml::from_str(toml_str).unwrap();
446        assert_eq!(manifest.triggers.len(), 5);
447
448        assert!(matches!(manifest.triggers[0], SopTrigger::Mqtt { .. }));
449        assert!(matches!(manifest.triggers[1], SopTrigger::Webhook { .. }));
450        assert!(matches!(manifest.triggers[2], SopTrigger::Cron { .. }));
451        assert!(matches!(
452            manifest.triggers[3],
453            SopTrigger::Peripheral { .. }
454        ));
455        assert!(matches!(manifest.triggers[4], SopTrigger::Manual));
456    }
457
458    #[test]
459    fn deterministic_flag_overrides_execution_mode() {
460        let dir = tempfile::tempdir().unwrap();
461        let sop_dir = dir.path().join("det-sop");
462        fs::create_dir_all(&sop_dir).unwrap();
463
464        fs::write(
465            sop_dir.join("SOP.toml"),
466            r#"
467[sop]
468name = "det-sop"
469description = "A deterministic SOP"
470deterministic = true
471
472[[triggers]]
473type = "manual"
474"#,
475        )
476        .unwrap();
477
478        fs::write(
479            sop_dir.join("SOP.md"),
480            r#"# Det SOP
481
482## Steps
483
4841. **Step one** — First step.
485   - kind: execute
486
4872. **Checkpoint** — Pause for approval.
488   - kind: checkpoint
489
4903. **Step three** — Final step.
491"#,
492        )
493        .unwrap();
494
495        let sops = load_sops_from_directory(dir.path(), SopExecutionMode::Supervised);
496        assert_eq!(sops.len(), 1);
497
498        let sop = &sops[0];
499        assert_eq!(sop.name, "det-sop");
500        assert_eq!(sop.execution_mode, SopExecutionMode::Deterministic);
501        assert!(sop.deterministic);
502        assert_eq!(sop.steps.len(), 3);
503        assert_eq!(sop.steps[0].kind, SopStepKind::Execute);
504        assert_eq!(sop.steps[1].kind, SopStepKind::Checkpoint);
505        assert_eq!(sop.steps[2].kind, SopStepKind::Execute);
506    }
507
508    #[test]
509    fn parse_steps_with_checkpoint_kind() {
510        let md = r#"## Steps
511
5121. **Read data** — Read from sensor.
513   - tools: gpio_read
514   - kind: execute
515
5162. **Review** — Human review checkpoint.
517   - kind: checkpoint
518
5193. **Apply** — Apply changes.
520"#;
521        let steps = parse_steps(md);
522        assert_eq!(steps.len(), 3);
523        assert_eq!(steps[0].kind, SopStepKind::Execute);
524        assert_eq!(steps[1].kind, SopStepKind::Checkpoint);
525        // Default kind should be Execute
526        assert_eq!(steps[2].kind, SopStepKind::Execute);
527    }
528}