Skip to main content

zeroclaw/sop/
mod.rs

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