Skip to main content

bn/commands/close/
mod.rs

1mod verify;
2
3use std::path::Path;
4
5use anyhow::{anyhow, Context, Result};
6use chrono::Utc;
7
8use crate::bean::{Bean, OnCloseAction, OnFailAction, RunRecord, RunResult, Status};
9use crate::config::Config;
10use crate::discovery::{archive_path_for_bean, find_archived_bean, find_bean_file};
11use crate::failure;
12use crate::hooks::{
13    current_git_branch, execute_config_hook, execute_hook, is_trusted, HookEvent, HookVars,
14};
15use crate::index::{ArchiveIndex, Index, IndexEntry};
16use crate::util::title_to_slug;
17use crate::worktree;
18
19use verify::{format_failure_note, run_verify, truncate_output};
20
21#[cfg(test)]
22use std::fs;
23
24/// Maximum stdout size to capture as outputs (64 KB).
25const MAX_OUTPUT_BYTES: usize = 64 * 1024;
26
27/// Find the largest byte index <= `max_bytes` that falls on a UTF-8 char boundary.
28///
29/// Slicing a `&str` at an arbitrary byte offset panics if it lands inside a
30/// multi-byte character. This helper walks backward to find a safe boundary.
31fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> usize {
32    if max_bytes >= s.len() {
33        return s.len();
34    }
35    let mut end = max_bytes;
36    while !s.is_char_boundary(end) {
37        end -= 1;
38    }
39    end
40}
41
42/// Check if all children of a parent bean are closed (in archive or with status=closed).
43///
44/// Returns true if:
45/// - The parent has no children, OR
46/// - All children are either in the archive (closed) or have status=closed
47fn all_children_closed(beans_dir: &Path, parent_id: &str) -> Result<bool> {
48    // Always rebuild the index fresh - we can't rely on staleness check because
49    // files may have just been moved to archive (which isn't tracked in staleness)
50    let index = Index::build(beans_dir)?;
51    let archived = Index::collect_archived(beans_dir).unwrap_or_default();
52
53    // Combine active and archived beans
54    let mut all_beans = index.beans;
55    all_beans.extend(archived);
56
57    // Find children of this parent
58    let children: Vec<_> = all_beans
59        .iter()
60        .filter(|b| b.parent.as_deref() == Some(parent_id))
61        .collect();
62
63    // If no children, return true (nothing to check)
64    if children.is_empty() {
65        return Ok(true);
66    }
67
68    // Check if all children are closed
69    for child in children {
70        if child.status != Status::Closed {
71            return Ok(false);
72        }
73    }
74
75    Ok(true)
76}
77
78/// Close a parent bean automatically when all its children are closed.
79/// This is called recursively to close ancestor beans.
80///
81/// Unlike normal close, auto-close:
82/// - Skips verify command (children already verified)
83/// - Sets close_reason to indicate auto-close
84/// - Recursively checks grandparent
85fn auto_close_parent(beans_dir: &Path, parent_id: &str) -> Result<()> {
86    // Find the parent bean
87    let bean_path = match find_bean_file(beans_dir, parent_id) {
88        Ok(path) => path,
89        Err(_) => {
90            // Parent might already be archived, skip
91            return Ok(());
92        }
93    };
94
95    let mut bean = Bean::from_file(&bean_path)
96        .with_context(|| format!("Failed to load parent bean: {}", parent_id))?;
97
98    // Skip if already closed
99    if bean.status == Status::Closed {
100        return Ok(());
101    }
102
103    // Feature beans are never auto-closed — they require human review
104    if bean.feature {
105        return Ok(());
106    }
107
108    let now = Utc::now();
109
110    // Close the parent (skip verify - children already verified)
111    bean.status = Status::Closed;
112    bean.closed_at = Some(now);
113    bean.close_reason = Some("Auto-closed: all children completed".to_string());
114    bean.updated_at = now;
115
116    bean.to_file(&bean_path)
117        .with_context(|| format!("Failed to save parent bean: {}", parent_id))?;
118
119    // Archive the closed bean
120    let slug = bean
121        .slug
122        .clone()
123        .unwrap_or_else(|| title_to_slug(&bean.title));
124    let ext = bean_path
125        .extension()
126        .and_then(|e| e.to_str())
127        .unwrap_or("md");
128    let today = chrono::Local::now().naive_local().date();
129    let archive_path = archive_path_for_bean(beans_dir, parent_id, &slug, ext, today);
130
131    // Create archive directories if needed
132    if let Some(parent) = archive_path.parent() {
133        std::fs::create_dir_all(parent).with_context(|| {
134            format!(
135                "Failed to create archive directories for bean {}",
136                parent_id
137            )
138        })?;
139    }
140
141    // Move the bean file to archive
142    std::fs::rename(&bean_path, &archive_path)
143        .with_context(|| format!("Failed to move bean {} to archive", parent_id))?;
144
145    // Update bean metadata to mark as archived
146    bean.is_archived = true;
147    bean.to_file(&archive_path)
148        .with_context(|| format!("Failed to save archived parent bean: {}", parent_id))?;
149
150    // Append to archive index
151    {
152        let mut archive_index =
153            ArchiveIndex::load(beans_dir).unwrap_or(ArchiveIndex { beans: Vec::new() });
154        archive_index.append(IndexEntry::from(&bean));
155        let _ = archive_index.save(beans_dir);
156    }
157
158    println!("Auto-closed parent bean {}: {}", parent_id, bean.title);
159
160    // Recursively check if this bean's parent should also be auto-closed
161    if let Some(grandparent_id) = &bean.parent {
162        if all_children_closed(beans_dir, grandparent_id)? {
163            auto_close_parent(beans_dir, grandparent_id)?;
164        }
165    }
166
167    Ok(())
168}
169
170/// Walk up the parent chain to find the root ancestor of a bean.
171///
172/// Returns the ID of the topmost parent (the bean with no parent).
173/// If the bean itself has no parent, returns its own ID.
174/// Handles archived parents gracefully by checking both active and archived beans.
175fn find_root_parent(beans_dir: &Path, bean: &Bean) -> Result<String> {
176    let mut current_id = match &bean.parent {
177        None => return Ok(bean.id.clone()),
178        Some(pid) => pid.clone(),
179    };
180
181    loop {
182        let path = find_bean_file(beans_dir, &current_id)
183            .or_else(|_| find_archived_bean(beans_dir, &current_id));
184
185        match path {
186            Ok(p) => {
187                let b = Bean::from_file(&p)
188                    .with_context(|| format!("Failed to load parent bean: {}", current_id))?;
189                match b.parent {
190                    Some(parent_id) => current_id = parent_id,
191                    None => return Ok(current_id),
192                }
193            }
194            Err(_) => return Ok(current_id), // Can't find parent, assume it's root
195        }
196    }
197}
198
199/// Close one or more beans.
200///
201/// Sets status=closed, closed_at=now, and optionally close_reason.
202/// If the bean has a verify command, it must pass before closing (unless force=true).
203/// Calls pre-close hook before verify (can block close if hook fails).
204/// Auto-closes parent beans when all children are closed (if enabled in config).
205/// Rebuilds the index.
206pub fn cmd_close(
207    beans_dir: &Path,
208    ids: Vec<String>,
209    reason: Option<String>,
210    force: bool,
211) -> Result<()> {
212    if ids.is_empty() {
213        return Err(anyhow!("At least one bean ID is required"));
214    }
215
216    let now = Utc::now();
217    let mut any_closed = false;
218    let mut rejected_beans = Vec::new();
219
220    let project_root = beans_dir
221        .parent()
222        .ok_or_else(|| anyhow!("Cannot determine project root from beans dir"))?;
223
224    let config = Config::load(beans_dir).ok();
225
226    for id in &ids {
227        let bean_path =
228            find_bean_file(beans_dir, id).with_context(|| format!("Bean not found: {}", id))?;
229
230        let mut bean =
231            Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
232
233        let pre_close_result =
234            execute_hook(HookEvent::PreClose, &bean, project_root, reason.clone());
235
236        let pre_close_passed = match pre_close_result {
237            Ok(hook_passed) => {
238                // Hook executed successfully, use its result
239                hook_passed
240            }
241            Err(e) => {
242                // Hook execution failed (not executable, timeout, etc.), log but don't block
243                eprintln!("Bean {} pre-close hook error: {}", id, e);
244                true // Silently pass (allow close to proceed)
245            }
246        };
247
248        if !pre_close_passed {
249            eprintln!("Bean {} rejected by pre-close hook", id);
250            rejected_beans.push(id.clone());
251            continue;
252        }
253
254        // Check if bean has a verify command (runs AFTER pre-close hook passes)
255        if let Some(ref verify_cmd) = bean.verify {
256            if verify_cmd.trim().is_empty() {
257                eprintln!("Warning: bean {} has empty verify command, skipping", id);
258            } else if force {
259                println!("Skipping verify for bean {} (--force)", id);
260            } else {
261                // Record timing for history
262                let started_at = Utc::now();
263
264                // Compute effective timeout: bean-level overrides config-level.
265                let timeout_secs =
266                    bean.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout));
267
268                // Run the verify command
269                let verify_result = run_verify(beans_dir, verify_cmd, timeout_secs)?;
270
271                let finished_at = Utc::now();
272                let duration_secs = (finished_at - started_at).num_milliseconds() as f64 / 1000.0;
273
274                // Read agent name from env var (deli/bw set this when spawning)
275                let agent = std::env::var("BEANS_AGENT").ok();
276
277                if !verify_result.success {
278                    // Increment attempts
279                    bean.attempts += 1;
280                    bean.updated_at = Utc::now();
281
282                    // Surface timeout prominently
283                    if verify_result.timed_out {
284                        let secs = timeout_secs.unwrap_or(0);
285                        println!("Verify timed out after {}s for bean {}", secs, id);
286                    }
287
288                    // Append failure to notes for future agents (backward compat)
289                    let failure_note = format_failure_note(
290                        bean.attempts,
291                        verify_result.exit_code,
292                        &verify_result.output,
293                    );
294                    match &mut bean.notes {
295                        Some(notes) => notes.push_str(&failure_note),
296                        None => bean.notes = Some(failure_note),
297                    }
298
299                    // Record structured history entry
300                    let output_snippet = if verify_result.output.is_empty() {
301                        None
302                    } else {
303                        Some(truncate_output(&verify_result.output, 20))
304                    };
305                    bean.history.push(RunRecord {
306                        attempt: bean.attempts,
307                        started_at,
308                        finished_at: Some(finished_at),
309                        duration_secs: Some(duration_secs),
310                        agent: agent.clone(),
311                        result: if verify_result.timed_out {
312                            RunResult::Timeout
313                        } else {
314                            RunResult::Fail
315                        },
316                        exit_code: verify_result.exit_code,
317                        tokens: None,
318                        cost: None,
319                        output_snippet,
320                    });
321
322                    // Circuit breaker: check if subtree attempts exceed max_loops
323                    let root_id = find_root_parent(beans_dir, &bean)?;
324                    let config_max = config.as_ref().map(|c| c.max_loops).unwrap_or(10);
325                    let max_loops_limit = if root_id == bean.id {
326                        bean.effective_max_loops(config_max)
327                    } else {
328                        let root_path = find_bean_file(beans_dir, &root_id)
329                            .or_else(|_| find_archived_bean(beans_dir, &root_id));
330                        match root_path {
331                            Ok(p) => Bean::from_file(&p)
332                                .map(|b| b.effective_max_loops(config_max))
333                                .unwrap_or(config_max),
334                            Err(_) => config_max,
335                        }
336                    };
337
338                    if max_loops_limit > 0 {
339                        // Save bean first so subtree count is accurate
340                        bean.to_file(&bean_path)
341                            .with_context(|| format!("Failed to save bean: {}", id))?;
342
343                        let subtree_total =
344                            crate::graph::count_subtree_attempts(beans_dir, &root_id)?;
345                        if subtree_total >= max_loops_limit {
346                            // Trip circuit breaker
347                            if !bean.labels.contains(&"circuit-breaker".to_string()) {
348                                bean.labels.push("circuit-breaker".to_string());
349                            }
350                            bean.priority = 0;
351                            bean.to_file(&bean_path)
352                                .with_context(|| format!("Failed to save bean: {}", id))?;
353
354                            eprintln!(
355                                "⚡ Circuit breaker tripped for bean {} \
356                                 (subtree total {} >= max_loops {} across root {})",
357                                id, subtree_total, max_loops_limit, root_id
358                            );
359                            eprintln!(
360                                "Bean {} escalated to P0 with 'circuit-breaker' label. \
361                                 Manual intervention required.",
362                                id
363                            );
364                            continue;
365                        }
366                    }
367
368                    // Process on_fail action
369                    if let Some(ref on_fail) = bean.on_fail {
370                        match on_fail {
371                            OnFailAction::Retry { max, delay_secs } => {
372                                let max_retries = max.unwrap_or(bean.max_attempts);
373                                if bean.attempts < max_retries {
374                                    println!(
375                                        "on_fail: will retry (attempt {}/{})",
376                                        bean.attempts, max_retries
377                                    );
378                                    if let Some(delay) = delay_secs {
379                                        println!(
380                                            "on_fail: retry delay {}s (enforced by orchestrator)",
381                                            delay
382                                        );
383                                    }
384                                    // Release claim so bw/deli can pick it up
385                                    bean.claimed_by = None;
386                                    bean.claimed_at = None;
387                                } else {
388                                    println!("on_fail: max retries ({}) exhausted", max_retries);
389                                }
390                            }
391                            OnFailAction::Escalate { priority, message } => {
392                                if let Some(p) = priority {
393                                    let old_priority = bean.priority;
394                                    bean.priority = *p;
395                                    println!(
396                                        "on_fail: escalated priority P{} → P{}",
397                                        old_priority, p
398                                    );
399                                }
400                                if let Some(msg) = message {
401                                    // Append escalation message to notes
402                                    let note = format!(
403                                        "\n## Escalated — {}\n{}",
404                                        Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
405                                        msg
406                                    );
407                                    match &mut bean.notes {
408                                        Some(notes) => notes.push_str(&note),
409                                        None => bean.notes = Some(note),
410                                    }
411                                    println!("on_fail: {}", msg);
412                                }
413                                // Add escalated label
414                                if !bean.labels.contains(&"escalated".to_string()) {
415                                    bean.labels.push("escalated".to_string());
416                                }
417                            }
418                        }
419                    }
420
421                    bean.to_file(&bean_path)
422                        .with_context(|| format!("Failed to save bean: {}", id))?;
423
424                    // Display detailed failure feedback
425                    if verify_result.timed_out {
426                        println!("✗ Verify timed out for bean {}", id);
427                    } else {
428                        println!("✗ Verify failed for bean {}", id);
429                    }
430                    println!();
431                    println!("Command: {}", verify_cmd);
432                    if verify_result.timed_out {
433                        println!("Timed out after {}s", timeout_secs.unwrap_or(0));
434                    } else if let Some(code) = verify_result.exit_code {
435                        println!("Exit code: {}", code);
436                    }
437                    if !verify_result.output.is_empty() {
438                        println!("Output:");
439                        for line in verify_result.output.lines() {
440                            println!("  {}", line);
441                        }
442                    }
443                    println!();
444                    println!("Attempt {}. Bean remains open.", bean.attempts);
445                    println!("Tip: Run `bn verify {}` to test without closing.", id);
446                    println!("Tip: Use `bn close {} --force` to skip verify.", id);
447
448                    // Fire on_fail config hook (async, non-blocking)
449                    if let Some(ref config) = config {
450                        if let Some(ref on_fail_template) = config.on_fail {
451                            let output_text = &verify_result.output;
452                            let vars = HookVars {
453                                id: Some(id.clone()),
454                                title: Some(bean.title.clone()),
455                                status: Some(format!("{}", bean.status)),
456                                attempt: Some(bean.attempts),
457                                output: Some(output_text.clone()),
458                                branch: current_git_branch(),
459                                ..Default::default()
460                            };
461                            execute_config_hook("on_fail", on_fail_template, &vars, project_root);
462                        }
463                    }
464
465                    continue;
466                }
467
468                // Record success in history
469                bean.history.push(RunRecord {
470                    attempt: bean.attempts + 1,
471                    started_at,
472                    finished_at: Some(finished_at),
473                    duration_secs: Some(duration_secs),
474                    agent,
475                    result: RunResult::Pass,
476                    exit_code: verify_result.exit_code,
477                    tokens: None,
478                    cost: None,
479                    output_snippet: None,
480                });
481
482                // Capture stdout as bean outputs
483                let stdout = &verify_result.stdout;
484                if !stdout.is_empty() {
485                    if stdout.len() > MAX_OUTPUT_BYTES {
486                        let end = truncate_to_char_boundary(stdout, MAX_OUTPUT_BYTES);
487                        let truncated = &stdout[..end];
488                        eprintln!(
489                            "Warning: verify stdout ({} bytes) exceeds 64KB, truncating",
490                            stdout.len()
491                        );
492                        bean.outputs = Some(serde_json::json!({
493                            "text": truncated,
494                            "truncated": true,
495                            "original_bytes": stdout.len()
496                        }));
497                    } else {
498                        match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
499                            Ok(json) => {
500                                bean.outputs = Some(json);
501                            }
502                            Err(_) => {
503                                bean.outputs = Some(serde_json::json!({
504                                    "text": stdout.trim()
505                                }));
506                            }
507                        }
508                    }
509                }
510
511                println!("Verify passed for bean {}", id);
512            }
513        }
514
515        // Handle worktree merge (after verify passes, before archiving)
516        //
517        // detect_worktree() uses the process-global CWD. If CWD was deleted or
518        // points to an unrelated directory (e.g. during parallel test execution),
519        // we gracefully skip worktree operations. We also validate that the
520        // detected worktree actually contains this project's root — this prevents
521        // acting on a foreign repository when CWD is polluted.
522        let worktree_info = worktree::detect_worktree().unwrap_or(None);
523        let worktree_info = worktree_info.filter(|wt_info| {
524            let canonical_root =
525                std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
526            canonical_root.starts_with(&wt_info.worktree_path)
527        });
528        if let Some(ref wt_info) = worktree_info {
529            // Commit any uncommitted changes
530            worktree::commit_worktree_changes(&format!("Close bean {}: {}", id, bean.title))?;
531
532            // Merge to main
533            match worktree::merge_to_main(wt_info, id)? {
534                worktree::MergeResult::Success | worktree::MergeResult::NothingToCommit => {
535                    // Continue to archive
536                }
537                worktree::MergeResult::Conflict { files } => {
538                    eprintln!("Merge conflict in files: {:?}", files);
539                    eprintln!("Resolve conflicts and run `bn close {}` again", id);
540                    return Ok(()); // Don't archive yet
541                }
542            }
543        }
544
545        // Feature beans require human confirmation via TTY
546        if bean.feature {
547            use std::io::IsTerminal;
548            if !std::io::stdin().is_terminal() {
549                println!(
550                    "Feature \"{}\" requires human review to close.",
551                    bean.title
552                );
553                continue; // Skip this bean, exit 0 (not an error)
554            }
555            // TTY available — ask for confirmation
556            eprintln!(
557                "Feature: \"{}\" — mark as complete? [y/N] ",
558                bean.title
559            );
560            let mut input = String::new();
561            std::io::stdin().read_line(&mut input).unwrap_or(0);
562            if !input.trim().eq_ignore_ascii_case("y") {
563                println!("Skipped feature \"{}\"", bean.title);
564                continue;
565            }
566        }
567
568        // Close the bean
569        bean.status = crate::bean::Status::Closed;
570        bean.closed_at = Some(now);
571        bean.close_reason = reason.clone();
572        bean.updated_at = now;
573
574        // Finalize the current attempt as success (memory system tracking)
575        if let Some(attempt) = bean.attempt_log.last_mut() {
576            if attempt.finished_at.is_none() {
577                attempt.outcome = crate::bean::AttemptOutcome::Success;
578                attempt.finished_at = Some(now);
579                attempt.notes = reason.clone();
580            }
581        }
582
583        // Update last_verified for facts (staleness tracking)
584        if bean.bean_type == "fact" {
585            bean.last_verified = Some(now);
586        }
587
588        bean.to_file(&bean_path)
589            .with_context(|| format!("Failed to save bean: {}", id))?;
590
591        // Archive the closed bean
592        let slug = bean
593            .slug
594            .clone()
595            .unwrap_or_else(|| title_to_slug(&bean.title));
596        let ext = bean_path
597            .extension()
598            .and_then(|e| e.to_str())
599            .unwrap_or("md");
600        let today = chrono::Local::now().naive_local().date();
601        let archive_path = archive_path_for_bean(beans_dir, id, &slug, ext, today);
602
603        // Create archive directories if needed
604        if let Some(parent) = archive_path.parent() {
605            std::fs::create_dir_all(parent)
606                .with_context(|| format!("Failed to create archive directories for bean {}", id))?;
607        }
608
609        // Move the bean file to archive
610        std::fs::rename(&bean_path, &archive_path)
611            .with_context(|| format!("Failed to move bean {} to archive", id))?;
612
613        // Update bean metadata to mark as archived
614        bean.is_archived = true;
615        bean.to_file(&archive_path)
616            .with_context(|| format!("Failed to save archived bean: {}", id))?;
617
618        // Append to archive index
619        {
620            let mut archive_index =
621                ArchiveIndex::load(beans_dir).unwrap_or(ArchiveIndex { beans: Vec::new() });
622            archive_index.append(IndexEntry::from(&bean));
623            let _ = archive_index.save(beans_dir);
624        }
625
626        println!("Closed bean {}: {}", id, bean.title);
627        any_closed = true;
628
629        // Fire post-close hook (failure warns but does NOT revert the close)
630        match execute_hook(HookEvent::PostClose, &bean, project_root, reason.clone()) {
631            Ok(false) => {
632                eprintln!("Warning: post-close hook returned non-zero for bean {}", id);
633            }
634            Err(e) => {
635                eprintln!("Warning: post-close hook error for bean {}: {}", id, e);
636            }
637            Ok(true) => {}
638        }
639
640        // Process on_close actions (after post-close hook)
641        for action in &bean.on_close {
642            match action {
643                OnCloseAction::Run { command } => {
644                    if !is_trusted(project_root) {
645                        eprintln!(
646                            "on_close: skipping `{}` (not trusted — run `bn trust` to enable)",
647                            command
648                        );
649                        continue;
650                    }
651                    eprintln!("on_close: running `{}`", command);
652                    let status = std::process::Command::new("sh")
653                        .args(["-c", command.as_str()])
654                        .current_dir(project_root)
655                        .status();
656                    match status {
657                        Ok(s) if !s.success() => {
658                            eprintln!("on_close run command failed: {}", command)
659                        }
660                        Err(e) => eprintln!("on_close run command error: {}", e),
661                        _ => {}
662                    }
663                }
664                OnCloseAction::Notify { message } => {
665                    println!("[bean {}] {}", id, message);
666                }
667            }
668        }
669
670        // Fire on_close config hook (async, non-blocking)
671        if let Some(ref config) = config {
672            if let Some(ref on_close_template) = config.on_close {
673                let vars = HookVars {
674                    id: Some(id.clone()),
675                    title: Some(bean.title.clone()),
676                    status: Some("closed".into()),
677                    branch: current_git_branch(),
678                    ..Default::default()
679                };
680                execute_config_hook("on_close", on_close_template, &vars, project_root);
681            }
682        }
683
684        // Clean up worktree after successful close
685        if let Some(ref wt_info) = worktree_info {
686            if let Err(e) = worktree::cleanup_worktree(wt_info) {
687                eprintln!("Warning: failed to clean up worktree: {}", e);
688            }
689        }
690
691        // Check if parent should be auto-closed
692        // (skip if beans_dir was removed by worktree cleanup)
693        if beans_dir.exists() {
694            if let Some(parent_id) = &bean.parent {
695                // Check config for auto_close_parent setting
696                let auto_close_enabled =
697                    config.as_ref().map(|c| c.auto_close_parent).unwrap_or(true); // Default to true
698
699                if auto_close_enabled && all_children_closed(beans_dir, parent_id)? {
700                    auto_close_parent(beans_dir, parent_id)?;
701                }
702            }
703        }
704    }
705
706    // Report rejected beans
707    if !rejected_beans.is_empty() {
708        eprintln!(
709            "Failed to close {} bean(s) due to pre-close hook rejection: {}",
710            rejected_beans.len(),
711            rejected_beans.join(", ")
712        );
713    }
714
715    // Rebuild index once after all updates (even if some failed verification)
716    // Skip if beans_dir was removed by worktree cleanup
717    if (any_closed || !ids.is_empty()) && beans_dir.exists() {
718        let index = Index::build(beans_dir).with_context(|| "Failed to rebuild index")?;
719        index
720            .save(beans_dir)
721            .with_context(|| "Failed to save index")?;
722    }
723
724    Ok(())
725}
726
727/// Mark an attempt as explicitly failed.
728///
729/// The bean stays open and the claim is released so another agent can retry.
730/// Records the failure in attempt_log for episodic memory.
731pub fn cmd_close_failed(beans_dir: &Path, ids: Vec<String>, reason: Option<String>) -> Result<()> {
732    if ids.is_empty() {
733        return Err(anyhow!("At least one bean ID is required"));
734    }
735
736    let now = Utc::now();
737
738    for id in &ids {
739        let bean_path =
740            find_bean_file(beans_dir, id).with_context(|| format!("Bean not found: {}", id))?;
741
742        let mut bean =
743            Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
744
745        // Finalize the current attempt as failed
746        if let Some(attempt) = bean.attempt_log.last_mut() {
747            if attempt.finished_at.is_none() {
748                attempt.outcome = crate::bean::AttemptOutcome::Failed;
749                attempt.finished_at = Some(now);
750                attempt.notes = reason.clone();
751            }
752        }
753
754        // Release the claim (bean stays open for retry)
755        bean.claimed_by = None;
756        bean.claimed_at = None;
757        bean.status = Status::Open;
758        bean.updated_at = now;
759
760        // Generate structured failure summary and append to notes.
761        // This is a lightweight summary (no tool logs or token data from the CLI
762        // path), but still captures duration, error, and suggestions for the
763        // next agent.
764        {
765            let attempt_num = bean.attempt_log.len() as u32;
766            let duration_secs = bean
767                .attempt_log
768                .last()
769                .and_then(|a| a.started_at)
770                .map(|started| (now - started).num_seconds().max(0) as u64)
771                .unwrap_or(0);
772
773            let ctx = failure::FailureContext {
774                bean_id: id.clone(),
775                bean_title: bean.title.clone(),
776                attempt: attempt_num.max(1),
777                duration_secs,
778                tool_count: 0,
779                turns: 0,
780                input_tokens: 0,
781                output_tokens: 0,
782                cost: 0.0,
783                error: reason.clone(),
784                tool_log: vec![],
785                verify_command: bean.verify.clone(),
786            };
787            let summary = failure::build_failure_summary(&ctx);
788
789            match &mut bean.notes {
790                Some(notes) => {
791                    notes.push('\n');
792                    notes.push_str(&summary);
793                }
794                None => bean.notes = Some(summary),
795            }
796        }
797
798        bean.to_file(&bean_path)
799            .with_context(|| format!("Failed to save bean: {}", id))?;
800
801        let attempt_count = bean.attempt_log.len();
802        println!(
803            "Marked bean {} as failed (attempt #{}): {}",
804            id, attempt_count, bean.title
805        );
806        if let Some(ref reason_text) = reason {
807            println!("  Reason: {}", reason_text);
808        }
809        println!("  Bean remains open for retry.");
810    }
811
812    // Rebuild index
813    let index = Index::build(beans_dir).with_context(|| "Failed to rebuild index")?;
814    index
815        .save(beans_dir)
816        .with_context(|| "Failed to save index")?;
817
818    Ok(())
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824    use crate::util::title_to_slug;
825    use tempfile::TempDir;
826
827    fn setup_test_beans_dir() -> (TempDir, std::path::PathBuf) {
828        let dir = TempDir::new().unwrap();
829        let beans_dir = dir.path().join(".beans");
830        fs::create_dir(&beans_dir).unwrap();
831        (dir, beans_dir)
832    }
833
834    #[test]
835    fn test_close_single_bean() {
836        let (_dir, beans_dir) = setup_test_beans_dir();
837        let bean = Bean::new("1", "Task");
838        let slug = title_to_slug(&bean.title);
839        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
840            .unwrap();
841
842        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
843
844        // Bean should be archived, not in root beans dir
845        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
846        let updated = Bean::from_file(&archived).unwrap();
847        assert_eq!(updated.status, Status::Closed);
848        assert!(updated.closed_at.is_some());
849        assert!(updated.close_reason.is_none());
850        assert!(updated.is_archived);
851    }
852
853    #[test]
854    fn test_close_with_reason() {
855        let (_dir, beans_dir) = setup_test_beans_dir();
856        let bean = Bean::new("1", "Task");
857        let slug = title_to_slug(&bean.title);
858        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
859            .unwrap();
860
861        cmd_close(
862            &beans_dir,
863            vec!["1".to_string()],
864            Some("Fixed".to_string()),
865            false,
866        )
867        .unwrap();
868
869        // Bean should be archived
870        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
871        let updated = Bean::from_file(&archived).unwrap();
872        assert_eq!(updated.status, Status::Closed);
873        assert_eq!(updated.close_reason, Some("Fixed".to_string()));
874        assert!(updated.is_archived);
875    }
876
877    #[test]
878    fn test_close_multiple_beans() {
879        let (_dir, beans_dir) = setup_test_beans_dir();
880        let bean1 = Bean::new("1", "Task 1");
881        let bean2 = Bean::new("2", "Task 2");
882        let bean3 = Bean::new("3", "Task 3");
883        let slug1 = title_to_slug(&bean1.title);
884        let slug2 = title_to_slug(&bean2.title);
885        let slug3 = title_to_slug(&bean3.title);
886        bean1
887            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
888            .unwrap();
889        bean2
890            .to_file(beans_dir.join(format!("2-{}.md", slug2)))
891            .unwrap();
892        bean3
893            .to_file(beans_dir.join(format!("3-{}.md", slug3)))
894            .unwrap();
895
896        cmd_close(
897            &beans_dir,
898            vec!["1".to_string(), "2".to_string(), "3".to_string()],
899            None,
900            false,
901        )
902        .unwrap();
903
904        for id in &["1", "2", "3"] {
905            // All beans should be archived
906            let archived = crate::discovery::find_archived_bean(&beans_dir, id).unwrap();
907            let bean = Bean::from_file(&archived).unwrap();
908            assert_eq!(bean.status, Status::Closed);
909            assert!(bean.closed_at.is_some());
910            assert!(bean.is_archived);
911        }
912    }
913
914    #[test]
915    fn test_close_nonexistent_bean() {
916        let (_dir, beans_dir) = setup_test_beans_dir();
917        let result = cmd_close(&beans_dir, vec!["99".to_string()], None, false);
918        assert!(result.is_err());
919    }
920
921    #[test]
922    fn test_close_no_ids() {
923        let (_dir, beans_dir) = setup_test_beans_dir();
924        let result = cmd_close(&beans_dir, vec![], None, false);
925        assert!(result.is_err());
926    }
927
928    #[test]
929    fn test_close_rebuilds_index() {
930        let (_dir, beans_dir) = setup_test_beans_dir();
931        let bean1 = Bean::new("1", "Task 1");
932        let bean2 = Bean::new("2", "Task 2");
933        let slug1 = title_to_slug(&bean1.title);
934        let slug2 = title_to_slug(&bean2.title);
935        bean1
936            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
937            .unwrap();
938        bean2
939            .to_file(beans_dir.join(format!("2-{}.md", slug2)))
940            .unwrap();
941
942        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
943
944        let index = Index::load(&beans_dir).unwrap();
945        // After closing, bean 1 is archived, so only bean 2 should be in the index
946        assert_eq!(index.beans.len(), 1);
947        let entry2 = index.beans.iter().find(|e| e.id == "2").unwrap();
948        assert_eq!(entry2.status, Status::Open);
949
950        // Verify bean 1 was archived and still closed
951        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
952        let bean1_archived = Bean::from_file(&archived).unwrap();
953        assert_eq!(bean1_archived.status, Status::Closed);
954    }
955
956    #[test]
957    fn test_close_sets_updated_at() {
958        let (_dir, beans_dir) = setup_test_beans_dir();
959        let bean = Bean::new("1", "Task");
960        let original_updated_at = bean.updated_at;
961        let slug = title_to_slug(&bean.title);
962        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
963            .unwrap();
964
965        std::thread::sleep(std::time::Duration::from_millis(10));
966
967        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
968
969        // Read from archive
970        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
971        let updated = Bean::from_file(&archived).unwrap();
972        assert!(updated.updated_at > original_updated_at);
973    }
974
975    #[test]
976    fn test_close_with_passing_verify() {
977        let (_dir, beans_dir) = setup_test_beans_dir();
978        let mut bean = Bean::new("1", "Task with verify");
979        bean.verify = Some("true".to_string());
980        let slug = title_to_slug(&bean.title);
981        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
982            .unwrap();
983
984        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
985
986        // Verify bean is archived after passing verify
987        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
988        let updated = Bean::from_file(&archived).unwrap();
989        assert_eq!(updated.status, Status::Closed);
990        assert!(updated.closed_at.is_some());
991        assert!(updated.is_archived);
992    }
993
994    #[test]
995    fn test_close_with_failing_verify_increments_attempts() {
996        let (_dir, beans_dir) = setup_test_beans_dir();
997        let mut bean = Bean::new("1", "Task with failing verify");
998        bean.verify = Some("false".to_string());
999        bean.attempts = 0;
1000        let slug = title_to_slug(&bean.title);
1001        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1002            .unwrap();
1003
1004        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1005
1006        let updated =
1007            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1008        assert_eq!(updated.status, Status::Open); // Not closed
1009        assert_eq!(updated.attempts, 1); // Incremented
1010        assert!(updated.closed_at.is_none());
1011    }
1012
1013    #[test]
1014    fn test_close_with_failing_verify_multiple_attempts() {
1015        let (_dir, beans_dir) = setup_test_beans_dir();
1016        let mut bean = Bean::new("1", "Task with failing verify");
1017        bean.verify = Some("false".to_string());
1018        bean.attempts = 0;
1019        let slug = title_to_slug(&bean.title);
1020        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1021            .unwrap();
1022
1023        // First attempt
1024        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1025        let updated =
1026            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1027        assert_eq!(updated.attempts, 1);
1028        assert_eq!(updated.status, Status::Open);
1029
1030        // Second attempt
1031        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1032        let updated =
1033            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1034        assert_eq!(updated.attempts, 2);
1035        assert_eq!(updated.status, Status::Open);
1036
1037        // Third attempt - no limit, keeps incrementing
1038        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1039        let updated =
1040            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1041        assert_eq!(updated.attempts, 3);
1042        assert_eq!(updated.status, Status::Open);
1043
1044        // Fourth attempt - still works, no max
1045        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1046        let updated =
1047            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1048        assert_eq!(updated.attempts, 4);
1049        assert_eq!(updated.status, Status::Open);
1050    }
1051
1052    #[test]
1053    fn test_close_failure_appends_to_notes() {
1054        let (_dir, beans_dir) = setup_test_beans_dir();
1055        let mut bean = Bean::new("1", "Task with failing verify");
1056        // Use a command that produces output
1057        bean.verify = Some("echo 'test error output' && exit 1".to_string());
1058        bean.notes = Some("Original notes".to_string());
1059        let slug = title_to_slug(&bean.title);
1060        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1061            .unwrap();
1062
1063        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1064
1065        let updated =
1066            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1067        let notes = updated.notes.unwrap();
1068
1069        // Original notes preserved
1070        assert!(notes.contains("Original notes"));
1071        // Failure appended
1072        assert!(notes.contains("## Attempt 1"));
1073        assert!(notes.contains("Exit code: 1"));
1074        assert!(notes.contains("test error output"));
1075    }
1076
1077    #[test]
1078    fn test_close_failure_creates_notes_if_none() {
1079        let (_dir, beans_dir) = setup_test_beans_dir();
1080        let mut bean = Bean::new("1", "Task with no notes");
1081        bean.verify = Some("echo 'failure' && exit 1".to_string());
1082        // No notes set
1083        let slug = title_to_slug(&bean.title);
1084        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1085            .unwrap();
1086
1087        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1088
1089        let updated =
1090            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1091        let notes = updated.notes.unwrap();
1092
1093        assert!(notes.contains("## Attempt 1"));
1094        assert!(notes.contains("failure"));
1095    }
1096
1097    #[test]
1098    fn test_close_without_verify_still_works() {
1099        let (_dir, beans_dir) = setup_test_beans_dir();
1100        let bean = Bean::new("1", "Task without verify");
1101        // No verify command set
1102        let slug = title_to_slug(&bean.title);
1103        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1104            .unwrap();
1105
1106        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1107
1108        // Verify bean is archived
1109        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1110        let updated = Bean::from_file(&archived).unwrap();
1111        assert_eq!(updated.status, Status::Closed);
1112        assert!(updated.closed_at.is_some());
1113        assert!(updated.is_archived);
1114    }
1115
1116    #[test]
1117    fn test_close_with_force_skips_verify() {
1118        let (_dir, beans_dir) = setup_test_beans_dir();
1119        let mut bean = Bean::new("1", "Task with failing verify");
1120        // This verify command would normally fail
1121        bean.verify = Some("false".to_string());
1122        let slug = title_to_slug(&bean.title);
1123        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1124            .unwrap();
1125
1126        // Close with force=true should skip verify and close anyway
1127        cmd_close(&beans_dir, vec!["1".to_string()], None, true).unwrap();
1128
1129        // Bean should be archived despite failing verify
1130        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1131        let updated = Bean::from_file(&archived).unwrap();
1132        assert_eq!(updated.status, Status::Closed);
1133        assert!(updated.is_archived);
1134        assert_eq!(updated.attempts, 0); // Attempts should not be incremented
1135    }
1136
1137    #[test]
1138    fn test_close_with_empty_verify_still_closes() {
1139        let (_dir, beans_dir) = setup_test_beans_dir();
1140        let mut bean = Bean::new("1", "Task with empty verify");
1141        bean.verify = Some("".to_string());
1142        let slug = title_to_slug(&bean.title);
1143        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1144            .unwrap();
1145
1146        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1147
1148        // Bean should be closed (empty verify treated as no-verify)
1149        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1150        let updated = Bean::from_file(&archived).unwrap();
1151        assert_eq!(updated.status, Status::Closed);
1152        assert!(updated.is_archived);
1153        assert_eq!(updated.attempts, 0); // No attempts recorded
1154    }
1155
1156    #[test]
1157    fn test_close_with_whitespace_verify_still_closes() {
1158        let (_dir, beans_dir) = setup_test_beans_dir();
1159        let mut bean = Bean::new("1", "Task with whitespace verify");
1160        bean.verify = Some("   ".to_string());
1161        let slug = title_to_slug(&bean.title);
1162        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1163            .unwrap();
1164
1165        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1166
1167        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1168        let updated = Bean::from_file(&archived).unwrap();
1169        assert_eq!(updated.status, Status::Closed);
1170        assert!(updated.is_archived);
1171    }
1172
1173    #[test]
1174    fn test_close_with_shell_operators_work() {
1175        let (_dir, beans_dir) = setup_test_beans_dir();
1176        let mut bean = Bean::new("1", "Task with shell operators");
1177        // Shell operators like && should work in verify commands
1178        bean.verify = Some("true && true".to_string());
1179        let slug = title_to_slug(&bean.title);
1180        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1181            .unwrap();
1182
1183        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1184
1185        // Bean should be archived after passing verify
1186        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1187        let updated = Bean::from_file(&archived).unwrap();
1188        assert_eq!(updated.status, Status::Closed);
1189        assert!(updated.is_archived);
1190    }
1191
1192    #[test]
1193    fn test_close_with_pipe_propagates_exit_code() {
1194        let (_dir, beans_dir) = setup_test_beans_dir();
1195        let mut bean = Bean::new("1", "Task with pipe");
1196        // Pipe exit code is determined by last command: false returns 1
1197        bean.verify = Some("true | false".to_string());
1198        let slug = title_to_slug(&bean.title);
1199        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1200            .unwrap();
1201
1202        let _ = cmd_close(&beans_dir, vec!["1".to_string()], None, false);
1203
1204        // Verify fails because `false` returns exit code 1
1205        let updated =
1206            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1207        assert_eq!(updated.status, Status::Open); // Not closed
1208        assert_eq!(updated.attempts, 1); // Attempts incremented
1209    }
1210
1211    // =====================================================================
1212    // Pre-Close Hook Tests
1213    // =====================================================================
1214
1215    #[test]
1216    fn test_close_with_passing_pre_close_hook() {
1217        let (dir, beans_dir) = setup_test_beans_dir();
1218        let project_root = dir.path();
1219        let hooks_dir = beans_dir.join("hooks");
1220        fs::create_dir_all(&hooks_dir).unwrap();
1221
1222        // Enable trust so hooks execute - pass project root, not .beans dir
1223        crate::hooks::create_trust(project_root).unwrap();
1224
1225        // Create a pre-close hook that passes (exits 0)
1226        let hook_path = hooks_dir.join("pre-close");
1227        fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
1228
1229        #[cfg(unix)]
1230        {
1231            use std::os::unix::fs::PermissionsExt;
1232            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1233        }
1234
1235        let bean = Bean::new("1", "Task with passing hook");
1236        let slug = title_to_slug(&bean.title);
1237        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1238            .unwrap();
1239
1240        // Close should succeed
1241        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1242
1243        // Bean should be archived
1244        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1245        let updated = Bean::from_file(&archived).unwrap();
1246        assert_eq!(updated.status, Status::Closed);
1247        assert!(updated.is_archived);
1248    }
1249
1250    #[test]
1251    fn test_close_with_failing_pre_close_hook_blocks_close() {
1252        let (dir, beans_dir) = setup_test_beans_dir();
1253        let project_root = dir.path();
1254        let hooks_dir = beans_dir.join("hooks");
1255        fs::create_dir_all(&hooks_dir).unwrap();
1256
1257        // Enable trust so hooks execute - pass project root, not .beans dir
1258        crate::hooks::create_trust(project_root).unwrap();
1259
1260        // Create a pre-close hook that fails (exits 1)
1261        let hook_path = hooks_dir.join("pre-close");
1262        fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
1263
1264        #[cfg(unix)]
1265        {
1266            use std::os::unix::fs::PermissionsExt;
1267            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1268        }
1269
1270        let bean = Bean::new("1", "Task with failing hook");
1271        let slug = title_to_slug(&bean.title);
1272        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1273            .unwrap();
1274
1275        // Close should still succeed (returns Ok), but bean not closed
1276        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1277
1278        // Bean should NOT be archived or closed
1279        let not_archived = crate::discovery::find_bean_file(&beans_dir, "1");
1280        assert!(not_archived.is_ok());
1281        let updated = Bean::from_file(not_archived.unwrap()).unwrap();
1282        assert_eq!(updated.status, Status::Open);
1283        assert!(!updated.is_archived);
1284    }
1285
1286    #[test]
1287    fn test_close_batch_with_mixed_hook_results() {
1288        let (dir, beans_dir) = setup_test_beans_dir();
1289        let project_root = dir.path();
1290        let hooks_dir = beans_dir.join("hooks");
1291        fs::create_dir_all(&hooks_dir).unwrap();
1292
1293        // Enable trust so hooks execute - pass project root, not .beans dir
1294        crate::hooks::create_trust(project_root).unwrap();
1295
1296        // Create a pre-close hook that passes
1297        let hook_path = hooks_dir.join("pre-close");
1298        fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
1299
1300        #[cfg(unix)]
1301        {
1302            use std::os::unix::fs::PermissionsExt;
1303            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1304        }
1305
1306        // Create three beans
1307        let bean1 = Bean::new("1", "Task 1 - will close");
1308        let bean2 = Bean::new("2", "Task 2 - will close");
1309        let bean3 = Bean::new("3", "Task 3 - will close");
1310        let slug1 = title_to_slug(&bean1.title);
1311        let slug2 = title_to_slug(&bean2.title);
1312        let slug3 = title_to_slug(&bean3.title);
1313        bean1
1314            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
1315            .unwrap();
1316        bean2
1317            .to_file(beans_dir.join(format!("2-{}.md", slug2)))
1318            .unwrap();
1319        bean3
1320            .to_file(beans_dir.join(format!("3-{}.md", slug3)))
1321            .unwrap();
1322
1323        // Close all three (hook passes for all)
1324        cmd_close(
1325            &beans_dir,
1326            vec!["1".to_string(), "2".to_string(), "3".to_string()],
1327            None,
1328            false,
1329        )
1330        .unwrap();
1331
1332        // All should be archived
1333        for id in &["1", "2", "3"] {
1334            let archived = crate::discovery::find_archived_bean(&beans_dir, id).unwrap();
1335            let bean = Bean::from_file(&archived).unwrap();
1336            assert_eq!(bean.status, Status::Closed);
1337            assert!(bean.is_archived);
1338        }
1339    }
1340
1341    #[test]
1342    fn test_close_with_untrusted_hooks_silently_skips() {
1343        let (_dir, beans_dir) = setup_test_beans_dir();
1344        let hooks_dir = beans_dir.join("hooks");
1345        fs::create_dir_all(&hooks_dir).unwrap();
1346
1347        // DO NOT enable trust - hooks should not execute
1348
1349        // Create a pre-close hook that would fail if executed
1350        let hook_path = hooks_dir.join("pre-close");
1351        fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
1352
1353        #[cfg(unix)]
1354        {
1355            use std::os::unix::fs::PermissionsExt;
1356            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1357        }
1358
1359        let bean = Bean::new("1", "Task with untrusted hook");
1360        let slug = title_to_slug(&bean.title);
1361        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1362            .unwrap();
1363
1364        // Close should succeed (hooks are untrusted so they're skipped)
1365        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1366
1367        // Bean should be archived
1368        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1369        let updated = Bean::from_file(&archived).unwrap();
1370        assert_eq!(updated.status, Status::Closed);
1371        assert!(updated.is_archived);
1372    }
1373
1374    #[test]
1375    fn test_close_with_missing_hook_silently_succeeds() {
1376        let (dir, beans_dir) = setup_test_beans_dir();
1377        let project_root = dir.path();
1378
1379        // Enable trust but don't create hook - pass project root, not .beans dir
1380        crate::hooks::create_trust(project_root).unwrap();
1381
1382        let bean = Bean::new("1", "Task with missing hook");
1383        let slug = title_to_slug(&bean.title);
1384        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1385            .unwrap();
1386
1387        // Close should succeed (missing hooks silently pass)
1388        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1389
1390        // Bean should be archived
1391        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1392        let updated = Bean::from_file(&archived).unwrap();
1393        assert_eq!(updated.status, Status::Closed);
1394        assert!(updated.is_archived);
1395    }
1396
1397    #[test]
1398    fn test_close_passes_reason_to_pre_close_hook() {
1399        let (dir, beans_dir) = setup_test_beans_dir();
1400        let project_root = dir.path();
1401        let hooks_dir = beans_dir.join("hooks");
1402        fs::create_dir_all(&hooks_dir).unwrap();
1403
1404        // Enable trust - pass project root, not .beans dir
1405        crate::hooks::create_trust(project_root).unwrap();
1406
1407        // Create a simple passing hook
1408        let hook_path = hooks_dir.join("pre-close");
1409        fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
1410
1411        #[cfg(unix)]
1412        {
1413            use std::os::unix::fs::PermissionsExt;
1414            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1415        }
1416
1417        let bean = Bean::new("1", "Task with reason");
1418        let slug = title_to_slug(&bean.title);
1419        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1420            .unwrap();
1421
1422        // Close with a reason
1423        cmd_close(
1424            &beans_dir,
1425            vec!["1".to_string()],
1426            Some("Completed".to_string()),
1427            false,
1428        )
1429        .unwrap();
1430
1431        // Verify bean is closed with reason
1432        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1433        let updated = Bean::from_file(&archived).unwrap();
1434        assert_eq!(updated.status, Status::Closed);
1435        assert_eq!(updated.close_reason, Some("Completed".to_string()));
1436    }
1437
1438    #[test]
1439    fn test_close_batch_partial_rejection_by_hook() {
1440        let (dir, beans_dir) = setup_test_beans_dir();
1441        let project_root = dir.path();
1442        let hooks_dir = beans_dir.join("hooks");
1443        fs::create_dir_all(&hooks_dir).unwrap();
1444
1445        // Enable trust - pass project root, not .beans dir
1446        crate::hooks::create_trust(project_root).unwrap();
1447
1448        // Create a hook that checks bean ID - reject ID 2
1449        // Use dd with timeout to consume stdin and check content
1450        let hook_path = hooks_dir.join("pre-close");
1451        fs::write(&hook_path, "#!/bin/bash\ntimeout 5 dd bs=1M 2>/dev/null | grep -q '\"id\":\"2\"' && exit 1 || exit 0").unwrap();
1452
1453        #[cfg(unix)]
1454        {
1455            use std::os::unix::fs::PermissionsExt;
1456            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1457        }
1458
1459        // Create three beans
1460        let bean1 = Bean::new("1", "Task 1");
1461        let bean2 = Bean::new("2", "Task 2 - will be rejected");
1462        let bean3 = Bean::new("3", "Task 3");
1463        let slug1 = title_to_slug(&bean1.title);
1464        let slug2 = title_to_slug(&bean2.title);
1465        let slug3 = title_to_slug(&bean3.title);
1466        bean1
1467            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
1468            .unwrap();
1469        bean2
1470            .to_file(beans_dir.join(format!("2-{}.md", slug2)))
1471            .unwrap();
1472        bean3
1473            .to_file(beans_dir.join(format!("3-{}.md", slug3)))
1474            .unwrap();
1475
1476        // Try to close all three
1477        cmd_close(
1478            &beans_dir,
1479            vec!["1".to_string(), "2".to_string(), "3".to_string()],
1480            None,
1481            false,
1482        )
1483        .unwrap();
1484
1485        // Bean 1 should be archived
1486        let archived1 = crate::discovery::find_archived_bean(&beans_dir, "1");
1487        assert!(archived1.is_ok());
1488        let bean1_result = Bean::from_file(archived1.unwrap()).unwrap();
1489        assert_eq!(bean1_result.status, Status::Closed);
1490
1491        // Bean 2 should NOT be archived (rejected by hook)
1492        let open2 = crate::discovery::find_bean_file(&beans_dir, "2");
1493        assert!(open2.is_ok());
1494        let bean2_result = Bean::from_file(open2.unwrap()).unwrap();
1495        assert_eq!(bean2_result.status, Status::Open);
1496
1497        // Bean 3 should be archived
1498        let archived3 = crate::discovery::find_archived_bean(&beans_dir, "3");
1499        assert!(archived3.is_ok());
1500        let bean3_result = Bean::from_file(archived3.unwrap()).unwrap();
1501        assert_eq!(bean3_result.status, Status::Closed);
1502    }
1503
1504    // =====================================================================
1505    // Post-Close Hook Tests
1506    // =====================================================================
1507
1508    #[test]
1509    fn test_post_close_hook_fires_after_successful_close() {
1510        let (dir, beans_dir) = setup_test_beans_dir();
1511        let project_root = dir.path();
1512        let hooks_dir = beans_dir.join("hooks");
1513        fs::create_dir_all(&hooks_dir).unwrap();
1514
1515        // Enable trust
1516        crate::hooks::create_trust(project_root).unwrap();
1517
1518        // Create a post-close hook that writes a marker file
1519        let marker = project_root.join("post-close-fired");
1520        let hook_path = hooks_dir.join("post-close");
1521        fs::write(
1522            &hook_path,
1523            format!("#!/bin/bash\ntouch {}\nexit 0", marker.display()),
1524        )
1525        .unwrap();
1526
1527        #[cfg(unix)]
1528        {
1529            use std::os::unix::fs::PermissionsExt;
1530            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1531        }
1532
1533        let bean = Bean::new("1", "Task with post-close hook");
1534        let slug = title_to_slug(&bean.title);
1535        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1536            .unwrap();
1537
1538        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1539
1540        // Marker file should exist, proving the post-close hook fired
1541        assert!(marker.exists(), "post-close hook should have fired");
1542
1543        // Bean should still be archived
1544        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1545        let updated = Bean::from_file(&archived).unwrap();
1546        assert_eq!(updated.status, Status::Closed);
1547        assert!(updated.is_archived);
1548    }
1549
1550    #[test]
1551    fn test_post_close_hook_failure_does_not_prevent_close() {
1552        let (dir, beans_dir) = setup_test_beans_dir();
1553        let project_root = dir.path();
1554        let hooks_dir = beans_dir.join("hooks");
1555        fs::create_dir_all(&hooks_dir).unwrap();
1556
1557        // Enable trust
1558        crate::hooks::create_trust(project_root).unwrap();
1559
1560        // Create a post-close hook that FAILS (exits 1)
1561        let hook_path = hooks_dir.join("post-close");
1562        fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
1563
1564        #[cfg(unix)]
1565        {
1566            use std::os::unix::fs::PermissionsExt;
1567            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1568        }
1569
1570        let bean = Bean::new("1", "Task with failing post-close hook");
1571        let slug = title_to_slug(&bean.title);
1572        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1573            .unwrap();
1574
1575        // Close should succeed even though post-close hook fails
1576        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1577
1578        // Bean should still be archived and closed
1579        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
1580        let updated = Bean::from_file(&archived).unwrap();
1581        assert_eq!(updated.status, Status::Closed);
1582        assert!(updated.is_archived);
1583    }
1584
1585    // =====================================================================
1586    // Auto-Close Parent Tests
1587    // =====================================================================
1588
1589    fn setup_test_beans_dir_with_config() -> (TempDir, std::path::PathBuf) {
1590        let dir = TempDir::new().unwrap();
1591        let beans_dir = dir.path().join(".beans");
1592        fs::create_dir(&beans_dir).unwrap();
1593
1594        // Create config with auto_close_parent enabled
1595        let config = crate::config::Config {
1596            project: "test".to_string(),
1597            next_id: 100,
1598            auto_close_parent: true,
1599            run: None,
1600            plan: None,
1601            max_loops: 10,
1602            max_concurrent: 4,
1603            poll_interval: 30,
1604            extends: vec![],
1605            rules_file: None,
1606            file_locking: false,
1607            worktree: false,
1608            on_close: None,
1609            on_fail: None,
1610            post_plan: None,
1611            verify_timeout: None,
1612            review: None,
1613            user: None,
1614            user_email: None,
1615        };
1616        config.save(&beans_dir).unwrap();
1617
1618        (dir, beans_dir)
1619    }
1620
1621    #[test]
1622    fn test_auto_close_parent_when_all_children_closed() {
1623        let (_dir, beans_dir) = setup_test_beans_dir_with_config();
1624
1625        // Create parent bean
1626        let parent = Bean::new("1", "Parent Task");
1627        let parent_slug = title_to_slug(&parent.title);
1628        parent
1629            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
1630            .unwrap();
1631
1632        // Create child beans
1633        let mut child1 = Bean::new("1.1", "Child 1");
1634        child1.parent = Some("1".to_string());
1635        let child1_slug = title_to_slug(&child1.title);
1636        child1
1637            .to_file(beans_dir.join(format!("1.1-{}.md", child1_slug)))
1638            .unwrap();
1639
1640        let mut child2 = Bean::new("1.2", "Child 2");
1641        child2.parent = Some("1".to_string());
1642        let child2_slug = title_to_slug(&child2.title);
1643        child2
1644            .to_file(beans_dir.join(format!("1.2-{}.md", child2_slug)))
1645            .unwrap();
1646
1647        // Close first child - parent should NOT auto-close yet
1648        cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1649
1650        // Parent should still be open
1651        let parent_still_open = crate::discovery::find_bean_file(&beans_dir, "1");
1652        assert!(parent_still_open.is_ok());
1653        let parent_bean = Bean::from_file(parent_still_open.unwrap()).unwrap();
1654        assert_eq!(parent_bean.status, Status::Open);
1655
1656        // Close second child - parent should auto-close now
1657        cmd_close(&beans_dir, vec!["1.2".to_string()], None, false).unwrap();
1658
1659        // Parent should now be archived
1660        let parent_archived = crate::discovery::find_archived_bean(&beans_dir, "1");
1661        assert!(parent_archived.is_ok(), "Parent should be auto-archived");
1662        let parent_result = Bean::from_file(parent_archived.unwrap()).unwrap();
1663        assert_eq!(parent_result.status, Status::Closed);
1664        assert!(parent_result
1665            .close_reason
1666            .as_ref()
1667            .unwrap()
1668            .contains("Auto-closed"));
1669    }
1670
1671    #[test]
1672    fn test_no_auto_close_when_children_still_open() {
1673        let (_dir, beans_dir) = setup_test_beans_dir_with_config();
1674
1675        // Create parent bean
1676        let parent = Bean::new("1", "Parent Task");
1677        let parent_slug = title_to_slug(&parent.title);
1678        parent
1679            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
1680            .unwrap();
1681
1682        // Create child beans
1683        let mut child1 = Bean::new("1.1", "Child 1");
1684        child1.parent = Some("1".to_string());
1685        let child1_slug = title_to_slug(&child1.title);
1686        child1
1687            .to_file(beans_dir.join(format!("1.1-{}.md", child1_slug)))
1688            .unwrap();
1689
1690        let mut child2 = Bean::new("1.2", "Child 2");
1691        child2.parent = Some("1".to_string());
1692        let child2_slug = title_to_slug(&child2.title);
1693        child2
1694            .to_file(beans_dir.join(format!("1.2-{}.md", child2_slug)))
1695            .unwrap();
1696
1697        // Close first child only
1698        cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1699
1700        // Parent should still be open (not all children closed)
1701        let parent_still_open = crate::discovery::find_bean_file(&beans_dir, "1");
1702        assert!(parent_still_open.is_ok());
1703        let parent_bean = Bean::from_file(parent_still_open.unwrap()).unwrap();
1704        assert_eq!(parent_bean.status, Status::Open);
1705    }
1706
1707    #[test]
1708    fn test_auto_close_disabled_via_config() {
1709        let dir = TempDir::new().unwrap();
1710        let beans_dir = dir.path().join(".beans");
1711        fs::create_dir(&beans_dir).unwrap();
1712
1713        // Create config with auto_close_parent DISABLED
1714        let config = crate::config::Config {
1715            project: "test".to_string(),
1716            next_id: 100,
1717            auto_close_parent: false,
1718            run: None,
1719            plan: None,
1720            max_loops: 10,
1721            max_concurrent: 4,
1722            poll_interval: 30,
1723            extends: vec![],
1724            rules_file: None,
1725            file_locking: false,
1726            worktree: false,
1727            on_close: None,
1728            on_fail: None,
1729            post_plan: None,
1730            verify_timeout: None,
1731            review: None,
1732            user: None,
1733            user_email: None,
1734        };
1735        config.save(&beans_dir).unwrap();
1736
1737        // Create parent bean
1738        let parent = Bean::new("1", "Parent Task");
1739        let parent_slug = title_to_slug(&parent.title);
1740        parent
1741            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
1742            .unwrap();
1743
1744        // Create single child bean
1745        let mut child = Bean::new("1.1", "Only Child");
1746        child.parent = Some("1".to_string());
1747        let child_slug = title_to_slug(&child.title);
1748        child
1749            .to_file(beans_dir.join(format!("1.1-{}.md", child_slug)))
1750            .unwrap();
1751
1752        // Close the child
1753        cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1754
1755        // Parent should still be open (auto-close disabled)
1756        let parent_still_open = crate::discovery::find_bean_file(&beans_dir, "1");
1757        assert!(parent_still_open.is_ok());
1758        let parent_bean = Bean::from_file(parent_still_open.unwrap()).unwrap();
1759        assert_eq!(parent_bean.status, Status::Open);
1760    }
1761
1762    #[test]
1763    fn test_auto_close_recursive_grandparent() {
1764        let (_dir, beans_dir) = setup_test_beans_dir_with_config();
1765
1766        // Create grandparent bean
1767        let grandparent = Bean::new("1", "Grandparent");
1768        let gp_slug = title_to_slug(&grandparent.title);
1769        grandparent
1770            .to_file(beans_dir.join(format!("1-{}.md", gp_slug)))
1771            .unwrap();
1772
1773        // Create parent bean (child of grandparent)
1774        let mut parent = Bean::new("1.1", "Parent");
1775        parent.parent = Some("1".to_string());
1776        let p_slug = title_to_slug(&parent.title);
1777        parent
1778            .to_file(beans_dir.join(format!("1.1-{}.md", p_slug)))
1779            .unwrap();
1780
1781        // Create grandchild bean (child of parent)
1782        let mut grandchild = Bean::new("1.1.1", "Grandchild");
1783        grandchild.parent = Some("1.1".to_string());
1784        let gc_slug = title_to_slug(&grandchild.title);
1785        grandchild
1786            .to_file(beans_dir.join(format!("1.1.1-{}.md", gc_slug)))
1787            .unwrap();
1788
1789        // Close the grandchild - should cascade up
1790        cmd_close(&beans_dir, vec!["1.1.1".to_string()], None, false).unwrap();
1791
1792        // All three should be archived
1793        let gc_archived = crate::discovery::find_archived_bean(&beans_dir, "1.1.1");
1794        assert!(gc_archived.is_ok(), "Grandchild should be archived");
1795
1796        let p_archived = crate::discovery::find_archived_bean(&beans_dir, "1.1");
1797        assert!(p_archived.is_ok(), "Parent should be auto-archived");
1798
1799        let gp_archived = crate::discovery::find_archived_bean(&beans_dir, "1");
1800        assert!(gp_archived.is_ok(), "Grandparent should be auto-archived");
1801
1802        // Check auto-close reasons
1803        let p_bean = Bean::from_file(p_archived.unwrap()).unwrap();
1804        assert!(p_bean
1805            .close_reason
1806            .as_ref()
1807            .unwrap()
1808            .contains("Auto-closed"));
1809
1810        let gp_bean = Bean::from_file(gp_archived.unwrap()).unwrap();
1811        assert!(gp_bean
1812            .close_reason
1813            .as_ref()
1814            .unwrap()
1815            .contains("Auto-closed"));
1816    }
1817
1818    #[test]
1819    fn test_auto_close_with_no_parent() {
1820        let (_dir, beans_dir) = setup_test_beans_dir_with_config();
1821
1822        // Create a standalone bean (no parent)
1823        let bean = Bean::new("1", "Standalone Task");
1824        let slug = title_to_slug(&bean.title);
1825        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1826            .unwrap();
1827
1828        // Close the bean - should work fine with no parent
1829        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1830
1831        // Bean should be archived
1832        let archived = crate::discovery::find_archived_bean(&beans_dir, "1");
1833        assert!(archived.is_ok());
1834        let bean_result = Bean::from_file(archived.unwrap()).unwrap();
1835        assert_eq!(bean_result.status, Status::Closed);
1836    }
1837
1838    #[test]
1839    fn test_all_children_closed_checks_archived_beans() {
1840        let (_dir, beans_dir) = setup_test_beans_dir_with_config();
1841
1842        // Create parent bean
1843        let parent = Bean::new("1", "Parent Task");
1844        let parent_slug = title_to_slug(&parent.title);
1845        parent
1846            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
1847            .unwrap();
1848
1849        // Create two child beans
1850        let mut child1 = Bean::new("1.1", "Child 1");
1851        child1.parent = Some("1".to_string());
1852        let child1_slug = title_to_slug(&child1.title);
1853        child1
1854            .to_file(beans_dir.join(format!("1.1-{}.md", child1_slug)))
1855            .unwrap();
1856
1857        let mut child2 = Bean::new("1.2", "Child 2");
1858        child2.parent = Some("1".to_string());
1859        let child2_slug = title_to_slug(&child2.title);
1860        child2
1861            .to_file(beans_dir.join(format!("1.2-{}.md", child2_slug)))
1862            .unwrap();
1863
1864        // Close first child (will be archived)
1865        cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1866
1867        // Verify child1 is archived
1868        let child1_archived = crate::discovery::find_archived_bean(&beans_dir, "1.1");
1869        assert!(child1_archived.is_ok(), "Child 1 should be archived");
1870
1871        // Now close child2 - parent should auto-close even though child1 is in archive
1872        cmd_close(&beans_dir, vec!["1.2".to_string()], None, false).unwrap();
1873
1874        // Parent should be archived
1875        let parent_archived = crate::discovery::find_archived_bean(&beans_dir, "1");
1876        assert!(
1877            parent_archived.is_ok(),
1878            "Parent should be auto-archived when all children (including archived) are closed"
1879        );
1880    }
1881
1882    // =====================================================================
1883    // Feature Bean Close Tests
1884    // =====================================================================
1885
1886    #[test]
1887    fn test_feature_bean_not_closed_in_non_tty() {
1888        // In test/CI context, stdin is not a TTY.
1889        // Feature beans should stay open and cmd_close should succeed (exit 0).
1890        let (_dir, beans_dir) = setup_test_beans_dir();
1891
1892        let mut bean = Bean::new("1", "Task management");
1893        bean.feature = true;
1894        let slug = title_to_slug(&bean.title);
1895        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1896            .unwrap();
1897
1898        // Close should succeed (no error) but the bean stays open
1899        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1900
1901        // Bean should still be open in the beans dir (not archived)
1902        let still_open = crate::discovery::find_bean_file(&beans_dir, "1").unwrap();
1903        let updated = Bean::from_file(&still_open).unwrap();
1904        assert_eq!(updated.status, Status::Open);
1905    }
1906
1907    #[test]
1908    fn test_feature_bean_force_still_blocked_in_non_tty() {
1909        // Even with --force, feature beans should not close in non-TTY
1910        let (_dir, beans_dir) = setup_test_beans_dir();
1911
1912        let mut bean = Bean::new("1", "Release v2");
1913        bean.feature = true;
1914        let slug = title_to_slug(&bean.title);
1915        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1916            .unwrap();
1917
1918        cmd_close(&beans_dir, vec!["1".to_string()], None, true).unwrap();
1919
1920        let still_open = crate::discovery::find_bean_file(&beans_dir, "1").unwrap();
1921        let updated = Bean::from_file(&still_open).unwrap();
1922        assert_eq!(updated.status, Status::Open);
1923    }
1924
1925    #[test]
1926    fn test_feature_parent_not_auto_closed() {
1927        let (_dir, beans_dir) = setup_test_beans_dir_with_config();
1928
1929        // Create a feature parent bean
1930        let mut parent = Bean::new("1", "Feature parent");
1931        parent.feature = true;
1932        let parent_slug = title_to_slug(&parent.title);
1933        parent
1934            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
1935            .unwrap();
1936
1937        // Create child beans
1938        let mut child1 = Bean::new("1.1", "Child 1");
1939        child1.parent = Some("1".to_string());
1940        let child1_slug = title_to_slug(&child1.title);
1941        child1
1942            .to_file(beans_dir.join(format!("1.1-{}.md", child1_slug)))
1943            .unwrap();
1944
1945        let mut child2 = Bean::new("1.2", "Child 2");
1946        child2.parent = Some("1".to_string());
1947        let child2_slug = title_to_slug(&child2.title);
1948        child2
1949            .to_file(beans_dir.join(format!("1.2-{}.md", child2_slug)))
1950            .unwrap();
1951
1952        // Close both children
1953        cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1954        cmd_close(&beans_dir, vec!["1.2".to_string()], None, false).unwrap();
1955
1956        // Feature parent should still be open (not auto-closed)
1957        let parent_still_open = crate::discovery::find_bean_file(&beans_dir, "1");
1958        assert!(
1959            parent_still_open.is_ok(),
1960            "Feature parent should NOT be auto-closed"
1961        );
1962        let parent_bean = Bean::from_file(parent_still_open.unwrap()).unwrap();
1963        assert_eq!(parent_bean.status, Status::Open);
1964    }
1965
1966    #[test]
1967    fn test_non_feature_parent_still_auto_closes() {
1968        // Ensure normal (non-feature) parents still auto-close as before
1969        let (_dir, beans_dir) = setup_test_beans_dir_with_config();
1970
1971        let parent = Bean::new("1", "Regular parent");
1972        let parent_slug = title_to_slug(&parent.title);
1973        parent
1974            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
1975            .unwrap();
1976
1977        let mut child = Bean::new("1.1", "Only child");
1978        child.parent = Some("1".to_string());
1979        let child_slug = title_to_slug(&child.title);
1980        child
1981            .to_file(beans_dir.join(format!("1.1-{}.md", child_slug)))
1982            .unwrap();
1983
1984        cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1985
1986        // Regular parent should be auto-closed
1987        let parent_archived = crate::discovery::find_archived_bean(&beans_dir, "1");
1988        assert!(
1989            parent_archived.is_ok(),
1990            "Regular parent should be auto-archived"
1991        );
1992        let parent_bean = Bean::from_file(parent_archived.unwrap()).unwrap();
1993        assert_eq!(parent_bean.status, Status::Closed);
1994    }
1995
1996    #[test]
1997    fn test_feature_grandparent_blocks_recursive_auto_close() {
1998        // When a feature bean is in the middle of the chain, auto-close stops there
1999        let (_dir, beans_dir) = setup_test_beans_dir_with_config();
2000
2001        // Feature grandparent
2002        let mut grandparent = Bean::new("1", "Feature goal");
2003        grandparent.feature = true;
2004        let gp_slug = title_to_slug(&grandparent.title);
2005        grandparent
2006            .to_file(beans_dir.join(format!("1-{}.md", gp_slug)))
2007            .unwrap();
2008
2009        // Regular parent (child of feature grandparent)
2010        let mut parent = Bean::new("1.1", "Regular parent");
2011        parent.parent = Some("1".to_string());
2012        let p_slug = title_to_slug(&parent.title);
2013        parent
2014            .to_file(beans_dir.join(format!("1.1-{}.md", p_slug)))
2015            .unwrap();
2016
2017        // Leaf child
2018        let mut child = Bean::new("1.1.1", "Leaf child");
2019        child.parent = Some("1.1".to_string());
2020        let c_slug = title_to_slug(&child.title);
2021        child
2022            .to_file(beans_dir.join(format!("1.1.1-{}.md", c_slug)))
2023            .unwrap();
2024
2025        // Close the leaf — parent should auto-close, but grandparent (feature) should NOT
2026        cmd_close(&beans_dir, vec!["1.1.1".to_string()], None, false).unwrap();
2027
2028        // Leaf should be archived
2029        assert!(crate::discovery::find_archived_bean(&beans_dir, "1.1.1").is_ok());
2030
2031        // Regular parent should be auto-archived
2032        assert!(
2033            crate::discovery::find_archived_bean(&beans_dir, "1.1").is_ok(),
2034            "Regular parent should be auto-archived"
2035        );
2036
2037        // Feature grandparent should still be open
2038        let gp_still_open = crate::discovery::find_bean_file(&beans_dir, "1");
2039        assert!(
2040            gp_still_open.is_ok(),
2041            "Feature grandparent should NOT be auto-closed"
2042        );
2043        let gp_bean = Bean::from_file(gp_still_open.unwrap()).unwrap();
2044        assert_eq!(gp_bean.status, Status::Open);
2045    }
2046
2047    // =====================================================================
2048    // Truncation Helper Tests
2049    // =====================================================================
2050
2051    #[test]
2052    fn test_truncate_to_char_boundary_ascii() {
2053        let s = "hello world";
2054        assert_eq!(truncate_to_char_boundary(s, 5), 5);
2055        assert_eq!(&s[..truncate_to_char_boundary(s, 5)], "hello");
2056    }
2057
2058    #[test]
2059    fn test_truncate_to_char_boundary_multibyte() {
2060        // Each emoji is 4 bytes: "😀😁😂" = 12 bytes
2061        let s = "😀😁😂";
2062        assert_eq!(s.len(), 12);
2063
2064        // Truncating at byte 5 (mid-codepoint) should back up to byte 4
2065        assert_eq!(truncate_to_char_boundary(s, 5), 4);
2066        assert_eq!(&s[..truncate_to_char_boundary(s, 5)], "😀");
2067
2068        // Truncating at byte 8 (exact boundary) should stay at 8
2069        assert_eq!(truncate_to_char_boundary(s, 8), 8);
2070        assert_eq!(&s[..truncate_to_char_boundary(s, 8)], "😀😁");
2071    }
2072
2073    #[test]
2074    fn test_truncate_to_char_boundary_beyond_len() {
2075        let s = "short";
2076        assert_eq!(truncate_to_char_boundary(s, 100), 5);
2077    }
2078
2079    #[test]
2080    fn test_truncate_to_char_boundary_zero() {
2081        let s = "hello";
2082        assert_eq!(truncate_to_char_boundary(s, 0), 0);
2083    }
2084
2085    // =====================================================================
2086    // on_close Action Tests
2087    // =====================================================================
2088
2089    #[test]
2090    fn on_close_run_action_executes_command() {
2091        let (dir, beans_dir) = setup_test_beans_dir();
2092        let project_root = dir.path();
2093        crate::hooks::create_trust(project_root).unwrap();
2094        let marker = project_root.join("on_close_ran");
2095
2096        let mut bean = Bean::new("1", "Task with on_close run");
2097        bean.on_close = vec![OnCloseAction::Run {
2098            command: format!("touch {}", marker.display()),
2099        }];
2100        let slug = title_to_slug(&bean.title);
2101        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2102            .unwrap();
2103
2104        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2105
2106        assert!(marker.exists(), "on_close run command should have executed");
2107    }
2108
2109    #[test]
2110    fn on_close_notify_action_prints_message() {
2111        let (_dir, beans_dir) = setup_test_beans_dir();
2112
2113        let mut bean = Bean::new("1", "Task with on_close notify");
2114        bean.on_close = vec![OnCloseAction::Notify {
2115            message: "All done!".to_string(),
2116        }];
2117        let slug = title_to_slug(&bean.title);
2118        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2119            .unwrap();
2120
2121        // Should not error — notify just prints
2122        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2123
2124        // Bean should still be archived
2125        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2126        let updated = Bean::from_file(&archived).unwrap();
2127        assert_eq!(updated.status, Status::Closed);
2128    }
2129
2130    #[test]
2131    fn on_close_run_failure_does_not_prevent_close() {
2132        let (dir, beans_dir) = setup_test_beans_dir();
2133        let project_root = dir.path();
2134        crate::hooks::create_trust(project_root).unwrap();
2135
2136        let mut bean = Bean::new("1", "Task with failing on_close");
2137        bean.on_close = vec![OnCloseAction::Run {
2138            command: "false".to_string(), // exits 1
2139        }];
2140        let slug = title_to_slug(&bean.title);
2141        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2142            .unwrap();
2143
2144        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2145
2146        // Bean should still be archived despite on_close failure
2147        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2148        let updated = Bean::from_file(&archived).unwrap();
2149        assert_eq!(updated.status, Status::Closed);
2150        assert!(updated.is_archived);
2151    }
2152
2153    #[test]
2154    fn on_close_multiple_actions_all_run() {
2155        let (dir, beans_dir) = setup_test_beans_dir();
2156        let project_root = dir.path();
2157        crate::hooks::create_trust(project_root).unwrap();
2158        let marker1 = project_root.join("on_close_1");
2159        let marker2 = project_root.join("on_close_2");
2160
2161        let mut bean = Bean::new("1", "Task with multiple on_close");
2162        bean.on_close = vec![
2163            OnCloseAction::Run {
2164                command: format!("touch {}", marker1.display()),
2165            },
2166            OnCloseAction::Notify {
2167                message: "Between actions".to_string(),
2168            },
2169            OnCloseAction::Run {
2170                command: format!("touch {}", marker2.display()),
2171            },
2172        ];
2173        let slug = title_to_slug(&bean.title);
2174        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2175            .unwrap();
2176
2177        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2178
2179        assert!(marker1.exists(), "First on_close run should have executed");
2180        assert!(marker2.exists(), "Second on_close run should have executed");
2181    }
2182
2183    #[test]
2184    fn on_close_run_skipped_without_trust() {
2185        let (dir, beans_dir) = setup_test_beans_dir();
2186        let project_root = dir.path();
2187        // DO NOT enable trust — on_close Run should be skipped
2188        let marker = project_root.join("on_close_should_not_exist");
2189
2190        let mut bean = Bean::new("1", "Task with untrusted on_close");
2191        bean.on_close = vec![OnCloseAction::Run {
2192            command: format!("touch {}", marker.display()),
2193        }];
2194        let slug = title_to_slug(&bean.title);
2195        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2196            .unwrap();
2197
2198        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2199
2200        // Command should NOT have executed (no trust)
2201        assert!(
2202            !marker.exists(),
2203            "on_close run should be skipped without trust"
2204        );
2205
2206        // Bean should still be archived (on_close skip doesn't block close)
2207        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2208        let updated = Bean::from_file(&archived).unwrap();
2209        assert_eq!(updated.status, Status::Closed);
2210        assert!(updated.is_archived);
2211    }
2212
2213    #[test]
2214    fn on_close_runs_in_project_root() {
2215        let (dir, beans_dir) = setup_test_beans_dir();
2216        let project_root = dir.path();
2217        crate::hooks::create_trust(project_root).unwrap();
2218
2219        let mut bean = Bean::new("1", "Task with pwd check");
2220        // Write the working directory to a file so we can verify it
2221        let pwd_file = project_root.join("on_close_pwd");
2222        bean.on_close = vec![OnCloseAction::Run {
2223            command: format!("pwd > {}", pwd_file.display()),
2224        }];
2225        let slug = title_to_slug(&bean.title);
2226        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2227            .unwrap();
2228
2229        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2230
2231        let pwd_output = fs::read_to_string(&pwd_file).unwrap();
2232        // Resolve symlinks for macOS /private/var/... vs /var/...
2233        let expected = std::fs::canonicalize(project_root).unwrap();
2234        let actual = std::fs::canonicalize(pwd_output.trim()).unwrap();
2235        assert_eq!(actual, expected);
2236    }
2237
2238    // =====================================================================
2239    // History Recording Tests
2240    // =====================================================================
2241
2242    #[test]
2243    fn history_failure_creates_run_record() {
2244        let (_dir, beans_dir) = setup_test_beans_dir();
2245        let mut bean = Bean::new("1", "Task with failing verify");
2246        bean.verify = Some("echo 'some error' && exit 1".to_string());
2247        let slug = title_to_slug(&bean.title);
2248        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2249            .unwrap();
2250
2251        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2252
2253        let updated =
2254            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2255        assert_eq!(updated.history.len(), 1);
2256        let record = &updated.history[0];
2257        assert_eq!(record.result, RunResult::Fail);
2258        assert_eq!(record.attempt, 1);
2259        assert_eq!(record.exit_code, Some(1));
2260        assert!(record.output_snippet.is_some());
2261        assert!(record
2262            .output_snippet
2263            .as_ref()
2264            .unwrap()
2265            .contains("some error"));
2266    }
2267
2268    #[test]
2269    fn history_success_creates_run_record() {
2270        let (_dir, beans_dir) = setup_test_beans_dir();
2271        let mut bean = Bean::new("1", "Task with passing verify");
2272        bean.verify = Some("true".to_string());
2273        let slug = title_to_slug(&bean.title);
2274        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2275            .unwrap();
2276
2277        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2278
2279        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2280        let updated = Bean::from_file(&archived).unwrap();
2281        assert_eq!(updated.history.len(), 1);
2282        let record = &updated.history[0];
2283        assert_eq!(record.result, RunResult::Pass);
2284        assert_eq!(record.attempt, 1);
2285        assert!(record.output_snippet.is_none());
2286    }
2287
2288    #[test]
2289    fn history_has_correct_duration() {
2290        let (_dir, beans_dir) = setup_test_beans_dir();
2291        let mut bean = Bean::new("1", "Task with timed verify");
2292        // sleep 0.1 to ensure measurable duration
2293        bean.verify = Some("sleep 0.1 && true".to_string());
2294        let slug = title_to_slug(&bean.title);
2295        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2296            .unwrap();
2297
2298        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2299
2300        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2301        let updated = Bean::from_file(&archived).unwrap();
2302        assert_eq!(updated.history.len(), 1);
2303        let record = &updated.history[0];
2304        assert!(record.finished_at.is_some());
2305        assert!(record.duration_secs.is_some());
2306        let dur = record.duration_secs.unwrap();
2307        assert!(dur >= 0.05, "Duration should be >= 0.05s, got {}", dur);
2308        // Verify finished_at > started_at
2309        assert!(record.finished_at.unwrap() >= record.started_at);
2310    }
2311
2312    #[test]
2313    fn history_records_exit_code() {
2314        let (_dir, beans_dir) = setup_test_beans_dir();
2315        let mut bean = Bean::new("1", "Task with exit code 42");
2316        bean.verify = Some("exit 42".to_string());
2317        let slug = title_to_slug(&bean.title);
2318        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2319            .unwrap();
2320
2321        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2322
2323        let updated =
2324            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2325        assert_eq!(updated.history.len(), 1);
2326        assert_eq!(updated.history[0].exit_code, Some(42));
2327        assert_eq!(updated.history[0].result, RunResult::Fail);
2328    }
2329
2330    #[test]
2331    fn history_multiple_attempts_accumulate() {
2332        let (_dir, beans_dir) = setup_test_beans_dir();
2333        let mut bean = Bean::new("1", "Task with multiple failures");
2334        bean.verify = Some("false".to_string());
2335        let slug = title_to_slug(&bean.title);
2336        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2337            .unwrap();
2338
2339        // Three failed attempts
2340        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2341        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2342        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2343
2344        let updated =
2345            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2346        assert_eq!(updated.history.len(), 3);
2347        assert_eq!(updated.history[0].attempt, 1);
2348        assert_eq!(updated.history[1].attempt, 2);
2349        assert_eq!(updated.history[2].attempt, 3);
2350        for record in &updated.history {
2351            assert_eq!(record.result, RunResult::Fail);
2352        }
2353    }
2354
2355    #[test]
2356    fn history_agent_from_env_var() {
2357        // Set env var before close, then verify it's captured
2358        // NOTE: env var tests are inherently racy with parallel execution,
2359        // but set_var + close + remove_var in sequence is the best we can do.
2360        std::env::set_var("BEANS_AGENT", "test-agent-42");
2361
2362        let (_dir, beans_dir) = setup_test_beans_dir();
2363        let mut bean = Bean::new("1", "Task with agent env");
2364        bean.verify = Some("true".to_string());
2365        let slug = title_to_slug(&bean.title);
2366        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2367            .unwrap();
2368
2369        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2370
2371        // Clean up env var immediately
2372        std::env::remove_var("BEANS_AGENT");
2373
2374        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2375        let updated = Bean::from_file(&archived).unwrap();
2376        assert_eq!(updated.history.len(), 1);
2377        assert_eq!(updated.history[0].agent, Some("test-agent-42".to_string()));
2378    }
2379
2380    #[test]
2381    fn history_no_record_without_verify() {
2382        let (_dir, beans_dir) = setup_test_beans_dir();
2383        let bean = Bean::new("1", "Task without verify");
2384        let slug = title_to_slug(&bean.title);
2385        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2386            .unwrap();
2387
2388        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2389
2390        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2391        let updated = Bean::from_file(&archived).unwrap();
2392        assert!(
2393            updated.history.is_empty(),
2394            "No history when no verify command"
2395        );
2396    }
2397
2398    #[test]
2399    fn history_no_record_when_force_skip() {
2400        let (_dir, beans_dir) = setup_test_beans_dir();
2401        let mut bean = Bean::new("1", "Task force closed");
2402        bean.verify = Some("false".to_string());
2403        let slug = title_to_slug(&bean.title);
2404        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2405            .unwrap();
2406
2407        cmd_close(&beans_dir, vec!["1".to_string()], None, true).unwrap();
2408
2409        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2410        let updated = Bean::from_file(&archived).unwrap();
2411        assert!(
2412            updated.history.is_empty(),
2413            "No history when verify skipped with --force"
2414        );
2415    }
2416
2417    #[test]
2418    fn history_failure_then_success_accumulates() {
2419        let (_dir, beans_dir) = setup_test_beans_dir();
2420        let mut bean = Bean::new("1", "Task that eventually passes");
2421        bean.verify = Some("false".to_string());
2422        let slug = title_to_slug(&bean.title);
2423        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2424            .unwrap();
2425
2426        // First attempt fails
2427        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2428
2429        // Change verify to pass
2430        let mut updated =
2431            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2432        updated.verify = Some("true".to_string());
2433        updated
2434            .to_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap())
2435            .unwrap();
2436
2437        // Second attempt succeeds
2438        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2439
2440        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2441        let final_bean = Bean::from_file(&archived).unwrap();
2442        assert_eq!(final_bean.history.len(), 2);
2443        assert_eq!(final_bean.history[0].result, RunResult::Fail);
2444        assert_eq!(final_bean.history[0].attempt, 1);
2445        assert_eq!(final_bean.history[1].result, RunResult::Pass);
2446        assert_eq!(final_bean.history[1].attempt, 2);
2447    }
2448
2449    // =====================================================================
2450    // on_fail Action Tests
2451    // =====================================================================
2452
2453    #[test]
2454    fn on_fail_retry_releases_claim_when_under_max() {
2455        let (_dir, beans_dir) = setup_test_beans_dir();
2456        let mut bean = Bean::new("1", "Task with retry on_fail");
2457        bean.verify = Some("false".to_string());
2458        bean.on_fail = Some(OnFailAction::Retry {
2459            max: Some(5),
2460            delay_secs: None,
2461        });
2462        bean.claimed_by = Some("agent-1".to_string());
2463        bean.claimed_at = Some(Utc::now());
2464        let slug = title_to_slug(&bean.title);
2465        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2466            .unwrap();
2467
2468        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2469
2470        let updated =
2471            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2472        assert_eq!(updated.status, Status::Open);
2473        assert_eq!(updated.attempts, 1);
2474        // Claim should be released for retry
2475        assert!(updated.claimed_by.is_none());
2476        assert!(updated.claimed_at.is_none());
2477    }
2478
2479    #[test]
2480    fn on_fail_retry_keeps_claim_when_at_max() {
2481        let (_dir, beans_dir) = setup_test_beans_dir();
2482        let mut bean = Bean::new("1", "Task exhausted retries");
2483        bean.verify = Some("false".to_string());
2484        bean.on_fail = Some(OnFailAction::Retry {
2485            max: Some(2),
2486            delay_secs: None,
2487        });
2488        bean.attempts = 1; // Next failure will be attempt 2 == max
2489        bean.claimed_by = Some("agent-1".to_string());
2490        bean.claimed_at = Some(Utc::now());
2491        let slug = title_to_slug(&bean.title);
2492        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2493            .unwrap();
2494
2495        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2496
2497        let updated =
2498            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2499        assert_eq!(updated.attempts, 2);
2500        // Claim should NOT be released (max exhausted)
2501        assert_eq!(updated.claimed_by, Some("agent-1".to_string()));
2502        assert!(updated.claimed_at.is_some());
2503    }
2504
2505    #[test]
2506    fn on_fail_retry_max_defaults_to_max_attempts() {
2507        let (_dir, beans_dir) = setup_test_beans_dir();
2508        let mut bean = Bean::new("1", "Task with default max");
2509        bean.verify = Some("false".to_string());
2510        bean.max_attempts = 3;
2511        bean.on_fail = Some(OnFailAction::Retry {
2512            max: None, // Should default to bean.max_attempts (3)
2513            delay_secs: None,
2514        });
2515        bean.claimed_by = Some("agent-1".to_string());
2516        bean.claimed_at = Some(Utc::now());
2517        let slug = title_to_slug(&bean.title);
2518        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2519            .unwrap();
2520
2521        // First attempt (1 < 3) — should release
2522        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2523        let updated =
2524            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2525        assert_eq!(updated.attempts, 1);
2526        assert!(updated.claimed_by.is_none());
2527
2528        // Re-claim and fail again (2 < 3) — should release
2529        let mut bean2 =
2530            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2531        bean2.claimed_by = Some("agent-2".to_string());
2532        bean2.claimed_at = Some(Utc::now());
2533        bean2
2534            .to_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap())
2535            .unwrap();
2536        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2537        let updated =
2538            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2539        assert_eq!(updated.attempts, 2);
2540        assert!(updated.claimed_by.is_none());
2541
2542        // Re-claim and fail again (3 >= 3) — should NOT release
2543        let mut bean3 =
2544            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2545        bean3.claimed_by = Some("agent-3".to_string());
2546        bean3.claimed_at = Some(Utc::now());
2547        bean3
2548            .to_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap())
2549            .unwrap();
2550        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2551        let updated =
2552            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2553        assert_eq!(updated.attempts, 3);
2554        assert_eq!(updated.claimed_by, Some("agent-3".to_string()));
2555    }
2556
2557    #[test]
2558    fn on_fail_retry_with_delay_releases_claim() {
2559        let (_dir, beans_dir) = setup_test_beans_dir();
2560        let mut bean = Bean::new("1", "Task with delay");
2561        bean.verify = Some("false".to_string());
2562        bean.on_fail = Some(OnFailAction::Retry {
2563            max: Some(3),
2564            delay_secs: Some(30),
2565        });
2566        bean.claimed_by = Some("agent-1".to_string());
2567        bean.claimed_at = Some(Utc::now());
2568        let slug = title_to_slug(&bean.title);
2569        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2570            .unwrap();
2571
2572        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2573
2574        let updated =
2575            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2576        assert_eq!(updated.attempts, 1);
2577        // Claim released even with delay (delay is enforced by orchestrator)
2578        assert!(updated.claimed_by.is_none());
2579        assert!(updated.claimed_at.is_none());
2580    }
2581
2582    #[test]
2583    fn on_fail_escalate_updates_priority() {
2584        let (_dir, beans_dir) = setup_test_beans_dir();
2585        let mut bean = Bean::new("1", "Task to escalate");
2586        bean.verify = Some("false".to_string());
2587        bean.priority = 2;
2588        bean.on_fail = Some(OnFailAction::Escalate {
2589            priority: Some(0),
2590            message: None,
2591        });
2592        let slug = title_to_slug(&bean.title);
2593        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2594            .unwrap();
2595
2596        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2597
2598        let updated =
2599            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2600        assert_eq!(updated.priority, 0);
2601        assert!(updated.labels.contains(&"escalated".to_string()));
2602    }
2603
2604    #[test]
2605    fn on_fail_escalate_appends_message_to_notes() {
2606        let (_dir, beans_dir) = setup_test_beans_dir();
2607        let mut bean = Bean::new("1", "Task with escalation message");
2608        bean.verify = Some("false".to_string());
2609        bean.notes = Some("Existing notes".to_string());
2610        bean.on_fail = Some(OnFailAction::Escalate {
2611            priority: None,
2612            message: Some("Needs human review".to_string()),
2613        });
2614        let slug = title_to_slug(&bean.title);
2615        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2616            .unwrap();
2617
2618        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2619
2620        let updated =
2621            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2622        let notes = updated.notes.unwrap();
2623        assert!(notes.contains("Existing notes"));
2624        assert!(notes.contains("## Escalated"));
2625        assert!(notes.contains("Needs human review"));
2626        assert!(updated.labels.contains(&"escalated".to_string()));
2627    }
2628
2629    #[test]
2630    fn on_fail_escalate_adds_label() {
2631        let (_dir, beans_dir) = setup_test_beans_dir();
2632        let mut bean = Bean::new("1", "Task to label");
2633        bean.verify = Some("false".to_string());
2634        bean.on_fail = Some(OnFailAction::Escalate {
2635            priority: None,
2636            message: None,
2637        });
2638        let slug = title_to_slug(&bean.title);
2639        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2640            .unwrap();
2641
2642        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2643
2644        let updated =
2645            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2646        assert!(updated.labels.contains(&"escalated".to_string()));
2647    }
2648
2649    #[test]
2650    fn on_fail_escalate_no_duplicate_label() {
2651        let (_dir, beans_dir) = setup_test_beans_dir();
2652        let mut bean = Bean::new("1", "Task already escalated");
2653        bean.verify = Some("false".to_string());
2654        bean.labels = vec!["escalated".to_string()];
2655        bean.on_fail = Some(OnFailAction::Escalate {
2656            priority: None,
2657            message: None,
2658        });
2659        let slug = title_to_slug(&bean.title);
2660        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2661            .unwrap();
2662
2663        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2664
2665        let updated =
2666            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2667        let count = updated
2668            .labels
2669            .iter()
2670            .filter(|l| l.as_str() == "escalated")
2671            .count();
2672        assert_eq!(count, 1, "Should not duplicate 'escalated' label");
2673    }
2674
2675    #[test]
2676    fn on_fail_none_existing_behavior_unchanged() {
2677        let (_dir, beans_dir) = setup_test_beans_dir();
2678        let mut bean = Bean::new("1", "Task with no on_fail");
2679        bean.verify = Some("false".to_string());
2680        bean.claimed_by = Some("agent-1".to_string());
2681        bean.claimed_at = Some(Utc::now());
2682        // on_fail is None by default
2683        let slug = title_to_slug(&bean.title);
2684        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2685            .unwrap();
2686
2687        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2688
2689        let updated =
2690            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2691        assert_eq!(updated.status, Status::Open);
2692        assert_eq!(updated.attempts, 1);
2693        // Claim should remain (no on_fail to release it)
2694        assert_eq!(updated.claimed_by, Some("agent-1".to_string()));
2695        assert!(updated.labels.is_empty());
2696    }
2697
2698    // =====================================================================
2699    // Output Capture Tests
2700    // =====================================================================
2701
2702    #[test]
2703    fn output_capture_json_stdout_stored_as_outputs() {
2704        let (_dir, beans_dir) = setup_test_beans_dir();
2705        let mut bean = Bean::new("1", "Task with JSON output");
2706        bean.verify = Some(r#"echo '{"passed":42,"failed":0}'"#.to_string());
2707        let slug = title_to_slug(&bean.title);
2708        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2709            .unwrap();
2710
2711        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2712
2713        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2714        let updated = Bean::from_file(&archived).unwrap();
2715        assert_eq!(updated.status, Status::Closed);
2716        let outputs = updated.outputs.expect("outputs should be set");
2717        assert_eq!(outputs["passed"], 42);
2718        assert_eq!(outputs["failed"], 0);
2719    }
2720
2721    #[test]
2722    fn output_capture_non_json_stdout_stored_as_text() {
2723        let (_dir, beans_dir) = setup_test_beans_dir();
2724        let mut bean = Bean::new("1", "Task with plain text output");
2725        bean.verify = Some("echo 'hello world'".to_string());
2726        let slug = title_to_slug(&bean.title);
2727        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2728            .unwrap();
2729
2730        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2731
2732        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2733        let updated = Bean::from_file(&archived).unwrap();
2734        let outputs = updated.outputs.expect("outputs should be set");
2735        assert_eq!(outputs["text"], "hello world");
2736    }
2737
2738    #[test]
2739    fn output_capture_empty_stdout_no_outputs() {
2740        let (_dir, beans_dir) = setup_test_beans_dir();
2741        let mut bean = Bean::new("1", "Task with no stdout");
2742        bean.verify = Some("true".to_string());
2743        let slug = title_to_slug(&bean.title);
2744        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2745            .unwrap();
2746
2747        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2748
2749        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2750        let updated = Bean::from_file(&archived).unwrap();
2751        assert!(
2752            updated.outputs.is_none(),
2753            "empty stdout should not set outputs"
2754        );
2755    }
2756
2757    #[test]
2758    fn output_capture_large_stdout_truncated() {
2759        let (_dir, beans_dir) = setup_test_beans_dir();
2760        let mut bean = Bean::new("1", "Task with large output");
2761        // Generate >64KB of stdout using printf (faster than many echos)
2762        bean.verify = Some("python3 -c \"print('x' * 70000)\"".to_string());
2763        let slug = title_to_slug(&bean.title);
2764        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2765            .unwrap();
2766
2767        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2768
2769        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2770        let updated = Bean::from_file(&archived).unwrap();
2771        let outputs = updated
2772            .outputs
2773            .expect("outputs should be set for large output");
2774        assert_eq!(outputs["truncated"], true);
2775        assert!(outputs["original_bytes"].as_u64().unwrap() > 64 * 1024);
2776        // The text should be truncated to 64KB
2777        let text = outputs["text"].as_str().unwrap();
2778        assert!(text.len() <= 64 * 1024);
2779    }
2780
2781    #[test]
2782    fn output_capture_stderr_not_captured_as_outputs() {
2783        let (_dir, beans_dir) = setup_test_beans_dir();
2784        let mut bean = Bean::new("1", "Task with stderr only");
2785        // Write to stderr only, nothing to stdout
2786        bean.verify = Some("echo 'error info' >&2".to_string());
2787        let slug = title_to_slug(&bean.title);
2788        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2789            .unwrap();
2790
2791        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2792
2793        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2794        let updated = Bean::from_file(&archived).unwrap();
2795        assert!(
2796            updated.outputs.is_none(),
2797            "stderr-only output should not set outputs"
2798        );
2799    }
2800
2801    #[test]
2802    fn output_capture_failure_unchanged() {
2803        let (_dir, beans_dir) = setup_test_beans_dir();
2804        let mut bean = Bean::new("1", "Task that fails with output");
2805        bean.verify = Some(r#"echo '{"result":"data"}' && exit 1"#.to_string());
2806        let slug = title_to_slug(&bean.title);
2807        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2808            .unwrap();
2809
2810        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2811
2812        let updated =
2813            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2814        assert_eq!(updated.status, Status::Open);
2815        assert!(
2816            updated.outputs.is_none(),
2817            "failed verify should not capture outputs"
2818        );
2819    }
2820
2821    #[test]
2822    fn output_capture_json_array() {
2823        let (_dir, beans_dir) = setup_test_beans_dir();
2824        let mut bean = Bean::new("1", "Task with JSON array output");
2825        bean.verify = Some(r#"echo '["a","b","c"]'"#.to_string());
2826        let slug = title_to_slug(&bean.title);
2827        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2828            .unwrap();
2829
2830        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2831
2832        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2833        let updated = Bean::from_file(&archived).unwrap();
2834        let outputs = updated.outputs.expect("outputs should be set");
2835        let arr = outputs.as_array().unwrap();
2836        assert_eq!(arr.len(), 3);
2837        assert_eq!(arr[0], "a");
2838    }
2839
2840    #[test]
2841    fn output_capture_mixed_stdout_stderr() {
2842        let (_dir, beans_dir) = setup_test_beans_dir();
2843        let mut bean = Bean::new("1", "Task with mixed output");
2844        // stdout has JSON, stderr has logs — only stdout should be captured
2845        bean.verify = Some(r#"echo '{"key":"value"}' && echo 'debug log' >&2"#.to_string());
2846        let slug = title_to_slug(&bean.title);
2847        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2848            .unwrap();
2849
2850        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2851
2852        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
2853        let updated = Bean::from_file(&archived).unwrap();
2854        let outputs = updated.outputs.expect("outputs should capture stdout only");
2855        assert_eq!(outputs["key"], "value");
2856        // stderr content should NOT be in outputs
2857        assert!(
2858            outputs.get("text").is_none()
2859                || !outputs["text"].as_str().unwrap_or("").contains("debug log")
2860        );
2861    }
2862
2863    // =====================================================================
2864    // Circuit Breaker (max_loops) Tests
2865    // =====================================================================
2866
2867    /// Helper: set up beans dir with config specifying max_loops.
2868    fn setup_beans_dir_with_max_loops(max_loops: u32) -> (TempDir, std::path::PathBuf) {
2869        let dir = TempDir::new().unwrap();
2870        let beans_dir = dir.path().join(".beans");
2871        fs::create_dir(&beans_dir).unwrap();
2872
2873        let config = crate::config::Config {
2874            project: "test".to_string(),
2875            next_id: 100,
2876            auto_close_parent: true,
2877            run: None,
2878            plan: None,
2879            max_loops,
2880            max_concurrent: 4,
2881            poll_interval: 30,
2882            extends: vec![],
2883            rules_file: None,
2884            file_locking: false,
2885            worktree: false,
2886            on_close: None,
2887            on_fail: None,
2888            post_plan: None,
2889            verify_timeout: None,
2890            review: None,
2891            user: None,
2892            user_email: None,
2893        };
2894        config.save(&beans_dir).unwrap();
2895
2896        (dir, beans_dir)
2897    }
2898
2899    #[test]
2900    fn max_loops_circuit_breaker_triggers_at_limit() {
2901        let (_dir, beans_dir) = setup_beans_dir_with_max_loops(3);
2902
2903        // Create parent and child beans
2904        let parent = Bean::new("1", "Parent");
2905        let parent_slug = title_to_slug(&parent.title);
2906        parent
2907            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
2908            .unwrap();
2909
2910        let mut child1 = Bean::new("1.1", "Child with attempts");
2911        child1.parent = Some("1".to_string());
2912        child1.verify = Some("false".to_string());
2913        child1.attempts = 2; // Already has 2 attempts
2914        let child1_slug = title_to_slug(&child1.title);
2915        child1
2916            .to_file(beans_dir.join(format!("1.1-{}.md", child1_slug)))
2917            .unwrap();
2918
2919        // Close child1 → attempts becomes 3, subtree total = 0+3 = 3 >= 3
2920        cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
2921
2922        let updated =
2923            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1.1").unwrap()).unwrap();
2924        assert_eq!(updated.status, Status::Open);
2925        assert_eq!(updated.attempts, 3);
2926        assert!(
2927            updated.labels.contains(&"circuit-breaker".to_string()),
2928            "Circuit breaker label should be added"
2929        );
2930        assert_eq!(updated.priority, 0, "Priority should be escalated to P0");
2931    }
2932
2933    #[test]
2934    fn max_loops_circuit_breaker_does_not_trigger_below_limit() {
2935        let (_dir, beans_dir) = setup_beans_dir_with_max_loops(5);
2936
2937        let parent = Bean::new("1", "Parent");
2938        let parent_slug = title_to_slug(&parent.title);
2939        parent
2940            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
2941            .unwrap();
2942
2943        let mut child = Bean::new("1.1", "Child");
2944        child.parent = Some("1".to_string());
2945        child.verify = Some("false".to_string());
2946        child.attempts = 1; // After fail: 2, subtree = 0+2 = 2 < 5
2947        let child_slug = title_to_slug(&child.title);
2948        child
2949            .to_file(beans_dir.join(format!("1.1-{}.md", child_slug)))
2950            .unwrap();
2951
2952        cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
2953
2954        let updated =
2955            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1.1").unwrap()).unwrap();
2956        assert_eq!(updated.attempts, 2);
2957        assert!(
2958            !updated.labels.contains(&"circuit-breaker".to_string()),
2959            "Circuit breaker should NOT trigger below limit"
2960        );
2961        assert_ne!(updated.priority, 0, "Priority should not change");
2962    }
2963
2964    #[test]
2965    fn max_loops_zero_disables_circuit_breaker() {
2966        let (_dir, beans_dir) = setup_beans_dir_with_max_loops(0);
2967
2968        let mut bean = Bean::new("1", "Unlimited retries");
2969        bean.verify = Some("false".to_string());
2970        bean.attempts = 100; // Many attempts — should not trip if max_loops=0
2971        let slug = title_to_slug(&bean.title);
2972        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
2973            .unwrap();
2974
2975        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2976
2977        let updated =
2978            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
2979        assert_eq!(updated.attempts, 101);
2980        assert!(
2981            !updated.labels.contains(&"circuit-breaker".to_string()),
2982            "Circuit breaker should not trigger when max_loops=0"
2983        );
2984    }
2985
2986    #[test]
2987    fn max_loops_per_bean_overrides_config() {
2988        // Config has max_loops=100 (high), but root bean has max_loops=3 (low)
2989        let (_dir, beans_dir) = setup_beans_dir_with_max_loops(100);
2990
2991        let mut parent = Bean::new("1", "Parent with low max_loops");
2992        parent.max_loops = Some(3); // Override: only 3 allowed
2993        let parent_slug = title_to_slug(&parent.title);
2994        parent
2995            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
2996            .unwrap();
2997
2998        let mut child = Bean::new("1.1", "Child");
2999        child.parent = Some("1".to_string());
3000        child.verify = Some("false".to_string());
3001        child.attempts = 2; // After fail: 3, subtree = 0+3 = 3 >= 3
3002        let child_slug = title_to_slug(&child.title);
3003        child
3004            .to_file(beans_dir.join(format!("1.1-{}.md", child_slug)))
3005            .unwrap();
3006
3007        cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
3008
3009        let updated =
3010            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1.1").unwrap()).unwrap();
3011        assert!(
3012            updated.labels.contains(&"circuit-breaker".to_string()),
3013            "Per-bean max_loops should override config"
3014        );
3015        assert_eq!(updated.priority, 0);
3016    }
3017
3018    #[test]
3019    fn max_loops_circuit_breaker_skips_on_fail_retry() {
3020        // Circuit breaker should prevent on_fail retry from releasing the claim
3021        let (_dir, beans_dir) = setup_beans_dir_with_max_loops(2);
3022
3023        let mut bean = Bean::new("1", "Bean with retry that should be blocked");
3024        bean.verify = Some("false".to_string());
3025        bean.attempts = 1; // After fail: 2 >= max_loops=2 → circuit breaker
3026        bean.on_fail = Some(OnFailAction::Retry {
3027            max: Some(10),
3028            delay_secs: None,
3029        });
3030        bean.claimed_by = Some("agent-1".to_string());
3031        bean.claimed_at = Some(Utc::now());
3032        let slug = title_to_slug(&bean.title);
3033        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3034            .unwrap();
3035
3036        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3037
3038        let updated =
3039            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3040        // Circuit breaker should have tripped, preventing on_fail retry
3041        assert!(updated.labels.contains(&"circuit-breaker".to_string()));
3042        assert_eq!(updated.priority, 0);
3043        // Claim should NOT be released (circuit breaker bypasses on_fail)
3044        assert_eq!(
3045            updated.claimed_by,
3046            Some("agent-1".to_string()),
3047            "on_fail retry should not release claim when circuit breaker trips"
3048        );
3049    }
3050
3051    #[test]
3052    fn max_loops_counts_across_siblings() {
3053        let (_dir, beans_dir) = setup_beans_dir_with_max_loops(5);
3054
3055        let parent = Bean::new("1", "Parent");
3056        let parent_slug = title_to_slug(&parent.title);
3057        parent
3058            .to_file(beans_dir.join(format!("1-{}.md", parent_slug)))
3059            .unwrap();
3060
3061        // Sibling 1.1 already has 2 attempts
3062        let mut sibling = Bean::new("1.1", "Sibling");
3063        sibling.parent = Some("1".to_string());
3064        sibling.attempts = 2;
3065        let sib_slug = title_to_slug(&sibling.title);
3066        sibling
3067            .to_file(beans_dir.join(format!("1.1-{}.md", sib_slug)))
3068            .unwrap();
3069
3070        // Child 1.2 has 2 attempts, will increment to 3
3071        // subtree total = 0 + 2 + 3 = 5 >= 5
3072        let mut child = Bean::new("1.2", "Child");
3073        child.parent = Some("1".to_string());
3074        child.verify = Some("false".to_string());
3075        child.attempts = 2;
3076        let child_slug = title_to_slug(&child.title);
3077        child
3078            .to_file(beans_dir.join(format!("1.2-{}.md", child_slug)))
3079            .unwrap();
3080
3081        cmd_close(&beans_dir, vec!["1.2".to_string()], None, false).unwrap();
3082
3083        let updated =
3084            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1.2").unwrap()).unwrap();
3085        assert!(
3086            updated.labels.contains(&"circuit-breaker".to_string()),
3087            "Circuit breaker should count sibling attempts"
3088        );
3089        assert_eq!(updated.priority, 0);
3090    }
3091
3092    #[test]
3093    fn max_loops_standalone_bean_uses_own_max_loops() {
3094        let (_dir, beans_dir) = setup_beans_dir_with_max_loops(100);
3095
3096        // Standalone bean (no parent) with its own max_loops
3097        let mut bean = Bean::new("1", "Standalone");
3098        bean.verify = Some("false".to_string());
3099        bean.max_loops = Some(2);
3100        bean.attempts = 1; // After fail: 2, subtree(self) = 2 >= 2
3101        let slug = title_to_slug(&bean.title);
3102        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3103            .unwrap();
3104
3105        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3106
3107        let updated =
3108            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3109        assert!(updated.labels.contains(&"circuit-breaker".to_string()));
3110        assert_eq!(updated.priority, 0);
3111    }
3112
3113    #[test]
3114    fn max_loops_no_config_defaults_to_10() {
3115        // No config file — should default max_loops to 10
3116        let (_dir, beans_dir) = setup_test_beans_dir();
3117
3118        let mut bean = Bean::new("1", "No config");
3119        bean.verify = Some("false".to_string());
3120        bean.attempts = 9; // After fail: 10, subtree = 10 >= default 10
3121        let slug = title_to_slug(&bean.title);
3122        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3123            .unwrap();
3124
3125        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3126
3127        let updated =
3128            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3129        assert!(
3130            updated.labels.contains(&"circuit-breaker".to_string()),
3131            "Should use default max_loops=10"
3132        );
3133    }
3134
3135    #[test]
3136    fn max_loops_no_duplicate_label() {
3137        let (_dir, beans_dir) = setup_beans_dir_with_max_loops(1);
3138
3139        let mut bean = Bean::new("1", "Already has label");
3140        bean.verify = Some("false".to_string());
3141        bean.labels = vec!["circuit-breaker".to_string()];
3142        bean.attempts = 0; // After fail: 1 >= 1
3143        let slug = title_to_slug(&bean.title);
3144        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3145            .unwrap();
3146
3147        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3148
3149        let updated =
3150            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3151        let count = updated
3152            .labels
3153            .iter()
3154            .filter(|l| l.as_str() == "circuit-breaker")
3155            .count();
3156        assert_eq!(count, 1, "Should not duplicate 'circuit-breaker' label");
3157    }
3158
3159    // =====================================================================
3160    // Close Failed Tests
3161    // =====================================================================
3162
3163    #[test]
3164    fn test_close_failed_marks_attempt_as_failed() {
3165        let (_dir, beans_dir) = setup_test_beans_dir();
3166        let mut bean = Bean::new("1", "Task");
3167        bean.status = Status::InProgress;
3168        bean.claimed_by = Some("agent-1".to_string());
3169        // Simulate a claim-started attempt
3170        bean.attempt_log.push(crate::bean::AttemptRecord {
3171            num: 1,
3172            outcome: crate::bean::AttemptOutcome::Abandoned,
3173            notes: None,
3174            agent: Some("agent-1".to_string()),
3175            started_at: Some(Utc::now()),
3176            finished_at: None,
3177        });
3178        let slug = title_to_slug(&bean.title);
3179        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3180            .unwrap();
3181
3182        cmd_close_failed(
3183            &beans_dir,
3184            vec!["1".to_string()],
3185            Some("blocked by upstream".to_string()),
3186        )
3187        .unwrap();
3188
3189        let updated =
3190            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3191        assert_eq!(updated.status, Status::Open);
3192        assert!(updated.claimed_by.is_none());
3193        assert_eq!(updated.attempt_log.len(), 1);
3194        assert_eq!(
3195            updated.attempt_log[0].outcome,
3196            crate::bean::AttemptOutcome::Failed
3197        );
3198        assert!(updated.attempt_log[0].finished_at.is_some());
3199        assert_eq!(
3200            updated.attempt_log[0].notes.as_deref(),
3201            Some("blocked by upstream")
3202        );
3203    }
3204
3205    #[test]
3206    fn test_close_failed_appends_to_notes() {
3207        let (_dir, beans_dir) = setup_test_beans_dir();
3208        let mut bean = Bean::new("1", "Task");
3209        bean.status = Status::InProgress;
3210        bean.attempt_log.push(crate::bean::AttemptRecord {
3211            num: 1,
3212            outcome: crate::bean::AttemptOutcome::Abandoned,
3213            notes: None,
3214            agent: None,
3215            started_at: Some(Utc::now()),
3216            finished_at: None,
3217        });
3218        let slug = title_to_slug(&bean.title);
3219        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3220            .unwrap();
3221
3222        cmd_close_failed(
3223            &beans_dir,
3224            vec!["1".to_string()],
3225            Some("JWT incompatible".to_string()),
3226        )
3227        .unwrap();
3228
3229        let updated =
3230            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3231        assert!(updated.notes.is_some());
3232        assert!(updated.notes.unwrap().contains("JWT incompatible"));
3233    }
3234
3235    #[test]
3236    fn test_close_failed_without_reason() {
3237        let (_dir, beans_dir) = setup_test_beans_dir();
3238        let mut bean = Bean::new("1", "Task");
3239        bean.status = Status::InProgress;
3240        bean.attempt_log.push(crate::bean::AttemptRecord {
3241            num: 1,
3242            outcome: crate::bean::AttemptOutcome::Abandoned,
3243            notes: None,
3244            agent: None,
3245            started_at: Some(Utc::now()),
3246            finished_at: None,
3247        });
3248        let slug = title_to_slug(&bean.title);
3249        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3250            .unwrap();
3251
3252        cmd_close_failed(&beans_dir, vec!["1".to_string()], None).unwrap();
3253
3254        let updated =
3255            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3256        assert_eq!(updated.status, Status::Open);
3257        assert_eq!(
3258            updated.attempt_log[0].outcome,
3259            crate::bean::AttemptOutcome::Failed
3260        );
3261    }
3262
3263    // =====================================================================
3264    // Worktree Merge Integration Tests
3265    // =====================================================================
3266
3267    mod worktree_merge {
3268        use super::*;
3269        use std::path::PathBuf;
3270        use std::sync::Mutex;
3271
3272        /// Serialize worktree tests that change the process-global CWD.
3273        /// These tests must not run concurrently with each other because they
3274        /// all call set_current_dir, which is process-global.
3275        static CWD_LOCK: Mutex<()> = Mutex::new(());
3276
3277        /// RAII guard that restores the process CWD on drop (even on panic).
3278        struct CwdGuard(PathBuf);
3279        impl Drop for CwdGuard {
3280            fn drop(&mut self) {
3281                let _ = std::env::set_current_dir(&self.0);
3282            }
3283        }
3284
3285        /// Run a git command in the given directory, panicking on failure.
3286        fn run_git(dir: &Path, args: &[&str]) {
3287            let output = std::process::Command::new("git")
3288                .args(args)
3289                .current_dir(dir)
3290                .output()
3291                .unwrap_or_else(|e| unreachable!("git {:?} failed to execute: {}", args, e));
3292            assert!(
3293                output.status.success(),
3294                "git {:?} in {} failed (exit {:?}):\nstdout: {}\nstderr: {}",
3295                args,
3296                dir.display(),
3297                output.status.code(),
3298                String::from_utf8_lossy(&output.stdout),
3299                String::from_utf8_lossy(&output.stderr),
3300            );
3301        }
3302
3303        /// Create a git repo with a secondary worktree for testing.
3304        ///
3305        /// Returns (tempdir, main_repo_dir, worktree_beans_dir).
3306        /// Main repo is on `main` branch; worktree is on `feature` branch.
3307        fn setup_git_worktree() -> (TempDir, PathBuf, PathBuf) {
3308            let dir = TempDir::new().unwrap();
3309            let base = std::fs::canonicalize(dir.path()).unwrap();
3310            let main_dir = base.join("main");
3311            let worktree_dir = base.join("worktree");
3312            fs::create_dir(&main_dir).unwrap();
3313
3314            // Initialize git repo on an explicit "main" branch
3315            run_git(&main_dir, &["init"]);
3316            run_git(&main_dir, &["config", "user.email", "test@test.com"]);
3317            run_git(&main_dir, &["config", "user.name", "Test"]);
3318            run_git(&main_dir, &["checkout", "-b", "main"]);
3319
3320            // Create an initial commit so the branch exists
3321            fs::write(main_dir.join("initial.txt"), "initial content").unwrap();
3322            run_git(&main_dir, &["add", "-A"]);
3323            run_git(&main_dir, &["commit", "-m", "Initial commit"]);
3324
3325            // Add .beans/ directory and commit it
3326            let beans_dir = main_dir.join(".beans");
3327            fs::create_dir(&beans_dir).unwrap();
3328            fs::write(beans_dir.join(".gitkeep"), "").unwrap();
3329            run_git(&main_dir, &["add", "-A"]);
3330            run_git(&main_dir, &["commit", "-m", "Add .beans directory"]);
3331
3332            // Create a secondary worktree on a feature branch
3333            run_git(
3334                &main_dir,
3335                &[
3336                    "worktree",
3337                    "add",
3338                    worktree_dir.to_str().unwrap(),
3339                    "-b",
3340                    "feature",
3341                ],
3342            );
3343
3344            let worktree_beans_dir = worktree_dir.join(".beans");
3345
3346            (dir, main_dir, worktree_beans_dir)
3347        }
3348
3349        #[test]
3350        fn test_close_in_worktree_commits_and_merges() {
3351            let _lock = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3352            let _guard = CwdGuard(std::env::current_dir().unwrap());
3353
3354            let (_dir, main_dir, worktree_beans_dir) = setup_git_worktree();
3355            let worktree_dir = worktree_beans_dir.parent().unwrap();
3356
3357            // Create a bean in the worktree's .beans/
3358            let bean = Bean::new("1", "Worktree Task");
3359            let slug = title_to_slug(&bean.title);
3360            bean.to_file(worktree_beans_dir.join(format!("1-{}.md", slug)))
3361                .unwrap();
3362
3363            // Make a feature change in the worktree
3364            fs::write(worktree_dir.join("feature.txt"), "feature content").unwrap();
3365
3366            // Set CWD to worktree so detect_worktree() identifies it
3367            std::env::set_current_dir(worktree_dir).unwrap();
3368
3369            // Close the bean — should commit changes, merge to main, and archive
3370            cmd_close(&worktree_beans_dir, vec!["1".to_string()], None, false).unwrap();
3371
3372            // Verify: feature changes were merged into the main branch
3373            assert!(
3374                main_dir.join("feature.txt").exists(),
3375                "feature.txt should be merged to main"
3376            );
3377            let content = fs::read_to_string(main_dir.join("feature.txt")).unwrap();
3378            assert_eq!(content, "feature content");
3379        }
3380
3381        #[test]
3382        fn test_close_with_merge_conflict_aborts() {
3383            let _lock = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3384            let _guard = CwdGuard(std::env::current_dir().unwrap());
3385
3386            let (_dir, main_dir, worktree_beans_dir) = setup_git_worktree();
3387            let worktree_dir = worktree_beans_dir.parent().unwrap();
3388
3389            // Create a conflicting change on main (modify initial.txt)
3390            fs::write(main_dir.join("initial.txt"), "main version").unwrap();
3391            run_git(&main_dir, &["add", "-A"]);
3392            run_git(&main_dir, &["commit", "-m", "Diverge on main"]);
3393
3394            // Create a conflicting change in the worktree (same file, different content)
3395            fs::write(worktree_dir.join("initial.txt"), "feature version").unwrap();
3396
3397            // Create a bean in the worktree
3398            let bean = Bean::new("1", "Conflict Task");
3399            let slug = title_to_slug(&bean.title);
3400            bean.to_file(worktree_beans_dir.join(format!("1-{}.md", slug)))
3401                .unwrap();
3402
3403            // Set CWD to worktree
3404            std::env::set_current_dir(worktree_dir).unwrap();
3405
3406            // Close should detect conflict, abort merge, and leave bean open
3407            cmd_close(&worktree_beans_dir, vec!["1".to_string()], None, false).unwrap();
3408
3409            // Bean should NOT be closed — merge conflict prevents archiving
3410            let bean_file = crate::discovery::find_bean_file(&worktree_beans_dir, "1").unwrap();
3411            let updated = Bean::from_file(&bean_file).unwrap();
3412            assert_eq!(
3413                updated.status,
3414                Status::Open,
3415                "Bean should remain open when merge conflicts"
3416            );
3417        }
3418
3419        #[test]
3420        fn test_close_in_main_worktree_skips_merge() {
3421            let _lock = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3422            let _guard = CwdGuard(std::env::current_dir().unwrap());
3423
3424            let dir = TempDir::new().unwrap();
3425            let base = std::fs::canonicalize(dir.path()).unwrap();
3426            let repo_dir = base.join("repo");
3427            fs::create_dir(&repo_dir).unwrap();
3428
3429            // Initialize a git repo (no secondary worktrees)
3430            run_git(&repo_dir, &["init"]);
3431            run_git(&repo_dir, &["config", "user.email", "test@test.com"]);
3432            run_git(&repo_dir, &["config", "user.name", "Test"]);
3433            run_git(&repo_dir, &["checkout", "-b", "main"]);
3434
3435            fs::write(repo_dir.join("file.txt"), "content").unwrap();
3436            run_git(&repo_dir, &["add", "-A"]);
3437            run_git(&repo_dir, &["commit", "-m", "Initial commit"]);
3438
3439            // Create .beans/ and a bean
3440            let beans_dir = repo_dir.join(".beans");
3441            fs::create_dir(&beans_dir).unwrap();
3442
3443            let bean = Bean::new("1", "Main Worktree Task");
3444            let slug = title_to_slug(&bean.title);
3445            bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3446                .unwrap();
3447
3448            // CWD is the main worktree — detect_worktree() should return None
3449            std::env::set_current_dir(&repo_dir).unwrap();
3450
3451            cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3452
3453            // Bean should be archived normally (no merge step)
3454            let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
3455            let updated = Bean::from_file(&archived).unwrap();
3456            assert_eq!(updated.status, Status::Closed);
3457            assert!(updated.is_archived);
3458        }
3459
3460        #[test]
3461        fn test_close_outside_git_repo_works() {
3462            let _lock = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3463            let _guard = CwdGuard(std::env::current_dir().unwrap());
3464
3465            // Plain temp directory — no git repo at all
3466            let dir = TempDir::new().unwrap();
3467            let base = std::fs::canonicalize(dir.path()).unwrap();
3468            let beans_dir = base.join(".beans");
3469            fs::create_dir(&beans_dir).unwrap();
3470
3471            let bean = Bean::new("1", "No Git Task");
3472            let slug = title_to_slug(&bean.title);
3473            bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3474                .unwrap();
3475
3476            // CWD in a non-git directory — detect_worktree() should return None
3477            std::env::set_current_dir(&base).unwrap();
3478
3479            cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3480
3481            // Bean should be archived normally
3482            let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
3483            let updated = Bean::from_file(&archived).unwrap();
3484            assert_eq!(updated.status, Status::Closed);
3485            assert!(updated.is_archived);
3486        }
3487    }
3488}
3489
3490// =====================================================================
3491// verify_timeout tests (live outside the git-worktree module)
3492// =====================================================================
3493
3494#[cfg(test)]
3495mod verify_timeout_tests {
3496    use super::*;
3497    use crate::bean::{Bean, RunResult, Status};
3498    use crate::util::title_to_slug;
3499    use std::fs;
3500    use tempfile::TempDir;
3501
3502    fn setup_test_beans_dir() -> (TempDir, std::path::PathBuf) {
3503        let dir = TempDir::new().unwrap();
3504        let beans_dir = dir.path().join(".beans");
3505        fs::create_dir(&beans_dir).unwrap();
3506        (dir, beans_dir)
3507    }
3508
3509    /// A verify command that takes longer than the timeout is killed and
3510    /// treated as a failure. The bean remains open, attempts is incremented,
3511    /// and the history entry records RunResult::Timeout.
3512    #[test]
3513    fn verify_timeout_kills_slow_process_and_records_timeout() {
3514        let (_dir, beans_dir) = setup_test_beans_dir();
3515
3516        let mut bean = Bean::new("1", "Slow verify task");
3517        bean.verify = Some("sleep 60".to_string());
3518        bean.verify_timeout = Some(1); // 1-second timeout
3519        let slug = title_to_slug(&bean.title);
3520        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3521            .unwrap();
3522
3523        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3524
3525        // Bean should still be open (verify timed out = failure)
3526        let updated =
3527            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3528        assert_eq!(updated.status, Status::Open);
3529        assert_eq!(updated.attempts, 1);
3530        assert!(updated.closed_at.is_none());
3531
3532        // History should contain a Timeout entry
3533        assert_eq!(updated.history.len(), 1);
3534        assert_eq!(updated.history[0].result, RunResult::Timeout);
3535        assert!(updated.history[0].exit_code.is_none()); // killed, no exit code
3536
3537        // The output_snippet should mention the timeout
3538        let snippet = updated.history[0].output_snippet.as_deref().unwrap_or("");
3539        assert!(
3540            snippet.contains("timed out"),
3541            "expected snippet to contain 'timed out', got: {:?}",
3542            snippet
3543        );
3544    }
3545
3546    /// A verify command that finishes within the timeout is not affected.
3547    #[test]
3548    fn verify_timeout_does_not_affect_fast_commands() {
3549        let (_dir, beans_dir) = setup_test_beans_dir();
3550
3551        let mut bean = Bean::new("1", "Fast verify task");
3552        bean.verify = Some("true".to_string());
3553        bean.verify_timeout = Some(30); // generous timeout
3554        let slug = title_to_slug(&bean.title);
3555        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3556            .unwrap();
3557
3558        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3559
3560        // Bean should be closed normally
3561        let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
3562        let updated = Bean::from_file(&archived).unwrap();
3563        assert_eq!(updated.status, Status::Closed);
3564        assert!(updated.is_archived);
3565    }
3566
3567    /// Bean-level verify_timeout overrides the config-level default.
3568    #[test]
3569    fn verify_timeout_bean_level_overrides_config() {
3570        let (_dir, beans_dir) = setup_test_beans_dir();
3571
3572        // Write a config with a generous timeout (should be overridden by bean)
3573        let config_yaml = "project: test\nnext_id: 2\nverify_timeout: 60\n";
3574        fs::write(beans_dir.join("config.yaml"), config_yaml).unwrap();
3575
3576        let mut bean = Bean::new("1", "Bean timeout overrides config");
3577        bean.verify = Some("sleep 60".to_string());
3578        bean.verify_timeout = Some(1); // bean says 1s — should override config's 60s
3579        let slug = title_to_slug(&bean.title);
3580        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3581            .unwrap();
3582
3583        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3584
3585        let updated =
3586            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3587        assert_eq!(updated.status, Status::Open);
3588        assert_eq!(updated.history[0].result, RunResult::Timeout);
3589    }
3590
3591    /// Config-level verify_timeout applies when bean has no per-bean override.
3592    #[test]
3593    fn verify_timeout_config_level_applies_when_bean_has_none() {
3594        let (_dir, beans_dir) = setup_test_beans_dir();
3595
3596        // Write a config with a short timeout
3597        let config_yaml = "project: test\nnext_id: 2\nverify_timeout: 1\n";
3598        fs::write(beans_dir.join("config.yaml"), config_yaml).unwrap();
3599
3600        let mut bean = Bean::new("1", "Config timeout applies");
3601        bean.verify = Some("sleep 60".to_string());
3602        // No bean-level verify_timeout — config's 1s should apply
3603        let slug = title_to_slug(&bean.title);
3604        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3605            .unwrap();
3606
3607        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3608
3609        let updated =
3610            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3611        assert_eq!(updated.status, Status::Open);
3612        assert_eq!(updated.history[0].result, RunResult::Timeout);
3613    }
3614
3615    /// Notes are updated with a timeout message when verify times out.
3616    #[test]
3617    fn verify_timeout_appends_to_notes() {
3618        let (_dir, beans_dir) = setup_test_beans_dir();
3619
3620        let mut bean = Bean::new("1", "Timeout notes test");
3621        bean.verify = Some("sleep 60".to_string());
3622        bean.verify_timeout = Some(1);
3623        let slug = title_to_slug(&bean.title);
3624        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
3625            .unwrap();
3626
3627        cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3628
3629        let updated =
3630            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
3631        let notes = updated.notes.unwrap_or_default();
3632        // Notes should contain the timeout message
3633        assert!(
3634            notes.contains("timed out"),
3635            "expected notes to contain 'timed out', got: {:?}",
3636            notes
3637        );
3638    }
3639
3640    /// effective_verify_timeout: bean overrides config when both set.
3641    #[test]
3642    fn effective_verify_timeout_bean_wins_over_config() {
3643        let bean = {
3644            let mut b = Bean::new("1", "Test");
3645            b.verify_timeout = Some(5);
3646            b
3647        };
3648        assert_eq!(bean.effective_verify_timeout(Some(30)), Some(5));
3649    }
3650
3651    /// effective_verify_timeout: config applies when bean has none.
3652    #[test]
3653    fn effective_verify_timeout_config_fallback() {
3654        let bean = Bean::new("1", "Test");
3655        assert_eq!(bean.effective_verify_timeout(Some(30)), Some(30));
3656    }
3657
3658    /// effective_verify_timeout: both None → None (no limit).
3659    #[test]
3660    fn effective_verify_timeout_both_none() {
3661        let bean = Bean::new("1", "Test");
3662        assert_eq!(bean.effective_verify_timeout(None), None);
3663    }
3664}