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    /// File paths this bean touches (for scope-based blocking)
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub paths: Vec<String>,
51}
52
53impl From<&Bean> for IndexEntry {
54    fn from(bean: &Bean) -> Self {
55        Self {
56            id: bean.id.clone(),
57            title: bean.title.clone(),
58            status: bean.status,
59            priority: bean.priority,
60            parent: bean.parent.clone(),
61            dependencies: bean.dependencies.clone(),
62            labels: bean.labels.clone(),
63            assignee: bean.assignee.clone(),
64            updated_at: bean.updated_at,
65            produces: bean.produces.clone(),
66            requires: bean.requires.clone(),
67            has_verify: bean.verify.is_some(),
68            claimed_by: bean.claimed_by.clone(),
69            attempts: bean.attempts,
70            paths: bean.paths.clone(),
71        }
72    }
73}
74
75// ---------------------------------------------------------------------------
76// Index
77// ---------------------------------------------------------------------------
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct Index {
81    pub beans: Vec<IndexEntry>,
82}
83
84// Files to exclude when scanning for bean YAMLs.
85const EXCLUDED_FILES: &[&str] = &["config.yaml", "index.yaml", "bean.yaml", "archive.yaml"];
86
87/// Check if a filename represents a bean file (not a config/index/template file).
88fn is_bean_filename(filename: &str) -> bool {
89    if EXCLUDED_FILES.contains(&filename) {
90        return false;
91    }
92    let ext = std::path::Path::new(filename)
93        .extension()
94        .and_then(|e| e.to_str());
95    match ext {
96        Some("md") => filename.contains('-'), // New format: {id}-{slug}.md
97        Some("yaml") => true,                 // Legacy format: {id}.yaml
98        _ => false,
99    }
100}
101
102/// Count bean files by format in the beans directory.
103/// Returns (md_count, yaml_count) tuple.
104pub fn count_bean_formats(beans_dir: &Path) -> Result<(usize, usize)> {
105    let mut md_count = 0;
106    let mut yaml_count = 0;
107
108    let dir_entries = fs::read_dir(beans_dir)
109        .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
110
111    for entry in dir_entries {
112        let entry = entry?;
113        let path = entry.path();
114
115        let filename = path
116            .file_name()
117            .and_then(|n| n.to_str())
118            .unwrap_or_default();
119
120        if !is_bean_filename(filename) {
121            continue;
122        }
123
124        let ext = path.extension().and_then(|e| e.to_str());
125        match ext {
126            Some("md") => md_count += 1,
127            Some("yaml") => yaml_count += 1,
128            _ => {}
129        }
130    }
131
132    Ok((md_count, yaml_count))
133}
134
135impl Index {
136    /// Build the index by reading all bean files from the beans directory.
137    /// Supports both new format ({id}-{slug}.md) and legacy format ({id}.yaml).
138    /// Excludes config.yaml, index.yaml, and bean.yaml.
139    /// Sorts entries by ID using natural ordering.
140    /// Returns an error if duplicate bean IDs are detected.
141    pub fn build(beans_dir: &Path) -> Result<Self> {
142        let mut entries = Vec::new();
143        // Track which files define each ID to detect duplicates
144        let mut id_to_files: HashMap<String, Vec<String>> = HashMap::new();
145
146        let dir_entries = fs::read_dir(beans_dir)
147            .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
148
149        for entry in dir_entries {
150            let entry = entry?;
151            let path = entry.path();
152
153            let filename = path
154                .file_name()
155                .and_then(|n| n.to_str())
156                .unwrap_or_default();
157
158            if !is_bean_filename(filename) {
159                continue;
160            }
161
162            let bean = Bean::from_file(&path)
163                .with_context(|| format!("Failed to parse bean: {}", path.display()))?;
164
165            // Track this ID's file for duplicate detection
166            id_to_files
167                .entry(bean.id.clone())
168                .or_default()
169                .push(filename.to_string());
170
171            entries.push(IndexEntry::from(&bean));
172        }
173
174        // Check for duplicate IDs
175        let duplicates: Vec<_> = id_to_files
176            .iter()
177            .filter(|(_, files)| files.len() > 1)
178            .collect();
179
180        if !duplicates.is_empty() {
181            let mut msg = String::from("Duplicate bean IDs detected:\n");
182            for (id, files) in duplicates {
183                msg.push_str(&format!("  ID '{}' defined in: {}\n", id, files.join(", ")));
184            }
185            return Err(anyhow!(msg));
186        }
187
188        entries.sort_by(|a, b| natural_cmp(&a.id, &b.id));
189
190        Ok(Index { beans: entries })
191    }
192
193    /// Check whether the cached index is stale.
194    /// Returns true if the index file is missing or if any bean file (.md or .yaml)
195    /// in the beans directory has been modified after the index was last written.
196    pub fn is_stale(beans_dir: &Path) -> Result<bool> {
197        let index_path = beans_dir.join("index.yaml");
198
199        // If index doesn't exist, it's stale
200        if !index_path.exists() {
201            return Ok(true);
202        }
203
204        let index_mtime = fs::metadata(&index_path)
205            .with_context(|| "Failed to read index.yaml metadata")?
206            .modified()
207            .with_context(|| "Failed to get index.yaml mtime")?;
208
209        let dir_entries = fs::read_dir(beans_dir)
210            .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
211
212        for entry in dir_entries {
213            let entry = entry?;
214            let path = entry.path();
215
216            let filename = path
217                .file_name()
218                .and_then(|n| n.to_str())
219                .unwrap_or_default();
220
221            if !is_bean_filename(filename) {
222                continue;
223            }
224
225            let file_mtime = fs::metadata(&path)
226                .with_context(|| format!("Failed to read metadata: {}", path.display()))?
227                .modified()
228                .with_context(|| format!("Failed to get mtime: {}", path.display()))?;
229
230            if file_mtime > index_mtime {
231                return Ok(true);
232            }
233        }
234
235        Ok(false)
236    }
237
238    /// Load the cached index or rebuild it if stale.
239    /// This is the main entry point for read-heavy commands.
240    pub fn load_or_rebuild(beans_dir: &Path) -> Result<Self> {
241        if Self::is_stale(beans_dir)? {
242            let index = Self::build(beans_dir)?;
243            index.save(beans_dir)?;
244            Ok(index)
245        } else {
246            Self::load(beans_dir)
247        }
248    }
249
250    /// Load the index from the cached index.yaml file.
251    pub fn load(beans_dir: &Path) -> Result<Self> {
252        let index_path = beans_dir.join("index.yaml");
253        let contents = fs::read_to_string(&index_path)
254            .with_context(|| format!("Failed to read {}", index_path.display()))?;
255        let index: Index =
256            serde_yml::from_str(&contents).with_context(|| "Failed to parse index.yaml")?;
257        Ok(index)
258    }
259
260    /// Save the index to .beans/index.yaml.
261    pub fn save(&self, beans_dir: &Path) -> Result<()> {
262        let index_path = beans_dir.join("index.yaml");
263        let yaml = serde_yml::to_string(self).with_context(|| "Failed to serialize index")?;
264        atomic_write(&index_path, &yaml)
265            .with_context(|| format!("Failed to write {}", index_path.display()))?;
266        Ok(())
267    }
268
269    /// Collect all archived beans from .beans/archive/ directory.
270    /// Walks through year/month subdirectories and loads all bean files.
271    /// Returns IndexEntry items for archived beans.
272    pub fn collect_archived(beans_dir: &Path) -> Result<Vec<IndexEntry>> {
273        let mut entries = Vec::new();
274        let archive_dir = beans_dir.join("archive");
275
276        if !archive_dir.is_dir() {
277            return Ok(entries);
278        }
279
280        // Walk through archive directory recursively
281        Self::walk_archive_dir(&archive_dir, &mut entries)?;
282
283        Ok(entries)
284    }
285
286    /// Recursively walk archive directory and collect bean entries.
287    /// Uses catch_unwind to survive YAML parser panics from corrupt files.
288    fn walk_archive_dir(dir: &Path, entries: &mut Vec<IndexEntry>) -> Result<()> {
289        use crate::bean::Bean;
290
291        if !dir.is_dir() {
292            return Ok(());
293        }
294
295        for entry in fs::read_dir(dir)? {
296            let entry = entry?;
297            let path = entry.path();
298
299            if path.is_dir() {
300                Self::walk_archive_dir(&path, entries)?;
301            } else if path.is_file() {
302                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
303                    if is_bean_filename(filename) {
304                        let path_clone = path.clone();
305                        let result = std::panic::catch_unwind(|| Bean::from_file(&path_clone));
306                        match result {
307                            Ok(Ok(bean)) => entries.push(IndexEntry::from(&bean)),
308                            Ok(Err(_)) => {} // normal parse error, skip silently
309                            Err(_) => {
310                                eprintln!(
311                                    "warning: skipping corrupt archive file (parser panic): {}",
312                                    path.display()
313                                );
314                            }
315                        }
316                    }
317                }
318            }
319        }
320
321        Ok(())
322    }
323}
324
325// ---------------------------------------------------------------------------
326// ArchiveIndex — cached index of archived beans
327// ---------------------------------------------------------------------------
328
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub struct ArchiveIndex {
331    pub beans: Vec<IndexEntry>,
332}
333
334impl ArchiveIndex {
335    /// Build the archive index by walking `.beans/archive/` recursively.
336    /// Reuses `Index::collect_archived` to find all archived bean files,
337    /// then sorts entries by ID using natural ordering.
338    pub fn build(beans_dir: &Path) -> Result<Self> {
339        let mut entries = Index::collect_archived(beans_dir)?;
340        entries.sort_by(|a, b| natural_cmp(&a.id, &b.id));
341        Ok(ArchiveIndex { beans: entries })
342    }
343
344    /// Load the archive index from `.beans/archive.yaml`.
345    pub fn load(beans_dir: &Path) -> Result<Self> {
346        let path = beans_dir.join("archive.yaml");
347        let contents = fs::read_to_string(&path)
348            .with_context(|| format!("Failed to read {}", path.display()))?;
349        let index: ArchiveIndex =
350            serde_yml::from_str(&contents).with_context(|| "Failed to parse archive.yaml")?;
351        Ok(index)
352    }
353
354    /// Save the archive index to `.beans/archive.yaml`.
355    pub fn save(&self, beans_dir: &Path) -> Result<()> {
356        let path = beans_dir.join("archive.yaml");
357        let yaml =
358            serde_yml::to_string(self).with_context(|| "Failed to serialize archive index")?;
359        atomic_write(&path, &yaml)
360            .with_context(|| format!("Failed to write {}", path.display()))?;
361        Ok(())
362    }
363
364    /// Load cached archive index or rebuild if stale.
365    pub fn load_or_rebuild(beans_dir: &Path) -> Result<Self> {
366        let archive_yaml = beans_dir.join("archive.yaml");
367        if Self::is_stale(beans_dir)? {
368            let index = Self::build(beans_dir)?;
369            // Only save if there are entries or the file already exists
370            // (avoids creating archive.yaml when there's no archive dir)
371            if !index.beans.is_empty() || archive_yaml.exists() {
372                index.save(beans_dir)?;
373            }
374            Ok(index)
375        } else if archive_yaml.exists() {
376            Self::load(beans_dir)
377        } else {
378            // No archive dir and no archive.yaml — return empty
379            Ok(ArchiveIndex { beans: Vec::new() })
380        }
381    }
382
383    /// Check whether the cached archive index is stale.
384    /// Returns true if archive.yaml is missing (and archive dir exists),
385    /// or if any file in the archive tree has been modified after archive.yaml.
386    pub fn is_stale(beans_dir: &Path) -> Result<bool> {
387        let archive_yaml = beans_dir.join("archive.yaml");
388        let archive_dir = beans_dir.join("archive");
389
390        if !archive_yaml.exists() {
391            // If the archive dir doesn't exist either, nothing to index
392            return Ok(archive_dir.is_dir());
393        }
394
395        if !archive_dir.is_dir() {
396            return Ok(false);
397        }
398
399        let index_mtime = fs::metadata(&archive_yaml)
400            .with_context(|| "Failed to read archive.yaml metadata")?
401            .modified()
402            .with_context(|| "Failed to get archive.yaml mtime")?;
403
404        Self::any_file_newer(&archive_dir, index_mtime)
405    }
406
407    /// Check if any file in the given directory tree is newer than the reference time.
408    fn any_file_newer(dir: &Path, reference: std::time::SystemTime) -> Result<bool> {
409        for entry in fs::read_dir(dir)? {
410            let entry = entry?;
411            let path = entry.path();
412            if path.is_dir() {
413                if Self::any_file_newer(&path, reference)? {
414                    return Ok(true);
415                }
416            } else if path.is_file() {
417                let mtime = fs::metadata(&path)?.modified()?;
418                if mtime > reference {
419                    return Ok(true);
420                }
421            }
422        }
423        Ok(false)
424    }
425
426    /// Append an entry, deduplicating by ID (replaces any existing entry with the same ID).
427    pub fn append(&mut self, entry: IndexEntry) {
428        self.beans.retain(|e| e.id != entry.id);
429        self.beans.push(entry);
430        self.beans.sort_by(|a, b| natural_cmp(&a.id, &b.id));
431    }
432
433    /// Remove an entry by ID.
434    pub fn remove(&mut self, id: &str) {
435        self.beans.retain(|e| e.id != id);
436    }
437}
438
439// ---------------------------------------------------------------------------
440// LockedIndex — exclusive access during read-modify-write
441// ---------------------------------------------------------------------------
442
443/// Default timeout for acquiring the index lock.
444const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
445
446/// Exclusive handle to the index, backed by an advisory flock on `.beans/index.lock`.
447///
448/// Prevents concurrent read-modify-write races when multiple agents run in parallel.
449/// The lock is held from acquisition until `save_and_release` is called or the
450/// `LockedIndex` is dropped.
451///
452/// ```no_run
453/// # use anyhow::Result;
454/// # use std::path::Path;
455/// # fn example(beans_dir: &Path) -> Result<()> {
456/// use bn::index::LockedIndex;
457/// let mut locked = LockedIndex::acquire(beans_dir)?;
458/// locked.index.beans[0].title = "Updated".to_string();
459/// locked.save_and_release()?;
460/// # Ok(())
461/// # }
462/// ```
463#[derive(Debug)]
464pub struct LockedIndex {
465    pub index: Index,
466    lock_file: fs::File,
467    beans_dir: std::path::PathBuf,
468}
469
470impl LockedIndex {
471    /// Acquire an exclusive lock on the index, then load or rebuild it.
472    /// Uses the default 5-second timeout.
473    pub fn acquire(beans_dir: &Path) -> Result<Self> {
474        Self::acquire_with_timeout(beans_dir, LOCK_TIMEOUT)
475    }
476
477    /// Acquire an exclusive lock with a custom timeout.
478    pub fn acquire_with_timeout(beans_dir: &Path, timeout: Duration) -> Result<Self> {
479        let lock_path = beans_dir.join("index.lock");
480        let lock_file = fs::File::create(&lock_path)
481            .with_context(|| format!("Failed to create lock file: {}", lock_path.display()))?;
482
483        Self::flock_with_timeout(&lock_file, timeout)?;
484
485        let index = Index::load_or_rebuild(beans_dir)?;
486
487        Ok(Self {
488            index,
489            lock_file,
490            beans_dir: beans_dir.to_path_buf(),
491        })
492    }
493
494    /// Save the modified index and release the lock.
495    pub fn save_and_release(self) -> Result<()> {
496        self.index.save(&self.beans_dir)?;
497        // self drops here, releasing the flock via Drop
498        Ok(())
499    }
500
501    /// Poll for an exclusive flock with timeout.
502    fn flock_with_timeout(file: &fs::File, timeout: Duration) -> Result<()> {
503        let start = Instant::now();
504        loop {
505            match file.try_lock_exclusive() {
506                Ok(()) => return Ok(()),
507                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
508                    if start.elapsed() >= timeout {
509                        return Err(anyhow!(
510                            "Timed out after {}s waiting for .beans/index.lock — \
511                             another bn process may be running. \
512                             If no other process is active, delete .beans/index.lock and retry.",
513                            timeout.as_secs()
514                        ));
515                    }
516                    std::thread::sleep(Duration::from_millis(50));
517                }
518                Err(e) => {
519                    return Err(anyhow!("Failed to acquire index lock: {}", e));
520                }
521            }
522        }
523    }
524}
525
526impl Drop for LockedIndex {
527    fn drop(&mut self) {
528        // Use fs2's unlock explicitly (std's File::unlock stabilized in 1.89, above our MSRV)
529        let _ = fs2::FileExt::unlock(&self.lock_file);
530    }
531}
532
533// ---------------------------------------------------------------------------
534// Tests
535// ---------------------------------------------------------------------------
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use std::cmp::Ordering;
541    use std::fs;
542    use std::thread;
543    use std::time::Duration;
544    use tempfile::TempDir;
545
546    /// Helper: create a .beans directory with some bean YAML files.
547    fn setup_beans_dir() -> (TempDir, std::path::PathBuf) {
548        let dir = TempDir::new().unwrap();
549        let beans_dir = dir.path().join(".beans");
550        fs::create_dir(&beans_dir).unwrap();
551
552        // Create a few beans
553        let bean1 = Bean::new("1", "First task");
554        let bean2 = Bean::new("2", "Second task");
555        let bean10 = Bean::new("10", "Tenth task");
556        let mut bean3_1 = Bean::new("3.1", "Subtask");
557        bean3_1.parent = Some("3".to_string());
558        bean3_1.labels = vec!["backend".to_string()];
559        bean3_1.dependencies = vec!["1".to_string()];
560
561        bean1.to_file(beans_dir.join("1.yaml")).unwrap();
562        bean2.to_file(beans_dir.join("2.yaml")).unwrap();
563        bean10.to_file(beans_dir.join("10.yaml")).unwrap();
564        bean3_1.to_file(beans_dir.join("3.1.yaml")).unwrap();
565
566        // Create files that should be excluded
567        fs::write(
568            beans_dir.join("config.yaml"),
569            "project: test\nnext_id: 11\n",
570        )
571        .unwrap();
572
573        (dir, beans_dir)
574    }
575
576    // -- natural_cmp tests --
577
578    #[test]
579    fn natural_sort_basic() {
580        assert_eq!(natural_cmp("1", "2"), Ordering::Less);
581        assert_eq!(natural_cmp("2", "1"), Ordering::Greater);
582        assert_eq!(natural_cmp("1", "1"), Ordering::Equal);
583    }
584
585    #[test]
586    fn natural_sort_numeric_not_lexicographic() {
587        // Lexicographic: "10" < "2", but natural: "10" > "2"
588        assert_eq!(natural_cmp("2", "10"), Ordering::Less);
589        assert_eq!(natural_cmp("10", "2"), Ordering::Greater);
590    }
591
592    #[test]
593    fn natural_sort_dotted_ids() {
594        assert_eq!(natural_cmp("3", "3.1"), Ordering::Less);
595        assert_eq!(natural_cmp("3.1", "3.2"), Ordering::Less);
596        assert_eq!(natural_cmp("3.2", "10"), Ordering::Less);
597    }
598
599    #[test]
600    fn natural_sort_full_sequence() {
601        let mut ids = vec!["10", "3.2", "1", "3", "3.1", "2"];
602        ids.sort_by(|a, b| natural_cmp(a, b));
603        assert_eq!(ids, vec!["1", "2", "3", "3.1", "3.2", "10"]);
604    }
605
606    // -- build tests --
607
608    #[test]
609    fn build_reads_all_beans_and_excludes_config() {
610        let (_dir, beans_dir) = setup_beans_dir();
611        let index = Index::build(&beans_dir).unwrap();
612
613        // Should have 4 beans: 1, 2, 3.1, 10
614        assert_eq!(index.beans.len(), 4);
615
616        // Should be naturally sorted
617        let ids: Vec<&str> = index.beans.iter().map(|e| e.id.as_str()).collect();
618        assert_eq!(ids, vec!["1", "2", "3.1", "10"]);
619    }
620
621    #[test]
622    fn build_extracts_fields_correctly() {
623        let (_dir, beans_dir) = setup_beans_dir();
624        let index = Index::build(&beans_dir).unwrap();
625
626        let entry = index.beans.iter().find(|e| e.id == "3.1").unwrap();
627        assert_eq!(entry.title, "Subtask");
628        assert_eq!(entry.status, Status::Open);
629        assert_eq!(entry.priority, 2);
630        assert_eq!(entry.parent, Some("3".to_string()));
631        assert_eq!(entry.dependencies, vec!["1".to_string()]);
632        assert_eq!(entry.labels, vec!["backend".to_string()]);
633    }
634
635    #[test]
636    fn build_excludes_index_and_bean_yaml() {
637        let (_dir, beans_dir) = setup_beans_dir();
638
639        // Create index.yaml and bean.yaml — these should be excluded
640        fs::write(beans_dir.join("index.yaml"), "beans: []\n").unwrap();
641        fs::write(
642            beans_dir.join("bean.yaml"),
643            "id: template\ntitle: Template\n",
644        )
645        .unwrap();
646
647        let index = Index::build(&beans_dir).unwrap();
648        assert_eq!(index.beans.len(), 4);
649        assert!(!index.beans.iter().any(|e| e.id == "template"));
650    }
651
652    #[test]
653    fn build_detects_duplicate_ids() {
654        let dir = TempDir::new().unwrap();
655        let beans_dir = dir.path().join(".beans");
656        fs::create_dir(&beans_dir).unwrap();
657
658        // Create two beans with the same ID in different files
659        let bean_a = Bean::new("99", "Bean A");
660        let bean_b = Bean::new("99", "Bean B");
661
662        bean_a.to_file(beans_dir.join("99-a.md")).unwrap();
663        bean_b.to_file(beans_dir.join("99-b.md")).unwrap();
664
665        let result = Index::build(&beans_dir);
666        assert!(result.is_err());
667
668        let err = result.unwrap_err().to_string();
669        assert!(err.contains("Duplicate bean IDs detected"));
670        assert!(err.contains("99"));
671        assert!(err.contains("99-a.md"));
672        assert!(err.contains("99-b.md"));
673    }
674
675    #[test]
676    fn build_detects_multiple_duplicate_ids() {
677        let dir = TempDir::new().unwrap();
678        let beans_dir = dir.path().join(".beans");
679        fs::create_dir(&beans_dir).unwrap();
680
681        // Create duplicates for two different IDs
682        Bean::new("1", "First A")
683            .to_file(beans_dir.join("1-a.md"))
684            .unwrap();
685        Bean::new("1", "First B")
686            .to_file(beans_dir.join("1-b.md"))
687            .unwrap();
688        Bean::new("2", "Second A")
689            .to_file(beans_dir.join("2-a.md"))
690            .unwrap();
691        Bean::new("2", "Second B")
692            .to_file(beans_dir.join("2-b.md"))
693            .unwrap();
694
695        let result = Index::build(&beans_dir);
696        assert!(result.is_err());
697
698        let err = result.unwrap_err().to_string();
699        assert!(err.contains("ID '1'"));
700        assert!(err.contains("ID '2'"));
701    }
702
703    // -- is_stale tests --
704
705    #[test]
706    fn is_stale_when_index_missing() {
707        let (_dir, beans_dir) = setup_beans_dir();
708        assert!(Index::is_stale(&beans_dir).unwrap());
709    }
710
711    #[test]
712    fn is_stale_when_yaml_newer_than_index() {
713        let (_dir, beans_dir) = setup_beans_dir();
714
715        // Build and save the index first
716        let index = Index::build(&beans_dir).unwrap();
717        index.save(&beans_dir).unwrap();
718
719        // Wait a moment to ensure distinct mtimes
720        thread::sleep(Duration::from_millis(50));
721
722        // Modify a bean file — this makes it newer than the index
723        let bean = Bean::new("1", "Modified first task");
724        bean.to_file(beans_dir.join("1.yaml")).unwrap();
725
726        assert!(Index::is_stale(&beans_dir).unwrap());
727    }
728
729    #[test]
730    fn not_stale_when_index_is_fresh() {
731        let (_dir, beans_dir) = setup_beans_dir();
732
733        // Build and save
734        let index = Index::build(&beans_dir).unwrap();
735        index.save(&beans_dir).unwrap();
736
737        // The index was just written, so it should not be stale
738        // (index.yaml mtime >= all other yaml mtimes)
739        assert!(!Index::is_stale(&beans_dir).unwrap());
740    }
741
742    // -- load_or_rebuild tests --
743
744    #[test]
745    fn load_or_rebuild_builds_when_no_index() {
746        let (_dir, beans_dir) = setup_beans_dir();
747
748        let index = Index::load_or_rebuild(&beans_dir).unwrap();
749        assert_eq!(index.beans.len(), 4);
750
751        // Should have created index.yaml
752        assert!(beans_dir.join("index.yaml").exists());
753    }
754
755    #[test]
756    fn load_or_rebuild_loads_when_fresh() {
757        let (_dir, beans_dir) = setup_beans_dir();
758
759        // Build + save
760        let original = Index::build(&beans_dir).unwrap();
761        original.save(&beans_dir).unwrap();
762
763        // load_or_rebuild should load without rebuilding
764        let loaded = Index::load_or_rebuild(&beans_dir).unwrap();
765        assert_eq!(original, loaded);
766    }
767
768    // -- save / load round-trip --
769
770    #[test]
771    fn save_and_load_round_trip() {
772        let (_dir, beans_dir) = setup_beans_dir();
773
774        let index = Index::build(&beans_dir).unwrap();
775        index.save(&beans_dir).unwrap();
776
777        let loaded = Index::load(&beans_dir).unwrap();
778        assert_eq!(index, loaded);
779    }
780
781    // -- empty directory --
782
783    #[test]
784    fn build_empty_directory() {
785        let dir = TempDir::new().unwrap();
786        let beans_dir = dir.path().join(".beans");
787        fs::create_dir(&beans_dir).unwrap();
788
789        let index = Index::build(&beans_dir).unwrap();
790        assert!(index.beans.is_empty());
791    }
792
793    // -- LockedIndex tests --
794
795    #[test]
796    fn locked_index_acquire_and_save() {
797        let (_dir, beans_dir) = setup_beans_dir();
798
799        let mut locked = LockedIndex::acquire(&beans_dir).unwrap();
800        assert_eq!(locked.index.beans.len(), 4);
801
802        // Modify a title
803        locked.index.beans[0].title = "Modified".to_string();
804        locked.save_and_release().unwrap();
805
806        // Verify the change persisted
807        let index = Index::load(&beans_dir).unwrap();
808        assert_eq!(index.beans[0].title, "Modified");
809    }
810
811    #[test]
812    fn locked_index_blocks_concurrent_access() {
813        let (_dir, beans_dir) = setup_beans_dir();
814
815        // First lock
816        let _locked = LockedIndex::acquire(&beans_dir).unwrap();
817
818        // Second lock should fail with timeout
819        let result = LockedIndex::acquire_with_timeout(&beans_dir, Duration::from_millis(200));
820        assert!(result.is_err());
821        let err = result.unwrap_err().to_string();
822        assert!(
823            err.contains("Timed out"),
824            "Expected timeout error, got: {}",
825            err
826        );
827    }
828
829    #[test]
830    fn locked_index_released_on_drop() {
831        let (_dir, beans_dir) = setup_beans_dir();
832
833        {
834            let _locked = LockedIndex::acquire(&beans_dir).unwrap();
835            // lock held in this scope
836        }
837        // lock released on drop
838
839        // Should be able to acquire again
840        let _locked = LockedIndex::acquire(&beans_dir).unwrap();
841    }
842
843    #[test]
844    fn locked_index_creates_lock_file() {
845        let (_dir, beans_dir) = setup_beans_dir();
846
847        let _locked = LockedIndex::acquire(&beans_dir).unwrap();
848        assert!(beans_dir.join("index.lock").exists());
849    }
850
851    // -- is_stale ignores non-yaml files --
852
853    #[test]
854    fn is_stale_ignores_non_yaml() {
855        let (_dir, beans_dir) = setup_beans_dir();
856
857        let index = Index::build(&beans_dir).unwrap();
858        index.save(&beans_dir).unwrap();
859
860        // Create a non-yaml file after the index
861        thread::sleep(Duration::from_millis(50));
862        fs::write(beans_dir.join("notes.txt"), "some notes").unwrap();
863
864        // Should NOT be stale — non-yaml files don't count
865        assert!(!Index::is_stale(&beans_dir).unwrap());
866    }
867}
868
869#[cfg(test)]
870mod archive_tests {
871    use super::*;
872    use tempfile::TempDir;
873
874    #[test]
875    fn collect_archived_finds_beans() {
876        let dir = TempDir::new().unwrap();
877        let beans_dir = dir.path().join(".beans");
878        fs::create_dir(&beans_dir).unwrap();
879
880        // Create archive structure
881        let archive_dir = beans_dir.join("archive").join("2026").join("02");
882        fs::create_dir_all(&archive_dir).unwrap();
883
884        // Create an archived bean
885        let mut bean = crate::bean::Bean::new("1", "Archived task");
886        bean.status = crate::bean::Status::Closed;
887        bean.to_file(archive_dir.join("1-archived-task.md"))
888            .unwrap();
889
890        let archived = Index::collect_archived(&beans_dir).unwrap();
891        assert_eq!(archived.len(), 1);
892        assert_eq!(archived[0].id, "1");
893        assert_eq!(archived[0].status, crate::bean::Status::Closed);
894    }
895
896    #[test]
897    fn collect_archived_empty_when_no_archive() {
898        let dir = TempDir::new().unwrap();
899        let beans_dir = dir.path().join(".beans");
900        fs::create_dir(&beans_dir).unwrap();
901
902        let archived = Index::collect_archived(&beans_dir).unwrap();
903        assert!(archived.is_empty());
904    }
905}
906
907#[cfg(test)]
908mod format_count_tests {
909    use super::*;
910    use tempfile::TempDir;
911
912    #[test]
913    fn count_bean_formats_only_yaml() {
914        let dir = TempDir::new().unwrap();
915        let beans_dir = dir.path().join(".beans");
916        fs::create_dir(&beans_dir).unwrap();
917
918        // Create only yaml files
919        let bean1 = crate::bean::Bean::new("1", "Task 1");
920        let bean2 = crate::bean::Bean::new("2", "Task 2");
921        bean1.to_file(beans_dir.join("1.yaml")).unwrap();
922        bean2.to_file(beans_dir.join("2.yaml")).unwrap();
923
924        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
925        assert_eq!(md_count, 0);
926        assert_eq!(yaml_count, 2);
927    }
928
929    #[test]
930    fn count_bean_formats_only_md() {
931        let dir = TempDir::new().unwrap();
932        let beans_dir = dir.path().join(".beans");
933        fs::create_dir(&beans_dir).unwrap();
934
935        // Create only md files
936        let bean1 = crate::bean::Bean::new("1", "Task 1");
937        let bean2 = crate::bean::Bean::new("2", "Task 2");
938        bean1.to_file(beans_dir.join("1-task-1.md")).unwrap();
939        bean2.to_file(beans_dir.join("2-task-2.md")).unwrap();
940
941        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
942        assert_eq!(md_count, 2);
943        assert_eq!(yaml_count, 0);
944    }
945
946    #[test]
947    fn count_bean_formats_mixed() {
948        let dir = TempDir::new().unwrap();
949        let beans_dir = dir.path().join(".beans");
950        fs::create_dir(&beans_dir).unwrap();
951
952        // Create mixed formats
953        let bean1 = crate::bean::Bean::new("1", "Task 1");
954        let bean2 = crate::bean::Bean::new("2", "Task 2");
955        let bean3 = crate::bean::Bean::new("3", "Task 3");
956        bean1.to_file(beans_dir.join("1.yaml")).unwrap();
957        bean2.to_file(beans_dir.join("2-task-2.md")).unwrap();
958        bean3.to_file(beans_dir.join("3-task-3.md")).unwrap();
959
960        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
961        assert_eq!(md_count, 2);
962        assert_eq!(yaml_count, 1);
963    }
964
965    #[test]
966    fn count_bean_formats_excludes_config_files() {
967        let dir = TempDir::new().unwrap();
968        let beans_dir = dir.path().join(".beans");
969        fs::create_dir(&beans_dir).unwrap();
970
971        // Create excluded yaml files (config.yaml, index.yaml)
972        fs::write(beans_dir.join("config.yaml"), "project: test").unwrap();
973        fs::write(beans_dir.join("index.yaml"), "beans: []").unwrap();
974
975        // Create one actual bean
976        let bean1 = crate::bean::Bean::new("1", "Task 1");
977        bean1.to_file(beans_dir.join("1-task-1.md")).unwrap();
978
979        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
980        assert_eq!(md_count, 1);
981        assert_eq!(yaml_count, 0); // config.yaml and index.yaml are excluded
982    }
983
984    #[test]
985    fn count_bean_formats_empty_dir() {
986        let dir = TempDir::new().unwrap();
987        let beans_dir = dir.path().join(".beans");
988        fs::create_dir(&beans_dir).unwrap();
989
990        let (md_count, yaml_count) = count_bean_formats(&beans_dir).unwrap();
991        assert_eq!(md_count, 0);
992        assert_eq!(yaml_count, 0);
993    }
994}
995
996#[cfg(test)]
997mod archive_index_tests {
998    use super::*;
999    use tempfile::TempDir;
1000
1001    fn setup_beans_dir_with_archive() -> (TempDir, std::path::PathBuf) {
1002        let dir = TempDir::new().unwrap();
1003        let beans_dir = dir.path().join(".beans");
1004        fs::create_dir(&beans_dir).unwrap();
1005
1006        let archive_dir = beans_dir.join("archive").join("2026").join("03");
1007        fs::create_dir_all(&archive_dir).unwrap();
1008
1009        let mut bean1 = crate::bean::Bean::new("5", "Archived task five");
1010        bean1.status = crate::bean::Status::Closed;
1011        bean1.is_archived = true;
1012        bean1
1013            .to_file(archive_dir.join("5-archived-task-five.md"))
1014            .unwrap();
1015
1016        let mut bean2 = crate::bean::Bean::new("3", "Archived task three");
1017        bean2.status = crate::bean::Status::Closed;
1018        bean2.is_archived = true;
1019        bean2
1020            .to_file(archive_dir.join("3-archived-task-three.md"))
1021            .unwrap();
1022
1023        (dir, beans_dir)
1024    }
1025
1026    #[test]
1027    fn archive_index_build_from_archive_dir() {
1028        let (_dir, beans_dir) = setup_beans_dir_with_archive();
1029        let archive = ArchiveIndex::build(&beans_dir).unwrap();
1030
1031        assert_eq!(archive.beans.len(), 2);
1032        // Should be sorted by natural ordering: "3" before "5"
1033        assert_eq!(archive.beans[0].id, "3");
1034        assert_eq!(archive.beans[1].id, "5");
1035        assert_eq!(archive.beans[0].status, crate::bean::Status::Closed);
1036        assert_eq!(archive.beans[1].status, crate::bean::Status::Closed);
1037    }
1038
1039    #[test]
1040    fn archive_index_build_empty_when_no_archive_dir() {
1041        let dir = TempDir::new().unwrap();
1042        let beans_dir = dir.path().join(".beans");
1043        fs::create_dir(&beans_dir).unwrap();
1044
1045        let archive = ArchiveIndex::build(&beans_dir).unwrap();
1046        assert!(archive.beans.is_empty());
1047    }
1048
1049    #[test]
1050    fn archive_index_save_load_roundtrip() {
1051        let (_dir, beans_dir) = setup_beans_dir_with_archive();
1052        let original = ArchiveIndex::build(&beans_dir).unwrap();
1053        original.save(&beans_dir).unwrap();
1054
1055        let loaded = ArchiveIndex::load(&beans_dir).unwrap();
1056        assert_eq!(original, loaded);
1057    }
1058
1059    #[test]
1060    fn archive_index_append_deduplicates() {
1061        let (_dir, beans_dir) = setup_beans_dir_with_archive();
1062        let mut archive = ArchiveIndex::build(&beans_dir).unwrap();
1063        assert_eq!(archive.beans.len(), 2);
1064
1065        // Append a new entry
1066        let mut new_bean = crate::bean::Bean::new("7", "New archived");
1067        new_bean.status = crate::bean::Status::Closed;
1068        archive.append(IndexEntry::from(&new_bean));
1069        assert_eq!(archive.beans.len(), 3);
1070
1071        // Append again with same ID — should replace, not duplicate
1072        let mut updated_bean = crate::bean::Bean::new("7", "Updated title");
1073        updated_bean.status = crate::bean::Status::Closed;
1074        archive.append(IndexEntry::from(&updated_bean));
1075        assert_eq!(archive.beans.len(), 3);
1076
1077        let entry = archive.beans.iter().find(|e| e.id == "7").unwrap();
1078        assert_eq!(entry.title, "Updated title");
1079    }
1080
1081    #[test]
1082    fn archive_index_remove() {
1083        let (_dir, beans_dir) = setup_beans_dir_with_archive();
1084        let mut archive = ArchiveIndex::build(&beans_dir).unwrap();
1085        assert_eq!(archive.beans.len(), 2);
1086
1087        archive.remove("3");
1088        assert_eq!(archive.beans.len(), 1);
1089        assert_eq!(archive.beans[0].id, "5");
1090
1091        // Removing non-existent ID is a no-op
1092        archive.remove("999");
1093        assert_eq!(archive.beans.len(), 1);
1094    }
1095
1096    #[test]
1097    fn archive_index_is_stale_when_no_archive_yaml() {
1098        let (_dir, beans_dir) = setup_beans_dir_with_archive();
1099        // Archive dir exists but archive.yaml doesn't
1100        assert!(ArchiveIndex::is_stale(&beans_dir).unwrap());
1101    }
1102
1103    #[test]
1104    fn archive_index_not_stale_when_no_archive_dir() {
1105        let dir = TempDir::new().unwrap();
1106        let beans_dir = dir.path().join(".beans");
1107        fs::create_dir(&beans_dir).unwrap();
1108        // Neither archive dir nor archive.yaml exist
1109        assert!(!ArchiveIndex::is_stale(&beans_dir).unwrap());
1110    }
1111
1112    #[test]
1113    fn archive_index_not_stale_after_build_and_save() {
1114        let (_dir, beans_dir) = setup_beans_dir_with_archive();
1115        let archive = ArchiveIndex::build(&beans_dir).unwrap();
1116        archive.save(&beans_dir).unwrap();
1117        assert!(!ArchiveIndex::is_stale(&beans_dir).unwrap());
1118    }
1119
1120    #[test]
1121    fn archive_index_stale_when_file_newer() {
1122        let (_dir, beans_dir) = setup_beans_dir_with_archive();
1123        let archive = ArchiveIndex::build(&beans_dir).unwrap();
1124        archive.save(&beans_dir).unwrap();
1125
1126        // Wait and add a new file to the archive
1127        std::thread::sleep(std::time::Duration::from_millis(50));
1128        let archive_dir = beans_dir.join("archive").join("2026").join("03");
1129        let mut new_bean = crate::bean::Bean::new("9", "Newer");
1130        new_bean.status = crate::bean::Status::Closed;
1131        new_bean.is_archived = true;
1132        new_bean.to_file(archive_dir.join("9-newer.md")).unwrap();
1133
1134        assert!(ArchiveIndex::is_stale(&beans_dir).unwrap());
1135    }
1136
1137    #[test]
1138    fn archive_index_load_or_rebuild_builds_when_stale() {
1139        let (_dir, beans_dir) = setup_beans_dir_with_archive();
1140        let archive = ArchiveIndex::load_or_rebuild(&beans_dir).unwrap();
1141        assert_eq!(archive.beans.len(), 2);
1142        // Should have created archive.yaml
1143        assert!(beans_dir.join("archive.yaml").exists());
1144    }
1145
1146    #[test]
1147    fn archive_index_load_or_rebuild_returns_empty_when_no_archive() {
1148        let dir = TempDir::new().unwrap();
1149        let beans_dir = dir.path().join(".beans");
1150        fs::create_dir(&beans_dir).unwrap();
1151
1152        let archive = ArchiveIndex::load_or_rebuild(&beans_dir).unwrap();
1153        assert!(archive.beans.is_empty());
1154        // Should NOT create archive.yaml when there's nothing to index
1155        assert!(!beans_dir.join("archive.yaml").exists());
1156    }
1157}