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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub produces: Vec<String>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub requires: Vec<String>,
39 #[serde(default)]
41 pub has_verify: bool,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub claimed_by: Option<String>,
45 #[serde(default)]
47 pub attempts: u32,
48 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct Index {
81 pub beans: Vec<IndexEntry>,
82}
83
84const EXCLUDED_FILES: &[&str] = &["config.yaml", "index.yaml", "bean.yaml", "archive.yaml"];
86
87fn 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('-'), Some("yaml") => true, _ => false,
99 }
100}
101
102pub 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 pub fn build(beans_dir: &Path) -> Result<Self> {
142 let mut entries = Vec::new();
143 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 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 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 pub fn is_stale(beans_dir: &Path) -> Result<bool> {
197 let index_path = beans_dir.join("index.yaml");
198
199 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 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 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 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 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 Self::walk_archive_dir(&archive_dir, &mut entries)?;
282
283 Ok(entries)
284 }
285
286 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(_)) => {} 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub struct ArchiveIndex {
331 pub beans: Vec<IndexEntry>,
332}
333
334impl ArchiveIndex {
335 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 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 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 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 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 Ok(ArchiveIndex { beans: Vec::new() })
380 }
381 }
382
383 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 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 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 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 pub fn remove(&mut self, id: &str) {
435 self.beans.retain(|e| e.id != id);
436 }
437}
438
439const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
445
446#[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 pub fn acquire(beans_dir: &Path) -> Result<Self> {
474 Self::acquire_with_timeout(beans_dir, LOCK_TIMEOUT)
475 }
476
477 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 pub fn save_and_release(self) -> Result<()> {
496 self.index.save(&self.beans_dir)?;
497 Ok(())
499 }
500
501 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 let _ = fs2::FileExt::unlock(&self.lock_file);
530 }
531}
532
533#[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 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 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 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 #[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 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 #[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 assert_eq!(index.beans.len(), 4);
615
616 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 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 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 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 #[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 let index = Index::build(&beans_dir).unwrap();
717 index.save(&beans_dir).unwrap();
718
719 thread::sleep(Duration::from_millis(50));
721
722 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 let index = Index::build(&beans_dir).unwrap();
735 index.save(&beans_dir).unwrap();
736
737 assert!(!Index::is_stale(&beans_dir).unwrap());
740 }
741
742 #[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 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 let original = Index::build(&beans_dir).unwrap();
761 original.save(&beans_dir).unwrap();
762
763 let loaded = Index::load_or_rebuild(&beans_dir).unwrap();
765 assert_eq!(original, loaded);
766 }
767
768 #[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 #[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 #[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 locked.index.beans[0].title = "Modified".to_string();
804 locked.save_and_release().unwrap();
805
806 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 let _locked = LockedIndex::acquire(&beans_dir).unwrap();
817
818 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 }
837 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 #[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 thread::sleep(Duration::from_millis(50));
862 fs::write(beans_dir.join("notes.txt"), "some notes").unwrap();
863
864 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 let archive_dir = beans_dir.join("archive").join("2026").join("02");
882 fs::create_dir_all(&archive_dir).unwrap();
883
884 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 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 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 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 fs::write(beans_dir.join("config.yaml"), "project: test").unwrap();
973 fs::write(beans_dir.join("index.yaml"), "beans: []").unwrap();
974
975 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); }
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 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 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 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 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 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 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 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 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 assert!(!beans_dir.join("archive.yaml").exists());
1156 }
1157}