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
22const MAX_OUTPUT_BYTES: usize = 64 * 1024;
24
25fn 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
40struct VerifyResult {
42 success: bool,
43 exit_code: Option<i32>,
44 stdout: String,
45 #[allow(dead_code)]
46 stderr: String,
47 output: String, timed_out: bool,
50}
51
52fn run_verify(
58 beans_dir: &Path,
59 verify_cmd: &str,
60 timeout_secs: Option<u64>,
61) -> Result<VerifyResult> {
62 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 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 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
157fn 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
177fn 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
191fn all_children_closed(beans_dir: &Path, parent_id: &str) -> Result<bool> {
197 let index = Index::build(beans_dir)?;
200 let archived = Index::collect_archived(beans_dir).unwrap_or_default();
201
202 let mut all_beans = index.beans;
204 all_beans.extend(archived);
205
206 let children: Vec<_> = all_beans
208 .iter()
209 .filter(|b| b.parent.as_deref() == Some(parent_id))
210 .collect();
211
212 if children.is_empty() {
214 return Ok(true);
215 }
216
217 for child in children {
219 if child.status != Status::Closed {
220 return Ok(false);
221 }
222 }
223
224 Ok(true)
225}
226
227fn auto_close_parent(beans_dir: &Path, parent_id: &str) -> Result<()> {
235 let bean_path = match find_bean_file(beans_dir, parent_id) {
237 Ok(path) => path,
238 Err(_) => {
239 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 if bean.status == Status::Closed {
249 return Ok(());
250 }
251
252 let now = Utc::now();
253
254 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 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 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 std::fs::rename(&bean_path, &archive_path)
287 .with_context(|| format!("Failed to move bean {} to archive", parent_id))?;
288
289 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 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
306fn 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, ¤t_id)
319 .or_else(|_| find_archived_bean(beans_dir, ¤t_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), }
332 }
333}
334
335pub 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_passed
376 }
377 Err(e) => {
378 eprintln!("Bean {} pre-close hook error: {}", id, e);
380 true }
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 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 let started_at = Utc::now();
399
400 let timeout_secs =
402 bean.effective_verify_timeout(config.as_ref().and_then(|c| c.verify_timeout));
403
404 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 let agent = std::env::var("BEANS_AGENT").ok();
412
413 if !verify_result.success {
414 bean.attempts += 1;
416 bean.updated_at = Utc::now();
417
418 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 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 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 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 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 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 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 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 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(¬e),
545 None => bean.notes = Some(note),
546 }
547 println!("on_fail: {}", msg);
548 }
549 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 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 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 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 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 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 worktree::commit_worktree_changes(&format!("Close bean {}: {}", id, bean.title))?;
667
668 match worktree::merge_to_main(wt_info, id)? {
670 worktree::MergeResult::Success | worktree::MergeResult::NothingToCommit => {
671 }
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(()); }
678 }
679 }
680
681 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 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 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 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 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 std::fs::rename(&bean_path, &archive_path)
724 .with_context(|| format!("Failed to move bean {} to archive", id))?;
725
726 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 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 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 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 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 if beans_dir.exists() {
799 if let Some(parent_id) = &bean.parent {
800 let auto_close_enabled =
802 config.as_ref().map(|c| c.auto_close_parent).unwrap_or(true); 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 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 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
832pub 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 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 bean.claimed_by = None;
861 bean.claimed_at = None;
862 bean.status = Status::Open;
863 bean.updated_at = now;
864
865 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 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 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 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 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 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 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 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 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); assert_eq!(updated.attempts, 1); 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 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 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 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 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 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 assert!(notes.contains("Original notes"));
1151 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 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 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 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 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 cmd_close(&beans_dir, vec!["1".to_string()], None, true).unwrap();
1208
1209 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); }
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 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); }
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 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 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 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 let updated =
1286 Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1287 assert_eq!(updated.status, Status::Open); assert_eq!(updated.attempts, 1); }
1290
1291 #[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 crate::hooks::create_trust(project_root).unwrap();
1304
1305 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 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1322
1323 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 crate::hooks::create_trust(project_root).unwrap();
1339
1340 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 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1357
1358 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 crate::hooks::create_trust(project_root).unwrap();
1375
1376 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 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 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 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 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 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1446
1447 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 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 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1469
1470 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 crate::hooks::create_trust(project_root).unwrap();
1486
1487 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 cmd_close(
1504 &beans_dir,
1505 vec!["1".to_string()],
1506 Some("Completed".to_string()),
1507 false,
1508 )
1509 .unwrap();
1510
1511 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 crate::hooks::create_trust(project_root).unwrap();
1527
1528 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 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 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 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 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 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 #[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 crate::hooks::create_trust(project_root).unwrap();
1597
1598 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 assert!(marker.exists(), "post-close hook should have fired");
1622
1623 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 crate::hooks::create_trust(project_root).unwrap();
1639
1640 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 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1657
1658 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 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 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 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 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 cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1727
1728 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 cmd_close(&beans_dir, vec!["1.2".to_string()], None, false).unwrap();
1736
1737 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 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 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 cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1777
1778 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 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 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 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 cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1830
1831 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 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 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 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 cmd_close(&beans_dir, vec!["1.1.1".to_string()], None, false).unwrap();
1867
1868 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 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 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 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
1906
1907 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 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 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 cmd_close(&beans_dir, vec!["1.1".to_string()], None, false).unwrap();
1942
1943 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 cmd_close(&beans_dir, vec!["1.2".to_string()], None, false).unwrap();
1949
1950 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 #[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); }
1968
1969 #[test]
1970 fn test_truncate_output_exact_boundary() {
1971 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 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 let s = "😀😁😂";
2005 assert_eq!(s.len(), 12);
2006
2007 assert_eq!(truncate_to_char_boundary(s, 5), 4);
2009 assert_eq!(&s[..truncate_to_char_boundary(s, 5)], "😀");
2010
2011 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("```")); }
2037
2038 #[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 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2076
2077 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(), }];
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 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 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 assert!(
2155 !marker.exists(),
2156 "on_close run should be skipped without trust"
2157 );
2158
2159 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 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 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 #[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 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 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 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 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 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 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
2381
2382 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 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 #[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 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; 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 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, 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 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 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 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 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 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 assert_eq!(updated.claimed_by, Some("agent-1".to_string()));
2648 assert!(updated.labels.is_empty());
2649 }
2650
2651 #[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 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 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 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 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 assert!(
2811 outputs.get("text").is_none()
2812 || !outputs["text"].as_str().unwrap_or("").contains("debug log")
2813 );
2814 }
2815
2816 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 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; 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 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; 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; 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 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); 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; 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 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; 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 assert!(updated.labels.contains(&"circuit-breaker".to_string()));
2993 assert_eq!(updated.priority, 0);
2994 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 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 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 let mut bean = Bean::new("1", "Standalone");
3049 bean.verify = Some("false".to_string());
3050 bean.max_loops = Some(2);
3051 bean.attempts = 1; 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 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; 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; 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 #[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 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 mod worktree_merge {
3219 use super::*;
3220 use std::path::PathBuf;
3221 use std::sync::Mutex;
3222
3223 static CWD_LOCK: Mutex<()> = Mutex::new(());
3227
3228 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 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 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 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 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 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 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 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 fs::write(worktree_dir.join("feature.txt"), "feature content").unwrap();
3316
3317 std::env::set_current_dir(worktree_dir).unwrap();
3319
3320 cmd_close(&worktree_beans_dir, vec!["1".to_string()], None, false).unwrap();
3322
3323 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 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 fs::write(worktree_dir.join("initial.txt"), "feature version").unwrap();
3347
3348 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 std::env::set_current_dir(worktree_dir).unwrap();
3356
3357 cmd_close(&worktree_beans_dir, vec!["1".to_string()], None, false).unwrap();
3359
3360 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 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 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 std::env::set_current_dir(&repo_dir).unwrap();
3401
3402 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3403
3404 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 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 std::env::set_current_dir(&base).unwrap();
3429
3430 cmd_close(&beans_dir, vec!["1".to_string()], None, false).unwrap();
3431
3432 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#[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 #[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); 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 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 assert_eq!(updated.history.len(), 1);
3485 assert_eq!(updated.history[0].result, RunResult::Timeout);
3486 assert!(updated.history[0].exit_code.is_none()); 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 #[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); 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 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 #[test]
3520 fn verify_timeout_bean_level_overrides_config() {
3521 let (_dir, beans_dir) = setup_test_beans_dir();
3522
3523 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); 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 #[test]
3544 fn verify_timeout_config_level_applies_when_bean_has_none() {
3545 let (_dir, beans_dir) = setup_test_beans_dir();
3546
3547 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 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 #[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 assert!(
3585 notes.contains("timed out"),
3586 "expected notes to contain 'timed out', got: {:?}",
3587 notes
3588 );
3589 }
3590
3591 #[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 #[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 #[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}