1#[allow(dead_code)]
2mod worktree_merge;
3
4use std::path::Path;
5
6use anyhow::{anyhow, Context, Result};
7
8use crate::unit::Unit;
9
10use mana_core::ops::close::{
12 self as ops_close, AutoCommitResult, CloseOpts, CloseOutcome, CloseWarning,
13 OnCloseActionResult, OnFailActionTaken,
14};
15
16#[allow(unused_imports)]
18use crate::index::Index;
19#[allow(unused_imports)]
20use crate::unit::{RunResult, Status};
21#[allow(unused_imports)]
22use chrono::Utc;
23#[allow(unused_imports)]
24use mana_core::ops::close::truncate_to_char_boundary;
25
26#[cfg(test)]
27use std::fs;
28
29fn print_close_warnings(unit_id: &str, warnings: &[CloseWarning]) {
30 for warning in warnings {
31 match warning {
32 CloseWarning::PreCloseHookError { message } => {
33 eprintln!(
34 "Warning: pre-close hook error for unit {}: {}",
35 unit_id, message
36 );
37 }
38 CloseWarning::PostCloseHookRejected => {
39 eprintln!(
40 "Warning: post-close hook returned non-zero for unit {}",
41 unit_id
42 );
43 }
44 CloseWarning::PostCloseHookError { message } => {
45 eprintln!(
46 "Warning: post-close hook error for unit {}: {}",
47 unit_id, message
48 );
49 }
50 CloseWarning::WorktreeCleanupFailed { message } => {
51 eprintln!(
52 "Warning: failed to clean up worktree for unit {}: {}",
53 unit_id, message
54 );
55 }
56 }
57 }
58}
59
60fn print_on_close_results(unit_id: &str, results: &[OnCloseActionResult]) {
61 for result in results {
62 match result {
63 OnCloseActionResult::RanCommand {
64 command,
65 success,
66 exit_code,
67 error,
68 } => {
69 if *success {
70 eprintln!("on_close: ran `{}`", command);
71 } else if let Some(error) = error {
72 eprintln!(
73 "on_close run command error for unit {} (`{}`): {}",
74 unit_id, command, error
75 );
76 } else {
77 eprintln!(
78 "on_close run command failed for unit {} (`{}`) with exit {}",
79 unit_id,
80 command,
81 exit_code.unwrap_or(-1)
82 );
83 }
84 }
85 OnCloseActionResult::Notified { message } => {
86 println!("[unit {}] {}", unit_id, message);
87 }
88 OnCloseActionResult::Skipped { command } => {
89 eprintln!(
90 "on_close: skipping `{}` for unit {} (not trusted — run `mana trust` to enable)",
91 command,
92 unit_id
93 );
94 }
95 }
96 }
97}
98
99fn print_auto_commit_result(result: &AutoCommitResult) {
100 if result.committed {
101 eprintln!("auto_commit: {}", result.message);
102 }
103 if let Some(warning) = &result.warning {
104 eprintln!("Warning: {}", warning);
105 }
106}
107
108pub fn cmd_close(
116 mana_dir: &Path,
117 ids: Vec<String>,
118 reason: Option<String>,
119 force: bool,
120 defer_verify: bool,
121) -> Result<()> {
122 if ids.is_empty() {
123 return Err(anyhow!("At least one unit ID is required"));
124 }
125
126 let mut any_closed = false;
127 let mut rejected_units = Vec::new();
128
129 for id in &ids {
130 let outcome = ops_close::close(
131 mana_dir,
132 id,
133 CloseOpts {
134 reason: reason.clone(),
135 force,
136 defer_verify,
137 },
138 )?;
139
140 match outcome {
141 CloseOutcome::Closed(result) => {
142 println!("Closed unit {}: {}", id, result.unit.title);
143 any_closed = true;
144
145 print_on_close_results(&result.unit.id, &result.on_close_results);
146 print_close_warnings(&result.unit.id, &result.warnings);
147 if let Some(auto_commit_result) = &result.auto_commit_result {
148 print_auto_commit_result(auto_commit_result);
149 }
150
151 for parent_id in &result.auto_closed_parents {
152 if let Ok(archived_path) =
154 crate::discovery::find_archived_unit(mana_dir, parent_id)
155 {
156 if let Ok(parent) = Unit::from_file(&archived_path) {
157 println!("Auto-closed parent unit {}: {}", parent_id, parent.title);
158 }
159 }
160 }
161 }
162 CloseOutcome::VerifyFailed(result) => {
163 print_close_warnings(&result.unit.id, &result.warnings);
164
165 if result.timed_out {
167 println!("✗ Verify timed out for unit {}", id);
168 } else {
169 println!("✗ Verify failed for unit {}", id);
170 }
171 println!();
172 println!("Command: {}", result.verify_command);
173 if result.timed_out {
174 println!("Timed out after {}s", result.timeout_secs.unwrap_or(0));
175 } else if let Some(code) = result.exit_code {
176 println!("Exit code: {}", code);
177 }
178 if !result.output.is_empty() {
179 println!("Output:");
180 for line in result.output.lines() {
181 println!(" {}", line);
182 }
183 }
184 println!();
185 println!("Attempt {}. Unit remains open.", result.attempt_number);
186 println!("Tip: Run `mana verify {}` to test without closing.", id);
187 println!("Tip: Use `mana close {} --force` to skip verify.", id);
188
189 if let Some(action) = result.on_fail_action_taken {
191 match action {
192 OnFailActionTaken::Retry {
193 attempt,
194 max,
195 delay_secs,
196 } => {
197 println!("on_fail: will retry (attempt {}/{})", attempt, max);
198 if let Some(delay) = delay_secs {
199 println!(
200 "on_fail: retry delay {}s (enforced by orchestrator)",
201 delay
202 );
203 }
204 }
205 OnFailActionTaken::RetryExhausted { max } => {
206 println!("on_fail: max retries ({}) exhausted", max);
207 }
208 OnFailActionTaken::Escalated => {
209 if let Some(crate::unit::OnFailAction::Escalate { priority, message }) =
211 &result.unit.on_fail
212 {
213 if let Some(p) = priority {
214 println!("on_fail: escalated priority → P{}", p);
215 }
216 if let Some(msg) = message {
217 println!("on_fail: {}", msg);
218 }
219 }
220 }
221 OnFailActionTaken::None => {}
222 }
223 }
224 }
225 CloseOutcome::RejectedByHook { unit_id } => {
226 eprintln!("Unit {} rejected by pre-close hook", unit_id);
227 rejected_units.push(unit_id);
228 }
229 CloseOutcome::FeatureRequiresHuman {
230 unit_id,
231 title,
232 warnings,
233 } => {
234 print_close_warnings(&unit_id, &warnings);
235
236 use std::io::IsTerminal;
237 if !std::io::stdin().is_terminal() {
238 println!("Feature \"{}\" requires human review to close.", title);
239 continue;
240 }
241 eprintln!("Feature: \"{}\" — mark as complete? [y/N] ", title);
242 let mut input = String::new();
243 std::io::stdin().read_line(&mut input).unwrap_or(0);
244 if !input.trim().eq_ignore_ascii_case("y") {
245 println!("Skipped feature \"{}\"", title);
246 continue;
247 }
248 let outcome = ops_close::close(
250 mana_dir,
251 &unit_id,
252 CloseOpts {
253 reason: reason.clone(),
254 force: true,
255 defer_verify: false,
256 },
257 );
258 match outcome {
259 Ok(CloseOutcome::Closed(result)) => {
260 println!("Closed unit {}: {}", unit_id, result.unit.title);
261 print_on_close_results(&result.unit.id, &result.on_close_results);
262 print_close_warnings(&result.unit.id, &result.warnings);
263 if let Some(auto_commit_result) = &result.auto_commit_result {
264 print_auto_commit_result(auto_commit_result);
265 }
266 any_closed = true;
267 }
268 Ok(other) => {
269 eprintln!("Failed to close feature unit {}: {:?}", unit_id, other);
270 }
271 Err(err) => {
272 eprintln!("Failed to close feature unit {}: {}", unit_id, err);
273 }
274 }
275 }
276 CloseOutcome::CircuitBreakerTripped {
277 unit_id,
278 total_attempts,
279 max,
280 warnings,
281 } => {
282 print_close_warnings(&unit_id, &warnings);
283 eprintln!(
284 "⚡ Circuit breaker tripped for unit {} \
285 (subtree total {} >= max_loops {})",
286 unit_id, total_attempts, max
287 );
288 eprintln!(
289 "Unit {} escalated to P0 with 'circuit-breaker' label. \
290 Manual intervention required.",
291 unit_id
292 );
293 }
294 CloseOutcome::MergeConflict { files, warnings } => {
295 print_close_warnings(id, &warnings);
296 eprintln!("Merge conflict in files: {:?}", files);
297 eprintln!("Resolve conflicts and run `mana close {}` again", id);
298 }
299 CloseOutcome::DeferredVerify { unit_id } => {
300 println!(
301 "Deferred verify for unit {} — status set to awaiting_verify",
302 unit_id
303 );
304 }
305 }
306 }
307
308 if !rejected_units.is_empty() {
310 eprintln!(
311 "Failed to close {} unit(s) due to pre-close hook rejection: {}",
312 rejected_units.len(),
313 rejected_units.join(", ")
314 );
315 }
316
317 if (any_closed || !ids.is_empty()) && mana_dir.exists() {
319 let index = Index::build(mana_dir).with_context(|| "Failed to rebuild index")?;
320 index
321 .save(mana_dir)
322 .with_context(|| "Failed to save index")?;
323 }
324
325 Ok(())
326}
327
328pub fn cmd_close_failed(mana_dir: &Path, ids: Vec<String>, reason: Option<String>) -> Result<()> {
333 if ids.is_empty() {
334 return Err(anyhow!("At least one unit ID is required"));
335 }
336
337 for id in &ids {
338 let result = ops_close::close_failed(mana_dir, id, reason.clone())?;
339
340 let attempt_count = result.attempt_log.len();
341 println!(
342 "Marked unit {} as failed (attempt #{}): {}",
343 id, attempt_count, result.title
344 );
345 if let Some(ref reason_text) = reason {
346 println!(" Reason: {}", reason_text);
347 }
348 println!(" Unit remains open for retry.");
349 }
350
351 Ok(())
352}
353
354#[cfg(test)]
355#[path = "tests_close.rs"]
356mod tests;
357
358#[cfg(test)]
359#[path = "tests_verify_timeout.rs"]
360mod verify_timeout_tests;