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};
20
21use types::{SopManifest, SopMeta};
22
23pub fn parse_execution_mode(s: &str) -> SopExecutionMode {
26 match s.trim().to_lowercase().as_str() {
27 "auto" => SopExecutionMode::Auto,
28 "step_by_step" => SopExecutionMode::StepByStep,
29 "priority_based" => SopExecutionMode::PriorityBased,
30 "deterministic" => SopExecutionMode::Deterministic,
31 _ => SopExecutionMode::Supervised,
33 }
34}
35
36fn sops_dir(workspace_dir: &Path) -> PathBuf {
40 workspace_dir.join("sops")
41}
42
43pub fn resolve_sops_dir(workspace_dir: &Path, config_dir: Option<&str>) -> PathBuf {
45 match config_dir {
46 Some(dir) if !dir.is_empty() => {
47 let expanded = shellexpand::tilde(dir);
48 PathBuf::from(expanded.as_ref())
49 }
50 _ => sops_dir(workspace_dir),
51 }
52}
53
54pub fn load_sops(
58 workspace_dir: &Path,
59 config_dir: Option<&str>,
60 default_execution_mode: SopExecutionMode,
61) -> Vec<Sop> {
62 let dir = resolve_sops_dir(workspace_dir, config_dir);
63 load_sops_from_directory(&dir, default_execution_mode)
64}
65
66pub fn load_sops_from_directory(
69 sops_dir: &Path,
70 default_execution_mode: SopExecutionMode,
71) -> Vec<Sop> {
72 if !sops_dir.exists() {
73 return Vec::new();
74 }
75
76 let mut sops = Vec::new();
77
78 let Ok(entries) = std::fs::read_dir(sops_dir) else {
79 return sops;
80 };
81
82 for entry in entries.flatten() {
83 let path = entry.path();
84 if !path.is_dir() {
85 continue;
86 }
87
88 let toml_path = path.join("SOP.toml");
89 if !toml_path.exists() {
90 continue;
91 }
92
93 match load_sop(&path, default_execution_mode) {
94 Ok(sop) => sops.push(sop),
95 Err(e) => {
96 ::zeroclaw_log::record!(
97 WARN,
98 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
99 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
100 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
101 &format!("Failed to load SOP from {}", path.display().to_string())
102 );
103 }
104 }
105 }
106
107 sops.sort_by(|a, b| a.name.cmp(&b.name));
108 sops
109}
110
111fn load_sop(sop_dir: &Path, default_execution_mode: SopExecutionMode) -> Result<Sop> {
113 let toml_path = sop_dir.join("SOP.toml");
114 let toml_content = std::fs::read_to_string(&toml_path)?;
115 let manifest: SopManifest = toml::from_str(&toml_content)?;
116
117 let md_path = sop_dir.join("SOP.md");
118 let steps = if md_path.exists() {
119 let md_content = std::fs::read_to_string(&md_path)?;
120 parse_steps(&md_content)
121 } else {
122 Vec::new()
123 };
124
125 let SopMeta {
126 name,
127 description,
128 version,
129 priority,
130 execution_mode,
131 cooldown_secs,
132 max_concurrent,
133 deterministic,
134 } = manifest.sop;
135
136 let effective_mode = if deterministic {
138 SopExecutionMode::Deterministic
139 } else {
140 execution_mode.unwrap_or(default_execution_mode)
141 };
142
143 Ok(Sop {
144 name,
145 description,
146 version,
147 priority,
148 execution_mode: effective_mode,
149 triggers: manifest.triggers,
150 steps,
151 cooldown_secs,
152 max_concurrent,
153 location: Some(sop_dir.to_path_buf()),
154 deterministic,
155 })
156}
157
158pub fn parse_steps(md: &str) -> Vec<SopStep> {
166 let mut steps = Vec::new();
167 let mut in_steps_section = false;
168 let mut current_number: Option<u32> = None;
169 let mut current_title = String::new();
170 let mut current_body = String::new();
171 let mut current_tools: Vec<String> = Vec::new();
172 let mut current_requires_confirmation = false;
173 let mut current_kind = SopStepKind::Execute;
174
175 for line in md.lines() {
176 let trimmed = line.trim();
177
178 if trimmed.starts_with("## ") {
180 if trimmed.eq_ignore_ascii_case("## steps") || trimmed.eq_ignore_ascii_case("## Steps")
181 {
182 in_steps_section = true;
183 continue;
184 }
185 if in_steps_section {
187 flush_step(
189 &mut steps,
190 &mut current_number,
191 &mut current_title,
192 &mut current_body,
193 &mut current_tools,
194 &mut current_requires_confirmation,
195 &mut current_kind,
196 );
197 in_steps_section = false;
198 }
199 continue;
200 }
201
202 if !in_steps_section {
203 continue;
204 }
205
206 if let Some(rest) = parse_numbered_item(trimmed) {
208 flush_step(
210 &mut steps,
211 &mut current_number,
212 &mut current_title,
213 &mut current_body,
214 &mut current_tools,
215 &mut current_requires_confirmation,
216 &mut current_kind,
217 );
218
219 let step_num = u32::try_from(steps.len())
220 .unwrap_or(u32::MAX)
221 .saturating_add(1);
222 current_number = Some(step_num);
223
224 if let Some((title, body)) = extract_bold_title(rest) {
226 current_title = title;
227 current_body = body;
228 } else {
229 current_title = rest.to_string();
230 current_body = String::new();
231 }
232 current_tools = Vec::new();
233 current_requires_confirmation = false;
234 continue;
235 }
236
237 if current_number.is_some() && trimmed.starts_with("- ") {
239 let bullet = trimmed.trim_start_matches("- ").trim();
240 if let Some(tools_str) = bullet.strip_prefix("tools:") {
241 current_tools = tools_str
242 .split(',')
243 .map(|t| t.trim().to_string())
244 .filter(|t| !t.is_empty())
245 .collect();
246 } else if bullet.starts_with("requires_confirmation:") {
247 if let Some(val) = bullet.strip_prefix("requires_confirmation:") {
248 current_requires_confirmation = val.trim().eq_ignore_ascii_case("true");
249 }
250 } else if bullet.starts_with("kind:") {
251 if let Some(val) = bullet.strip_prefix("kind:") {
252 let val = val.trim();
253 if val.eq_ignore_ascii_case("checkpoint") {
254 current_kind = SopStepKind::Checkpoint;
255 } else {
256 current_kind = SopStepKind::Execute;
257 }
258 }
259 } else {
260 if !current_body.is_empty() {
262 current_body.push('\n');
263 }
264 current_body.push_str(trimmed);
265 }
266 continue;
267 }
268
269 if current_number.is_some() && !trimmed.is_empty() {
271 if !current_body.is_empty() {
272 current_body.push('\n');
273 }
274 current_body.push_str(trimmed);
275 }
276 }
277
278 flush_step(
280 &mut steps,
281 &mut current_number,
282 &mut current_title,
283 &mut current_body,
284 &mut current_tools,
285 &mut current_requires_confirmation,
286 &mut current_kind,
287 );
288
289 steps
290}
291
292fn flush_step(
294 steps: &mut Vec<SopStep>,
295 number: &mut Option<u32>,
296 title: &mut String,
297 body: &mut String,
298 tools: &mut Vec<String>,
299 requires_confirmation: &mut bool,
300 kind: &mut SopStepKind,
301) {
302 if let Some(n) = number.take() {
303 steps.push(SopStep {
304 number: n,
305 title: std::mem::take(title),
306 body: body.trim().to_string(),
307 suggested_tools: std::mem::take(tools),
308 requires_confirmation: *requires_confirmation,
309 kind: *kind,
310 schema: None,
311 });
312 *body = String::new();
313 *requires_confirmation = false;
314 *kind = SopStepKind::Execute;
315 }
316}
317
318fn parse_numbered_item(line: &str) -> Option<&str> {
320 let dot_pos = line.find(". ")?;
321 let prefix = &line[..dot_pos];
322 if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
323 Some(line[dot_pos + 2..].trim())
324 } else {
325 None
326 }
327}
328
329pub fn extract_bold_title(text: &str) -> Option<(String, String)> {
331 let start = text.find("**")?;
332 let after_start = start + 2;
333 let end = text[after_start..].find("**")?;
334 let title = text[after_start..after_start + end].to_string();
335
336 let rest_start = after_start + end + 2;
338 let rest = text[rest_start..].trim();
339 let rest = rest
340 .strip_prefix("—")
341 .or_else(|| rest.strip_prefix("–"))
342 .or_else(|| rest.strip_prefix("-"))
343 .unwrap_or(rest)
344 .trim();
345
346 Some((title, rest.to_string()))
347}
348
349pub fn validate_sop(sop: &Sop) -> Vec<String> {
353 let mut warnings = Vec::new();
354
355 if sop.name.is_empty() {
356 warnings.push("SOP name is empty".into());
357 }
358 if sop.description.is_empty() {
359 warnings.push("SOP description is empty".into());
360 }
361 if sop.triggers.is_empty() {
362 warnings.push("SOP has no triggers defined".into());
363 }
364 if sop.steps.is_empty() {
365 warnings.push("SOP has no steps (missing or empty SOP.md)".into());
366 }
367
368 for (i, step) in sop.steps.iter().enumerate() {
370 let expected = u32::try_from(i).unwrap_or(u32::MAX).saturating_add(1);
371 if step.number != expected {
372 warnings.push(format!(
373 "Step numbering gap: expected {expected}, got {}",
374 step.number
375 ));
376 }
377 if step.title.is_empty() {
378 warnings.push(format!("Step {} has an empty title", step.number));
379 }
380 }
381
382 warnings
383}