1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use chrono::Utc;
5
6use crate::config::{Config, DEFAULT_COMMIT_TEMPLATE};
7use crate::discovery::{archive_path_for_unit, find_archived_unit, find_unit_file};
8use crate::graph;
9use crate::hooks::{
10 current_git_branch, execute_config_hook, execute_hook, is_trusted, HookEvent, HookVars,
11};
12use crate::index::{ArchiveIndex, Index, IndexEntry};
13use crate::ops::verify::run_verify_command;
14use crate::unit::{
15 AttemptOutcome, OnCloseAction, OnFailAction, RunRecord, RunResult, Status, Unit,
16};
17use crate::util::title_to_slug;
18
19#[derive(Debug)]
25pub enum OnFailActionTaken {
26 Retry {
28 attempt: u32,
29 max: u32,
30 delay_secs: Option<u64>,
31 },
32 RetryExhausted { max: u32 },
34 Escalated,
36 None,
38}
39
40#[derive(Debug)]
42pub struct CircuitBreakerStatus {
43 pub tripped: bool,
44 pub subtree_total: u32,
45 pub max_loops: u32,
46}
47
48#[derive(Debug)]
50pub struct VerifyFailure {
51 pub exit_code: Option<i32>,
52 pub output: String,
53 pub timed_out: bool,
54 pub duration_secs: f64,
55 pub started_at: chrono::DateTime<Utc>,
56 pub finished_at: chrono::DateTime<Utc>,
57 pub agent: Option<String>,
58}
59
60pub struct CloseOpts {
62 pub reason: Option<String>,
63 pub force: bool,
64 pub defer_verify: bool,
69}
70
71#[derive(Debug)]
73pub enum CloseWarning {
74 PreCloseHookError { message: String },
76 PostCloseHookRejected,
78 PostCloseHookError { message: String },
80 WorktreeCleanupFailed { message: String },
82}
83
84#[derive(Debug)]
86pub struct AutoCommitResult {
87 pub message: String,
88 pub committed: bool,
89 pub warning: Option<String>,
90}
91
92#[derive(Debug)]
94pub enum CloseOutcome {
95 Closed(CloseResult),
97 VerifyFailed(VerifyFailureResult),
99 RejectedByHook { unit_id: String },
101 FeatureRequiresHuman {
103 unit_id: String,
104 title: String,
105 warnings: Vec<CloseWarning>,
106 },
107 CircuitBreakerTripped {
109 unit_id: String,
110 total_attempts: u32,
111 max: u32,
112 warnings: Vec<CloseWarning>,
113 },
114 MergeConflict {
116 files: Vec<String>,
117 warnings: Vec<CloseWarning>,
118 },
119 DeferredVerify { unit_id: String },
124}
125
126#[derive(Debug)]
128pub struct CloseResult {
129 pub unit: Unit,
130 pub archive_path: PathBuf,
131 pub auto_closed_parents: Vec<String>,
132 pub on_close_results: Vec<OnCloseActionResult>,
133 pub warnings: Vec<CloseWarning>,
134 pub auto_commit_result: Option<AutoCommitResult>,
135}
136
137#[derive(Debug)]
139pub enum OnCloseActionResult {
140 RanCommand {
142 command: String,
143 success: bool,
144 exit_code: Option<i32>,
145 error: Option<String>,
146 },
147 Notified { message: String },
149 Skipped { command: String },
151}
152
153#[derive(Debug)]
155pub struct VerifyFailureResult {
156 pub unit: Unit,
157 pub attempt_number: u32,
158 pub exit_code: Option<i32>,
159 pub output: String,
160 pub timed_out: bool,
161 pub on_fail_action_taken: Option<OnFailActionTaken>,
162 pub verify_command: String,
163 pub timeout_secs: Option<u64>,
164 pub warnings: Vec<CloseWarning>,
165}
166
167struct HookDecision {
168 accepted: bool,
169 warning: Option<CloseWarning>,
170}
171
172struct PostCloseActionsReport {
173 warnings: Vec<CloseWarning>,
174 on_close_results: Vec<OnCloseActionResult>,
175}
176
177enum WorktreeMergeStatus {
178 Merged,
179 Conflict { files: Vec<String> },
180}
181
182const MAX_OUTPUT_BYTES: usize = 64 * 1024;
184
185pub fn close(mana_dir: &Path, id: &str, opts: CloseOpts) -> Result<CloseOutcome> {
197 let project_root = mana_dir
198 .parent()
199 .ok_or_else(|| anyhow::anyhow!("Cannot determine project root from units dir"))?;
200
201 let config = Config::load(mana_dir).ok();
202
203 let unit_path =
204 find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
205 let mut unit =
206 Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
207
208 let pre_close = run_pre_close_hook(&unit, project_root, opts.reason.as_deref());
210 if !pre_close.accepted {
211 return Ok(CloseOutcome::RejectedByHook {
212 unit_id: id.to_string(),
213 });
214 }
215
216 let mut warnings = Vec::new();
217 if let Some(warning) = pre_close.warning {
218 warnings.push(warning);
219 }
220
221 if opts.defer_verify {
226 unit.status = Status::AwaitingVerify;
227 unit.updated_at = Utc::now();
228 unit.to_file(&unit_path)
229 .with_context(|| format!("Failed to save unit: {}", id))?;
230 rebuild_index(mana_dir)?;
231 return Ok(CloseOutcome::DeferredVerify {
232 unit_id: id.to_string(),
233 });
234 }
235
236 if let Some(verify_cmd) = unit.verify.clone() {
238 if !verify_cmd.trim().is_empty() && !opts.force {
239 let timeout_secs =
240 unit.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout));
241
242 let started_at = Utc::now();
243 let verify_result = run_verify_command(&verify_cmd, project_root, timeout_secs)?;
244 let finished_at = Utc::now();
245 let duration_secs = (finished_at - started_at).num_milliseconds() as f64 / 1000.0;
246 let agent = std::env::var("MANA_AGENT").ok();
247
248 if !verify_result.passed {
249 let combined_output = if verify_result.timed_out {
251 format!("Verify timed out after {}s", timeout_secs.unwrap_or(0))
252 } else {
253 let stdout = verify_result.stdout.trim();
254 let stderr = verify_result.stderr.trim();
255 let sep = if !stdout.is_empty() && !stderr.is_empty() {
256 "\n"
257 } else {
258 ""
259 };
260 format!("{}{}{}", stdout, sep, stderr)
261 };
262
263 let failure = VerifyFailure {
265 exit_code: verify_result.exit_code,
266 output: combined_output,
267 timed_out: verify_result.timed_out,
268 duration_secs,
269 started_at,
270 finished_at,
271 agent,
272 };
273 record_failure_on_unit(&mut unit, &failure);
274
275 let root_id = find_root_parent(mana_dir, &unit)?;
277 let config_max = config.as_ref().map(|c| c.max_loops).unwrap_or(10);
278 let max_loops_limit = resolve_max_loops(mana_dir, &unit, &root_id, config_max);
279
280 if max_loops_limit > 0 {
281 unit.to_file(&unit_path)
283 .with_context(|| format!("Failed to save unit: {}", id))?;
284
285 let cb = check_circuit_breaker(mana_dir, &mut unit, &root_id, max_loops_limit)?;
286 if cb.tripped {
287 unit.to_file(&unit_path)
288 .with_context(|| format!("Failed to save unit: {}", id))?;
289
290 rebuild_index(mana_dir)?;
292
293 return Ok(CloseOutcome::CircuitBreakerTripped {
294 unit_id: id.to_string(),
295 total_attempts: cb.subtree_total,
296 max: cb.max_loops,
297 warnings,
298 });
299 }
300 }
301
302 let action_taken = process_on_fail(&mut unit);
304
305 unit.to_file(&unit_path)
306 .with_context(|| format!("Failed to save unit: {}", id))?;
307
308 run_on_fail_hook(&unit, project_root, config.as_ref(), &failure.output);
310
311 rebuild_index(mana_dir)?;
313
314 return Ok(CloseOutcome::VerifyFailed(VerifyFailureResult {
315 attempt_number: unit.attempts,
316 exit_code: failure.exit_code,
317 output: failure.output,
318 timed_out: failure.timed_out,
319 on_fail_action_taken: Some(action_taken),
320 verify_command: verify_cmd,
321 timeout_secs,
322 warnings,
323 unit,
324 }));
325 }
326
327 unit.history.push(RunRecord {
329 attempt: unit.attempts + 1,
330 started_at,
331 finished_at: Some(finished_at),
332 duration_secs: Some(duration_secs),
333 agent,
334 result: RunResult::Pass,
335 exit_code: verify_result.exit_code,
336 tokens: None,
337 cost: None,
338 output_snippet: None,
339 });
340
341 capture_verify_outputs(&mut unit, &verify_result.stdout);
343 }
344 }
345
346 let worktree_info = detect_valid_worktree(project_root);
348 if let Some(ref wt_info) = worktree_info {
349 match handle_worktree_merge(wt_info, &unit)? {
350 WorktreeMergeStatus::Merged => {}
351 WorktreeMergeStatus::Conflict { files } => {
352 return Ok(CloseOutcome::MergeConflict { files, warnings });
353 }
354 }
355 }
356
357 if unit.feature {
359 use std::io::IsTerminal;
360 if !opts.force || !std::io::stdin().is_terminal() {
361 return Ok(CloseOutcome::FeatureRequiresHuman {
362 unit_id: unit.id.clone(),
363 title: unit.title.clone(),
364 warnings,
365 });
366 }
367 }
368
369 let now = Utc::now();
371 unit.status = Status::Closed;
372 unit.closed_at = Some(now);
373 unit.close_reason = opts.reason.clone();
374 unit.updated_at = now;
375
376 if let Some(attempt) = unit.attempt_log.last_mut() {
378 if attempt.finished_at.is_none() {
379 attempt.outcome = AttemptOutcome::Success;
380 attempt.finished_at = Some(now);
381 attempt.notes = opts.reason.clone();
382 }
383 }
384
385 if unit.unit_type == "fact" {
387 unit.last_verified = Some(now);
388 }
389
390 unit.to_file(&unit_path)
391 .with_context(|| format!("Failed to save unit: {}", id))?;
392
393 let archive_path = archive_unit(mana_dir, &mut unit, &unit_path)?;
395
396 let post_close =
398 run_post_close_actions(&unit, project_root, opts.reason.as_deref(), config.as_ref());
399 warnings.extend(post_close.warnings);
400
401 if let Some(ref wt_info) = worktree_info {
403 if let Some(warning) = cleanup_worktree(wt_info) {
404 warnings.push(warning);
405 }
406 }
407
408 let auto_closed_parents = if mana_dir.exists() {
410 if let Some(parent_id) = &unit.parent {
411 let auto_close_enabled = config.as_ref().map(|c| c.auto_close_parent).unwrap_or(true);
412 if auto_close_enabled {
413 auto_close_parents(mana_dir, parent_id)?
414 } else {
415 vec![]
416 }
417 } else {
418 vec![]
419 }
420 } else {
421 vec![]
422 };
423
424 rebuild_index(mana_dir)?;
427
428 let auto_commit_result = if worktree_info.is_none() {
430 let auto_commit_enabled = config.as_ref().map(|c| c.auto_commit).unwrap_or(false);
431 if auto_commit_enabled {
432 let template = config.as_ref().and_then(|c| c.commit_template.clone());
433 Some(auto_commit_on_close(
434 project_root,
435 id,
436 &unit.title,
437 unit.parent.as_deref(),
438 &unit.labels,
439 template.as_deref(),
440 ))
441 } else {
442 None
443 }
444 } else {
445 None
446 };
447
448 Ok(CloseOutcome::Closed(CloseResult {
449 unit,
450 archive_path,
451 auto_closed_parents,
452 on_close_results: post_close.on_close_results,
453 warnings,
454 auto_commit_result,
455 }))
456}
457
458pub fn close_failed(mana_dir: &Path, id: &str, reason: Option<String>) -> Result<Unit> {
463 let now = Utc::now();
464
465 let unit_path =
466 find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
467 let mut unit =
468 Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
469
470 if let Some(attempt) = unit.attempt_log.last_mut() {
472 if attempt.finished_at.is_none() {
473 attempt.outcome = AttemptOutcome::Failed;
474 attempt.finished_at = Some(now);
475 attempt.notes = reason.clone();
476 }
477 }
478
479 unit.claimed_by = None;
481 unit.claimed_at = None;
482 unit.status = Status::Open;
483 unit.updated_at = now;
484
485 {
487 let attempt_num = unit.attempt_log.len() as u32;
488 let duration_secs = unit
489 .attempt_log
490 .last()
491 .and_then(|a| a.started_at)
492 .map(|started| (now - started).num_seconds().max(0) as u64)
493 .unwrap_or(0);
494
495 let ctx = crate::failure::FailureContext {
496 unit_id: id.to_string(),
497 unit_title: unit.title.clone(),
498 attempt: attempt_num.max(1),
499 duration_secs,
500 tool_count: 0,
501 turns: 0,
502 input_tokens: 0,
503 output_tokens: 0,
504 cost: 0.0,
505 error: reason,
506 tool_log: vec![],
507 verify_command: unit.verify.clone(),
508 };
509 let summary = crate::failure::build_failure_summary(&ctx);
510
511 match &mut unit.notes {
512 Some(notes) => {
513 notes.push('\n');
514 notes.push_str(&summary);
515 }
516 None => unit.notes = Some(summary),
517 }
518 }
519
520 unit.to_file(&unit_path)
521 .with_context(|| format!("Failed to save unit: {}", id))?;
522
523 rebuild_index(mana_dir)?;
525
526 Ok(unit)
527}
528
529pub fn all_children_closed(mana_dir: &Path, parent_id: &str) -> Result<bool> {
538 let index = Index::build(mana_dir)?;
539 let archived = Index::collect_archived(mana_dir).unwrap_or_default();
540
541 let mut all_units = index.units;
542 all_units.extend(archived);
543
544 let children: Vec<_> = all_units
545 .iter()
546 .filter(|b| b.parent.as_deref() == Some(parent_id))
547 .collect();
548
549 if children.is_empty() {
550 return Ok(true);
551 }
552
553 for child in children {
554 if child.status != Status::Closed {
555 return Ok(false);
556 }
557 }
558
559 Ok(true)
560}
561
562pub fn auto_close_parents(mana_dir: &Path, parent_id: &str) -> Result<Vec<String>> {
568 let mut closed = Vec::new();
569 auto_close_parent_recursive(mana_dir, parent_id, &mut closed)?;
570 Ok(closed)
571}
572
573fn auto_close_parent_recursive(
574 mana_dir: &Path,
575 parent_id: &str,
576 closed: &mut Vec<String>,
577) -> Result<()> {
578 if !all_children_closed(mana_dir, parent_id)? {
579 return Ok(());
580 }
581
582 let unit_path = match find_unit_file(mana_dir, parent_id) {
583 Ok(path) => path,
584 Err(_) => return Ok(()), };
586
587 let mut unit = Unit::from_file(&unit_path)
588 .with_context(|| format!("Failed to load parent unit: {}", parent_id))?;
589
590 if unit.status == Status::Closed {
591 return Ok(());
592 }
593
594 if unit.feature {
596 return Ok(());
597 }
598
599 let now = Utc::now();
600 unit.status = Status::Closed;
601 unit.closed_at = Some(now);
602 unit.close_reason = Some("Auto-closed: all children completed".to_string());
603 unit.updated_at = now;
604
605 unit.to_file(&unit_path)
606 .with_context(|| format!("Failed to save parent unit: {}", parent_id))?;
607
608 archive_unit(mana_dir, &mut unit, &unit_path)?;
609 closed.push(parent_id.to_string());
610
611 if let Some(grandparent_id) = &unit.parent {
613 auto_close_parent_recursive(mana_dir, grandparent_id, closed)?;
614 }
615
616 Ok(())
617}
618
619pub fn archive_unit(mana_dir: &Path, unit: &mut Unit, unit_path: &Path) -> Result<PathBuf> {
624 let id = &unit.id;
625 let slug = unit
626 .slug
627 .clone()
628 .unwrap_or_else(|| title_to_slug(&unit.title));
629 let ext = unit_path
630 .extension()
631 .and_then(|e| e.to_str())
632 .unwrap_or("md");
633 let today = chrono::Local::now().naive_local().date();
634 let archive_path = archive_path_for_unit(mana_dir, id, &slug, ext, today);
635
636 if let Some(parent) = archive_path.parent() {
637 std::fs::create_dir_all(parent)
638 .with_context(|| format!("Failed to create archive directories for unit {}", id))?;
639 }
640
641 std::fs::rename(unit_path, &archive_path)
642 .with_context(|| format!("Failed to move unit {} to archive", id))?;
643
644 unit.is_archived = true;
645 unit.to_file(&archive_path)
646 .with_context(|| format!("Failed to save archived unit: {}", id))?;
647
648 {
650 let mut archive_index =
651 ArchiveIndex::load(mana_dir).unwrap_or(ArchiveIndex { units: Vec::new() });
652 archive_index.append(IndexEntry::from(&*unit));
653 let _ = archive_index.save(mana_dir);
654 }
655
656 Ok(archive_path)
657}
658
659pub fn record_failure(unit: &mut Unit, failure: &VerifyFailure) {
664 record_failure_on_unit(unit, failure);
665}
666
667pub fn process_on_fail(unit: &mut Unit) -> OnFailActionTaken {
672 let on_fail = match &unit.on_fail {
673 Some(action) => action.clone(),
674 None => return OnFailActionTaken::None,
675 };
676
677 match on_fail {
678 OnFailAction::Retry { max, delay_secs } => {
679 let max_retries = max.unwrap_or(unit.max_attempts);
680 if unit.attempts < max_retries {
681 unit.claimed_by = None;
682 unit.claimed_at = None;
683 OnFailActionTaken::Retry {
684 attempt: unit.attempts,
685 max: max_retries,
686 delay_secs,
687 }
688 } else {
689 OnFailActionTaken::RetryExhausted { max: max_retries }
690 }
691 }
692 OnFailAction::Escalate { priority, message } => {
693 if let Some(p) = priority {
694 unit.priority = p;
695 }
696 if let Some(msg) = &message {
697 let note = format!(
698 "\n## Escalated — {}\n{}",
699 Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
700 msg
701 );
702 match &mut unit.notes {
703 Some(notes) => notes.push_str(¬e),
704 None => unit.notes = Some(note),
705 }
706 }
707 if !unit.labels.contains(&"escalated".to_string()) {
708 unit.labels.push("escalated".to_string());
709 }
710 OnFailActionTaken::Escalated
711 }
712 }
713}
714
715pub fn check_circuit_breaker(
721 mana_dir: &Path,
722 unit: &mut Unit,
723 root_id: &str,
724 max_loops: u32,
725) -> Result<CircuitBreakerStatus> {
726 if max_loops == 0 {
727 return Ok(CircuitBreakerStatus {
728 tripped: false,
729 subtree_total: 0,
730 max_loops: 0,
731 });
732 }
733
734 let subtree_total = graph::count_subtree_attempts(mana_dir, root_id)?;
735 if subtree_total >= max_loops {
736 if !unit.labels.contains(&"circuit-breaker".to_string()) {
737 unit.labels.push("circuit-breaker".to_string());
738 }
739 unit.priority = 0;
740 Ok(CircuitBreakerStatus {
741 tripped: true,
742 subtree_total,
743 max_loops,
744 })
745 } else {
746 Ok(CircuitBreakerStatus {
747 tripped: false,
748 subtree_total,
749 max_loops,
750 })
751 }
752}
753
754pub fn find_root_parent(mana_dir: &Path, unit: &Unit) -> Result<String> {
759 let mut current_id = match &unit.parent {
760 None => return Ok(unit.id.clone()),
761 Some(pid) => pid.clone(),
762 };
763
764 loop {
765 let path = find_unit_file(mana_dir, ¤t_id)
766 .or_else(|_| find_archived_unit(mana_dir, ¤t_id));
767
768 match path {
769 Ok(p) => {
770 let b = Unit::from_file(&p)
771 .with_context(|| format!("Failed to load parent unit: {}", current_id))?;
772 match b.parent {
773 Some(parent_id) => current_id = parent_id,
774 None => return Ok(current_id),
775 }
776 }
777 Err(_) => return Ok(current_id),
778 }
779 }
780}
781
782pub fn resolve_max_loops(mana_dir: &Path, unit: &Unit, root_id: &str, config_max: u32) -> u32 {
784 if root_id == unit.id {
785 unit.effective_max_loops(config_max)
786 } else {
787 let root_path =
788 find_unit_file(mana_dir, root_id).or_else(|_| find_archived_unit(mana_dir, root_id));
789 match root_path {
790 Ok(p) => Unit::from_file(&p)
791 .map(|b| b.effective_max_loops(config_max))
792 .unwrap_or(config_max),
793 Err(_) => config_max,
794 }
795 }
796}
797
798fn record_failure_on_unit(unit: &mut Unit, failure: &VerifyFailure) {
804 unit.attempts += 1;
805 unit.updated_at = Utc::now();
806
807 let failure_note = format_failure_note(unit.attempts, failure.exit_code, &failure.output);
809 match &mut unit.notes {
810 Some(notes) => notes.push_str(&failure_note),
811 None => unit.notes = Some(failure_note),
812 }
813
814 let output_snippet = if failure.output.is_empty() {
816 None
817 } else {
818 Some(truncate_output(&failure.output, 20))
819 };
820 unit.history.push(RunRecord {
821 attempt: unit.attempts,
822 started_at: failure.started_at,
823 finished_at: Some(failure.finished_at),
824 duration_secs: Some(failure.duration_secs),
825 agent: failure.agent.clone(),
826 result: if failure.timed_out {
827 RunResult::Timeout
828 } else {
829 RunResult::Fail
830 },
831 exit_code: failure.exit_code,
832 tokens: None,
833 cost: None,
834 output_snippet,
835 });
836}
837
838fn capture_verify_outputs(unit: &mut Unit, stdout: &str) {
840 let stdout = stdout.trim();
841 if stdout.is_empty() {
842 return;
843 }
844
845 if stdout.len() > MAX_OUTPUT_BYTES {
846 let end = truncate_to_char_boundary(stdout, MAX_OUTPUT_BYTES);
847 let truncated = &stdout[..end];
848 unit.outputs = Some(serde_json::json!({
849 "text": truncated,
850 "truncated": true,
851 "original_bytes": stdout.len()
852 }));
853 } else {
854 match serde_json::from_str::<serde_json::Value>(stdout) {
855 Ok(json) => {
856 unit.outputs = Some(json);
857 }
858 Err(_) => {
859 unit.outputs = Some(serde_json::json!({
860 "text": stdout
861 }));
862 }
863 }
864 }
865}
866
867pub fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> usize {
869 if max_bytes >= s.len() {
870 return s.len();
871 }
872 let mut end = max_bytes;
873 while !s.is_char_boundary(end) {
874 end -= 1;
875 }
876 end
877}
878
879pub fn truncate_output(output: &str, max_lines: usize) -> String {
881 let lines: Vec<&str> = output.lines().collect();
882
883 if lines.len() <= max_lines * 2 {
884 return output.to_string();
885 }
886
887 let first = &lines[..max_lines];
888 let last = &lines[lines.len() - max_lines..];
889
890 format!(
891 "{}\n\n... ({} lines omitted) ...\n\n{}",
892 first.join("\n"),
893 lines.len() - max_lines * 2,
894 last.join("\n")
895 )
896}
897
898pub fn format_failure_note(attempt: u32, exit_code: Option<i32>, output: &str) -> String {
900 let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
901 let truncated = truncate_output(output, 50);
902 let exit_str = exit_code
903 .map(|c| format!("Exit code: {}\n", c))
904 .unwrap_or_default();
905
906 format!(
907 "\n## Attempt {} — {}\n{}\n```\n{}\n```\n",
908 attempt, timestamp, exit_str, truncated
909 )
910}
911
912fn run_pre_close_hook(unit: &Unit, project_root: &Path, reason: Option<&str>) -> HookDecision {
918 let result = execute_hook(
919 HookEvent::PreClose,
920 unit,
921 project_root,
922 reason.map(|s| s.to_string()),
923 );
924
925 match result {
926 Ok(hook_passed) => HookDecision {
927 accepted: hook_passed,
928 warning: None,
929 },
930 Err(e) => HookDecision {
931 accepted: true,
932 warning: Some(CloseWarning::PreCloseHookError {
933 message: e.to_string(),
934 }),
935 },
936 }
937}
938
939fn run_post_close_actions(
941 unit: &Unit,
942 project_root: &Path,
943 reason: Option<&str>,
944 config: Option<&Config>,
945) -> PostCloseActionsReport {
946 let mut warnings = Vec::new();
947
948 match execute_hook(
950 HookEvent::PostClose,
951 unit,
952 project_root,
953 reason.map(|s| s.to_string()),
954 ) {
955 Ok(false) => warnings.push(CloseWarning::PostCloseHookRejected),
956 Err(e) => warnings.push(CloseWarning::PostCloseHookError {
957 message: e.to_string(),
958 }),
959 Ok(true) => {}
960 }
961
962 let mut on_close_results = Vec::new();
964 for action in &unit.on_close {
965 match action {
966 OnCloseAction::Run { command } => {
967 if !is_trusted(project_root) {
968 on_close_results.push(OnCloseActionResult::Skipped {
969 command: command.clone(),
970 });
971 continue;
972 }
973
974 let status = std::process::Command::new("sh")
975 .args(["-c", command.as_str()])
976 .current_dir(project_root)
977 .status();
978 let result = match status {
979 Ok(status) => OnCloseActionResult::RanCommand {
980 command: command.clone(),
981 success: status.success(),
982 exit_code: status.code(),
983 error: None,
984 },
985 Err(e) => OnCloseActionResult::RanCommand {
986 command: command.clone(),
987 success: false,
988 exit_code: None,
989 error: Some(e.to_string()),
990 },
991 };
992 on_close_results.push(result);
993 }
994 OnCloseAction::Notify { message } => {
995 on_close_results.push(OnCloseActionResult::Notified {
996 message: message.clone(),
997 });
998 }
999 }
1000 }
1001
1002 if let Some(config) = config {
1004 if let Some(ref on_close_template) = config.on_close {
1005 let vars = HookVars {
1006 id: Some(unit.id.clone()),
1007 title: Some(unit.title.clone()),
1008 status: Some("closed".into()),
1009 branch: current_git_branch(),
1010 ..Default::default()
1011 };
1012 execute_config_hook("on_close", on_close_template, &vars, project_root);
1013 }
1014 }
1015
1016 PostCloseActionsReport {
1017 warnings,
1018 on_close_results,
1019 }
1020}
1021
1022fn run_on_fail_hook(unit: &Unit, project_root: &Path, config: Option<&Config>, output: &str) {
1024 if let Some(config) = config {
1025 if let Some(ref on_fail_template) = config.on_fail {
1026 let vars = HookVars {
1027 id: Some(unit.id.clone()),
1028 title: Some(unit.title.clone()),
1029 status: Some(format!("{}", unit.status)),
1030 attempt: Some(unit.attempts),
1031 output: Some(output.to_string()),
1032 branch: current_git_branch(),
1033 ..Default::default()
1034 };
1035 execute_config_hook("on_fail", on_fail_template, &vars, project_root);
1036 }
1037 }
1038}
1039
1040fn detect_valid_worktree(project_root: &Path) -> Option<crate::worktree::WorktreeInfo> {
1046 let info = crate::worktree::detect_worktree(project_root).unwrap_or(None)?;
1047
1048 let canonical_root =
1049 std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1050 if canonical_root.starts_with(&info.worktree_path) {
1051 Some(info)
1052 } else {
1053 None
1054 }
1055}
1056
1057fn handle_worktree_merge(
1059 wt_info: &crate::worktree::WorktreeInfo,
1060 unit: &Unit,
1061) -> Result<WorktreeMergeStatus> {
1062 let message = expand_commit_template(
1063 DEFAULT_COMMIT_TEMPLATE,
1064 &unit.id,
1065 &unit.title,
1066 unit.parent.as_deref(),
1067 &unit.labels,
1068 );
1069 crate::worktree::commit_worktree_changes(&wt_info.worktree_path, &message)?;
1070
1071 match crate::worktree::merge_to_main(wt_info, &unit.id)? {
1072 crate::worktree::MergeResult::Success | crate::worktree::MergeResult::NothingToCommit => {
1073 Ok(WorktreeMergeStatus::Merged)
1074 }
1075 crate::worktree::MergeResult::Conflict { files } => {
1076 Ok(WorktreeMergeStatus::Conflict { files })
1077 }
1078 }
1079}
1080
1081fn cleanup_worktree(wt_info: &crate::worktree::WorktreeInfo) -> Option<CloseWarning> {
1083 crate::worktree::cleanup_worktree(wt_info)
1084 .err()
1085 .map(|e| CloseWarning::WorktreeCleanupFailed {
1086 message: e.to_string(),
1087 })
1088}
1089
1090fn expand_commit_template(
1094 template: &str,
1095 id: &str,
1096 title: &str,
1097 parent_id: Option<&str>,
1098 labels: &[String],
1099) -> String {
1100 template
1101 .replace("{id}", id)
1102 .replace("{title}", title)
1103 .replace("{parent_id}", parent_id.unwrap_or(""))
1104 .replace("{labels}", &labels.join(","))
1105}
1106
1107fn auto_commit_on_close(
1109 project_root: &Path,
1110 id: &str,
1111 title: &str,
1112 parent_id: Option<&str>,
1113 labels: &[String],
1114 template: Option<&str>,
1115) -> AutoCommitResult {
1116 let message = expand_commit_template(
1117 template.unwrap_or(DEFAULT_COMMIT_TEMPLATE),
1118 id,
1119 title,
1120 parent_id,
1121 labels,
1122 );
1123
1124 let add_status = std::process::Command::new("git")
1125 .args(["add", "-A"])
1126 .current_dir(project_root)
1127 .stdout(std::process::Stdio::null())
1128 .stderr(std::process::Stdio::piped())
1129 .status();
1130
1131 match add_status {
1132 Ok(status) if !status.success() => {
1133 return AutoCommitResult {
1134 message,
1135 committed: false,
1136 warning: Some(format!(
1137 "git add -A failed (exit {})",
1138 status.code().unwrap_or(-1)
1139 )),
1140 };
1141 }
1142 Err(e) => {
1143 return AutoCommitResult {
1144 message,
1145 committed: false,
1146 warning: Some(format!("git add -A failed: {}", e)),
1147 };
1148 }
1149 _ => {}
1150 }
1151
1152 let commit_result = std::process::Command::new("git")
1153 .args(["commit", "-m", &message])
1154 .current_dir(project_root)
1155 .stdout(std::process::Stdio::null())
1156 .stderr(std::process::Stdio::piped())
1157 .output();
1158
1159 match commit_result {
1160 Ok(output) if output.status.success() => AutoCommitResult {
1161 message,
1162 committed: true,
1163 warning: None,
1164 },
1165 Ok(output) if output.status.code() == Some(1) => AutoCommitResult {
1166 message,
1167 committed: false,
1168 warning: None,
1169 },
1170 Ok(output) => {
1171 let stderr = String::from_utf8_lossy(&output.stderr);
1172 AutoCommitResult {
1173 message,
1174 committed: false,
1175 warning: Some(format!(
1176 "git commit failed (exit {}): {}",
1177 output.status.code().unwrap_or(-1),
1178 stderr.trim()
1179 )),
1180 }
1181 }
1182 Err(e) => AutoCommitResult {
1183 message,
1184 committed: false,
1185 warning: Some(format!("git commit failed: {}", e)),
1186 },
1187 }
1188}
1189
1190fn rebuild_index(mana_dir: &Path) -> Result<()> {
1192 if mana_dir.exists() {
1193 let index = Index::build(mana_dir).with_context(|| "Failed to rebuild index")?;
1194 index
1195 .save(mana_dir)
1196 .with_context(|| "Failed to save index")?;
1197 }
1198 Ok(())
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204 use crate::config::{Config, DEFAULT_COMMIT_TEMPLATE};
1205 use std::fs;
1206 use tempfile::TempDir;
1207
1208 fn setup_mana_dir() -> (TempDir, PathBuf) {
1209 let dir = TempDir::new().unwrap();
1210 let mana_dir = dir.path().join(".mana");
1211 fs::create_dir(&mana_dir).unwrap();
1212 (dir, mana_dir)
1213 }
1214
1215 fn setup_mana_dir_with_config() -> (TempDir, PathBuf) {
1216 let dir = TempDir::new().unwrap();
1217 let mana_dir = dir.path().join(".mana");
1218 fs::create_dir(&mana_dir).unwrap();
1219
1220 Config {
1221 project: "test".to_string(),
1222 next_id: 100,
1223 auto_close_parent: true,
1224 run: None,
1225 plan: None,
1226 max_loops: 10,
1227 max_concurrent: 4,
1228 poll_interval: 30,
1229 extends: vec![],
1230 rules_file: None,
1231 file_locking: false,
1232 worktree: false,
1233 on_close: None,
1234 on_fail: None,
1235 post_plan: None,
1236 verify_timeout: None,
1237 review: None,
1238 user: None,
1239 user_email: None,
1240 auto_commit: false,
1241 commit_template: None,
1242 research: None,
1243 run_model: None,
1244 plan_model: None,
1245 review_model: None,
1246 research_model: None,
1247 batch_verify: false,
1248 memory_reserve_mb: 0,
1249 notify: None,
1250 }
1251 .save(&mana_dir)
1252 .unwrap();
1253
1254 (dir, mana_dir)
1255 }
1256
1257 fn setup_git_mana_dir_with_config(config: Config) -> (TempDir, PathBuf) {
1258 let dir = TempDir::new().unwrap();
1259 let project_root = dir.path();
1260 let mana_dir = project_root.join(".mana");
1261 fs::create_dir(&mana_dir).unwrap();
1262 config.save(&mana_dir).unwrap();
1263
1264 run_git(project_root, &["init"]);
1265 run_git(project_root, &["config", "user.email", "test@test.com"]);
1266 run_git(project_root, &["config", "user.name", "Test"]);
1267
1268 fs::write(project_root.join("initial.txt"), "initial").unwrap();
1269 run_git(project_root, &["add", "-A"]);
1270 run_git(project_root, &["commit", "-m", "Initial commit"]);
1271
1272 (dir, mana_dir)
1273 }
1274
1275 fn run_git(dir: &Path, args: &[&str]) {
1276 let output = std::process::Command::new("git")
1277 .args(args)
1278 .current_dir(dir)
1279 .output()
1280 .unwrap_or_else(|e| unreachable!("git {:?} failed to execute: {}", args, e));
1281 assert!(
1282 output.status.success(),
1283 "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
1284 args,
1285 dir.display(),
1286 output.status.code(),
1287 String::from_utf8_lossy(&output.stdout),
1288 String::from_utf8_lossy(&output.stderr),
1289 );
1290 }
1291
1292 fn git_stdout(dir: &Path, args: &[&str]) -> String {
1293 let output = std::process::Command::new("git")
1294 .args(args)
1295 .current_dir(dir)
1296 .output()
1297 .unwrap_or_else(|e| unreachable!("git {:?} failed to execute: {}", args, e));
1298 assert!(
1299 output.status.success(),
1300 "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
1301 args,
1302 dir.display(),
1303 output.status.code(),
1304 String::from_utf8_lossy(&output.stdout),
1305 String::from_utf8_lossy(&output.stderr),
1306 );
1307 String::from_utf8(output.stdout).unwrap()
1308 }
1309
1310 fn write_unit(mana_dir: &Path, unit: &Unit) {
1311 let slug = title_to_slug(&unit.title);
1312 unit.to_file(mana_dir.join(format!("{}-{}.md", unit.id, slug)))
1313 .unwrap();
1314 }
1315
1316 #[test]
1321 fn close_single_unit() {
1322 let (_dir, mana_dir) = setup_mana_dir();
1323 let unit = Unit::new("1", "Task");
1324 write_unit(&mana_dir, &unit);
1325
1326 let result = close(
1327 &mana_dir,
1328 "1",
1329 CloseOpts {
1330 reason: None,
1331 force: false,
1332 defer_verify: false,
1333 },
1334 )
1335 .unwrap();
1336
1337 match result {
1338 CloseOutcome::Closed(r) => {
1339 assert_eq!(r.unit.status, Status::Closed);
1340 assert!(r.unit.closed_at.is_some());
1341 assert!(r.unit.is_archived);
1342 assert!(r.archive_path.exists());
1343 }
1344 _ => panic!("Expected Closed outcome"),
1345 }
1346 }
1347
1348 #[test]
1349 fn close_with_reason() {
1350 let (_dir, mana_dir) = setup_mana_dir();
1351 let unit = Unit::new("1", "Task");
1352 write_unit(&mana_dir, &unit);
1353
1354 let result = close(
1355 &mana_dir,
1356 "1",
1357 CloseOpts {
1358 reason: Some("Fixed".to_string()),
1359 force: false,
1360 defer_verify: false,
1361 },
1362 )
1363 .unwrap();
1364
1365 match result {
1366 CloseOutcome::Closed(r) => {
1367 assert_eq!(r.unit.close_reason, Some("Fixed".to_string()));
1368 }
1369 _ => panic!("Expected Closed outcome"),
1370 }
1371 }
1372
1373 #[test]
1374 fn close_with_passing_verify() {
1375 let (_dir, mana_dir) = setup_mana_dir();
1376 let mut unit = Unit::new("1", "Task");
1377 unit.verify = Some("true".to_string());
1378 write_unit(&mana_dir, &unit);
1379
1380 let result = close(
1381 &mana_dir,
1382 "1",
1383 CloseOpts {
1384 reason: None,
1385 force: false,
1386 defer_verify: false,
1387 },
1388 )
1389 .unwrap();
1390
1391 match result {
1392 CloseOutcome::Closed(r) => {
1393 assert_eq!(r.unit.status, Status::Closed);
1394 assert!(r.unit.is_archived);
1395 assert_eq!(r.unit.history.len(), 1);
1396 assert_eq!(r.unit.history[0].result, RunResult::Pass);
1397 }
1398 _ => panic!("Expected Closed outcome"),
1399 }
1400 }
1401
1402 #[test]
1403 fn close_with_failing_verify() {
1404 let (_dir, mana_dir) = setup_mana_dir();
1405 let mut unit = Unit::new("1", "Task");
1406 unit.verify = Some("false".to_string());
1407 write_unit(&mana_dir, &unit);
1408
1409 let result = close(
1410 &mana_dir,
1411 "1",
1412 CloseOpts {
1413 reason: None,
1414 force: false,
1415 defer_verify: false,
1416 },
1417 )
1418 .unwrap();
1419
1420 match result {
1421 CloseOutcome::VerifyFailed(r) => {
1422 assert_eq!(r.unit.status, Status::Open);
1423 assert_eq!(r.unit.attempts, 1);
1424 }
1425 _ => panic!("Expected VerifyFailed outcome"),
1426 }
1427 }
1428
1429 #[test]
1430 fn close_force_skips_verify() {
1431 let (_dir, mana_dir) = setup_mana_dir();
1432 let mut unit = Unit::new("1", "Task");
1433 unit.verify = Some("false".to_string());
1434 write_unit(&mana_dir, &unit);
1435
1436 let result = close(
1437 &mana_dir,
1438 "1",
1439 CloseOpts {
1440 reason: None,
1441 force: true,
1442 defer_verify: false,
1443 },
1444 )
1445 .unwrap();
1446
1447 match result {
1448 CloseOutcome::Closed(r) => {
1449 assert_eq!(r.unit.status, Status::Closed);
1450 assert!(r.unit.is_archived);
1451 assert_eq!(r.unit.attempts, 0);
1452 }
1453 _ => panic!("Expected Closed outcome"),
1454 }
1455 }
1456
1457 #[test]
1458 fn close_feature_returns_requires_human() {
1459 let (_dir, mana_dir) = setup_mana_dir();
1460 let mut unit = Unit::new("1", "Feature");
1461 unit.feature = true;
1462 write_unit(&mana_dir, &unit);
1463
1464 let result = close(
1465 &mana_dir,
1466 "1",
1467 CloseOpts {
1468 reason: None,
1469 force: false,
1470 defer_verify: false,
1471 },
1472 )
1473 .unwrap();
1474
1475 assert!(matches!(result, CloseOutcome::FeatureRequiresHuman { .. }));
1476 }
1477
1478 #[test]
1479 fn close_nonexistent_unit() {
1480 let (_dir, mana_dir) = setup_mana_dir();
1481 let result = close(
1482 &mana_dir,
1483 "99",
1484 CloseOpts {
1485 reason: None,
1486 force: false,
1487 defer_verify: false,
1488 },
1489 );
1490 assert!(result.is_err());
1491 }
1492
1493 #[test]
1498 fn close_failed_marks_unit_as_failed() {
1499 let (_dir, mana_dir) = setup_mana_dir();
1500 let mut unit = Unit::new("1", "Task");
1501 unit.status = Status::InProgress;
1502 unit.claimed_by = Some("agent-1".to_string());
1503 unit.attempt_log.push(crate::unit::AttemptRecord {
1504 num: 1,
1505 outcome: AttemptOutcome::Abandoned,
1506 notes: None,
1507 agent: Some("agent-1".to_string()),
1508 started_at: Some(Utc::now()),
1509 finished_at: None,
1510 });
1511 write_unit(&mana_dir, &unit);
1512
1513 let result = close_failed(&mana_dir, "1", Some("blocked".to_string())).unwrap();
1514 assert_eq!(result.status, Status::Open);
1515 assert!(result.claimed_by.is_none());
1516 assert_eq!(result.attempt_log[0].outcome, AttemptOutcome::Failed);
1517 assert!(result.attempt_log[0].finished_at.is_some());
1518 }
1519
1520 #[test]
1525 fn all_children_closed_when_no_children() {
1526 let (_dir, mana_dir) = setup_mana_dir();
1527 let unit = Unit::new("1", "Parent");
1528 write_unit(&mana_dir, &unit);
1529
1530 assert!(all_children_closed(&mana_dir, "1").unwrap());
1531 }
1532
1533 #[test]
1534 fn all_children_closed_when_some_open() {
1535 let (_dir, mana_dir) = setup_mana_dir();
1536 let parent = Unit::new("1", "Parent");
1537 write_unit(&mana_dir, &parent);
1538
1539 let mut child1 = Unit::new("1.1", "Child 1");
1540 child1.parent = Some("1".to_string());
1541 child1.status = Status::Closed;
1542 write_unit(&mana_dir, &child1);
1543
1544 let mut child2 = Unit::new("1.2", "Child 2");
1545 child2.parent = Some("1".to_string());
1546 write_unit(&mana_dir, &child2);
1547
1548 assert!(!all_children_closed(&mana_dir, "1").unwrap());
1549 }
1550
1551 #[test]
1556 fn auto_close_parents_when_all_children_closed() {
1557 let (_dir, mana_dir) = setup_mana_dir_with_config();
1558 let parent = Unit::new("1", "Parent");
1559 write_unit(&mana_dir, &parent);
1560
1561 let mut child = Unit::new("1.1", "Child");
1562 child.parent = Some("1".to_string());
1563 write_unit(&mana_dir, &child);
1564
1565 let _ = close(
1567 &mana_dir,
1568 "1.1",
1569 CloseOpts {
1570 reason: None,
1571 force: false,
1572 defer_verify: false,
1573 },
1574 )
1575 .unwrap();
1576
1577 let parent_archived = find_archived_unit(&mana_dir, "1");
1579 assert!(parent_archived.is_ok());
1580 let p = Unit::from_file(parent_archived.unwrap()).unwrap();
1581 assert_eq!(p.status, Status::Closed);
1582 assert!(p.close_reason.as_ref().unwrap().contains("Auto-closed"));
1583 }
1584
1585 #[test]
1586 fn auto_close_skips_feature_parents() {
1587 let (_dir, mana_dir) = setup_mana_dir_with_config();
1588 let mut parent = Unit::new("1", "Feature Parent");
1589 parent.feature = true;
1590 write_unit(&mana_dir, &parent);
1591
1592 let mut child = Unit::new("1.1", "Child");
1593 child.parent = Some("1".to_string());
1594 write_unit(&mana_dir, &child);
1595
1596 let _ = close(
1597 &mana_dir,
1598 "1.1",
1599 CloseOpts {
1600 reason: None,
1601 force: false,
1602 defer_verify: false,
1603 },
1604 )
1605 .unwrap();
1606
1607 let parent_still_open = find_unit_file(&mana_dir, "1");
1609 assert!(parent_still_open.is_ok());
1610 let p = Unit::from_file(parent_still_open.unwrap()).unwrap();
1611 assert_eq!(p.status, Status::Open);
1612 }
1613
1614 #[test]
1619 fn archive_unit_moves_and_marks() {
1620 let (_dir, mana_dir) = setup_mana_dir();
1621 let mut unit = Unit::new("1", "Task");
1622 unit.status = Status::Closed;
1623 let slug = title_to_slug(&unit.title);
1624 let unit_path = mana_dir.join(format!("1-{}.md", slug));
1625 unit.to_file(&unit_path).unwrap();
1626
1627 let archive_path = archive_unit(&mana_dir, &mut unit, &unit_path).unwrap();
1628 assert!(archive_path.exists());
1629 assert!(!unit_path.exists());
1630 assert!(unit.is_archived);
1631 }
1632
1633 #[test]
1638 fn record_failure_increments_attempts() {
1639 let mut unit = Unit::new("1", "Task");
1640 let failure = VerifyFailure {
1641 exit_code: Some(1),
1642 output: "error".to_string(),
1643 timed_out: false,
1644 duration_secs: 1.0,
1645 started_at: Utc::now(),
1646 finished_at: Utc::now(),
1647 agent: None,
1648 };
1649 record_failure(&mut unit, &failure);
1650 assert_eq!(unit.attempts, 1);
1651 assert_eq!(unit.history.len(), 1);
1652 assert_eq!(unit.history[0].result, RunResult::Fail);
1653 }
1654
1655 #[test]
1656 fn record_failure_timeout() {
1657 let mut unit = Unit::new("1", "Task");
1658 let failure = VerifyFailure {
1659 exit_code: None,
1660 output: "timed out".to_string(),
1661 timed_out: true,
1662 duration_secs: 30.0,
1663 started_at: Utc::now(),
1664 finished_at: Utc::now(),
1665 agent: None,
1666 };
1667 record_failure(&mut unit, &failure);
1668 assert_eq!(unit.history[0].result, RunResult::Timeout);
1669 }
1670
1671 #[test]
1676 fn process_on_fail_retry_releases_claim() {
1677 let mut unit = Unit::new("1", "Task");
1678 unit.on_fail = Some(OnFailAction::Retry {
1679 max: Some(5),
1680 delay_secs: None,
1681 });
1682 unit.attempts = 1;
1683 unit.claimed_by = Some("agent-1".to_string());
1684 unit.claimed_at = Some(Utc::now());
1685
1686 let result = process_on_fail(&mut unit);
1687 assert!(matches!(result, OnFailActionTaken::Retry { .. }));
1688 assert!(unit.claimed_by.is_none());
1689 }
1690
1691 #[test]
1692 fn process_on_fail_escalate_sets_priority() {
1693 let mut unit = Unit::new("1", "Task");
1694 unit.on_fail = Some(OnFailAction::Escalate {
1695 priority: Some(0),
1696 message: None,
1697 });
1698 unit.priority = 2;
1699
1700 let result = process_on_fail(&mut unit);
1701 assert!(matches!(result, OnFailActionTaken::Escalated));
1702 assert_eq!(unit.priority, 0);
1703 assert!(unit.labels.contains(&"escalated".to_string()));
1704 }
1705
1706 #[test]
1707 fn process_on_fail_none() {
1708 let mut unit = Unit::new("1", "Task");
1709 let result = process_on_fail(&mut unit);
1710 assert!(matches!(result, OnFailActionTaken::None));
1711 }
1712
1713 #[test]
1718 fn circuit_breaker_zero_disabled() {
1719 let (_dir, mana_dir) = setup_mana_dir();
1720 let mut unit = Unit::new("1", "Task");
1721 let result = check_circuit_breaker(&mana_dir, &mut unit, "1", 0).unwrap();
1722 assert!(!result.tripped);
1723 }
1724
1725 #[test]
1730 fn truncate_to_char_boundary_ascii() {
1731 let s = "hello world";
1732 assert_eq!(truncate_to_char_boundary(s, 5), 5);
1733 }
1734
1735 #[test]
1736 fn truncate_to_char_boundary_multibyte() {
1737 let s = "😀😁😂";
1738 assert_eq!(truncate_to_char_boundary(s, 5), 4);
1739 }
1740
1741 #[test]
1742 fn truncate_output_short() {
1743 let output = "line1\nline2\nline3";
1744 let result = truncate_output(output, 50);
1745 assert_eq!(result, output);
1746 }
1747
1748 #[test]
1749 fn format_failure_note_includes_exit_code() {
1750 let note = format_failure_note(1, Some(1), "error message");
1751 assert!(note.contains("## Attempt 1"));
1752 assert!(note.contains("Exit code: 1"));
1753 assert!(note.contains("error message"));
1754 }
1755
1756 #[test]
1757 fn expand_commit_template_substitutes_all_placeholders() {
1758 let message = expand_commit_template(
1759 "feat(unit-{id}): {title} [{parent_id}] {labels}",
1760 "2.3",
1761 "Ship it",
1762 Some("2"),
1763 &["feature".to_string(), "git".to_string()],
1764 );
1765
1766 assert_eq!(message, "feat(unit-2.3): Ship it [2] feature,git");
1767 }
1768
1769 #[test]
1770 fn close_auto_commit_uses_default_template_and_includes_index_updates() {
1771 let config = Config {
1772 project: "test".to_string(),
1773 next_id: 100,
1774 auto_commit: true,
1775 ..Config::default()
1776 };
1777 let (_dir, mana_dir) = setup_git_mana_dir_with_config(config);
1778 let project_root = mana_dir.parent().unwrap();
1779
1780 let parent = Unit::new("1", "Parent");
1781 write_unit(&mana_dir, &parent);
1782
1783 let mut child = Unit::new("1.1", "Child");
1784 child.parent = Some("1".to_string());
1785 write_unit(&mana_dir, &child);
1786
1787 let result = close(
1788 &mana_dir,
1789 "1.1",
1790 CloseOpts {
1791 reason: None,
1792 force: false,
1793 defer_verify: false,
1794 },
1795 )
1796 .unwrap();
1797
1798 let close_result = match result {
1799 CloseOutcome::Closed(result) => result,
1800 other => panic!("Expected Closed outcome, got {:?}", other),
1801 };
1802 let auto_commit = close_result
1803 .auto_commit_result
1804 .expect("auto-commit result should be present when enabled");
1805 assert!(auto_commit.committed);
1806 assert_eq!(
1807 auto_commit.message,
1808 DEFAULT_COMMIT_TEMPLATE
1809 .replace("{id}", "1.1")
1810 .replace("{title}", "Child")
1811 );
1812 assert_eq!(close_result.auto_closed_parents, vec!["1".to_string()]);
1813
1814 let head_subject = git_stdout(project_root, &["log", "-1", "--pretty=%s"]);
1815 assert_eq!(head_subject.trim(), "feat(unit-1.1): Child");
1816
1817 let changed_files = git_stdout(project_root, &["show", "--name-only", "--format=", "HEAD"]);
1818 assert!(
1819 changed_files.contains(".mana/index.yaml"),
1820 "{changed_files}"
1821 );
1822 assert!(changed_files.contains("1-parent.md"), "{changed_files}");
1823 assert!(changed_files.contains("1.1-child.md"), "{changed_files}");
1824 }
1825
1826 #[test]
1833 fn close_defer_skips_verify() {
1834 let (_dir, mana_dir) = setup_mana_dir();
1835 let mut unit = Unit::new("1", "Task");
1836 unit.verify = Some("false".to_string());
1838 write_unit(&mana_dir, &unit);
1839
1840 let outcome = close(
1841 &mana_dir,
1842 "1",
1843 CloseOpts {
1844 reason: None,
1845 force: false,
1846 defer_verify: true,
1847 },
1848 )
1849 .unwrap();
1850
1851 match outcome {
1853 CloseOutcome::DeferredVerify { .. } => {}
1854 other => panic!("Expected DeferredVerify outcome, got {:?}", other),
1855 }
1856
1857 let saved = Unit::from_file(
1859 find_unit_file(&mana_dir, "1").expect("unit file should still be in active dir"),
1860 )
1861 .unwrap();
1862 assert_eq!(saved.status, Status::AwaitingVerify);
1863 assert_eq!(saved.attempts, 0);
1865 }
1866
1867 #[test]
1870 fn close_defer_returns_outcome() {
1871 let (_dir, mana_dir) = setup_mana_dir();
1872 let unit = Unit::new("42", "Deferred Task");
1873 write_unit(&mana_dir, &unit);
1874
1875 let outcome = close(
1876 &mana_dir,
1877 "42",
1878 CloseOpts {
1879 reason: None,
1880 force: false,
1881 defer_verify: true,
1882 },
1883 )
1884 .unwrap();
1885
1886 match outcome {
1887 CloseOutcome::DeferredVerify { unit_id } => {
1888 assert_eq!(unit_id, "42");
1889 }
1890 other => panic!("Expected DeferredVerify outcome, got {:?}", other),
1891 }
1892 }
1893
1894 #[test]
1897 fn close_defer_normal_unchanged() {
1898 let (_dir, mana_dir) = setup_mana_dir();
1899 let mut unit = Unit::new("1", "Task");
1900 unit.verify = Some("false".to_string());
1901 write_unit(&mana_dir, &unit);
1902
1903 let outcome = close(
1904 &mana_dir,
1905 "1",
1906 CloseOpts {
1907 reason: None,
1908 force: false,
1909 defer_verify: false,
1910 },
1911 )
1912 .unwrap();
1913
1914 match outcome {
1915 CloseOutcome::VerifyFailed(r) => {
1916 assert_eq!(r.unit.status, Status::Open);
1917 assert_eq!(r.unit.attempts, 1);
1918 }
1919 other => panic!("Expected VerifyFailed outcome, got {:?}", other),
1920 }
1921 }
1922}