1use std::io::Read;
2use std::path::Path;
3use std::process::{Command as ShellCommand, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{anyhow, Context, Result};
7
8use crate::config::Config;
9use crate::discovery::find_unit_file;
10use crate::unit::Unit;
11
12pub struct VerifyResult {
14 pub passed: bool,
16 pub exit_code: Option<i32>,
18 pub stdout: String,
20 pub stderr: String,
22 pub timed_out: bool,
24 pub command: String,
26 pub timeout_secs: Option<u64>,
28}
29
30pub fn run_verify(mana_dir: &Path, id: &str) -> Result<Option<VerifyResult>> {
37 let unit_path = find_unit_file(mana_dir, id).map_err(|_| anyhow!("Unit not found: {}", id))?;
38 let unit =
39 Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
40
41 let verify_cmd = match &unit.verify {
42 Some(cmd) if !cmd.trim().is_empty() => cmd.clone(),
43 _ => return Ok(None),
44 };
45
46 let config = Config::load(mana_dir).ok();
47 let timeout_secs =
48 unit.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout));
49
50 let project_root = mana_dir
51 .parent()
52 .ok_or_else(|| anyhow!("Cannot determine project root from units dir"))?;
53
54 run_verify_command(&verify_cmd, project_root, timeout_secs).map(Some)
55}
56
57pub fn run_verify_command(
62 verify_cmd: &str,
63 working_dir: &Path,
64 timeout_secs: Option<u64>,
65) -> Result<VerifyResult> {
66 let mut child = ShellCommand::new("sh")
67 .args(["-c", verify_cmd])
68 .current_dir(working_dir)
69 .stdout(Stdio::piped())
70 .stderr(Stdio::piped())
71 .spawn()
72 .with_context(|| format!("Failed to spawn verify command: {}", verify_cmd))?;
73
74 let stdout_thread = {
76 let stdout = child.stdout.take().expect("stdout is piped");
77 std::thread::spawn(move || {
78 let mut buf = Vec::new();
79 let mut reader = std::io::BufReader::new(stdout);
80 let _ = reader.read_to_end(&mut buf);
81 String::from_utf8_lossy(&buf).to_string()
82 })
83 };
84 let stderr_thread = {
85 let stderr = child.stderr.take().expect("stderr is piped");
86 std::thread::spawn(move || {
87 let mut buf = Vec::new();
88 let mut reader = std::io::BufReader::new(stderr);
89 let _ = reader.read_to_end(&mut buf);
90 String::from_utf8_lossy(&buf).to_string()
91 })
92 };
93
94 let timeout = timeout_secs.map(Duration::from_secs);
95 let start = Instant::now();
96
97 let (timed_out, exit_status) = loop {
98 match child
99 .try_wait()
100 .with_context(|| "Failed to poll verify process")?
101 {
102 Some(status) => break (false, Some(status)),
103 None => {
104 if let Some(limit) = timeout {
105 if start.elapsed() >= limit {
106 let _ = child.kill();
107 let _ = child.wait();
108 break (true, None);
109 }
110 }
111 std::thread::sleep(Duration::from_millis(50));
112 }
113 }
114 };
115
116 let stdout = stdout_thread.join().unwrap_or_default();
117 let stderr = stderr_thread.join().unwrap_or_default();
118
119 let exit_code = exit_status.and_then(|s| s.code());
120 let passed = !timed_out && exit_status.map(|s| s.success()).unwrap_or(false);
121
122 Ok(VerifyResult {
123 passed,
124 exit_code,
125 stdout,
126 stderr,
127 timed_out,
128 command: verify_cmd.to_string(),
129 timeout_secs,
130 })
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::config::Config;
137 use crate::ops::create::{self, tests::minimal_params};
138 use std::fs;
139 use std::path::PathBuf;
140 use tempfile::TempDir;
141
142 fn setup() -> (TempDir, PathBuf) {
143 let dir = TempDir::new().unwrap();
144 let bd = dir.path().join(".mana");
145 fs::create_dir(&bd).unwrap();
146 Config {
147 project: "test".to_string(),
148 next_id: 1,
149 auto_close_parent: true,
150 run: None,
151 plan: None,
152 max_loops: 10,
153 max_concurrent: 4,
154 poll_interval: 30,
155 extends: vec![],
156 rules_file: None,
157 file_locking: false,
158 worktree: false,
159 on_close: None,
160 on_fail: None,
161 post_plan: None,
162 verify_timeout: None,
163 review: None,
164 user: None,
165 user_email: None,
166 auto_commit: false,
167 commit_template: None,
168 research: None,
169 run_model: None,
170 plan_model: None,
171 review_model: None,
172 research_model: None,
173 batch_verify: false,
174 memory_reserve_mb: 0,
175 notify: None,
176 }
177 .save(&bd)
178 .unwrap();
179 (dir, bd)
180 }
181
182 #[test]
183 fn verify_passing_command() {
184 let (_dir, bd) = setup();
185 let mut params = minimal_params("Task");
186 params.verify =
187 Some("grep -q 'project: test' .mana/config.yaml && printf hello".to_string());
188 create::create(&bd, params).unwrap();
189
190 let result = run_verify(&bd, "1").unwrap().unwrap();
191 assert!(result.passed);
192 assert_eq!(result.exit_code, Some(0));
193 assert!(result.stdout.contains("hello"));
194 assert!(!result.timed_out);
195 }
196
197 #[test]
198 fn verify_failing_command() {
199 let (_dir, bd) = setup();
200 let mut params = minimal_params("Task");
201 params.verify = Some("exit 1".to_string());
202 create::create(&bd, params).unwrap();
203
204 let result = run_verify(&bd, "1").unwrap().unwrap();
205 assert!(!result.passed);
206 assert_eq!(result.exit_code, Some(1));
207 assert!(!result.timed_out);
208 }
209
210 #[test]
211 fn verify_no_command_returns_none() {
212 let (_dir, bd) = setup();
213 create::create(&bd, minimal_params("Task")).unwrap();
214
215 let result = run_verify(&bd, "1").unwrap();
216 assert!(result.is_none());
217 }
218
219 #[test]
220 fn verify_nonexistent_unit() {
221 let (_dir, bd) = setup();
222 assert!(run_verify(&bd, "99").is_err());
223 }
224
225 #[test]
226 fn verify_captures_stderr() {
227 let (_dir, bd) = setup();
228 let mut params = minimal_params("Task");
229 params.verify =
230 Some("grep -q 'project: test' .mana/config.yaml && printf err >&2".to_string());
231 create::create(&bd, params).unwrap();
232
233 let result = run_verify(&bd, "1").unwrap().unwrap();
234 assert!(result.passed);
235 assert!(result.stderr.contains("err"));
236 }
237
238 #[test]
239 fn run_verify_command_directly() {
240 let dir = TempDir::new().unwrap();
241 let result = run_verify_command("echo direct", dir.path(), None).unwrap();
242 assert!(result.passed);
243 assert!(result.stdout.contains("direct"));
244 }
245
246 #[test]
247 fn run_verify_command_timeout() {
248 let dir = TempDir::new().unwrap();
249 let result = run_verify_command("sleep 10", dir.path(), Some(1)).unwrap();
250 assert!(!result.passed);
251 assert!(result.timed_out);
252 }
253}