Skip to main content

bn/commands/
sync.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::index::{count_bean_formats, ArchiveIndex, Index};
6
7/// Force rebuild index unconditionally from YAML files
8pub fn cmd_sync(beans_dir: &Path) -> Result<()> {
9    // Check for mixed formats before building
10    let (md_count, yaml_count) = count_bean_formats(beans_dir)?;
11
12    let index = Index::build(beans_dir)?;
13    let count = index.beans.len();
14    index.save(beans_dir)?;
15
16    // Rebuild archive index
17    let archive_index = ArchiveIndex::build(beans_dir)?;
18    let archive_count = archive_index.beans.len();
19    if archive_count > 0 || beans_dir.join("archive.yaml").exists() {
20        archive_index.save(beans_dir)?;
21    }
22
23    println!("Index rebuilt: {} beans indexed.", count);
24    if archive_count > 0 {
25        println!(
26            "Archive index rebuilt: {} archived beans indexed.",
27            archive_count
28        );
29    }
30
31    // Warn about mixed formats
32    if md_count > 0 && yaml_count > 0 {
33        eprintln!();
34        eprintln!("Warning: Mixed bean formats detected!");
35        eprintln!("  {} .md files (current format)", md_count);
36        eprintln!("  {} .yaml files (legacy format)", yaml_count);
37        eprintln!();
38        eprintln!("This can cause confusion. Consider migrating legacy files:");
39        eprintln!("  - Remove or archive .yaml files: mkdir -p .beans/legacy && mv .beans/*.yaml .beans/legacy/");
40        eprintln!("  - Or run 'bn doctor' for more details");
41    }
42
43    Ok(())
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use crate::bean::Bean;
50    use crate::util::title_to_slug;
51    use std::fs;
52    use tempfile::TempDir;
53
54    #[test]
55    fn sync_rebuilds_index() {
56        let dir = TempDir::new().unwrap();
57        let beans_dir = dir.path().join(".beans");
58        fs::create_dir(&beans_dir).unwrap();
59
60        let bean1 = Bean::new("1", "Task one");
61        let bean2 = Bean::new("2", "Task two");
62
63        let slug1 = title_to_slug(&bean1.title);
64        let slug2 = title_to_slug(&bean2.title);
65
66        bean1
67            .to_file(beans_dir.join(format!("1-{}.md", slug1)))
68            .unwrap();
69        bean2
70            .to_file(beans_dir.join(format!("2-{}.md", slug2)))
71            .unwrap();
72
73        // Sync should create index with 2 beans
74        let result = cmd_sync(&beans_dir);
75        assert!(result.is_ok());
76
77        // Verify index was created
78        assert!(beans_dir.join("index.yaml").exists());
79
80        // Verify index contains both beans
81        let index = Index::load(&beans_dir).unwrap();
82        assert_eq!(index.beans.len(), 2);
83    }
84
85    #[test]
86    fn sync_counts_beans() {
87        let dir = TempDir::new().unwrap();
88        let beans_dir = dir.path().join(".beans");
89        fs::create_dir(&beans_dir).unwrap();
90
91        // Create 5 beans
92        for i in 1..=5 {
93            let bean = Bean::new(i.to_string(), format!("Task {}", i));
94            let slug = title_to_slug(&bean.title);
95            bean.to_file(beans_dir.join(format!("{}-{}.md", i, slug)))
96                .unwrap();
97        }
98
99        let result = cmd_sync(&beans_dir);
100        assert!(result.is_ok());
101
102        let index = Index::load(&beans_dir).unwrap();
103        assert_eq!(index.beans.len(), 5);
104    }
105
106    #[test]
107    fn sync_empty_project() {
108        let dir = TempDir::new().unwrap();
109        let beans_dir = dir.path().join(".beans");
110        fs::create_dir(&beans_dir).unwrap();
111
112        let result = cmd_sync(&beans_dir);
113        assert!(result.is_ok());
114
115        let index = Index::load(&beans_dir).unwrap();
116        assert_eq!(index.beans.len(), 0);
117    }
118
119    #[test]
120    fn sync_rebuilds_archive_yaml() {
121        let dir = TempDir::new().unwrap();
122        let beans_dir = dir.path().join(".beans");
123        fs::create_dir(&beans_dir).unwrap();
124
125        // Create archive structure with beans
126        let archive_dir = beans_dir.join("archive").join("2026").join("03");
127        fs::create_dir_all(&archive_dir).unwrap();
128
129        let mut bean1 = Bean::new("10", "Archived ten");
130        bean1.status = crate::bean::Status::Closed;
131        bean1.is_archived = true;
132        let slug1 = title_to_slug(&bean1.title);
133        bean1
134            .to_file(archive_dir.join(format!("10-{}.md", slug1)))
135            .unwrap();
136
137        let mut bean2 = Bean::new("20", "Archived twenty");
138        bean2.status = crate::bean::Status::Closed;
139        bean2.is_archived = true;
140        let slug2 = title_to_slug(&bean2.title);
141        bean2
142            .to_file(archive_dir.join(format!("20-{}.md", slug2)))
143            .unwrap();
144
145        // Sync should rebuild archive.yaml
146        cmd_sync(&beans_dir).unwrap();
147
148        assert!(beans_dir.join("archive.yaml").exists());
149        let archive = ArchiveIndex::load(&beans_dir).unwrap();
150        assert_eq!(archive.beans.len(), 2);
151        let ids: Vec<&str> = archive.beans.iter().map(|e| e.id.as_str()).collect();
152        assert!(ids.contains(&"10"));
153        assert!(ids.contains(&"20"));
154    }
155
156    #[test]
157    fn sync_does_not_create_archive_yaml_when_no_archive() {
158        let dir = TempDir::new().unwrap();
159        let beans_dir = dir.path().join(".beans");
160        fs::create_dir(&beans_dir).unwrap();
161
162        cmd_sync(&beans_dir).unwrap();
163
164        // Should NOT create archive.yaml when there's no archive dir
165        assert!(!beans_dir.join("archive.yaml").exists());
166    }
167}