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