Skip to main content

mana_core/ops/
batch_verify.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use chrono::Utc;
6
7use crate::config::Config;
8use crate::discovery::find_unit_file;
9use crate::index::Index;
10use crate::ops::close::{CloseOpts, CloseOutcome};
11use crate::ops::verify::run_verify_command;
12use crate::unit::{Status, Unit};
13
14// ---------------------------------------------------------------------------
15// Public types
16// ---------------------------------------------------------------------------
17
18/// Aggregated result of a batch verify run.
19pub struct BatchVerifyResult {
20    /// IDs of units whose verify command passed (now Closed).
21    pub passed: Vec<String>,
22    /// Per-unit failure details for units that failed verify (returned to Open).
23    pub failed: Vec<BatchVerifyFailure>,
24    /// Number of unique verify commands that were executed.
25    pub commands_run: usize,
26}
27
28/// Details of a single unit's verify failure during batch verification.
29pub struct BatchVerifyFailure {
30    pub unit_id: String,
31    pub verify_command: String,
32    pub exit_code: Option<i32>,
33    pub output: String,
34    pub timed_out: bool,
35}
36
37// ---------------------------------------------------------------------------
38// Public API
39// ---------------------------------------------------------------------------
40
41/// Run batch verification for all units currently in AwaitingVerify status.
42///
43/// Groups units by their verify command string, runs each unique command exactly
44/// once, then applies the result to all units sharing that command:
45/// - Pass → unit is closed via the full lifecycle (force: true skips re-verify)
46/// - Fail → unit is set back to Open with claim released
47pub fn batch_verify(mana_dir: &Path) -> Result<BatchVerifyResult> {
48    let index = Index::load_or_rebuild(mana_dir)?;
49    let awaiting_ids: Vec<String> = index
50        .units
51        .iter()
52        .filter(|e| e.status == Status::AwaitingVerify)
53        .map(|e| e.id.clone())
54        .collect();
55
56    batch_verify_ids(mana_dir, &awaiting_ids)
57}
58
59/// Run batch verification for a specific set of unit IDs.
60///
61/// Only processes units that are in AwaitingVerify status. Units with other
62/// statuses or missing verify commands are silently skipped.
63pub fn batch_verify_ids(mana_dir: &Path, ids: &[String]) -> Result<BatchVerifyResult> {
64    if ids.is_empty() {
65        return Ok(BatchVerifyResult {
66            passed: vec![],
67            failed: vec![],
68            commands_run: 0,
69        });
70    }
71
72    let project_root = mana_dir
73        .parent()
74        .ok_or_else(|| anyhow::anyhow!("Cannot determine project root from mana dir"))?;
75
76    let config = Config::load(mana_dir).ok();
77
78    // Load all units and group by verify command string.
79    // Units without a verify command or not in AwaitingVerify are skipped.
80    let mut groups: HashMap<String, Vec<Unit>> = HashMap::new();
81
82    for id in ids {
83        let unit_path = match find_unit_file(mana_dir, id) {
84            Ok(p) => p,
85            Err(_) => continue, // Unit not found — skip
86        };
87        let unit =
88            Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
89
90        if unit.status != Status::AwaitingVerify {
91            continue;
92        }
93
94        let verify_cmd = match &unit.verify {
95            Some(cmd) if !cmd.trim().is_empty() => cmd.clone(),
96            _ => continue, // No verify command — skip
97        };
98
99        groups.entry(verify_cmd).or_default().push(unit);
100    }
101
102    let commands_run = groups.len();
103    let mut passed = Vec::new();
104    let mut failed: Vec<BatchVerifyFailure> = Vec::new();
105
106    for (verify_cmd, units) in groups {
107        // Determine timeout from the first unit in the group (all share the same command,
108        // so using any unit's timeout is reasonable; the config fallback is always the same).
109        let timeout_secs =
110            units[0].effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout));
111
112        let result = run_verify_command(&verify_cmd, project_root, timeout_secs)?;
113
114        if result.passed {
115            // Close each unit — force: true skips the inline verify since we just ran it.
116            for unit in units {
117                let outcome = crate::ops::close::close(
118                    mana_dir,
119                    &unit.id,
120                    CloseOpts {
121                        reason: Some("Batch verify passed".to_string()),
122                        force: true,
123                        defer_verify: false,
124                    },
125                )?;
126
127                match outcome {
128                    CloseOutcome::Closed(_) => {
129                        passed.push(unit.id.clone());
130                    }
131                    // Other outcomes (hook rejection, circuit breaker, etc.) — treat as passed
132                    // since the verify itself succeeded. The close lifecycle handled the rest.
133                    other => {
134                        match other {
135                            CloseOutcome::RejectedByHook { unit_id }
136                            | CloseOutcome::DeferredVerify { unit_id }
137                            | CloseOutcome::FeatureRequiresHuman { unit_id, .. }
138                            | CloseOutcome::CircuitBreakerTripped { unit_id, .. } => {
139                                passed.push(unit_id);
140                            }
141                            CloseOutcome::MergeConflict { .. } => {
142                                // MergeConflict has no unit_id — record using the original unit id.
143                                passed.push(unit.id.clone());
144                            }
145                            CloseOutcome::VerifyFailed(_) => {
146                                // Should not happen with force: true.
147                            }
148                            CloseOutcome::Closed(_) => unreachable!(),
149                        }
150                    }
151                }
152            }
153        } else {
154            // Build combined output for failure reporting.
155            let combined_output = if result.timed_out {
156                format!("Verify timed out after {}s", timeout_secs.unwrap_or(0))
157            } else {
158                let stdout = result.stdout.trim();
159                let stderr = result.stderr.trim();
160                let sep = if !stdout.is_empty() && !stderr.is_empty() {
161                    "\n"
162                } else {
163                    ""
164                };
165                format!("{}{}{}", stdout, sep, stderr)
166            };
167
168            // Reopen each unit (set status back to Open, release claim).
169            for unit in &units {
170                reopen_awaiting_unit(mana_dir, &unit.id)?;
171                failed.push(BatchVerifyFailure {
172                    unit_id: unit.id.clone(),
173                    verify_command: verify_cmd.clone(),
174                    exit_code: result.exit_code,
175                    output: combined_output.clone(),
176                    timed_out: result.timed_out,
177                });
178            }
179        }
180    }
181
182    Ok(BatchVerifyResult {
183        passed,
184        failed,
185        commands_run,
186    })
187}
188
189// ---------------------------------------------------------------------------
190// Internal helpers
191// ---------------------------------------------------------------------------
192
193/// Set a unit back to Open status and release its claim.
194///
195/// Used when batch verify fails — returns the unit to the pool for re-dispatch.
196fn reopen_awaiting_unit(mana_dir: &Path, id: &str) -> Result<()> {
197    let unit_path =
198        find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
199    let mut unit =
200        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
201
202    unit.status = Status::Open;
203    unit.claimed_by = None;
204    unit.claimed_at = None;
205    unit.updated_at = Utc::now();
206
207    unit.to_file(&unit_path)
208        .with_context(|| format!("Failed to save unit: {}", id))?;
209
210    // Rebuild index to reflect the status change.
211    let index = Index::build(mana_dir)?;
212    index.save(mana_dir)?;
213
214    Ok(())
215}
216
217// ---------------------------------------------------------------------------
218// Tests
219// ---------------------------------------------------------------------------
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::config::Config;
225    use crate::discovery::{find_archived_unit, find_unit_file};
226    use crate::unit::{Status, Unit};
227    use std::fs;
228    use std::path::PathBuf;
229    use tempfile::TempDir;
230
231    fn setup() -> (TempDir, PathBuf) {
232        let dir = TempDir::new().unwrap();
233        let mana_dir = dir.path().join(".mana");
234        fs::create_dir(&mana_dir).unwrap();
235        Config {
236            project: "test".to_string(),
237            next_id: 1,
238            auto_close_parent: true,
239            run: None,
240            plan: None,
241            max_loops: 10,
242            max_concurrent: 4,
243            poll_interval: 30,
244            extends: vec![],
245            rules_file: None,
246            file_locking: false,
247            worktree: false,
248            on_close: None,
249            on_fail: None,
250            post_plan: None,
251            verify_timeout: None,
252            review: None,
253            user: None,
254            user_email: None,
255            auto_commit: false,
256            commit_template: None,
257            research: None,
258            run_model: None,
259            plan_model: None,
260            review_model: None,
261            research_model: None,
262            batch_verify: false,
263            memory_reserve_mb: 0,
264            notify: None,
265        }
266        .save(&mana_dir)
267        .unwrap();
268        (dir, mana_dir)
269    }
270
271    /// Write a unit in AwaitingVerify status to disk.
272    fn write_awaiting(mana_dir: &Path, id: &str, verify_cmd: &str) {
273        let mut unit = Unit::new(id, &format!("Task {}", id));
274        unit.status = Status::AwaitingVerify;
275        unit.verify = Some(verify_cmd.to_string());
276        let slug = id.replace('.', "-");
277        unit.to_file(mana_dir.join(format!("{}-task-{}.md", id, slug)))
278            .unwrap();
279    }
280
281    // ------------------------------------------------------------------
282    // batch_verify_groups_by_command
283    // ------------------------------------------------------------------
284
285    /// 3 units where 2 share a verify command → only 2 unique commands run.
286    #[test]
287    fn batch_verify_groups_by_command() {
288        let (_dir, mana_dir) = setup();
289
290        // Units 1 and 2 share the same verify command; unit 3 has a different one.
291        write_awaiting(&mana_dir, "1", "true");
292        write_awaiting(&mana_dir, "2", "true");
293        write_awaiting(&mana_dir, "3", "true && true");
294
295        // Rebuild index so batch_verify can find the units.
296        let index = Index::build(&mana_dir).unwrap();
297        index.save(&mana_dir).unwrap();
298
299        let result = batch_verify(&mana_dir).unwrap();
300
301        assert_eq!(result.commands_run, 2, "Expected 2 unique commands run");
302        assert_eq!(result.passed.len(), 3);
303        assert!(result.failed.is_empty());
304    }
305
306    // ------------------------------------------------------------------
307    // batch_verify_passes_close_units
308    // ------------------------------------------------------------------
309
310    /// When verify passes, units should be Closed (archived).
311    #[test]
312    fn batch_verify_passes_close_units() {
313        let (_dir, mana_dir) = setup();
314        write_awaiting(&mana_dir, "1", "true");
315
316        let index = Index::build(&mana_dir).unwrap();
317        index.save(&mana_dir).unwrap();
318
319        let result = batch_verify(&mana_dir).unwrap();
320
321        assert_eq!(result.passed, vec!["1"]);
322        assert!(result.failed.is_empty());
323        assert_eq!(result.commands_run, 1);
324
325        // Unit should be archived (Closed).
326        // After archiving, the unit file is moved to the archive dir — use find_archived_unit.
327        let archive_path = find_archived_unit(&mana_dir, "1").expect("unit should be in archive");
328        let unit = Unit::from_file(archive_path).unwrap();
329        assert_eq!(unit.status, Status::Closed);
330        assert!(unit.is_archived);
331    }
332
333    // ------------------------------------------------------------------
334    // batch_verify_fails_reopen_units
335    // ------------------------------------------------------------------
336
337    /// When verify fails, units should be set back to Open.
338    #[test]
339    fn batch_verify_fails_reopen_units() {
340        let (_dir, mana_dir) = setup();
341        write_awaiting(&mana_dir, "1", "false");
342
343        let index = Index::build(&mana_dir).unwrap();
344        index.save(&mana_dir).unwrap();
345
346        let result = batch_verify(&mana_dir).unwrap();
347
348        assert!(result.passed.is_empty());
349        assert_eq!(result.failed.len(), 1);
350        assert_eq!(result.failed[0].unit_id, "1");
351        assert_eq!(result.failed[0].exit_code, Some(1));
352        assert!(!result.failed[0].timed_out);
353        assert_eq!(result.commands_run, 1);
354
355        // Unit should be back to Open.
356        let unit_path = find_unit_file(&mana_dir, "1").unwrap();
357        let unit = Unit::from_file(unit_path).unwrap();
358        assert_eq!(unit.status, Status::Open);
359        assert!(unit.claimed_by.is_none());
360    }
361
362    // ------------------------------------------------------------------
363    // batch_verify_empty_noop
364    // ------------------------------------------------------------------
365
366    /// No AwaitingVerify units → empty result, no commands run.
367    #[test]
368    fn batch_verify_empty_noop() {
369        let (_dir, mana_dir) = setup();
370
371        // Write a regular Open unit (not AwaitingVerify).
372        let mut unit = Unit::new("1", "Task 1");
373        unit.verify = Some("true".to_string());
374        unit.to_file(mana_dir.join("1-task-1.md")).unwrap();
375
376        let index = Index::build(&mana_dir).unwrap();
377        index.save(&mana_dir).unwrap();
378
379        let result = batch_verify(&mana_dir).unwrap();
380
381        assert!(result.passed.is_empty());
382        assert!(result.failed.is_empty());
383        assert_eq!(result.commands_run, 0);
384    }
385
386    // ------------------------------------------------------------------
387    // batch_verify_mixed_results
388    // ------------------------------------------------------------------
389
390    /// Some units pass, some fail → correct split across passed/failed.
391    #[test]
392    fn batch_verify_mixed_results() {
393        let (_dir, mana_dir) = setup();
394
395        write_awaiting(&mana_dir, "1", "true");
396        write_awaiting(&mana_dir, "2", "false");
397        write_awaiting(&mana_dir, "3", "true");
398
399        let index = Index::build(&mana_dir).unwrap();
400        index.save(&mana_dir).unwrap();
401
402        let result = batch_verify(&mana_dir).unwrap();
403
404        // "true" and "false" are the 2 unique commands.
405        assert_eq!(result.commands_run, 2);
406
407        // Units 1 and 3 share "true" → both pass.
408        let mut passed = result.passed.clone();
409        passed.sort();
410        assert_eq!(passed, vec!["1", "3"]);
411
412        // Unit 2 has "false" → fails.
413        assert_eq!(result.failed.len(), 1);
414        assert_eq!(result.failed[0].unit_id, "2");
415
416        // Verify on-disk states.
417        // Passing units are archived; failing unit stays in active dir.
418        let u1 = Unit::from_file(find_archived_unit(&mana_dir, "1").unwrap()).unwrap();
419        assert_eq!(u1.status, Status::Closed);
420
421        let u2 = Unit::from_file(find_unit_file(&mana_dir, "2").unwrap()).unwrap();
422        assert_eq!(u2.status, Status::Open);
423
424        let u3 = Unit::from_file(find_archived_unit(&mana_dir, "3").unwrap()).unwrap();
425        assert_eq!(u3.status, Status::Closed);
426    }
427}