use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use chrono::Utc;
use crate::config::Config;
use crate::discovery::find_unit_file;
use crate::index::Index;
use crate::ops::close::{CloseOpts, CloseOutcome};
use crate::ops::verify::run_verify_command;
use crate::unit::{Status, Unit};
pub struct BatchVerifyResult {
pub passed: Vec<String>,
pub failed: Vec<BatchVerifyFailure>,
pub commands_run: usize,
}
pub struct BatchVerifyFailure {
pub unit_id: String,
pub verify_command: String,
pub exit_code: Option<i32>,
pub output: String,
pub timed_out: bool,
}
pub fn batch_verify(mana_dir: &Path) -> Result<BatchVerifyResult> {
let index = Index::load_or_rebuild(mana_dir)?;
let awaiting_ids: Vec<String> = index
.units
.iter()
.filter(|e| e.status == Status::AwaitingVerify)
.map(|e| e.id.clone())
.collect();
batch_verify_ids(mana_dir, &awaiting_ids)
}
pub fn batch_verify_ids(mana_dir: &Path, ids: &[String]) -> Result<BatchVerifyResult> {
if ids.is_empty() {
return Ok(BatchVerifyResult {
passed: vec![],
failed: vec![],
commands_run: 0,
});
}
let project_root = mana_dir
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine project root from mana dir"))?;
let config = Config::load_with_extends(mana_dir).ok();
let mut groups: HashMap<String, Vec<Unit>> = HashMap::new();
for id in ids {
let unit_path = match find_unit_file(mana_dir, id) {
Ok(p) => p,
Err(_) => continue, };
let unit =
Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
if unit.status != Status::AwaitingVerify {
continue;
}
let verify_cmd = match &unit.verify {
Some(cmd) if !cmd.trim().is_empty() => cmd.clone(),
_ => continue, };
groups.entry(verify_cmd).or_default().push(unit);
}
let commands_run = groups.len();
let mut passed = Vec::new();
let mut failed: Vec<BatchVerifyFailure> = Vec::new();
for (verify_cmd, units) in groups {
let timeout_secs =
units[0].effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout));
let result = run_verify_command(&verify_cmd, project_root, timeout_secs)?;
if result.passed {
for unit in units {
let outcome = crate::ops::close::close(
mana_dir,
&unit.id,
CloseOpts {
reason: Some("Batch verify passed".to_string()),
force: true,
defer_verify: false,
},
)?;
match outcome {
CloseOutcome::Closed(_) => {
passed.push(unit.id.clone());
}
other => {
match other {
CloseOutcome::RejectedByHook { unit_id }
| CloseOutcome::DeferredVerify { unit_id }
| CloseOutcome::FeatureRequiresHuman { unit_id, .. }
| CloseOutcome::CircuitBreakerTripped { unit_id, .. } => {
passed.push(unit_id);
}
CloseOutcome::MergeConflict { .. } => {
passed.push(unit.id.clone());
}
CloseOutcome::VerifyFrozenViolation { unit_id, .. } => {
passed.push(unit_id);
}
CloseOutcome::VerifyFailed(_) => {
}
CloseOutcome::Closed(_) => unreachable!(),
}
}
}
}
} else {
let combined_output = if result.timed_out {
format!("Verify timed out after {}s", timeout_secs.unwrap_or(0))
} else {
let stdout = result.stdout.trim();
let stderr = result.stderr.trim();
let sep = if !stdout.is_empty() && !stderr.is_empty() {
"\n"
} else {
""
};
format!("{}{}{}", stdout, sep, stderr)
};
for unit in &units {
reopen_awaiting_unit(mana_dir, &unit.id)?;
failed.push(BatchVerifyFailure {
unit_id: unit.id.clone(),
verify_command: verify_cmd.clone(),
exit_code: result.exit_code,
output: combined_output.clone(),
timed_out: result.timed_out,
});
}
}
}
Ok(BatchVerifyResult {
passed,
failed,
commands_run,
})
}
pub fn reopen_awaiting_unit(mana_dir: &Path, id: &str) -> Result<()> {
let unit_path =
find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
let mut unit =
Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
unit.status = Status::Open;
unit.claimed_by = None;
unit.claimed_at = None;
unit.updated_at = Utc::now();
unit.to_file(&unit_path)
.with_context(|| format!("Failed to save unit: {}", id))?;
let index = Index::build(mana_dir)?;
index.save(mana_dir)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::discovery::{find_archived_unit, find_unit_file};
use crate::unit::{Status, Unit};
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn setup() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
Config {
project: "test".to_string(),
next_id: 1,
auto_close_parent: true,
run: None,
plan: None,
max_loops: 10,
max_concurrent: 4,
poll_interval: 30,
extends: vec![],
rules_file: None,
file_locking: false,
worktree: false,
on_close: None,
on_fail: None,
verify_timeout: None,
review: None,
user: None,
user_email: None,
auto_commit: false,
commit_template: None,
research: None,
run_model: None,
plan_model: None,
review_model: None,
research_model: None,
batch_verify: false,
memory_reserve_mb: 0,
notify: None,
}
.save(&mana_dir)
.unwrap();
(dir, mana_dir)
}
fn write_awaiting(mana_dir: &Path, id: &str, verify_cmd: &str) {
let mut unit = Unit::new(id, format!("Task {}", id));
unit.status = Status::AwaitingVerify;
unit.verify = Some(verify_cmd.to_string());
let slug = id.replace('.', "-");
unit.to_file(mana_dir.join(format!("{}-task-{}.md", id, slug)))
.unwrap();
}
#[test]
fn batch_verify_groups_by_command() {
let (_dir, mana_dir) = setup();
write_awaiting(&mana_dir, "1", "true");
write_awaiting(&mana_dir, "2", "true");
write_awaiting(&mana_dir, "3", "true && true");
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
let result = batch_verify(&mana_dir).unwrap();
assert_eq!(result.commands_run, 2, "Expected 2 unique commands run");
assert_eq!(result.passed.len(), 3);
assert!(result.failed.is_empty());
}
#[test]
fn batch_verify_passes_close_units() {
let (_dir, mana_dir) = setup();
write_awaiting(&mana_dir, "1", "true");
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
let result = batch_verify(&mana_dir).unwrap();
assert_eq!(result.passed, vec!["1"]);
assert!(result.failed.is_empty());
assert_eq!(result.commands_run, 1);
let archive_path = find_archived_unit(&mana_dir, "1").expect("unit should be in archive");
let unit = Unit::from_file(archive_path).unwrap();
assert_eq!(unit.status, Status::Closed);
assert!(unit.is_archived);
}
#[test]
fn batch_verify_fails_reopen_units() {
let (_dir, mana_dir) = setup();
write_awaiting(&mana_dir, "1", "false");
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
let result = batch_verify(&mana_dir).unwrap();
assert!(result.passed.is_empty());
assert_eq!(result.failed.len(), 1);
assert_eq!(result.failed[0].unit_id, "1");
assert_eq!(result.failed[0].exit_code, Some(1));
assert!(!result.failed[0].timed_out);
assert_eq!(result.commands_run, 1);
let unit_path = find_unit_file(&mana_dir, "1").unwrap();
let unit = Unit::from_file(unit_path).unwrap();
assert_eq!(unit.status, Status::Open);
assert!(unit.claimed_by.is_none());
}
#[test]
fn batch_verify_empty_noop() {
let (_dir, mana_dir) = setup();
let mut unit = Unit::new("1", "Task 1");
unit.verify = Some("true".to_string());
unit.to_file(mana_dir.join("1-task-1.md")).unwrap();
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
let result = batch_verify(&mana_dir).unwrap();
assert!(result.passed.is_empty());
assert!(result.failed.is_empty());
assert_eq!(result.commands_run, 0);
}
#[test]
fn batch_verify_mixed_results() {
let (_dir, mana_dir) = setup();
write_awaiting(&mana_dir, "1", "true");
write_awaiting(&mana_dir, "2", "false");
write_awaiting(&mana_dir, "3", "true");
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
let result = batch_verify(&mana_dir).unwrap();
assert_eq!(result.commands_run, 2);
let mut passed = result.passed.clone();
passed.sort();
assert_eq!(passed, vec!["1", "3"]);
assert_eq!(result.failed.len(), 1);
assert_eq!(result.failed[0].unit_id, "2");
let u1 = Unit::from_file(find_archived_unit(&mana_dir, "1").unwrap()).unwrap();
assert_eq!(u1.status, Status::Closed);
let u2 = Unit::from_file(find_unit_file(&mana_dir, "2").unwrap()).unwrap();
assert_eq!(u2.status, Status::Open);
let u3 = Unit::from_file(find_archived_unit(&mana_dir, "3").unwrap()).unwrap();
assert_eq!(u3.status, Status::Closed);
}
}