Skip to main content

mana_core/ops/
verify.rs

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
12/// Result of running a verify command.
13pub struct VerifyResult {
14    /// Whether the verify command passed (exit 0).
15    pub passed: bool,
16    /// The process exit code, if available.
17    pub exit_code: Option<i32>,
18    /// Combined stdout content.
19    pub stdout: String,
20    /// Combined stderr content.
21    pub stderr: String,
22    /// Whether the command was killed due to timeout.
23    pub timed_out: bool,
24    /// The verify command that was run.
25    pub command: String,
26    /// Timeout that was applied, if any.
27    pub timeout_secs: Option<u64>,
28}
29
30/// Run the verify command for a unit without closing it.
31///
32/// Loads the unit, resolves the effective timeout, spawns the verify command,
33/// and captures all output. Returns a structured `VerifyResult`.
34///
35/// If the unit has no verify command, returns `Ok(None)`.
36pub 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
57/// Execute a verify command in the given directory with an optional timeout.
58///
59/// This is the low-level execution function — no unit loading, no config resolution.
60/// Useful when the caller already has the command and timeout.
61pub 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    // Drain output in background threads to prevent pipe deadlock.
75    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}