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}
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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct Index {
77 pub beans: Vec<IndexEntry>,
78}
79
80const EXCLUDED_FILES: &[&str] = &["config.yaml", "index.yaml", "bean.yaml"];
82
83fn 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('-'), Some("yaml") => true, _ => false,
95 }
96}
97
98pub 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 pub fn build(beans_dir: &Path) -> Result<Self> {
138 let mut entries = Vec::new();
139 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 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 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 pub fn is_stale(beans_dir: &Path) -> Result<bool> {
193 let index_path = beans_dir.join("index.yaml");
194
195 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 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 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 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 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 Self::walk_archive_dir(&archive_dir, &mut entries)?;
278
279 Ok(entries)
280 }
281
282 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(_)) => {} 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
321const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
327
328#[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 pub fn acquire(beans_dir: &Path) -> Result<Self> {
356 Self::acquire_with_timeout(beans_dir, LOCK_TIMEOUT)
357 }
358
359 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 pub fn save_and_release(self) -> Result<()> {
378 self.index.save(&self.beans_dir)?;
379 Ok(())
381 }
382
383 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 let _ = fs2::FileExt::unlock(&self.lock_file);
412 }
413}
414
415#[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 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 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 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 #[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 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 #[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 assert_eq!(index.beans.len(), 4);
497
498 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 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 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 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 #[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 let index = Index::build(&beans_dir).unwrap();
599 index.save(&beans_dir).unwrap();
600
601 thread::sleep(Duration::from_millis(50));
603
604 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 let index = Index::build(&beans_dir).unwrap();
617 index.save(&beans_dir).unwrap();
618
619 assert!(!Index::is_stale(&beans_dir).unwrap());
622 }
623
624 #[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 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 let original = Index::build(&beans_dir).unwrap();
643 original.save(&beans_dir).unwrap();
644
645 let loaded = Index::load_or_rebuild(&beans_dir).unwrap();
647 assert_eq!(original, loaded);
648 }
649
650 #[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 #[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 #[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 locked.index.beans[0].title = "Modified".to_string();
686 locked.save_and_release().unwrap();
687
688 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 let _locked = LockedIndex::acquire(&beans_dir).unwrap();
699
700 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 }
719 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 #[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 thread::sleep(Duration::from_millis(50));
744 fs::write(beans_dir.join("notes.txt"), "some notes").unwrap();
745
746 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 let archive_dir = beans_dir.join("archive").join("2026").join("02");
764 fs::create_dir_all(&archive_dir).unwrap();
765
766 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 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 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 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 fs::write(beans_dir.join("config.yaml"), "project: test").unwrap();
855 fs::write(beans_dir.join("index.yaml"), "beans: []").unwrap();
856
857 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); }
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}