Skip to main content

bn/commands/
close.rs

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