Skip to main content

bn/commands/
tidy.rs

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
11/// A record of one bean that was (or would be) archived during tidy.
12/// We collect these so we can print a summary at the end.
13struct TidiedBean {
14    id: String,
15    title: String,
16    archive_path: String,
17}
18
19/// A record of one bean that was (or would be) released during tidy.
20struct ReleasedBean {
21    id: String,
22    title: String,
23    reason: String,
24}
25
26/// Format a chrono Duration as a human-readable string like "3 days ago"
27/// or "2 hours ago".
28fn 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
48/// Check if a process matching a pattern is running (excluding our own PID).
49fn 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
69/// Check if any agent processes are currently running.
70///
71/// Uses the configured `run` command to determine what process pattern to
72/// look for. Falls back to checking for common agent patterns if no run
73/// command is configured.
74fn has_running_agents() -> bool {
75    // If a run command is configured, extract the binary name and search for it
76    if let Ok(config) = crate::config::Config::load(std::path::Path::new(".beans")) {
77        if let Some(ref run_cmd) = config.run {
78            // Extract the first word (binary name) from the run template
79            if let Some(binary) = run_cmd.split_whitespace().next() {
80                if pgrep_running(binary) {
81                    return true;
82                }
83            }
84            // Also search for the full command pattern (with {id} stripped)
85            let pattern = run_cmd.replace("{id}", "");
86            if pgrep_running(pattern.trim()) {
87                return true;
88            }
89            return false;
90        }
91    }
92
93    // No run command configured — check common agent patterns as fallback
94    pgrep_running("pi -p beans") || pgrep_running("deli spawn") || pgrep_running("claude")
95}
96
97/// Tidy the beans directory: archive closed beans, release stale in-progress
98/// beans, and rebuild the index.
99///
100/// Delegates to `cmd_tidy_inner` with the real agent-detection function.
101pub fn cmd_tidy(beans_dir: &Path, dry_run: bool) -> Result<()> {
102    cmd_tidy_inner(beans_dir, dry_run, has_running_agents)
103}
104
105/// Inner implementation of tidy, with an injectable agent-check function
106/// for testability.
107///
108/// This is a housekeeping command that catches state inconsistencies:
109///
110/// - **Closed beans not archived:** beans whose status was set to "closed"
111///   via `bn update --status closed` (which bypasses the close command's
112///   archiving logic), beans closed before archiving was added, or files
113///   edited by hand.
114///
115/// - **Stale in-progress beans:** beans whose status is "in_progress" but
116///   no agent is actually working on them. This happens when an agent
117///   crashes without releasing its claim, when `deli spawn` is killed, or
118///   when files are edited by hand. These are released back to "open".
119///
120/// The steps are:
121/// 1. Build a fresh index from disk so we see every bean, even if the
122///    cached index is stale.
123/// 2. Walk through the index looking for beans with status == Closed
124///    that are still sitting in the main .beans/ directory (is_archived
125///    is false).
126/// 3. For each one, compute its archive path (using closed_at if available,
127///    otherwise today's date) and move it there.
128/// 4. Check for in-progress beans that appear stale (no running agent
129///    processes detected) and release them back to open.
130/// 5. Rebuild and save the index one final time so it reflects the new
131///    state.
132///
133/// With `dry_run = true` we report what would change without touching
134/// any files.
135fn cmd_tidy_inner(beans_dir: &Path, dry_run: bool, check_agents: fn() -> bool) -> Result<()> {
136    // Step 1 — Build a fresh index so we're working from the truth on disk,
137    // not a potentially stale cache.
138    let index = Index::build(beans_dir).context("Failed to build index")?;
139
140    // Step 2 — Find every closed bean that's still in the main directory.
141    // We filter on two things:
142    //   • status == Closed  (the bean is done)
143    //   • find_bean_file succeeds (the file is still in .beans/, not archive/)
144    //
145    // We also skip beans that have open children — archiving them would
146    // orphan the children's parent reference without the parent being
147    // findable in the main directory.
148    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        // Double-check the file actually exists in the main directory.
159        // If find_bean_file fails, it's either already archived or
160        // something weird — either way, nothing for us to do.
161        let bean_path = match find_bean_file(beans_dir, &entry.id) {
162            Ok(path) => path,
163            Err(_) => continue,
164        };
165
166        // Load the full bean so we can read closed_at, slug, etc.
167        let mut bean = Bean::from_file(&bean_path)
168            .with_context(|| format!("Failed to load bean: {}", entry.id))?;
169
170        // Safety check: if this bean is already marked archived, skip it.
171        // (Shouldn't happen since it's in the main dir, but be defensive.)
172        if bean.is_archived {
173            continue;
174        }
175
176        // Guard: don't archive a parent whose children are still open.
177        // We check by looking for any bean in the index that lists this
178        // bean as its parent and isn't closed yet.
179        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        // Pick the date for the archive subdirectory.
190        // Prefer closed_at (when the bean was actually finished) because
191        // that groups archived beans by *completion* month.  Fall back to
192        // updated_at (always present) if closed_at was never set — this
193        // happens for beans that were closed via `bn update --status closed`
194        // which doesn't set closed_at.
195        let archive_date = bean
196            .closed_at
197            .unwrap_or(bean.updated_at)
198            .with_timezone(&chrono::Local)
199            .date_naive();
200
201        // Build the target path under .beans/archive/YYYY/MM/<id>-<slug>.<ext>
202        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        // Record what we're about to do (for the summary).
213        // We store the archive path relative to .beans/ to keep output tidy.
214        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        // In dry-run mode we stop here — no file moves.
224        if dry_run {
225            continue;
226        }
227
228        // Step 3 — Actually move the bean.
229        // Create the archive directory tree (archive/YYYY/MM) if needed.
230        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        // Move the file from .beans/<id>-<slug>.md → .beans/archive/YYYY/MM/…
237        std::fs::rename(&bean_path, &archive_path)
238            .with_context(|| format!("Failed to move bean {} to archive", entry.id))?;
239
240        // Mark the bean as archived and persist. This sets is_archived = true
241        // in the YAML front-matter so other commands (unarchive, list --all)
242        // know this bean lives in the archive.
243        bean.is_archived = true;
244        bean.to_file(&archive_path)
245            .with_context(|| format!("Failed to save archived bean: {}", entry.id))?;
246    }
247
248    // Step 4 — Release stale in-progress beans.
249    //
250    // An in-progress bean is "stale" if no agent process is currently
251    // running that could be working on it. We check for running `pi`
252    // and `deli spawn` processes. If none are found, all in-progress
253    // beans are considered stale and released back to open.
254    //
255    // If agents ARE running, we skip this step entirely because we
256    // can't reliably determine which beans they're working on.
257    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            // Agents are running — we can't safely release in-progress
270            // beans because one of them might be actively being worked on.
271            // Just report them.
272            eprintln!(
273                "Note: {} in-progress bean(s) found, but agent processes are running — skipping release.",
274                in_progress.len()
275            );
276        } else {
277            // No agents running — all in-progress beans are stale.
278            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                // Build a human-readable reason for the release.
290                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                // Release the bean: set status to Open, clear claim fields.
308                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    // Step 5 — Rebuild the index one final time.
321    // After moving files around and releasing stale beans, the old index
322    // is stale, so we rebuild from disk. In dry-run mode nothing changed,
323    // but we still rebuild because the user asked to "update the index."
324    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    // ── Print results ────────────────────────────────────────────────
330
331    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// ---------------------------------------------------------------------------
373// Tests
374// ---------------------------------------------------------------------------
375
376#[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    /// Create a .beans/ directory and return (TempDir guard, path).
385    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    /// Mock: no agents running (for testing stale-release behavior).
393    fn no_agents() -> bool {
394        false
395    }
396
397    /// Mock: agents are running (for testing skip behavior).
398    fn agents_running() -> bool {
399        true
400    }
401
402    /// Helper: write a bean to the main .beans/ directory.
403    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    // ── Basic behaviour ────────────────────────────────────────────
410
411    #[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        // Should no longer be in main directory
423        assert!(find_bean_file(&beans_dir, "1").is_err());
424        // Should be in archive
425        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        // Should still be in main directory
441        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        // First tidy archives it
454        cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
455        // Second tidy should be a no-op (no panic, no error)
456        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    // ── Dry-run ────────────────────────────────────────────────────
463
464    #[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        // File should still be in main directory (dry-run)
476        assert!(find_bean_file(&beans_dir, "1").is_ok());
477    }
478
479    // ── Skips parents with open children ───────────────────────────
480
481    #[test]
482    fn tidy_skips_closed_parent_with_open_children() {
483        let (_dir, beans_dir) = setup();
484
485        // Parent is closed
486        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        // Child is still open
492        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        // Parent should NOT be archived because child is still open
499        assert!(find_bean_file(&beans_dir, "1").is_ok());
500        // Child should still be in main dir
501        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        // Parent is closed
509        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        // Child is also closed
515        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        // Both should be archived
524        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    // ── Uses closed_at for archive path ────────────────────────────
531
532    #[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        // Force a specific closed_at date
539        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        // The archive path should contain 2025/06 (from closed_at)
550        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    // ── Mixed open and closed ──────────────────────────────────────
559
560    #[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        // With no agents running, in_progress beans get released
577        cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
578
579        // Open bean untouched
580        let b1 = Bean::from_file(find_bean_file(&beans_dir, "1").unwrap()).unwrap();
581        assert_eq!(b1.status, Status::Open);
582
583        // Closed bean archived
584        assert!(find_bean_file(&beans_dir, "2").is_err());
585        assert!(crate::discovery::find_archived_bean(&beans_dir, "2").is_ok());
586
587        // In-progress bean released (no agents running)
588        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        // With agents running, in_progress beans are NOT released
602        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    // ── Stale in-progress beans ──────────────────────────────────
610
611    #[test]
612    fn tidy_releases_stale_in_progress_beans() {
613        let (_dir, beans_dir) = setup();
614
615        // Create an in-progress bean with a stale claim (old claimed_at, no running agent)
616        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        // Bean should be released back to open
628        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        // Create a bean that was manually set to in_progress without proper claiming
639        let mut bean = Bean::new("1", "Manually set WIP");
640        bean.status = Status::InProgress;
641        // No claimed_at, no claimed_by — definitely stale
642        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        // Bean should still be in_progress (dry-run)
662        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        // An open bean — untouched
672        let open_bean = Bean::new("1", "Open");
673        write_bean(&beans_dir, &open_bean);
674
675        // A closed bean — archived
676        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        // A stale in-progress bean — released
682        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        // Open bean untouched
690        let b1 = Bean::from_file(find_bean_file(&beans_dir, "1").unwrap()).unwrap();
691        assert_eq!(b1.status, Status::Open);
692
693        // Closed bean archived
694        assert!(find_bean_file(&beans_dir, "2").is_err());
695        assert!(crate::discovery::find_archived_bean(&beans_dir, "2").is_ok());
696
697        // Stale in-progress bean released
698        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        // Bean was claimed by an agent that no longer exists
709        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    // ── Empty project ──────────────────────────────────────────────
724
725    #[test]
726    fn tidy_empty_project() {
727        let (_dir, beans_dir) = setup();
728        // Should succeed with nothing to do
729        cmd_tidy_inner(&beans_dir, false, no_agents).unwrap();
730    }
731
732    // ── Index is rebuilt ───────────────────────────────────────────
733
734    #[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        // Index should only contain the open bean (closed was archived)
749        let index = Index::load(&beans_dir).unwrap();
750        assert_eq!(index.beans.len(), 1);
751        assert_eq!(index.beans[0].id, "1");
752    }
753}