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 assert_eq!(steps[2].kind, SopStepKind::Execute);
527 }
528}