1use std::path::Path;
2
3use anyhow::{Context, Result};
4use chrono::Utc;
5
6use crate::bean::{Bean, Status};
7use crate::discovery::{archive_path_for_bean, find_bean_file};
8use crate::index::Index;
9use crate::util::title_to_slug;
10
11struct TidiedBean {
14 id: String,
15 title: String,
16 archive_path: String,
17}
18
19struct ReleasedBean {
21 id: String,
22 title: String,
23 reason: String,
24}
25
26fn format_duration(duration: chrono::Duration) -> String {
29 let secs = duration.num_seconds();
30 if secs < 0 {
31 return "just now".to_string();
32 }
33 let minutes = secs / 60;
34 let hours = minutes / 60;
35 let days = hours / 24;
36
37 if days > 0 {
38 format!("claimed {} day(s) ago", days)
39 } else if hours > 0 {
40 format!("claimed {} hour(s) ago", hours)
41 } else if minutes > 0 {
42 format!("claimed {} minute(s) ago", minutes)
43 } else {
44 "claimed just now".to_string()
45 }
46}
47
48fn pgrep_running(pattern: &str) -> bool {
50 let current_pid = std::process::id();
51 if let Ok(output) = std::process::Command::new("pgrep")
52 .args(["-f", pattern])
53 .output()
54 {
55 if output.status.success() {
56 let stdout = String::from_utf8_lossy(&output.stdout);
57 for line in stdout.lines() {
58 if let Ok(pid) = line.trim().parse::<u32>() {
59 if pid != current_pid {
60 return true;
61 }
62 }
63 }
64 }
65 }
66 false
67}
68
69fn has_running_agents() -> bool {
75 if let Ok(config) = crate::config::Config::load(std::path::Path::new(".beans")) {
77 if let Some(ref run_cmd) = config.run {
78 if let Some(binary) = run_cmd.split_whitespace().next() {
80 if pgrep_running(binary) {
81 return true;
82 }
83 }
84 let pattern = run_cmd.replace("{id}", "");
86 if pgrep_running(pattern.trim()) {
87 return true;
88 }
89 return false;
90 }
91 }
92
93 pgrep_running("pi -p beans") || pgrep_running("deli spawn") || pgrep_running("claude")
95}
96
97pub fn cmd_tidy(beans_dir: &Path, dry_run: bool) -> Result<()> {
102 cmd_tidy_inner(beans_dir, dry_run, has_running_agents)
103}
104
105fn cmd_tidy_inner(beans_dir: &Path, dry_run: bool, check_agents: fn() -> bool) -> Result<()> {
136 let index = Index::build(beans_dir).context("Failed to build index")?;
139
140 let closed: Vec<&crate::index::IndexEntry> = index
149 .beans
150 .iter()
151 .filter(|entry| entry.status == Status::Closed)
152 .collect();
153
154 let mut tidied: Vec<TidiedBean> = Vec::new();
155 let mut skipped_parent_ids: Vec<String> = Vec::new();
156
157 for entry in &closed {
158 let bean_path = match find_bean_file(beans_dir, &entry.id) {
162 Ok(path) => path,
163 Err(_) => continue,
164 };
165
166 let mut bean = Bean::from_file(&bean_path)
168 .with_context(|| format!("Failed to load bean: {}", entry.id))?;
169
170 if bean.is_archived {
173 continue;
174 }
175
176 let has_open_children = index
180 .beans
181 .iter()
182 .any(|b| b.parent.as_deref() == Some(entry.id.as_str()) && b.status != Status::Closed);
183
184 if has_open_children {
185 skipped_parent_ids.push(entry.id.clone());
186 continue;
187 }
188
189 let archive_date = bean
196 .closed_at
197 .unwrap_or(bean.updated_at)
198 .with_timezone(&chrono::Local)
199 .date_naive();
200
201 let slug = bean
203 .slug
204 .clone()
205 .unwrap_or_else(|| title_to_slug(&bean.title));
206 let ext = bean_path
207 .extension()
208 .and_then(|e| e.to_str())
209 .unwrap_or("md");
210 let archive_path = archive_path_for_bean(beans_dir, &entry.id, &slug, ext, archive_date);
211
212 let relative = archive_path
215 .strip_prefix(beans_dir)
216 .unwrap_or(&archive_path);
217 tidied.push(TidiedBean {
218 id: entry.id.clone(),
219 title: entry.title.clone(),
220 archive_path: relative.display().to_string(),
221 });
222
223 if dry_run {
225 continue;
226 }
227
228 if let Some(parent) = archive_path.parent() {
231 std::fs::create_dir_all(parent).with_context(|| {
232 format!("Failed to create archive directory for bean {}", entry.id)
233 })?;
234 }
235
236 std::fs::rename(&bean_path, &archive_path)
238 .with_context(|| format!("Failed to move bean {} to archive", entry.id))?;
239
240 bean.is_archived = true;
244 bean.to_file(&archive_path)
245 .with_context(|| format!("Failed to save archived bean: {}", entry.id))?;
246 }
247
248 let in_progress: Vec<&crate::index::IndexEntry> = index
258 .beans
259 .iter()
260 .filter(|entry| entry.status == Status::InProgress)
261 .collect();
262
263 let mut released: Vec<ReleasedBean> = Vec::new();
264
265 if !in_progress.is_empty() {
266 let agents_running = check_agents();
267
268 if agents_running {
269 eprintln!(
273 "Note: {} in-progress bean(s) found, but agent processes are running — skipping release.",
274 in_progress.len()
275 );
276 } else {
277 for entry in &in_progress {
279 let bean_path = match find_bean_file(beans_dir, &entry.id) {
280 Ok(path) => path,
281 Err(_) => continue,
282 };
283
284 let mut bean = match Bean::from_file(&bean_path) {
285 Ok(b) => b,
286 Err(_) => continue,
287 };
288
289 let reason = if let Some(claimed_at) = bean.claimed_at {
291 let age = Utc::now().signed_duration_since(claimed_at);
292 format_duration(age)
293 } else {
294 "never properly claimed".to_string()
295 };
296
297 released.push(ReleasedBean {
298 id: entry.id.clone(),
299 title: entry.title.clone(),
300 reason,
301 });
302
303 if dry_run {
304 continue;
305 }
306
307 let now = Utc::now();
309 bean.status = Status::Open;
310 bean.claimed_by = None;
311 bean.claimed_at = None;
312 bean.updated_at = now;
313
314 bean.to_file(&bean_path)
315 .with_context(|| format!("Failed to release stale bean: {}", entry.id))?;
316 }
317 }
318 }
319
320 let final_index = Index::build(beans_dir).context("Failed to rebuild index after tidy")?;
325 final_index
326 .save(beans_dir)
327 .context("Failed to save index")?;
328
329 let archive_verb = if dry_run { "Would archive" } else { "Archived" };
332 let release_verb = if dry_run { "Would release" } else { "Released" };
333
334 if tidied.is_empty() && skipped_parent_ids.is_empty() && released.is_empty() {
335 println!("Nothing to tidy — all beans look good.");
336 }
337
338 if !tidied.is_empty() {
339 println!("{} {} bean(s):", archive_verb, tidied.len());
340 for t in &tidied {
341 println!(" → {}. {} → {}", t.id, t.title, t.archive_path);
342 }
343 }
344
345 if !released.is_empty() {
346 println!(
347 "{} {} stale in-progress bean(s):",
348 release_verb,
349 released.len()
350 );
351 for r in &released {
352 println!(" → {}. {} ({})", r.id, r.title, r.reason);
353 }
354 }
355
356 if !skipped_parent_ids.is_empty() {
357 println!(
358 "Skipped {} closed parent(s) with open children: {}",
359 skipped_parent_ids.len(),
360 skipped_parent_ids.join(", ")
361 );
362 }
363
364 println!(
365 "Index rebuilt: {} bean(s) indexed.",
366 final_index.beans.len()
367 );
368
369 Ok(())
370}
371
372#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::bean::Bean;
380 use crate::util::title_to_slug;
381 use std::fs;
382 use tempfile::TempDir;
383
384 fn setup() -> (TempDir, std::path::PathBuf) {
386 let dir = TempDir::new().unwrap();
387 let beans_dir = dir.path().join(".beans");
388 fs::create_dir(&beans_dir).unwrap();
389 (dir, beans_dir)
390 }
391
392 fn no_agents() -> bool {
394 false
395 }
396
397 fn agents_running() -> bool {
399 true
400 }
401
402 fn write_bean(beans_dir: &Path, bean: &Bean) {
404 let slug = title_to_slug(&bean.title);
405 let path = beans_dir.join(format!("{}-{}.md", bean.id, slug));
406 bean.to_file(path).unwrap();
407 }
408
409 #[test]
412 fn tidy_archives_closed_beans() {
413 let (_dir, beans_dir) = setup();
414
415 let mut bean = Bean::new("1", "Done task");
416 bean.status = Status::Closed;
417 bean.closed_at = Some(chrono::Utc::now());
418 write_bean(&beans_dir, &bean);
419
420 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
421
422 assert!(find_bean_file(&beans_dir, "1").is_err());
424 let archived = crate::discovery::find_archived_bean(&beans_dir, "1");
426 assert!(archived.is_ok());
427 let archived_bean = Bean::from_file(archived.unwrap()).unwrap();
428 assert!(archived_bean.is_archived);
429 }
430
431 #[test]
432 fn tidy_leaves_open_beans_alone() {
433 let (_dir, beans_dir) = setup();
434
435 let bean = Bean::new("1", "Open task");
436 write_bean(&beans_dir, &bean);
437
438 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
439
440 assert!(find_bean_file(&beans_dir, "1").is_ok());
442 }
443
444 #[test]
445 fn tidy_idempotent() {
446 let (_dir, beans_dir) = setup();
447
448 let mut bean = Bean::new("1", "Done task");
449 bean.status = Status::Closed;
450 bean.closed_at = Some(chrono::Utc::now());
451 write_bean(&beans_dir, &bean);
452
453 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
455 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
457
458 let archived = crate::discovery::find_archived_bean(&beans_dir, "1");
459 assert!(archived.is_ok());
460 }
461
462 #[test]
465 fn tidy_dry_run_does_not_move_files() {
466 let (_dir, beans_dir) = setup();
467
468 let mut bean = Bean::new("1", "Done task");
469 bean.status = Status::Closed;
470 bean.closed_at = Some(chrono::Utc::now());
471 write_bean(&beans_dir, &bean);
472
473 cmd_tidy_inner(&beans_dir, true, no_agents).unwrap();
474
475 assert!(find_bean_file(&beans_dir, "1").is_ok());
477 }
478
479 #[test]
482 fn tidy_skips_closed_parent_with_open_children() {
483 let (_dir, beans_dir) = setup();
484
485 let mut parent = Bean::new("1", "Parent");
487 parent.status = Status::Closed;
488 parent.closed_at = Some(chrono::Utc::now());
489 write_bean(&beans_dir, &parent);
490
491 let mut child = Bean::new("1.1", "Child");
493 child.parent = Some("1".to_string());
494 write_bean(&beans_dir, &child);
495
496 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
497
498 assert!(find_bean_file(&beans_dir, "1").is_ok());
500 assert!(find_bean_file(&beans_dir, "1.1").is_ok());
502 }
503
504 #[test]
505 fn tidy_archives_parent_when_all_children_closed() {
506 let (_dir, beans_dir) = setup();
507
508 let mut parent = Bean::new("1", "Parent");
510 parent.status = Status::Closed;
511 parent.closed_at = Some(chrono::Utc::now());
512 write_bean(&beans_dir, &parent);
513
514 let mut child = Bean::new("1.1", "Child");
516 child.parent = Some("1".to_string());
517 child.status = Status::Closed;
518 child.closed_at = Some(chrono::Utc::now());
519 write_bean(&beans_dir, &child);
520
521 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
522
523 assert!(find_bean_file(&beans_dir, "1").is_err());
525 assert!(find_bean_file(&beans_dir, "1.1").is_err());
526 assert!(crate::discovery::find_archived_bean(&beans_dir, "1").is_ok());
527 assert!(crate::discovery::find_archived_bean(&beans_dir, "1.1").is_ok());
528 }
529
530 #[test]
533 fn tidy_uses_closed_at_for_archive_date() {
534 let (_dir, beans_dir) = setup();
535
536 let mut bean = Bean::new("1", "January task");
537 bean.status = Status::Closed;
538 bean.closed_at = Some(
540 chrono::DateTime::parse_from_rfc3339("2025-06-15T12:00:00Z")
541 .unwrap()
542 .with_timezone(&chrono::Utc),
543 );
544 write_bean(&beans_dir, &bean);
545
546 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
547
548 let archived = crate::discovery::find_archived_bean(&beans_dir, "1").unwrap();
549 let path_str = archived.display().to_string();
551 assert!(
552 path_str.contains("2025") && path_str.contains("06"),
553 "Expected archive under 2025/06, got: {}",
554 path_str
555 );
556 }
557
558 #[test]
561 fn tidy_handles_mix_of_open_closed_and_in_progress() {
562 let (_dir, beans_dir) = setup();
563
564 let open_bean = Bean::new("1", "Still open");
565 write_bean(&beans_dir, &open_bean);
566
567 let mut closed_bean = Bean::new("2", "Already done");
568 closed_bean.status = Status::Closed;
569 closed_bean.closed_at = Some(chrono::Utc::now());
570 write_bean(&beans_dir, &closed_bean);
571
572 let mut in_progress = Bean::new("3", "Working on it");
573 in_progress.status = Status::InProgress;
574 write_bean(&beans_dir, &in_progress);
575
576 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
578
579 let b1 = Bean::from_file(find_bean_file(&beans_dir, "1").unwrap()).unwrap();
581 assert_eq!(b1.status, Status::Open);
582
583 assert!(find_bean_file(&beans_dir, "2").is_err());
585 assert!(crate::discovery::find_archived_bean(&beans_dir, "2").is_ok());
586
587 let b3 = Bean::from_file(find_bean_file(&beans_dir, "3").unwrap()).unwrap();
589 assert_eq!(b3.status, Status::Open);
590 }
591
592 #[test]
593 fn tidy_skips_in_progress_when_agents_running() {
594 let (_dir, beans_dir) = setup();
595
596 let mut bean = Bean::new("1", "Active WIP");
597 bean.status = Status::InProgress;
598 bean.claimed_at = Some(chrono::Utc::now());
599 write_bean(&beans_dir, &bean);
600
601 cmd_tidy_inner(&beans_dir, false, agents_running).unwrap();
603
604 let updated = Bean::from_file(find_bean_file(&beans_dir, "1").unwrap()).unwrap();
605 assert_eq!(updated.status, Status::InProgress);
606 assert!(updated.claimed_at.is_some());
607 }
608
609 #[test]
612 fn tidy_releases_stale_in_progress_beans() {
613 let (_dir, beans_dir) = setup();
614
615 let mut bean = Bean::new("1", "Stale WIP");
617 bean.status = Status::InProgress;
618 bean.claimed_at = Some(
619 chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
620 .unwrap()
621 .with_timezone(&chrono::Utc),
622 );
623 write_bean(&beans_dir, &bean);
624
625 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
626
627 let updated = Bean::from_file(find_bean_file(&beans_dir, "1").unwrap()).unwrap();
629 assert_eq!(updated.status, Status::Open);
630 assert!(updated.claimed_by.is_none());
631 assert!(updated.claimed_at.is_none());
632 }
633
634 #[test]
635 fn tidy_releases_in_progress_bean_without_claimed_at() {
636 let (_dir, beans_dir) = setup();
637
638 let mut bean = Bean::new("1", "Manually set WIP");
640 bean.status = Status::InProgress;
641 write_bean(&beans_dir, &bean);
643
644 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
645
646 let updated = Bean::from_file(find_bean_file(&beans_dir, "1").unwrap()).unwrap();
647 assert_eq!(updated.status, Status::Open);
648 }
649
650 #[test]
651 fn tidy_dry_run_does_not_release_stale_beans() {
652 let (_dir, beans_dir) = setup();
653
654 let mut bean = Bean::new("1", "Stale WIP");
655 bean.status = Status::InProgress;
656 bean.claimed_at = Some(chrono::Utc::now());
657 write_bean(&beans_dir, &bean);
658
659 cmd_tidy_inner(&beans_dir, true, no_agents).unwrap();
660
661 let updated = Bean::from_file(find_bean_file(&beans_dir, "1").unwrap()).unwrap();
663 assert_eq!(updated.status, Status::InProgress);
664 assert!(updated.claimed_at.is_some());
665 }
666
667 #[test]
668 fn tidy_handles_mix_of_stale_and_closed() {
669 let (_dir, beans_dir) = setup();
670
671 let open_bean = Bean::new("1", "Open");
673 write_bean(&beans_dir, &open_bean);
674
675 let mut closed_bean = Bean::new("2", "Closed");
677 closed_bean.status = Status::Closed;
678 closed_bean.closed_at = Some(chrono::Utc::now());
679 write_bean(&beans_dir, &closed_bean);
680
681 let mut stale_bean = Bean::new("3", "Stale WIP");
683 stale_bean.status = Status::InProgress;
684 stale_bean.claimed_at = Some(chrono::Utc::now());
685 write_bean(&beans_dir, &stale_bean);
686
687 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
688
689 let b1 = Bean::from_file(find_bean_file(&beans_dir, "1").unwrap()).unwrap();
691 assert_eq!(b1.status, Status::Open);
692
693 assert!(find_bean_file(&beans_dir, "2").is_err());
695 assert!(crate::discovery::find_archived_bean(&beans_dir, "2").is_ok());
696
697 let b3 = Bean::from_file(find_bean_file(&beans_dir, "3").unwrap()).unwrap();
699 assert_eq!(b3.status, Status::Open);
700 assert!(b3.claimed_at.is_none());
701 assert!(b3.claimed_by.is_none());
702 }
703
704 #[test]
705 fn tidy_releases_in_progress_with_claimed_by() {
706 let (_dir, beans_dir) = setup();
707
708 let mut bean = Bean::new("1", "Agent crashed");
710 bean.status = Status::InProgress;
711 bean.claimed_by = Some("agent-42".to_string());
712 bean.claimed_at = Some(chrono::Utc::now());
713 write_bean(&beans_dir, &bean);
714
715 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
716
717 let updated = Bean::from_file(find_bean_file(&beans_dir, "1").unwrap()).unwrap();
718 assert_eq!(updated.status, Status::Open);
719 assert!(updated.claimed_by.is_none());
720 assert!(updated.claimed_at.is_none());
721 }
722
723 #[test]
726 fn tidy_empty_project() {
727 let (_dir, beans_dir) = setup();
728 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
730 }
731
732 #[test]
735 fn tidy_rebuilds_index() {
736 let (_dir, beans_dir) = setup();
737
738 let open_bean = Bean::new("1", "Open");
739 write_bean(&beans_dir, &open_bean);
740
741 let mut closed_bean = Bean::new("2", "Closed");
742 closed_bean.status = Status::Closed;
743 closed_bean.closed_at = Some(chrono::Utc::now());
744 write_bean(&beans_dir, &closed_bean);
745
746 cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
747
748 let index = Index::load(&beans_dir).unwrap();
750 assert_eq!(index.beans.len(), 1);
751 assert_eq!(index.beans[0].id, "1");
752 }
753}