Skip to main content

bn/
index.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4use std::time::{Duration, Instant};
5
6use anyhow::{anyhow, Context, Result};
7use chrono::{DateTime, Utc};
8use fs2::FileExt;
9use serde::{Deserialize, Serialize};
10
11use crate::bean::{Bean, Status};
12use crate::util::{atomic_write, natural_cmp};
13
14// ---------------------------------------------------------------------------
15// IndexEntry
16// ---------------------------------------------------------------------------
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct IndexEntry {
20    pub id: String,
21    pub title: String,
22    pub status: Status,
23    pub priority: u8,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub parent: Option<String>,
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub dependencies: Vec<String>,
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub labels: Vec<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub assignee: Option<String>,
32    pub updated_at: DateTime<Utc>,
33    /// Artifacts this bean produces (for smart dependency inference)
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub produces: Vec<String>,
36    /// Artifacts this bean requires (for smart dependency inference)
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub requires: Vec<String>,
39    /// Whether this bean has a verify command (SPECs have verify, GOALs don't)
40    #[serde(default)]
41    pub has_verify: bool,
42    /// Agent or user currently holding a claim on this bean (e.g., "spro:12345" for agent with PID)
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub claimed_by: Option<String>,
45    /// Number of verify attempts so far
46    #[serde(default)]
47    pub attempts: u32,
48}
49
50impl From<&Bean> for IndexEntry {
51    fn from(bean: &Bean) -> Self {
52        Self {
53            id: bean.id.clone(),
54            title: bean.title.clone(),
55            status: bean.status,
56            priority: bean.priority,
57            parent: bean.parent.clone(),
58            dependencies: bean.dependencies.clone(),
59            labels: bean.labels.clone(),
60            assignee: bean.assignee.clone(),
61            updated_at: bean.updated_at,
62            produces: bean.produces.clone(),
63            requires: bean.requires.clone(),
64            has_verify: bean.verify.is_some(),
65            claimed_by: bean.claimed_by.clone(),
66            attempts: bean.attempts,
67        }
68    }
69}
70
71// ---------------------------------------------------------------------------
72// Index
73// ---------------------------------------------------------------------------
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct Index {
77    pub beans: Vec<IndexEntry>,
78}
79
80// Files to exclude when scanning for bean YAMLs.
81const EXCLUDED_FILES: &[&str] = &["config.yaml", "index.yaml", "bean.yaml"];
82
83/// Check if a filename represents a bean file (not a config/index/template file).
84fn is_bean_filename(filename: &str) -> bool {
85    if EXCLUDED_FILES.contains(&filename) {
86        return false;
87    }
88    let ext = std::path::Path::new(filename)
89        .extension()
90        .and_then(|e| e.to_str());
91    match ext {
92        Some("md") => filename.contains('-'), // New format: {id}-{slug}.md
93        Some("yaml") => true,                 // Legacy format: {id}.yaml
94        _ => false,
95    }
96}
97
98/// Count bean files by format in the beans directory.
99/// Returns (md_count, yaml_count) tuple.
100pub fn count_bean_formats(beans_dir: &Path) -> Result<(usize, usize)> {
101    let mut md_count = 0;
102    let mut yaml_count = 0;
103
104    let dir_entries = fs::read_dir(beans_dir)
105        .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
106
107    for entry in dir_entries {
108        let entry = entry?;
109        let path = entry.path();
110
111        let filename = path
112            .file_name()
113            .and_then(|n| n.to_str())
114            .unwrap_or_default();
115
116        if !is_bean_filename(filename) {
117            continue;
118        }
119
120        let ext = path.extension().and_then(|e| e.to_str());
121        match ext {
122            Some("md") => md_count += 1,
123            Some("yaml") => yaml_count += 1,
124            _ => {}
125        }
126    }
127
128    Ok((md_count, yaml_count))
129}
130
131impl Index {
132    /// Build the index by reading all bean files from the beans directory.
133    /// Supports both new format ({id}-{slug}.md) and legacy format ({id}.yaml).
134    /// Excludes config.yaml, index.yaml, and bean.yaml.
135    /// Sorts entries by ID using natural ordering.
136    /// Returns an error if duplicate bean IDs are detected.
137    pub fn build(beans_dir: &Path) -> Result<Self> {
138        let mut entries = Vec::new();
139        // Track which files define each ID to detect duplicates
140        let mut id_to_files: HashMap<String, Vec<String>> = HashMap::new();
141
142        let dir_entries = fs::read_dir(beans_dir)
143            .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
144
145        for entry in dir_entries {
146            let entry = entry?;
147            let path = entry.path();
148
149            let filename = path
150                .file_name()
151                .and_then(|n| n.to_str())
152                .unwrap_or_default();
153
154            if !is_bean_filename(filename) {
155                continue;
156            }
157
158            let bean = Bean::from_file(&path)
159                .with_context(|| format!("Failed to parse bean: {}", path.display()))?;
160
161            // Track this ID's file for duplicate detection
162            id_to_files
163                .entry(bean.id.clone())
164                .or_default()
165                .push(filename.to_string());
166
167            entries.push(IndexEntry::from(&bean));
168        }
169
170        // Check for duplicate IDs
171        let duplicates: Vec<_> = id_to_files
172            .iter()
173            .filter(|(_, files)| files.len() > 1)
174            .collect();
175
176        if !duplicates.is_empty() {
177            let mut msg = String::from("Duplicate bean IDs detected:\n");
178            for (id, files) in duplicates {
179                msg.push_str(&format!("  ID '{}' defined in: {}\n", id, files.join(", ")));
180            }
181            return Err(anyhow!(msg));
182        }
183
184        entries.sort_by(|a, b| natural_cmp(&a.id, &b.id));
185
186        Ok(Index { beans: entries })
187    }
188
189    /// Check whether the cached index is stale.
190    /// Returns true if the index file is missing or if any bean file (.md or .yaml)
191    /// in the beans directory has been modified after the index was last written.
192    pub fn is_stale(beans_dir: &Path) -> Result<bool> {
193        let index_path = beans_dir.join("index.yaml");
194
195        // If index doesn't exist, it's stale
196        if !index_path.exists() {
197            return Ok(true);
198        }
199
200        let index_mtime = fs::metadata(&index_path)
201            .with_context(|| "Failed to read index.yaml metadata")?
202            .modified()
203            .with_context(|| "Failed to get index.yaml mtime")?;
204
205        let dir_entries = fs::read_dir(beans_dir)
206            .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
207
208        for entry in dir_entries {
209            let entry = entry?;
210            let path = entry.path();
211
212            let filename = path
213                .file_name()
214                .and_then(|n| n.to_str())
215                .unwrap_or_default();
216
217            if !is_bean_filename(filename) {
218                continue;
219            }
220
221            let file_mtime = fs::metadata(&path)
222                .with_context(|| format!("Failed to read metadata: {}", path.display()))?
223                .modified()
224                .with_context(|| format!("Failed to get mtime: {}", path.display()))?;
225
226            if file_mtime > index_mtime {
227                return Ok(true);
228            }
229        }
230
231        Ok(false)
232    }
233
234    /// Load the cached index or rebuild it if stale.
235    /// This is the main entry point for read-heavy commands.
236    pub fn load_or_rebuild(beans_dir: &Path) -> Result<Self> {
237        if Self::is_stale(beans_dir)? {
238            let index = Self::build(beans_dir)?;
239            index.save(beans_dir)?;
240            Ok(index)
241        } else {
242            Self::load(beans_dir)
243        }
244    }
245
246    /// Load the index from the cached index.yaml file.
247    pub fn load(beans_dir: &Path) -> Result<Self> {
248        let index_path = beans_dir.join("index.yaml");
249        let contents = fs::read_to_string(&index_path)
250            .with_context(|| format!("Failed to read {}", index_path.display()))?;
251        let index: Index =
252            serde_yml::from_str(&contents).with_context(|| "Failed to parse index.yaml")?;
253        Ok(index)
254    }
255
256    /// Save the index to .beans/index.yaml.
257    pub fn save(&self, beans_dir: &Path) -> Result<()> {
258        let index_path = beans_dir.join("index.yaml");
259        let yaml = serde_yml::to_string(self).with_context(|| "Failed to serialize index")?;
260        atomic_write(&index_path, &yaml)
261            .with_context(|| format!("Failed to write {}", index_path.display()))?;
262        Ok(())
263    }
264
265    /// Collect all archived beans from .beans/archive/ directory.
266    /// Walks through year/month subdirectories and loads all bean files.
267    /// Returns IndexEntry items for archived beans.
268    pub fn collect_archived(beans_dir: &Path) -> Result<Vec<IndexEntry>> {
269        let mut entries = Vec::new();
270        let archive_dir = beans_dir.join("archive");
271
272        if !archive_dir.is_dir() {
273            return Ok(entries);
274        }
275
276        // Walk through archive directory recursively
277        Self::walk_archive_dir(&archive_dir, &mut entries)?;
278
279        Ok(entries)
280    }
281
282    /// Recursively walk archive directory and collect bean entries.
283    /// Uses catch_unwind to survive YAML parser panics from corrupt files.
284    fn walk_archive_dir(dir: &Path, entries: &mut Vec<IndexEntry>) -> Result<()> {
285        use crate::bean::Bean;
286
287        if !dir.is_dir() {
288            return Ok(());
289        }
290
291        for entry in fs::read_dir(dir)? {
292            let entry = entry?;
293            let path = entry.path();
294
295            if path.is_dir() {
296                Self::walk_archive_dir(&path, entries)?;
297            } else if path.is_file() {
298                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
299                    if is_bean_filename(filename) {
300                        let path_clone = path.clone();
301                        let result = std::panic::catch_unwind(|| Bean::from_file(&path_clone));
302                        match result {
303                            Ok(Ok(bean)) => entries.push(IndexEntry::from(&bean)),
304                            Ok(Err(_)) => {} // normal parse error, skip silently
305                            Err(_) => {
306                                eprintln!(
307                                    "warning: skipping corrupt archive file (parser panic): {}",
308                                    path.display()
309                                );
310                            }
311                        }
312                    }
313                }
314            }
315        }
316
317        Ok(())
318    }
319}
320
321// ---------------------------------------------------------------------------
322// LockedIndex — exclusive access during read-modify-write
323// ---------------------------------------------------------------------------
324
325/// Default timeout for acquiring the index lock.
326const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
327
328/// Exclusive handle to the index, backed by an advisory flock on `.beans/index.lock`.
329///
330/// Prevents concurrent read-modify-write races when multiple agents run in parallel.
331/// The lock is held from acquisition until `save_and_release` is called or the
332/// `LockedIndex` is dropped.
333///
334/// ```no_run
335/// # use anyhow::Result;
336/// # use std::path::Path;
337/// # fn example(beans_dir: &Path) -> Result<()> {
338/// use bn::index::LockedIndex;
339/// let mut locked = LockedIndex::acquire(beans_dir)?;
340/// locked.index.beans[0].title = "Updated".to_string();
341/// locked.save_and_release()?;
342/// # Ok(())
343/// # }
344/// ```
345#[derive(Debug)]
346pub struct LockedIndex {
347    pub index: Index,
348    lock_file: fs::File,
349    beans_dir: std::path::PathBuf,
350}
351
352impl LockedIndex {
353    /// Acquire an exclusive lock on the index, then load or rebuild it.
354    /// Uses the default 5-second timeout.
355    pub fn acquire(beans_dir: &Path) -> Result<Self> {
356        Self::acquire_with_timeout(beans_dir, LOCK_TIMEOUT)
357    }
358
359    /// Acquire an exclusive lock with a custom timeout.
360    pub fn acquire_with_timeout(beans_dir: &Path, timeout: Duration) -> Result<Self> {
361        let lock_path = beans_dir.join("index.lock");
362        let lock_file = fs::File::create(&lock_path)
363            .with_context(|| format!("Failed to create lock file: {}", lock_path.display()))?;
364
365        Self::flock_with_timeout(&lock_file, timeout)?;
366
367        let index = Index::load_or_rebuild(beans_dir)?;
368
369        Ok(Self {
370            index,
371            lock_file,
372            beans_dir: beans_dir.to_path_buf(),
373        })
374    }
375
376    /// Save the modified index and release the lock.
377    pub fn save_and_release(self) -> Result<()> {
378        self.index.save(&self.beans_dir)?;
379        // self drops here, releasing the flock via Drop
380        Ok(())
381    }
382
383    /// Poll for an exclusive flock with timeout.
384    fn flock_with_timeout(file: &fs::File, timeout: Duration) -> Result<()> {
385        let start = Instant::now();
386        loop {
387            match file.try_lock_exclusive() {
388                Ok(()) => return Ok(()),
389                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
390                    if start.elapsed() >= timeout {
391                        return Err(anyhow!(
392                            "Timed out after {}s waiting for .beans/index.lock — \
393                             another bn process may be running. \
394                             If no other process is active, delete .beans/index.lock and retry.",
395                            timeout.as_secs()
396                        ));
397                    }
398                    std::thread::sleep(Duration::from_millis(50));
399                }
400                Err(e) => {
401                    return Err(anyhow!("Failed to acquire index lock: {}", e));
402                }
403            }
404        }
405    }
406}
407
408impl Drop for LockedIndex {
409    fn drop(&mut self) {
410        // Use fs2's unlock explicitly (std's File::unlock stabilized in 1.89, above our MSRV)
411        let _ = fs2::FileExt::unlock(&self.lock_file);
412    }
413}
414
415// ---------------------------------------------------------------------------
416// Tests
417// ---------------------------------------------------------------------------
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use std::cmp::Ordering;
423    use std::fs;
424    use std::thread;
425    use std::time::Duration;
426    use tempfile::TempDir;
427
428    /// Helper: create a .beans directory with some bean YAML files.
429    fn setup_beans_dir() -> (TempDir, std::path::PathBuf) {
430        let dir = TempDir::new().unwrap();
431        let beans_dir = dir.path().join(".beans");
432        fs::create_dir(&beans_dir).unwrap();
433
434        // Create a few beans
435        let bean1 = Bean::new("1", "First task");
436        let bean2 = Bean::new("2", "Second task");
437        let bean10 = Bean::new("10", "Tenth task");
438        let mut bean3_1 = Bean::new("3.1", "Subtask");
439        bean3_1.parent = Some("3".to_string());
440        bean3_1.labels = vec!["backend".to_string()];
441        bean3_1.dependencies = vec!["1".to_string()];
442
443        bean1.to_file(beans_dir.join("1.yaml")).unwrap();
444        bean2.to_file(beans_dir.join("2.yaml")).unwrap();
445        bean10.to_file(beans_dir.join("10.yaml")).unwrap();
446        bean3_1.to_file(beans_dir.join("3.1.yaml")).unwrap();
447
448        // Create files that should be excluded
449        fs::write(
450            beans_dir.join("config.yaml"),
451            "project: test\nnext_id: 11\n",
452        )
453        .unwrap();
454
455        (dir, beans_dir)
456    }
457
458    // -- natural_cmp tests --
459
460    #[test]
461    fn natural_sort_basic() {
462        assert_eq!(natural_cmp("1", "2"), Ordering::Less);
463        assert_eq!(natural_cmp("2", "1"), Ordering::Greater);
464        assert_eq!(natural_cmp("1", "1"), Ordering::Equal);
465    }
466
467    #[test]
468    fn natural_sort_numeric_not_lexicographic() {
469        // Lexicographic: "10" < "2", but natural: "10" > "2"
470        assert_eq!(natural_cmp("2", "10"), Ordering::Less);
471        assert_eq!(natural_cmp("10", "2"), Ordering::Greater);
472    }
473
474    #[test]
475    fn natural_sort_dotted_ids() {
476        assert_eq!(natural_cmp("3", "3.1"), Ordering::Less);
477        assert_eq!(natural_cmp("3.1", "3.2"), Ordering::Less);
478        assert_eq!(natural_cmp("3.2", "10"), Ordering::Less);
479    }
480
481    #[test]
482    fn natural_sort_full_sequence() {
483        let mut ids = vec!["10", "3.2", "1", "3", "3.1", "2"];
484        ids.sort_by(|a, b| natural_cmp(a, b));
485        assert_eq!(ids, vec!["1", "2", "3", "3.1", "3.2", "10"]);
486    }
487
488    // -- build tests --
489
490    #[test]
491    fn build_reads_all_beans_and_excludes_config() {
492        let (_dir, beans_dir) = setup_beans_dir();
493        let index = Index::build(&beans_dir).unwrap();
494
495        // Should have 4 beans: 1, 2, 3.1, 10
496        assert_eq!(index.beans.len(), 4);
497
498        // Should be naturally sorted
499        let ids: Vec<&str> = index.beans.iter().map(|e| e.id.as_str()).collect();
500        assert_eq!(ids, vec!["1", "2", "3.1", "10"]);
501    }
502
503    #[test]
504    fn build_extracts_fields_correctly() {
505        let (_dir, beans_dir) = setup_beans_dir();
506        let index = Index::build(&beans_dir).unwrap();
507
508        let entry = index.beans.iter().find(|e| e.id == "3.1").unwrap();
509        assert_eq!(entry.title, "Subtask");
510        assert_eq!(entry.status, Status::Open);
511        assert_eq!(entry.priority, 2);
512        assert_eq!(entry.parent, Some("3".to_string()));
513        assert_eq!(entry.dependencies, vec!["1".to_string()]);
514        assert_eq!(entry.labels, vec!["backend".to_string()]);
515    }
516
517    #[test]
518    fn build_excludes_index_and_bean_yaml() {
519        let (_dir, beans_dir) = setup_beans_dir();
520
521        // Create index.yaml and bean.yaml — these should be excluded
522        fs::write(beans_dir.join("index.yaml"), "beans: []\n").unwrap();
523        fs::write(
524            beans_dir.join("bean.yaml"),
525            "id: template\ntitle: Template\n",
526        )
527        .unwrap();
528
529        let index = Index::build(&beans_dir).unwrap();
530        assert_eq!(index.beans.len(), 4);
531        assert!(!index.beans.iter().any(|e| e.id == "template"));
532    }
533
534    #[test]
535    fn build_detects_duplicate_ids() {
536        let dir = TempDir::new().unwrap();
537        let beans_dir = dir.path().join(".beans");
538        fs::create_dir(&beans_dir).unwrap();
539
540        // Create two beans with the same ID in different files
541        let bean_a = Bean::new("99", "Bean A");
542        let bean_b = Bean::new("99", "Bean B");
543
544        bean_a.to_file(beans_dir.join("99-a.md")).unwrap();
545        bean_b.to_file(beans_dir.join("99-b.md")).unwrap();
546
547        let result = Index::build(&beans_dir);
548        assert!(result.is_err());
549
550        let err = result.unwrap_err().to_string();
551        assert!(err.contains("Duplicate bean IDs detected"));
552        assert!(err.contains("99"));
553        assert!(err.contains("99-a.md"));
554        assert!(err.contains("99-b.md"));
555    }
556
557    #[test]
558    fn build_detects_multiple_duplicate_ids() {
559        let dir = TempDir::new().unwrap();
560        let beans_dir = dir.path().join(".beans");
561        fs::create_dir(&beans_dir).unwrap();
562
563        // Create duplicates for two different IDs
564        Bean::new("1", "First A")
565            .to_file(beans_dir.join("1-a.md"))
566            .unwrap();
567        Bean::new("1", "First B")
568            .to_file(beans_dir.join("1-b.md"))
569            .unwrap();
570        Bean::new("2", "Second A")
571            .to_file(beans_dir.join("2-a.md"))
572            .unwrap();
573        Bean::new("2", "Second B")
574            .to_file(beans_dir.join("2-b.md"))
575            .unwrap();
576
577        let result = Index::build(&beans_dir);
578        assert!(result.is_err());
579
580        let err = result.unwrap_err().to_string();
581        assert!(err.contains("ID '1'"));
582        assert!(err.contains("ID '2'"));
583    }
584
585    // -- is_stale tests --
586
587    #[test]
588    fn is_stale_when_index_missing() {
589        let (_dir, beans_dir) = setup_beans_dir();
590        assert!(Index::is_stale(&beans_dir).unwrap());
591    }
592
593    #[test]
594    fn is_stale_when_yaml_newer_than_index() {
595        let (_dir, beans_dir) = setup_beans_dir();
596
597        // Build and save the index first
598        let index = Index::build(&beans_dir).unwrap();
599        index.save(&beans_dir).unwrap();
600
601        // Wait a moment to ensure distinct mtimes
602        thread::sleep(Duration::from_millis(50));
603
604        // Modify a bean file — this makes it newer than the index
605        let bean = Bean::new("1", "Modified first task");
606        bean.to_file(beans_dir.join("1.yaml")).unwrap();
607
608        assert!(Index::is_stale(&beans_dir).unwrap());
609    }
610
611    #[test]
612    fn not_stale_when_index_is_fresh() {
613        let (_dir, beans_dir) = setup_beans_dir();
614
615        // Build and save
616        let index = Index::build(&beans_dir).unwrap();
617        index.save(&beans_dir).unwrap();
618
619        // The index was just written, so it should not be stale
620        // (index.yaml mtime >= all other yaml mtimes)
621        assert!(!Index::is_stale(&beans_dir).unwrap());
622    }
623
624    // -- load_or_rebuild tests --
625
626    #[test]
627    fn load_or_rebuild_builds_when_no_index() {
628        let (_dir, beans_dir) = setup_beans_dir();
629
630        let index = Index::load_or_rebuild(&beans_dir).unwrap();
631        assert_eq!(index.beans.len(), 4);
632
633        // Should have created index.yaml
634        assert!(beans_dir.join("index.yaml").exists());
635    }
636
637    #[test]
638    fn load_or_rebuild_loads_when_fresh() {
639        let (_dir, beans_dir) = setup_beans_dir();
640
641        // Build + save
642        let original = Index::build(&beans_dir).unwrap();
643        original.save(&beans_dir).unwrap();
644
645        // load_or_rebuild should load without rebuilding
646        let loaded = Index::load_or_rebuild(&beans_dir).unwrap();
647        assert_eq!(original, loaded);
648    }
649
650    // -- save / load round-trip --
651
652    #[test]
653    fn save_and_load_round_trip() {
654        let (_dir, beans_dir) = setup_beans_dir();
655
656        let index = Index::build(&beans_dir).unwrap();
657        index.save(&beans_dir).unwrap();
658
659        let loaded = Index::load(&beans_dir).unwrap();
660        assert_eq!(index, loaded);
661    }
662
663    // -- empty directory --
664
665    #[test]
666    fn build_empty_directory() {
667        let dir = TempDir::new().unwrap();
668        let beans_dir = dir.path().join(".beans");
669        fs::create_dir(&beans_dir).unwrap();
670
671        let index = Index::build(&beans_dir).unwrap();
672        assert!(index.beans.is_empty());
673    }
674
675    // -- LockedIndex tests --
676
677    #[test]
678    fn locked_index_acquire_and_save() {
679        let (_dir, beans_dir) = setup_beans_dir();
680
681        let mut locked = LockedIndex::acquire(&beans_dir).unwrap();
682        assert_eq!(locked.index.beans.len(), 4);
683
684        // Modify a title
685        locked.index.beans[0].title = "Modified".to_string();
686        locked.save_and_release().unwrap();
687
688        // Verify the change persisted
689        let index = Index::load(&beans_dir).unwrap();
690        assert_eq!(index.beans[0].title, "Modified");
691    }
692
693    #[test]
694    fn locked_index_blocks_concurrent_access() {
695        let (_dir, beans_dir) = setup_beans_dir();
696
697        // First lock
698        let _locked = LockedIndex::acquire(&beans_dir).unwrap();
699
700        // Second lock should fail with timeout
701        let result = LockedIndex::acquire_with_timeout(&beans_dir, Duration::from_millis(200));
702        assert!(result.is_err());
703        let err = result.unwrap_err().to_string();
704        assert!(
705            err.contains("Timed out"),
706            "Expected timeout error, got: {}",
707            err
708        );
709    }
710
711    #[test]
712    fn locked_index_released_on_drop() {
713        let (_dir, beans_dir) = setup_beans_dir();
714
715        {
716            let _locked = LockedIndex::acquire(&beans_dir).unwrap();
717            // lock held in this scope
718        }
719        // lock released on drop
720
721        // Should be able to acquire again
722        let _locked = LockedIndex::acquire(&beans_dir).unwrap();
723    }
724
725    #[test]
726    fn locked_index_creates_lock_file() {
727        let (_dir, beans_dir) = setup_beans_dir();
728
729        let _locked = LockedIndex::acquire(&beans_dir).unwrap();
730        assert!(beans_dir.join("index.lock").exists());
731    }
732
733    // -- is_stale ignores non-yaml files --
734
735    #[test]
736    fn is_stale_ignores_non_yaml() {
737        let (_dir, beans_dir) = setup_beans_dir();
738
739        let index = Index::build(&beans_dir).unwrap();
740        index.save(&beans_dir).unwrap();
741
742        // Create a non-yaml file after the index
743        thread::sleep(Duration::from_millis(50));
744        fs::write(beans_dir.join("notes.txt"), "some notes").unwrap();
745
746        // Should NOT be stale — non-yaml files don't count
747        assert!(!Index::is_stale(&beans_dir).unwrap());
748    }
749}
750
751#[cfg(test)]
752mod archive_tests {
753    use super::*;
754    use tempfile::TempDir;
755
756    #[test]
757    fn collect_archived_finds_beans() {
758        let dir = TempDir::new().unwrap();
759        let beans_dir = dir.path().join(".beans");
760        fs::create_dir(&beans_dir).unwrap();
761
762        // Create archive structure
763        let archive_dir = beans_dir.join("archive").join("2026").join("02");
764        fs::create_dir_all(&archive_dir).unwrap();
765
766        // Create an archived bean
767        let mut bean = crate::bean::Bean::new("1", "Archived task");
768        bean.status = crate::bean::Status::Closed;
769        bean.to_file(archive_dir.join("1-archived-task.md"))
770            .unwrap();
771
772        let archived = Index::collect_archived(&beans_dir).unwrap();
773        assert_eq!(archived.len(), 1);
774        assert_eq!(archived[0].id, "1");
775        assert_eq!(archived[0].status, crate::bean::Status::Closed);
776    }
777
778    #[test]
779    fn collect_archived_empty_when_no_archive() {
780        let dir = TempDir::new().unwrap();
781        let beans_dir = dir.path().join(".beans");
782        fs::create_dir(&beans_dir).unwrap();
783
784        let archived = Index::collect_archived(&beans_dir).unwrap();
785        assert!(archived.is_empty());
786    }
787}
788
789#[cfg(test)]
790mod format_count_tests {
791    use super::*;
792    use tempfile::TempDir;
793
794    #[test]
795    fn count_bean_formats_only_yaml() {
796        let dir = TempDir::new().unwrap();
797        let beans_dir = dir.path().join(".beans");
798        fs::create_dir(&beans_dir).unwrap();
799
800        // Create only yaml files
801        let bean1 = crate::bean::Bean::new("1", "Task 1");
802        let bean2 = crate::bean::Bean::new("2", "Task 2");
803        bean1.to_file(beans_dir.join("1.yaml")).unwrap();
804        bean2.to_file(beans_dir.join("2.yaml")).unwrap();
805
806        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
807        assert_eq!(md_count, 0);
808        assert_eq!(yaml_count, 2);
809    }
810
811    #[test]
812    fn count_bean_formats_only_md() {
813        let dir = TempDir::new().unwrap();
814        let beans_dir = dir.path().join(".beans");
815        fs::create_dir(&beans_dir).unwrap();
816
817        // Create only md files
818        let bean1 = crate::bean::Bean::new("1", "Task 1");
819        let bean2 = crate::bean::Bean::new("2", "Task 2");
820        bean1.to_file(beans_dir.join("1-task-1.md")).unwrap();
821        bean2.to_file(beans_dir.join("2-task-2.md")).unwrap();
822
823        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
824        assert_eq!(md_count, 2);
825        assert_eq!(yaml_count, 0);
826    }
827
828    #[test]
829    fn count_bean_formats_mixed() {
830        let dir = TempDir::new().unwrap();
831        let beans_dir = dir.path().join(".beans");
832        fs::create_dir(&beans_dir).unwrap();
833
834        // Create mixed formats
835        let bean1 = crate::bean::Bean::new("1", "Task 1");
836        let bean2 = crate::bean::Bean::new("2", "Task 2");
837        let bean3 = crate::bean::Bean::new("3", "Task 3");
838        bean1.to_file(beans_dir.join("1.yaml")).unwrap();
839        bean2.to_file(beans_dir.join("2-task-2.md")).unwrap();
840        bean3.to_file(beans_dir.join("3-task-3.md")).unwrap();
841
842        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
843        assert_eq!(md_count, 2);
844        assert_eq!(yaml_count, 1);
845    }
846
847    #[test]
848    fn count_bean_formats_excludes_config_files() {
849        let dir = TempDir::new().unwrap();
850        let beans_dir = dir.path().join(".beans");
851        fs::create_dir(&beans_dir).unwrap();
852
853        // Create excluded yaml files (config.yaml, index.yaml)
854        fs::write(beans_dir.join("config.yaml"), "project: test").unwrap();
855        fs::write(beans_dir.join("index.yaml"), "beans: []").unwrap();
856
857        // Create one actual bean
858        let bean1 = crate::bean::Bean::new("1", "Task 1");
859        bean1.to_file(beans_dir.join("1-task-1.md")).unwrap();
860
861        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
862        assert_eq!(md_count, 1);
863        assert_eq!(yaml_count, 0); // config.yaml and index.yaml are excluded
864    }
865
866    #[test]
867    fn count_bean_formats_empty_dir() {
868        let dir = TempDir::new().unwrap();
869        let beans_dir = dir.path().join(".beans");
870        fs::create_dir(&beans_dir).unwrap();
871
872        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
873        assert_eq!(md_count, 0);
874        assert_eq!(yaml_count, 0);
875    }
876}