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_with_extends(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::VerifyFrozenViolation { unit_id, .. } => {
146 passed.push(unit_id);
148 }
149 CloseOutcome::VerifyFailed(_) => {
150 }
152 CloseOutcome::Closed(_) => unreachable!(),
153 }
154 }
155 }
156 }
157 } else {
158 let combined_output = if result.timed_out {
160 format!("Verify timed out after {}s", timeout_secs.unwrap_or(0))
161 } else {
162 let stdout = result.stdout.trim();
163 let stderr = result.stderr.trim();
164 let sep = if !stdout.is_empty() && !stderr.is_empty() {
165 "\n"
166 } else {
167 ""
168 };
169 format!("{}{}{}", stdout, sep, stderr)
170 };
171
172 for unit in &units {
174 reopen_awaiting_unit(mana_dir, &unit.id)?;
175 failed.push(BatchVerifyFailure {
176 unit_id: unit.id.clone(),
177 verify_command: verify_cmd.clone(),
178 exit_code: result.exit_code,
179 output: combined_output.clone(),
180 timed_out: result.timed_out,
181 });
182 }
183 }
184 }
185
186 Ok(BatchVerifyResult {
187 passed,
188 failed,
189 commands_run,
190 })
191}
192
193pub fn reopen_awaiting_unit(mana_dir: &Path, id: &str) -> Result<()> {
201 let unit_path =
202 find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
203 let mut unit =
204 Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
205
206 unit.status = Status::Open;
207 unit.claimed_by = None;
208 unit.claimed_at = None;
209 unit.updated_at = Utc::now();
210
211 unit.to_file(&unit_path)
212 .with_context(|| format!("Failed to save unit: {}", id))?;
213
214 let index = Index::build(mana_dir)?;
216 index.save(mana_dir)?;
217
218 Ok(())
219}
220
221#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::config::Config;
229 use crate::discovery::{find_archived_unit, find_unit_file};
230 use crate::unit::{Status, Unit};
231 use std::fs;
232 use std::path::PathBuf;
233 use tempfile::TempDir;
234
235 fn setup() -> (TempDir, PathBuf) {
236 let dir = TempDir::new().unwrap();
237 let mana_dir = dir.path().join(".mana");
238 fs::create_dir(&mana_dir).unwrap();
239 Config {
240 project: "test".to_string(),
241 next_id: 1,
242 auto_close_parent: true,
243 run: None,
244 plan: None,
245 max_loops: 10,
246 max_concurrent: 4,
247 poll_interval: 30,
248 extends: vec![],
249 rules_file: None,
250 file_locking: false,
251 worktree: false,
252 on_close: None,
253 on_fail: None,
254 verify_timeout: None,
255 review: None,
256 user: None,
257 user_email: None,
258 auto_commit: false,
259 commit_template: None,
260 research: None,
261 run_model: None,
262 plan_model: None,
263 review_model: None,
264 research_model: None,
265 batch_verify: false,
266 memory_reserve_mb: 0,
267 notify: None,
268 }
269 .save(&mana_dir)
270 .unwrap();
271 (dir, mana_dir)
272 }
273
274 fn write_awaiting(mana_dir: &Path, id: &str, verify_cmd: &str) {
276 let mut unit = Unit::new(id, format!("Task {}", id));
277 unit.status = Status::AwaitingVerify;
278 unit.verify = Some(verify_cmd.to_string());
279 let slug = id.replace('.', "-");
280 unit.to_file(mana_dir.join(format!("{}-task-{}.md", id, slug)))
281 .unwrap();
282 }
283
284 #[test]
290 fn batch_verify_groups_by_command() {
291 let (_dir, mana_dir) = setup();
292
293 write_awaiting(&mana_dir, "1", "true");
295 write_awaiting(&mana_dir, "2", "true");
296 write_awaiting(&mana_dir, "3", "true && true");
297
298 let index = Index::build(&mana_dir).unwrap();
300 index.save(&mana_dir).unwrap();
301
302 let result = batch_verify(&mana_dir).unwrap();
303
304 assert_eq!(result.commands_run, 2, "Expected 2 unique commands run");
305 assert_eq!(result.passed.len(), 3);
306 assert!(result.failed.is_empty());
307 }
308
309 #[test]
315 fn batch_verify_passes_close_units() {
316 let (_dir, mana_dir) = setup();
317 write_awaiting(&mana_dir, "1", "true");
318
319 let index = Index::build(&mana_dir).unwrap();
320 index.save(&mana_dir).unwrap();
321
322 let result = batch_verify(&mana_dir).unwrap();
323
324 assert_eq!(result.passed, vec!["1"]);
325 assert!(result.failed.is_empty());
326 assert_eq!(result.commands_run, 1);
327
328 let archive_path = find_archived_unit(&mana_dir, "1").expect("unit should be in archive");
331 let unit = Unit::from_file(archive_path).unwrap();
332 assert_eq!(unit.status, Status::Closed);
333 assert!(unit.is_archived);
334 }
335
336 #[test]
342 fn batch_verify_fails_reopen_units() {
343 let (_dir, mana_dir) = setup();
344 write_awaiting(&mana_dir, "1", "false");
345
346 let index = Index::build(&mana_dir).unwrap();
347 index.save(&mana_dir).unwrap();
348
349 let result = batch_verify(&mana_dir).unwrap();
350
351 assert!(result.passed.is_empty());
352 assert_eq!(result.failed.len(), 1);
353 assert_eq!(result.failed[0].unit_id, "1");
354 assert_eq!(result.failed[0].exit_code, Some(1));
355 assert!(!result.failed[0].timed_out);
356 assert_eq!(result.commands_run, 1);
357
358 let unit_path = find_unit_file(&mana_dir, "1").unwrap();
360 let unit = Unit::from_file(unit_path).unwrap();
361 assert_eq!(unit.status, Status::Open);
362 assert!(unit.claimed_by.is_none());
363 }
364
365 #[test]
371 fn batch_verify_empty_noop() {
372 let (_dir, mana_dir) = setup();
373
374 let mut unit = Unit::new("1", "Task 1");
376 unit.verify = Some("true".to_string());
377 unit.to_file(mana_dir.join("1-task-1.md")).unwrap();
378
379 let index = Index::build(&mana_dir).unwrap();
380 index.save(&mana_dir).unwrap();
381
382 let result = batch_verify(&mana_dir).unwrap();
383
384 assert!(result.passed.is_empty());
385 assert!(result.failed.is_empty());
386 assert_eq!(result.commands_run, 0);
387 }
388
389 #[test]
395 fn batch_verify_mixed_results() {
396 let (_dir, mana_dir) = setup();
397
398 write_awaiting(&mana_dir, "1", "true");
399 write_awaiting(&mana_dir, "2", "false");
400 write_awaiting(&mana_dir, "3", "true");
401
402 let index = Index::build(&mana_dir).unwrap();
403 index.save(&mana_dir).unwrap();
404
405 let result = batch_verify(&mana_dir).unwrap();
406
407 assert_eq!(result.commands_run, 2);
409
410 let mut passed = result.passed.clone();
412 passed.sort();
413 assert_eq!(passed, vec!["1", "3"]);
414
415 assert_eq!(result.failed.len(), 1);
417 assert_eq!(result.failed[0].unit_id, "2");
418
419 let u1 = Unit::from_file(find_archived_unit(&mana_dir, "1").unwrap()).unwrap();
422 assert_eq!(u1.status, Status::Closed);
423
424 let u2 = Unit::from_file(find_unit_file(&mana_dir, "2").unwrap()).unwrap();
425 assert_eq!(u2.status, Status::Open);
426
427 let u3 = Unit::from_file(find_archived_unit(&mana_dir, "3").unwrap()).unwrap();
428 assert_eq!(u3.status, Status::Closed);
429 }
430}