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
14pub struct BatchVerifyResult {
20 pub passed: Vec<String>,
22 pub failed: Vec<BatchVerifyFailure>,
24 pub commands_run: usize,
26}
27
28pub 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
37pub 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
59pub 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 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, };
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, };
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 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 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 => {
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 passed.push(unit.id.clone());
144 }
145 CloseOutcome::VerifyFailed(_) => {
146 }
148 CloseOutcome::Closed(_) => unreachable!(),
149 }
150 }
151 }
152 }
153 } else {
154 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 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
189fn 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 let index = Index::build(mana_dir)?;
212 index.save(mana_dir)?;
213
214 Ok(())
215}
216
217#[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 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 #[test]
287 fn batch_verify_groups_by_command() {
288 let (_dir, mana_dir) = setup();
289
290 write_awaiting(&mana_dir, "1", "true");
292 write_awaiting(&mana_dir, "2", "true");
293 write_awaiting(&mana_dir, "3", "true && true");
294
295 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 #[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 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 #[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 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 #[test]
368 fn batch_verify_empty_noop() {
369 let (_dir, mana_dir) = setup();
370
371 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 #[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 assert_eq!(result.commands_run, 2);
406
407 let mut passed = result.passed.clone();
409 passed.sort();
410 assert_eq!(passed, vec!["1", "3"]);
411
412 assert_eq!(result.failed.len(), 1);
414 assert_eq!(result.failed[0].unit_id, "2");
415
416 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}