use std::path::{Path, PathBuf};
use crate::contracts::evidence::{ArtifactKind, ArtifactRef, VerifierResult, VerifierStatus};
pub use crate::contracts::worker::{WorkerAssignment, WorkerAttempt, WorkerResult, WorkerStatus};
use imp_llm::ThinkingLevel;
use mana_core::api;
use mana_core::ops::close::{CloseOpts, CloseOutcome, VerifyFailureResult};
use mana_core::ops::verify as mana_verify;
use crate::context_prefill::{self, AssembledContext, FileSpec, PrefillConfig};
use crate::imp_session::{ImpSession, SessionChoice, SessionOptions};
use crate::mana_prompt_context;
use crate::system_prompt::{Attempt, Dependency, Fact, TaskContext};
use crate::tools::LuaToolLoader;
pub fn load_assignment(
cwd: &Path,
unit_id: &str,
) -> Result<WorkerAssignment, Box<dyn std::error::Error>> {
load_assignment_with_mana_dir(cwd, unit_id, None)
}
pub fn load_assignment_with_mana_dir(
cwd: &Path,
unit_id: &str,
mana_dir_override: Option<&Path>,
) -> Result<WorkerAssignment, Box<dyn std::error::Error>> {
let mana_dir = match mana_dir_override {
Some(dir) => dir.to_path_buf(),
None => mana_core::discovery::find_mana_dir(cwd).map_err(|e| {
format!(
"Could not find .mana directory while walking up from {}: {e}",
cwd.display()
)
})?,
};
let workspace_root = mana_dir
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| cwd.to_path_buf());
let unit = api::get_unit(&mana_dir, unit_id)
.map_err(|e| format!("Failed to load mana unit {unit_id}: {e}"))?;
let description = unit.description.clone().unwrap_or_default();
let unit_path = mana_core::discovery::find_unit_file(&mana_dir, unit_id).ok();
let body = unit_path.as_ref().and_then(|path| {
let content = std::fs::read_to_string(path).ok()?;
let body = extract_markdown_body(&content)?;
if body.trim().is_empty() {
None
} else {
Some(body)
}
});
let full_description = match body {
Some(body_text) if !description.is_empty() => {
format!("{}\n\n{}", description.trim(), body_text.trim())
}
Some(body_text) => body_text.trim().to_string(),
None => description,
};
let attempts = unit
.attempt_log
.iter()
.map(|record| WorkerAttempt {
number: record.num,
outcome: format!("{:?}", record.outcome).to_lowercase(),
summary: record.notes.clone().unwrap_or_default(),
})
.collect();
let files: Vec<String> = Vec::new();
Ok(WorkerAssignment {
id: unit.id.clone(),
title: unit.title.clone(),
description: full_description,
acceptance: unit.acceptance.clone(),
verify: unit.verify.clone(),
notes: unit.notes.clone(),
decisions: unit.decisions.clone(),
dependencies: unit.dependencies.clone(),
paths: unit.paths.clone(),
files,
attempts,
workspace_root,
model: unit.model.clone(),
})
}
fn derive_task_constraints(assignment: &WorkerAssignment) -> Vec<String> {
let mut constraints = Vec::new();
if !assignment.paths.is_empty() || !assignment.files.is_empty() {
constraints.push(
"Scope changes to the declared file/path hints unless a clear dependency forces broader edits."
.to_string(),
);
}
if assignment.verify.is_some() {
constraints.push(
"Treat the verify command as the primary completion gate for this task.".to_string(),
);
} else {
constraints.push(
"Do not claim completion until the acceptance criteria are concretely satisfied."
.to_string(),
);
}
if !assignment.dependencies.is_empty() {
constraints.push(
"Respect dependency context and avoid reworking already-completed dependency work unless required."
.to_string(),
);
}
if !assignment.decisions.is_empty() {
constraints.push(
"Treat unresolved decisions as real constraints; either resolve them explicitly or work around them honestly."
.to_string(),
);
}
constraints
}
pub fn build_task_context(assignment: &WorkerAssignment) -> TaskContext {
let description = assignment.description.trim().to_string();
let notes = assignment
.notes
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty())
.map(str::to_string);
let dependencies = if assignment.dependencies.is_empty() {
Vec::new()
} else {
let mana_dir = assignment.workspace_root.join(".mana");
match api::load_index(&mana_dir) {
Ok(index) => assignment
.dependencies
.iter()
.map(|dep_id| {
let entry = index.units.iter().find(|e| e.id == *dep_id);
Dependency {
name: dep_id.clone(),
status: entry
.map(|e| e.status.to_string())
.unwrap_or_else(|| "unknown".to_string()),
detail: entry
.map(|e| e.title.clone())
.unwrap_or_else(|| "not found in active index".to_string()),
}
})
.collect(),
Err(_) => assignment
.dependencies
.iter()
.map(|dep_id| Dependency {
name: dep_id.clone(),
status: "unknown".to_string(),
detail: "dependency status unavailable".to_string(),
})
.collect(),
}
};
let mut context_paths = assignment.paths.clone();
for file in &assignment.files {
if !context_paths.iter().any(|path| path == file) {
context_paths.push(file.clone());
}
}
TaskContext {
title: assignment.title.clone(),
description,
acceptance: assignment.acceptance.clone(),
verify: assignment.verify.clone(),
notes,
attempts: assignment
.attempts
.iter()
.map(|a| Attempt {
number: a.number,
outcome: a.outcome.clone(),
summary: a.summary.clone(),
})
.collect(),
dependencies,
decisions: assignment.decisions.clone(),
context_paths,
constraints: derive_task_constraints(assignment),
}
}
pub struct WorkerRunOptions {
pub cwd: PathBuf,
pub model_override: Option<imp_llm::Model>,
pub model: Option<String>,
pub provider: Option<String>,
pub api_key: Option<String>,
pub thinking: Option<ThinkingLevel>,
pub max_turns: Option<u32>,
pub max_tokens: Option<u32>,
pub system_prompt: Option<String>,
pub no_tools: bool,
pub mana_dir_override: Option<PathBuf>,
pub defer_verify: bool,
pub lua_loader: Option<LuaToolLoader>,
}
pub struct PreparedWorkerRun {
pub assignment: WorkerAssignment,
pub task_context: TaskContext,
pub facts: Vec<Fact>,
pub prefilled_files: Vec<PathBuf>,
pub prefill_warnings: Vec<String>,
pub estimated_prefill_tokens: usize,
pub prompt: String,
pub session: ImpSession,
defer_verify: bool,
}
pub struct WorkerRunOutcome {
pub assignment: WorkerAssignment,
pub result: WorkerResult,
pub verify_passed: Option<bool>,
pub closed_after_verify: bool,
pub prefilled_files: Vec<PathBuf>,
pub prefill_warnings: Vec<String>,
pub estimated_prefill_tokens: usize,
pub verify_output: Option<String>,
pub verifier_result: Option<VerifierResult>,
}
struct MappedCloseOutcome {
status: WorkerStatus,
summary: String,
error: Option<String>,
closed_after_verify: bool,
verify_output: Option<String>,
verifier_result: Option<VerifierResult>,
}
pub async fn prepare_worker_run(
assignment: WorkerAssignment,
options: WorkerRunOptions,
) -> Result<PreparedWorkerRun, Box<dyn std::error::Error>> {
let assembled = assemble_prefill(&assignment, &options.cwd);
let task_context = build_task_context(&assignment);
let facts = options
.mana_dir_override
.clone()
.or_else(|| mana_prompt_context::nearest_mana_dir(&options.cwd))
.map(|mana_dir| {
mana_prompt_context::load_task_prompt_context(&mana_dir, &task_context.context_paths)
.facts
})
.unwrap_or_default();
let session_options = SessionOptions {
cwd: options.cwd,
model_override: options.model_override,
model: options.model,
provider: options.provider,
api_key: options.api_key,
thinking: options.thinking,
max_turns: options.max_turns,
max_tokens: options.max_tokens,
system_prompt: options.system_prompt,
no_tools: options.no_tools,
session: SessionChoice::InMemory,
task: Some(task_context.clone()),
facts: facts.clone(),
context_prefill: assembled.messages,
lua_loader: options.lua_loader,
..Default::default()
};
let session = ImpSession::create(session_options)
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
let prompt = build_task_prompt(&assignment);
Ok(PreparedWorkerRun {
assignment,
task_context,
facts,
prefilled_files: assembled.included_files,
prefill_warnings: assembled.warnings,
estimated_prefill_tokens: assembled.estimated_tokens,
prompt,
session,
defer_verify: options.defer_verify,
})
}
pub async fn finalize_worker_run(
prepared: PreparedWorkerRun,
) -> Result<WorkerRunOutcome, Box<dyn std::error::Error>> {
let PreparedWorkerRun {
assignment,
prefilled_files,
prefill_warnings,
estimated_prefill_tokens,
session,
defer_verify,
..
} = prepared;
let model = Some(session.model().meta.id.clone());
let tool_count = session
.session_manager()
.get_active_messages()
.iter()
.filter(|message| matches!(message, imp_llm::Message::ToolResult(_)))
.count();
let turns = session
.session_manager()
.get_active_messages()
.iter()
.filter(|message| matches!(message, imp_llm::Message::Assistant(_)))
.count();
let batch_verify = defer_verify || std::env::var("MANA_BATCH_VERIFY").is_ok();
let mut verify_passed = None;
let mut closed_after_verify = false;
let mut status = if assignment
.verify
.as_deref()
.map(str::trim)
.filter(|verify| !verify.is_empty())
.is_some()
{
WorkerStatus::AwaitingVerify
} else {
WorkerStatus::Completed
};
let mut summary_override = None;
let mut verify_output = None;
let mut verifier_result = None;
let mut error = None;
if !batch_verify {
if let Some(verify) = assignment
.verify
.as_deref()
.map(str::trim)
.filter(|verify| !verify.is_empty())
{
let (passed, output) =
run_verify_command(&assignment.id, verify, &assignment.workspace_root).await?;
verify_passed = Some(passed);
verify_output = output;
if passed {
let mapped = close_unit_after_verify(&assignment)?;
status = mapped.status;
error = mapped.error;
closed_after_verify = mapped.closed_after_verify;
summary_override = Some(mapped.summary);
verify_output = mapped.verify_output.or(verify_output);
verifier_result = mapped.verifier_result;
} else {
status = WorkerStatus::Failed;
error = Some(format!("Verify command failed: {verify}"));
}
}
}
let summary = summary_override.or_else(|| {
Some(match status {
WorkerStatus::Completed => {
if closed_after_verify {
format!(
"Unit {} completed and closed after verify pass.",
assignment.id
)
} else if verify_passed == Some(true) {
format!("Unit {} completed successfully.", assignment.id)
} else {
format!("Unit {} completed.", assignment.id)
}
}
WorkerStatus::AwaitingVerify => {
format!("Unit {} completed and is awaiting verify.", assignment.id)
}
WorkerStatus::Failed => {
if verify_passed == Some(false) {
format!("Unit {} finished but verify failed.", assignment.id)
} else {
format!("Unit {} failed.", assignment.id)
}
}
WorkerStatus::Blocked => format!("Unit {} is blocked.", assignment.id),
WorkerStatus::Cancelled => format!("Unit {} was cancelled.", assignment.id),
})
});
let result = WorkerResult {
unit_id: assignment.id.clone(),
status,
summary,
error,
tool_count,
turns,
tokens: None,
cost: None,
model,
};
Ok(WorkerRunOutcome {
assignment,
result,
verify_passed,
closed_after_verify,
prefilled_files,
prefill_warnings,
estimated_prefill_tokens,
verify_output,
verifier_result,
})
}
fn close_unit_after_verify(
assignment: &WorkerAssignment,
) -> Result<MappedCloseOutcome, Box<dyn std::error::Error>> {
let mana_dir = assignment.workspace_root.join(".mana");
let outcome = api::close_unit(
&mana_dir,
&assignment.id,
CloseOpts {
reason: None,
force: false,
defer_verify: false,
},
)?;
Ok(map_close_outcome(&assignment.id, outcome))
}
fn map_close_outcome(unit_id: &str, outcome: CloseOutcome) -> MappedCloseOutcome {
match outcome {
CloseOutcome::Closed(_) => MappedCloseOutcome {
status: WorkerStatus::Completed,
summary: format!("Unit {unit_id} completed and closed after verify pass."),
error: None,
closed_after_verify: true,
verify_output: None,
verifier_result: None,
},
CloseOutcome::DeferredVerify { .. } => MappedCloseOutcome {
status: WorkerStatus::AwaitingVerify,
summary: format!("Unit {unit_id} completed and is awaiting verify."),
error: None,
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::VerifyFailed(result) => map_verify_failed_close_outcome(unit_id, result),
CloseOutcome::RejectedByHook { unit_id } => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!("Unit {unit_id} is blocked by a pre-close hook."),
error: Some("Pre-close hook rejected close.".to_string()),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::FeatureRequiresHuman { unit_id, title, .. } => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!("Unit {unit_id} requires human review to close feature '{title}'."),
error: Some("Feature unit requires human close.".to_string()),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::CircuitBreakerTripped {
unit_id,
total_attempts,
max,
..
} => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!(
"Unit {unit_id} is blocked because the circuit breaker tripped ({total_attempts} >= {max})."
),
error: Some("Circuit breaker tripped during close.".to_string()),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::MergeConflict { files, .. } => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!(
"Unit {unit_id} is blocked by merge conflicts during close ({} file(s)).",
files.len()
),
error: Some(format!("Merge conflict during close: {}", files.join(", "))),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
CloseOutcome::VerifyFrozenViolation { unit_id, .. } => MappedCloseOutcome {
status: WorkerStatus::Blocked,
summary: format!("Unit {unit_id} is blocked because the verify command changed since claim."),
error: Some("Verify frozen violation during close.".to_string()),
closed_after_verify: false,
verify_output: None,
verifier_result: None,
},
}
}
fn map_verify_failed_close_outcome(
unit_id: &str,
result: VerifyFailureResult,
) -> MappedCloseOutcome {
let summary = if result.timed_out {
format!("Unit {unit_id} failed during close because verify timed out.")
} else {
format!("Unit {unit_id} failed during close because verify failed.")
};
let error = if result.output.trim().is_empty() {
Some(format!(
"Verify command failed during close: {}",
result.verify_command
))
} else {
Some(format!(
"Verify command failed during close: {}\n{}",
result.verify_command, result.output
))
};
let verifier_result = build_verify_failure_verifier_result(unit_id, &result);
MappedCloseOutcome {
status: WorkerStatus::Failed,
summary,
error,
closed_after_verify: false,
verify_output: Some(result.output),
verifier_result: Some(verifier_result),
}
}
fn build_verify_failure_verifier_result(
unit_id: &str,
result: &VerifyFailureResult,
) -> VerifierResult {
let mut artifact_refs = Vec::new();
if !result.output.trim().is_empty() {
artifact_refs.push(ArtifactRef {
artifact_id: format!("{unit_id}:verify-output"),
kind: ArtifactKind::VerifyOutput,
locator: format!("verify-output://{unit_id}"),
run_id: None,
unit_id: Some(unit_id.to_string()),
stage: Some("verify".to_string()),
});
}
VerifierResult {
verifier_name: "unit.verify".to_string(),
status: VerifierStatus::Failed,
command: Some(result.verify_command.clone()),
exit_code: result.exit_code,
summary: Some(if result.timed_out {
"verify timed out".to_string()
} else {
"verify failed".to_string()
}),
artifact_refs,
started_at: None,
finished_at: None,
run_id: None,
unit_id: Some(unit_id.to_string()),
}
}
pub async fn run_worker_assignment(
assignment: WorkerAssignment,
options: WorkerRunOptions,
) -> Result<WorkerRunOutcome, Box<dyn std::error::Error>> {
let mut prepared = prepare_worker_run(assignment, options).await?;
prepared
.session
.prompt(&prepared.prompt)
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
prepared
.session
.wait()
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
finalize_worker_run(prepared).await
}
async fn run_verify_command(
unit_id: &str,
verify: &str,
cwd: &Path,
) -> Result<(bool, Option<String>), Box<dyn std::error::Error>> {
let verify = verify.trim();
let working_dir = cwd.to_path_buf();
let verify_cmd = verify.to_string();
let mana_dir = cwd.join(".mana");
let timeout_secs = if mana_dir.exists() {
match api::get_unit(&mana_dir, unit_id) {
Ok(unit) => {
let config = mana_core::config::Config::load_with_extends(&mana_dir).ok();
unit.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout))
}
Err(_) => None,
}
} else {
None
};
let result = tokio::task::spawn_blocking(move || {
mana_verify::run_verify_command(&verify_cmd, &working_dir, timeout_secs)
})
.await
.map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?
.map_err(|e| -> Box<dyn std::error::Error> {
Box::new(std::io::Error::other(e.to_string()))
})?;
let output = if result.passed {
None
} else if result.timed_out {
Some(match result.timeout_secs {
Some(secs) => format!("Verify timed out after {secs}s"),
None => "Verify timed out".to_string(),
})
} else {
let stderr = result.stderr.trim();
let stdout = result.stdout.trim();
if !stderr.is_empty() {
Some(stderr.to_string())
} else if !stdout.is_empty() {
Some(stdout.to_string())
} else {
None
}
};
Ok((result.passed, output))
}
pub fn build_task_prompt(assignment: &WorkerAssignment) -> String {
let mut prompt = format!("Task: {}", assignment.title);
if !assignment.description.trim().is_empty() {
prompt.push_str("\n\n");
prompt.push_str(assignment.description.trim());
}
if let Some(notes) = assignment
.notes
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty())
{
prompt.push_str("\n\nNotes:\n");
prompt.push_str(notes);
}
if !assignment.files.is_empty() || !assignment.paths.is_empty() {
prompt.push_str("\n\nReferenced files:\n");
for path in assignment.paths.iter().chain(assignment.files.iter()) {
prompt.push_str("- ");
prompt.push_str(path);
prompt.push('\n');
}
while prompt.ends_with('\n') {
prompt.pop();
}
}
if !assignment.attempts.is_empty() {
prompt.push_str("\n\nPrevious attempts:\n");
for attempt in &assignment.attempts {
prompt.push_str(&format!(
"- Attempt {} ({}): {}\n",
attempt.number, attempt.outcome, attempt.summary
));
}
while prompt.ends_with('\n') {
prompt.pop();
}
}
if let Some(verify) = assignment
.verify
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
prompt.push_str("\n\nVerify command: ");
prompt.push_str(verify);
}
prompt
}
pub fn assemble_prefill(assignment: &WorkerAssignment, cwd: &Path) -> AssembledContext {
let file_specs = if !assignment.files.is_empty() {
assignment
.files
.iter()
.filter_map(|s| parse_file_spec(s))
.collect()
} else if !assignment.paths.is_empty() {
assignment
.paths
.iter()
.filter_map(|s| parse_file_spec(s))
.collect()
} else {
context_prefill::detect_file_paths(&assignment.description)
};
if file_specs.is_empty() {
return AssembledContext::empty();
}
let config = PrefillConfig::default();
context_prefill::assemble_context(&file_specs, cwd, &config)
}
fn parse_file_spec(s: &str) -> Option<FileSpec> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (path_str, suffix) = if let Some(dot_pos) = s.rfind('.') {
let after_ext = &s[dot_pos..];
if let Some(colon_pos) = after_ext.find(':') {
let split_at = dot_pos + colon_pos;
(&s[..split_at], Some(&s[split_at + 1..]))
} else {
(s, None)
}
} else {
(s, None)
};
let mode = match suffix {
Some(suf) if suf.starts_with("tail:") => suf[5..]
.parse::<usize>()
.ok()
.map(context_prefill::FileMode::Tail)
.unwrap_or(context_prefill::FileMode::Full),
Some(suf) if suf.contains('-') => {
let parts: Vec<&str> = suf.splitn(2, '-').collect();
match (
parts[0].parse::<usize>(),
parts.get(1).and_then(|p| p.parse::<usize>().ok()),
) {
(Ok(start), Some(end)) => context_prefill::FileMode::Range(start, end),
_ => context_prefill::FileMode::Full,
}
}
_ => context_prefill::FileMode::Full,
};
Some(FileSpec {
path: PathBuf::from(path_str),
mode,
})
}
fn extract_markdown_body(content: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
if lines.first().copied() != Some("---") {
return None;
}
let end = lines
.iter()
.enumerate()
.skip(1)
.find_map(|(i, line)| (*line == "---").then_some(i))?;
let body = lines[end + 1..].join("\n");
Some(body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_task_prompt_basic() {
let assignment = WorkerAssignment {
id: "1".to_string(),
title: "Fix the bug".to_string(),
description: "There is a null pointer in foo.rs".to_string(),
acceptance: None,
verify: Some("cargo test".to_string()),
notes: None,
decisions: Vec::new(),
dependencies: Vec::new(),
paths: Vec::new(),
files: Vec::new(),
attempts: Vec::new(),
workspace_root: PathBuf::from("/tmp"),
model: None,
};
let prompt = build_task_prompt(&assignment);
assert!(prompt.contains("Task: Fix the bug"));
assert!(prompt.contains("null pointer"));
assert!(prompt.contains("Verify command: cargo test"));
}
#[test]
fn build_task_prompt_with_attempts() {
let assignment = WorkerAssignment {
id: "2".to_string(),
title: "Add test".to_string(),
description: "Add a test for auth".to_string(),
acceptance: None,
verify: None,
notes: Some("Check the fixtures module".to_string()),
decisions: Vec::new(),
dependencies: Vec::new(),
paths: vec!["tests/auth.rs".to_string()],
files: vec!["src/fixtures.rs".to_string()],
attempts: vec![WorkerAttempt {
number: 1,
outcome: "fail".to_string(),
summary: "Wrong fixture path".to_string(),
}],
workspace_root: PathBuf::from("/tmp"),
model: None,
};
let prompt = build_task_prompt(&assignment);
assert!(prompt.contains("Notes:"));
assert!(prompt.contains("Check the fixtures module"));
assert!(prompt.contains("Previous attempts:"));
assert!(prompt.contains("Referenced files:"));
assert!(prompt.contains("tests/auth.rs"));
assert!(prompt.contains("src/fixtures.rs"));
assert!(prompt.contains("Attempt 1 (fail): Wrong fixture path"));
}
#[test]
fn build_task_context_populates_fields() {
let assignment = WorkerAssignment {
id: "3".to_string(),
title: "Refactor module".to_string(),
description: "Split into submodules".to_string(),
acceptance: Some("All tests pass".to_string()),
verify: Some("cargo test".to_string()),
notes: Some("Prefer touching parser and module wiring first".to_string()),
decisions: vec!["Use mod.rs or inline?".to_string()],
dependencies: Vec::new(),
paths: vec!["src/lib.rs".to_string()],
files: vec!["src/parser.rs".to_string()],
attempts: Vec::new(),
workspace_root: PathBuf::from("/tmp"),
model: None,
};
let ctx = build_task_context(&assignment);
assert_eq!(ctx.title, "Refactor module");
assert_eq!(ctx.acceptance.as_deref(), Some("All tests pass"));
assert_eq!(ctx.verify.as_deref(), Some("cargo test"));
assert_eq!(
ctx.notes.as_deref(),
Some("Prefer touching parser and module wiring first")
);
assert_eq!(ctx.decisions, vec!["Use mod.rs or inline?"]);
assert_eq!(ctx.context_paths, vec!["src/lib.rs", "src/parser.rs"]);
assert!(ctx.constraints.iter().any(|c| c.contains("Scope changes")));
assert!(ctx.constraints.iter().any(|c| c.contains("verify command")));
}
#[test]
fn parse_file_spec_plain() {
let spec = parse_file_spec("src/main.rs").unwrap();
assert_eq!(spec.path, PathBuf::from("src/main.rs"));
assert_eq!(spec.mode, context_prefill::FileMode::Full);
}
#[test]
fn parse_file_spec_tail() {
let spec = parse_file_spec("src/main.rs:tail:50").unwrap();
assert_eq!(spec.path, PathBuf::from("src/main.rs"));
assert_eq!(spec.mode, context_prefill::FileMode::Tail(50));
}
#[test]
fn parse_file_spec_range() {
let spec = parse_file_spec("src/main.rs:10-20").unwrap();
assert_eq!(spec.path, PathBuf::from("src/main.rs"));
assert_eq!(spec.mode, context_prefill::FileMode::Range(10, 20));
}
#[test]
fn parse_file_spec_empty() {
assert!(parse_file_spec("").is_none());
assert!(parse_file_spec(" ").is_none());
}
#[test]
fn extract_markdown_body_works() {
let content = "---\ntitle: Test\n---\n\nBody text here.";
let body = extract_markdown_body(content).unwrap();
assert!(body.contains("Body text here."));
}
#[tokio::test]
async fn run_verify_command_captures_stderr_without_printing() {
let dir = tempfile::tempdir().unwrap();
let (passed, output) =
run_verify_command("missing", "printf 'boom' >&2; exit 1", dir.path())
.await
.unwrap();
assert!(!passed);
assert_eq!(output.as_deref(), Some("boom"));
}
#[tokio::test]
async fn run_verify_command_reports_timeout_message() {
let dir = tempfile::tempdir().unwrap();
let mana_dir = dir.path().join(".mana");
std::fs::create_dir_all(&mana_dir).unwrap();
let unit = mana_core::unit::Unit {
verify_timeout: Some(1),
verify: Some("python3 -c 'import time; time.sleep(2)'".to_string()),
..mana_core::unit::Unit::new("11", "Slow verify")
};
unit.to_file(mana_dir.join("11-slow-verify.md")).unwrap();
let (passed, output) =
run_verify_command("11", "python3 -c 'import time; time.sleep(2)'", dir.path())
.await
.unwrap();
assert!(!passed);
assert_eq!(output.as_deref(), Some("Verify timed out after 1s"));
}
#[tokio::test]
async fn run_verify_command_falls_back_to_stdout_when_stderr_is_empty() {
let dir = tempfile::tempdir().unwrap();
let (passed, output) = run_verify_command("missing", "printf 'nope'; exit 1", dir.path())
.await
.unwrap();
assert!(!passed);
assert_eq!(output.as_deref(), Some("nope"));
}
#[test]
fn map_close_outcome_closed_is_completed() {
let outcome = CloseOutcome::Closed(mana_core::ops::close::CloseResult {
unit: mana_core::unit::Unit::new("1", "Task"),
archive_path: PathBuf::from("/tmp/archive"),
auto_closed_parents: Vec::new(),
on_close_results: Vec::new(),
warnings: Vec::new(),
auto_commit_result: None,
evidence: None,
});
let mapped = map_close_outcome("1", outcome);
assert_eq!(mapped.status, WorkerStatus::Completed);
assert!(mapped.closed_after_verify);
assert!(mapped.error.is_none());
}
#[test]
fn map_close_outcome_deferred_verify_is_awaiting_verify() {
let mapped = map_close_outcome(
"42",
CloseOutcome::DeferredVerify {
unit_id: "42".to_string(),
},
);
assert_eq!(mapped.status, WorkerStatus::AwaitingVerify);
assert!(!mapped.closed_after_verify);
}
#[test]
fn map_close_outcome_feature_requires_human_is_blocked() {
let mapped = map_close_outcome(
"7",
CloseOutcome::FeatureRequiresHuman {
unit_id: "7".to_string(),
title: "Feature work".to_string(),
warnings: Vec::new(),
},
);
assert_eq!(mapped.status, WorkerStatus::Blocked);
assert!(mapped
.summary
.contains("requires human review to close feature"));
assert!(mapped.error.is_some());
}
#[test]
fn map_close_outcome_verify_failed_is_failed() {
let mut unit = mana_core::unit::Unit::new("9", "Verify fail");
unit.verify = Some("cargo test".to_string());
let mapped = map_close_outcome(
"9",
CloseOutcome::VerifyFailed(VerifyFailureResult {
unit,
attempt_number: 1,
exit_code: Some(1),
output: "boom".to_string(),
timed_out: false,
on_fail_action_taken: None,
verify_command: "cargo test".to_string(),
timeout_secs: None,
warnings: Vec::new(),
}),
);
assert_eq!(mapped.status, WorkerStatus::Failed);
assert_eq!(mapped.verify_output.as_deref(), Some("boom"));
let verifier = mapped.verifier_result.expect("verifier result");
assert_eq!(verifier.status, VerifierStatus::Failed);
assert_eq!(verifier.command.as_deref(), Some("cargo test"));
assert_eq!(verifier.unit_id.as_deref(), Some("9"));
assert_eq!(verifier.artifact_refs.len(), 1);
assert_eq!(verifier.artifact_refs[0].kind, ArtifactKind::VerifyOutput);
assert!(mapped.error.unwrap().contains("cargo test"));
}
#[test]
fn build_verify_failure_verifier_result_omits_artifact_when_output_empty() {
let verifier = build_verify_failure_verifier_result(
"11",
&VerifyFailureResult {
unit: mana_core::unit::Unit::new("11", "Verify fail"),
attempt_number: 1,
exit_code: Some(124),
output: String::new(),
timed_out: true,
on_fail_action_taken: None,
verify_command: "cargo test slow".to_string(),
timeout_secs: Some(30),
warnings: Vec::new(),
},
);
assert_eq!(verifier.status, VerifierStatus::Failed);
assert_eq!(verifier.summary.as_deref(), Some("verify timed out"));
assert!(verifier.artifact_refs.is_empty());
}
#[test]
fn extract_markdown_body_no_frontmatter() {
assert!(extract_markdown_body("No frontmatter").is_none());
}
}