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