1use std::collections::HashMap;
28use std::fs;
29use std::path::Path;
30use std::time::{Duration, Instant};
31
32use anyhow::{anyhow, Context, Result};
33use chrono::{DateTime, Utc};
34use fs2::FileExt;
35use serde::{Deserialize, Serialize};
36
37use crate::unit::{Status, Unit};
38use crate::util::{atomic_write, natural_cmp};
39
40fn default_created_at() -> DateTime<Utc> {
46 DateTime::UNIX_EPOCH
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct IndexEntry {
57 pub id: String,
58 pub title: String,
59 pub status: Status,
60 pub priority: u8,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub parent: Option<String>,
63 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub dependencies: Vec<String>,
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub labels: Vec<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub assignee: Option<String>,
69 pub updated_at: DateTime<Utc>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub produces: Vec<String>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub requires: Vec<String>,
76 #[serde(default)]
78 pub has_verify: bool,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub verify: Option<String>,
82 #[serde(default = "default_created_at")]
83 pub created_at: DateTime<Utc>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub claimed_by: Option<String>,
87 #[serde(default)]
89 pub attempts: u32,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub paths: Vec<String>,
93 #[serde(default)]
95 pub feature: bool,
96 #[serde(default)]
98 pub has_decisions: bool,
99}
100
101impl From<&Unit> for IndexEntry {
102 fn from(unit: &Unit) -> Self {
103 Self {
104 id: unit.id.clone(),
105 title: unit.title.clone(),
106 status: unit.status,
107 priority: unit.priority,
108 parent: unit.parent.clone(),
109 dependencies: unit.dependencies.clone(),
110 labels: unit.labels.clone(),
111 assignee: unit.assignee.clone(),
112 updated_at: unit.updated_at,
113 produces: unit.produces.clone(),
114 requires: unit.requires.clone(),
115 has_verify: unit.verify.is_some(),
116 verify: unit.verify.clone(),
117 created_at: unit.created_at,
118 claimed_by: unit.claimed_by.clone(),
119 attempts: unit.attempts,
120 paths: unit.paths.clone(),
121 feature: unit.feature,
122 has_decisions: !unit.decisions.is_empty(),
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct Index {
141 pub units: Vec<IndexEntry>,
143}
144
145const EXCLUDED_FILES: &[&str] = &["config.yaml", "index.yaml", "unit.yaml", "archive.yaml"];
147
148fn is_unit_filename(filename: &str) -> bool {
150 if EXCLUDED_FILES.contains(&filename) {
151 return false;
152 }
153 let ext = std::path::Path::new(filename)
154 .extension()
155 .and_then(|e| e.to_str());
156 match ext {
157 Some("md") => filename.contains('-'), Some("yaml") => true, _ => false,
160 }
161}
162
163pub fn count_unit_formats(mana_dir: &Path) -> Result<(usize, usize)> {
166 let mut md_count = 0;
167 let mut yaml_count = 0;
168
169 let dir_entries = fs::read_dir(mana_dir)
170 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
171
172 for entry in dir_entries {
173 let entry = entry?;
174 let path = entry.path();
175
176 let filename = path
177 .file_name()
178 .and_then(|n| n.to_str())
179 .unwrap_or_default();
180
181 if !is_unit_filename(filename) {
182 continue;
183 }
184
185 let ext = path.extension().and_then(|e| e.to_str());
186 match ext {
187 Some("md") => md_count += 1,
188 Some("yaml") => yaml_count += 1,
189 _ => {}
190 }
191 }
192
193 Ok((md_count, yaml_count))
194}
195
196impl Index {
197 pub fn build(mana_dir: &Path) -> Result<Self> {
203 let mut entries = Vec::new();
204 let mut id_to_files: HashMap<String, Vec<String>> = HashMap::new();
206
207 let dir_entries = fs::read_dir(mana_dir)
208 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
209
210 for entry in dir_entries {
211 let entry = entry?;
212 let path = entry.path();
213
214 let filename = path
215 .file_name()
216 .and_then(|n| n.to_str())
217 .unwrap_or_default();
218
219 if !is_unit_filename(filename) {
220 continue;
221 }
222
223 let unit = Unit::from_file(&path)
224 .with_context(|| format!("Failed to parse unit: {}", path.display()))?;
225
226 id_to_files
228 .entry(unit.id.clone())
229 .or_default()
230 .push(filename.to_string());
231
232 entries.push(IndexEntry::from(&unit));
233 }
234
235 let duplicates: Vec<_> = id_to_files
237 .iter()
238 .filter(|(_, files)| files.len() > 1)
239 .collect();
240
241 if !duplicates.is_empty() {
242 let mut msg = String::from("Duplicate unit IDs detected:\n");
243 for (id, files) in duplicates {
244 msg.push_str(&format!(" ID '{}' defined in: {}\n", id, files.join(", ")));
245 }
246 return Err(anyhow!(msg));
247 }
248
249 entries.sort_by(|a, b| natural_cmp(&a.id, &b.id));
250
251 Ok(Index { units: entries })
252 }
253
254 pub fn is_stale(mana_dir: &Path) -> Result<bool> {
258 let index_path = mana_dir.join("index.yaml");
259
260 if !index_path.exists() {
262 return Ok(true);
263 }
264
265 let index_mtime = fs::metadata(&index_path)
266 .with_context(|| "Failed to read index.yaml metadata")?
267 .modified()
268 .with_context(|| "Failed to get index.yaml mtime")?;
269
270 let dir_entries = fs::read_dir(mana_dir)
271 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
272
273 for entry in dir_entries {
274 let entry = entry?;
275 let path = entry.path();
276
277 let filename = path
278 .file_name()
279 .and_then(|n| n.to_str())
280 .unwrap_or_default();
281
282 if !is_unit_filename(filename) {
283 continue;
284 }
285
286 let file_mtime = fs::metadata(&path)
287 .with_context(|| format!("Failed to read metadata: {}", path.display()))?
288 .modified()
289 .with_context(|| format!("Failed to get mtime: {}", path.display()))?;
290
291 if file_mtime > index_mtime {
292 return Ok(true);
293 }
294 }
295
296 Ok(false)
297 }
298
299 pub fn load_or_rebuild(mana_dir: &Path) -> Result<Self> {
302 if Self::is_stale(mana_dir)? {
303 let index = Self::build(mana_dir)?;
304 index.save(mana_dir)?;
305 Ok(index)
306 } else {
307 Self::load(mana_dir)
308 }
309 }
310
311 pub fn load(mana_dir: &Path) -> Result<Self> {
313 let index_path = mana_dir.join("index.yaml");
314 let contents = fs::read_to_string(&index_path)
315 .with_context(|| format!("Failed to read {}", index_path.display()))?;
316 let index: Index =
317 serde_yml::from_str(&contents).with_context(|| "Failed to parse index.yaml")?;
318 Ok(index)
319 }
320
321 pub fn save(&self, mana_dir: &Path) -> Result<()> {
323 let index_path = mana_dir.join("index.yaml");
324 let yaml = serde_yml::to_string(self).with_context(|| "Failed to serialize index")?;
325 atomic_write(&index_path, &yaml)
326 .with_context(|| format!("Failed to write {}", index_path.display()))?;
327 Ok(())
328 }
329
330 pub fn collect_archived(mana_dir: &Path) -> Result<Vec<IndexEntry>> {
334 let mut entries = Vec::new();
335 let archive_dir = mana_dir.join("archive");
336
337 if !archive_dir.is_dir() {
338 return Ok(entries);
339 }
340
341 Self::walk_archive_dir(&archive_dir, &mut entries)?;
343
344 Ok(entries)
345 }
346
347 fn walk_archive_dir(dir: &Path, entries: &mut Vec<IndexEntry>) -> Result<()> {
350 use crate::unit::Unit;
351
352 if !dir.is_dir() {
353 return Ok(());
354 }
355
356 for entry in fs::read_dir(dir)? {
357 let entry = entry?;
358 let path = entry.path();
359
360 if path.is_dir() {
361 Self::walk_archive_dir(&path, entries)?;
362 } else if path.is_file() {
363 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
364 if is_unit_filename(filename) {
365 let path_clone = path.clone();
366 let result = std::panic::catch_unwind(|| Unit::from_file(&path_clone));
367 match result {
368 Ok(Ok(unit)) => entries.push(IndexEntry::from(&unit)),
369 Ok(Err(_)) => {} Err(_) => {
371 eprintln!(
372 "warning: skipping corrupt archive file (parser panic): {}",
373 path.display()
374 );
375 }
376 }
377 }
378 }
379 }
380 }
381
382 Ok(())
383 }
384}
385
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391pub struct ArchiveIndex {
392 pub units: Vec<IndexEntry>,
393}
394
395impl ArchiveIndex {
396 pub fn build(mana_dir: &Path) -> Result<Self> {
400 let mut entries = Index::collect_archived(mana_dir)?;
401 entries.sort_by(|a, b| natural_cmp(&a.id, &b.id));
402 Ok(ArchiveIndex { units: entries })
403 }
404
405 pub fn load(mana_dir: &Path) -> Result<Self> {
407 let path = mana_dir.join("archive.yaml");
408 let contents = fs::read_to_string(&path)
409 .with_context(|| format!("Failed to read {}", path.display()))?;
410 let index: ArchiveIndex =
411 serde_yml::from_str(&contents).with_context(|| "Failed to parse archive.yaml")?;
412 Ok(index)
413 }
414
415 pub fn save(&self, mana_dir: &Path) -> Result<()> {
417 let path = mana_dir.join("archive.yaml");
418 let yaml =
419 serde_yml::to_string(self).with_context(|| "Failed to serialize archive index")?;
420 atomic_write(&path, &yaml)
421 .with_context(|| format!("Failed to write {}", path.display()))?;
422 Ok(())
423 }
424
425 pub fn load_or_rebuild(mana_dir: &Path) -> Result<Self> {
427 let archive_yaml = mana_dir.join("archive.yaml");
428 if Self::is_stale(mana_dir)? {
429 let index = Self::build(mana_dir)?;
430 if !index.units.is_empty() || archive_yaml.exists() {
433 index.save(mana_dir)?;
434 }
435 Ok(index)
436 } else if archive_yaml.exists() {
437 Self::load(mana_dir)
438 } else {
439 Ok(ArchiveIndex { units: Vec::new() })
441 }
442 }
443
444 pub fn is_stale(mana_dir: &Path) -> Result<bool> {
448 let archive_yaml = mana_dir.join("archive.yaml");
449 let archive_dir = mana_dir.join("archive");
450
451 if !archive_yaml.exists() {
452 return Ok(archive_dir.is_dir());
454 }
455
456 if !archive_dir.is_dir() {
457 return Ok(false);
458 }
459
460 let index_mtime = fs::metadata(&archive_yaml)
461 .with_context(|| "Failed to read archive.yaml metadata")?
462 .modified()
463 .with_context(|| "Failed to get archive.yaml mtime")?;
464
465 Self::any_file_newer(&archive_dir, index_mtime)
466 }
467
468 fn any_file_newer(dir: &Path, reference: std::time::SystemTime) -> Result<bool> {
470 for entry in fs::read_dir(dir)? {
471 let entry = entry?;
472 let path = entry.path();
473 if path.is_dir() {
474 if Self::any_file_newer(&path, reference)? {
475 return Ok(true);
476 }
477 } else if path.is_file() {
478 let mtime = fs::metadata(&path)?.modified()?;
479 if mtime > reference {
480 return Ok(true);
481 }
482 }
483 }
484 Ok(false)
485 }
486
487 pub fn append(&mut self, entry: IndexEntry) {
489 self.units.retain(|e| e.id != entry.id);
490 self.units.push(entry);
491 self.units.sort_by(|a, b| natural_cmp(&a.id, &b.id));
492 }
493
494 pub fn remove(&mut self, id: &str) {
496 self.units.retain(|e| e.id != id);
497 }
498}
499
500const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
506
507#[derive(Debug)]
525pub struct LockedIndex {
526 pub index: Index,
527 lock_file: fs::File,
528 mana_dir: std::path::PathBuf,
529}
530
531impl LockedIndex {
532 pub fn acquire(mana_dir: &Path) -> Result<Self> {
535 Self::acquire_with_timeout(mana_dir, LOCK_TIMEOUT)
536 }
537
538 pub fn acquire_with_timeout(mana_dir: &Path, timeout: Duration) -> Result<Self> {
540 let lock_path = mana_dir.join("index.lock");
541 let lock_file = fs::File::create(&lock_path)
542 .with_context(|| format!("Failed to create lock file: {}", lock_path.display()))?;
543
544 Self::flock_with_timeout(&lock_file, timeout)?;
545
546 let index = Index::load_or_rebuild(mana_dir)?;
547
548 Ok(Self {
549 index,
550 lock_file,
551 mana_dir: mana_dir.to_path_buf(),
552 })
553 }
554
555 pub fn save_and_release(self) -> Result<()> {
557 self.index.save(&self.mana_dir)?;
558 Ok(())
560 }
561
562 fn flock_with_timeout(file: &fs::File, timeout: Duration) -> Result<()> {
564 let start = Instant::now();
565 loop {
566 match file.try_lock_exclusive() {
567 Ok(()) => return Ok(()),
568 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
569 if start.elapsed() >= timeout {
570 return Err(anyhow!(
571 "Timed out after {}s waiting for .mana/index.lock — \
572 another mana process may be running. \
573 If no other process is active, delete .mana/index.lock and retry.",
574 timeout.as_secs()
575 ));
576 }
577 std::thread::sleep(Duration::from_millis(50));
578 }
579 Err(e) => {
580 return Err(anyhow!("Failed to acquire index lock: {}", e));
581 }
582 }
583 }
584 }
585}
586
587impl Drop for LockedIndex {
588 fn drop(&mut self) {
589 let _ = fs2::FileExt::unlock(&self.lock_file);
591 }
592}
593
594#[cfg(test)]
599mod tests {
600 use super::*;
601 use std::cmp::Ordering;
602 use std::fs;
603 use std::thread;
604 use std::time::Duration;
605 use tempfile::TempDir;
606
607 fn setup_mana_dir() -> (TempDir, std::path::PathBuf) {
609 let dir = TempDir::new().unwrap();
610 let mana_dir = dir.path().join(".mana");
611 fs::create_dir(&mana_dir).unwrap();
612
613 let unit1 = Unit::new("1", "First task");
615 let unit2 = Unit::new("2", "Second task");
616 let unit10 = Unit::new("10", "Tenth task");
617 let mut unit3_1 = Unit::new("3.1", "Subtask");
618 unit3_1.parent = Some("3".to_string());
619 unit3_1.labels = vec!["backend".to_string()];
620 unit3_1.dependencies = vec!["1".to_string()];
621
622 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
623 unit2.to_file(mana_dir.join("2.yaml")).unwrap();
624 unit10.to_file(mana_dir.join("10.yaml")).unwrap();
625 unit3_1.to_file(mana_dir.join("3.1.yaml")).unwrap();
626
627 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 11\n").unwrap();
629
630 (dir, mana_dir)
631 }
632
633 #[test]
636 fn natural_sort_basic() {
637 assert_eq!(natural_cmp("1", "2"), Ordering::Less);
638 assert_eq!(natural_cmp("2", "1"), Ordering::Greater);
639 assert_eq!(natural_cmp("1", "1"), Ordering::Equal);
640 }
641
642 #[test]
643 fn natural_sort_numeric_not_lexicographic() {
644 assert_eq!(natural_cmp("2", "10"), Ordering::Less);
646 assert_eq!(natural_cmp("10", "2"), Ordering::Greater);
647 }
648
649 #[test]
650 fn natural_sort_dotted_ids() {
651 assert_eq!(natural_cmp("3", "3.1"), Ordering::Less);
652 assert_eq!(natural_cmp("3.1", "3.2"), Ordering::Less);
653 assert_eq!(natural_cmp("3.2", "10"), Ordering::Less);
654 }
655
656 #[test]
657 fn natural_sort_full_sequence() {
658 let mut ids = vec!["10", "3.2", "1", "3", "3.1", "2"];
659 ids.sort_by(|a, b| natural_cmp(a, b));
660 assert_eq!(ids, vec!["1", "2", "3", "3.1", "3.2", "10"]);
661 }
662
663 #[test]
666 fn build_reads_all_units_and_excludes_config() {
667 let (_dir, mana_dir) = setup_mana_dir();
668 let index = Index::build(&mana_dir).unwrap();
669
670 assert_eq!(index.units.len(), 4);
672
673 let ids: Vec<&str> = index.units.iter().map(|e| e.id.as_str()).collect();
675 assert_eq!(ids, vec!["1", "2", "3.1", "10"]);
676 }
677
678 #[test]
679 fn build_extracts_fields_correctly() {
680 let (_dir, mana_dir) = setup_mana_dir();
681 let index = Index::build(&mana_dir).unwrap();
682
683 let entry = index.units.iter().find(|e| e.id == "3.1").unwrap();
684 assert_eq!(entry.title, "Subtask");
685 assert_eq!(entry.status, Status::Open);
686 assert_eq!(entry.priority, 2);
687 assert_eq!(entry.parent, Some("3".to_string()));
688 assert_eq!(entry.dependencies, vec!["1".to_string()]);
689 assert_eq!(entry.labels, vec!["backend".to_string()]);
690 }
691
692 #[test]
693 fn build_excludes_index_and_unit_yaml() {
694 let (_dir, mana_dir) = setup_mana_dir();
695
696 fs::write(mana_dir.join("index.yaml"), "units: []\n").unwrap();
698 fs::write(
699 mana_dir.join("unit.yaml"),
700 "id: template\ntitle: Template\n",
701 )
702 .unwrap();
703
704 let index = Index::build(&mana_dir).unwrap();
705 assert_eq!(index.units.len(), 4);
706 assert!(!index.units.iter().any(|e| e.id == "template"));
707 }
708
709 #[test]
710 fn build_detects_duplicate_ids() {
711 let dir = TempDir::new().unwrap();
712 let mana_dir = dir.path().join(".mana");
713 fs::create_dir(&mana_dir).unwrap();
714
715 let unit_a = Unit::new("99", "Unit A");
717 let unit_b = Unit::new("99", "Unit B");
718
719 unit_a.to_file(mana_dir.join("99-a.md")).unwrap();
720 unit_b.to_file(mana_dir.join("99-b.md")).unwrap();
721
722 let result = Index::build(&mana_dir);
723 assert!(result.is_err());
724
725 let err = result.unwrap_err().to_string();
726 assert!(err.contains("Duplicate unit IDs detected"));
727 assert!(err.contains("99"));
728 assert!(err.contains("99-a.md"));
729 assert!(err.contains("99-b.md"));
730 }
731
732 #[test]
733 fn build_detects_multiple_duplicate_ids() {
734 let dir = TempDir::new().unwrap();
735 let mana_dir = dir.path().join(".mana");
736 fs::create_dir(&mana_dir).unwrap();
737
738 Unit::new("1", "First A")
740 .to_file(mana_dir.join("1-a.md"))
741 .unwrap();
742 Unit::new("1", "First B")
743 .to_file(mana_dir.join("1-b.md"))
744 .unwrap();
745 Unit::new("2", "Second A")
746 .to_file(mana_dir.join("2-a.md"))
747 .unwrap();
748 Unit::new("2", "Second B")
749 .to_file(mana_dir.join("2-b.md"))
750 .unwrap();
751
752 let result = Index::build(&mana_dir);
753 assert!(result.is_err());
754
755 let err = result.unwrap_err().to_string();
756 assert!(err.contains("ID '1'"));
757 assert!(err.contains("ID '2'"));
758 }
759
760 #[test]
763 fn is_stale_when_index_missing() {
764 let (_dir, mana_dir) = setup_mana_dir();
765 assert!(Index::is_stale(&mana_dir).unwrap());
766 }
767
768 #[test]
769 fn is_stale_when_yaml_newer_than_index() {
770 let (_dir, mana_dir) = setup_mana_dir();
771
772 let index = Index::build(&mana_dir).unwrap();
774 index.save(&mana_dir).unwrap();
775
776 thread::sleep(Duration::from_millis(50));
778
779 let unit = Unit::new("1", "Modified first task");
781 unit.to_file(mana_dir.join("1.yaml")).unwrap();
782
783 assert!(Index::is_stale(&mana_dir).unwrap());
784 }
785
786 #[test]
787 fn not_stale_when_index_is_fresh() {
788 let (_dir, mana_dir) = setup_mana_dir();
789
790 let index = Index::build(&mana_dir).unwrap();
792 index.save(&mana_dir).unwrap();
793
794 assert!(!Index::is_stale(&mana_dir).unwrap());
797 }
798
799 #[test]
802 fn load_or_rebuild_builds_when_no_index() {
803 let (_dir, mana_dir) = setup_mana_dir();
804
805 let index = Index::load_or_rebuild(&mana_dir).unwrap();
806 assert_eq!(index.units.len(), 4);
807
808 assert!(mana_dir.join("index.yaml").exists());
810 }
811
812 #[test]
813 fn load_or_rebuild_loads_when_fresh() {
814 let (_dir, mana_dir) = setup_mana_dir();
815
816 let original = Index::build(&mana_dir).unwrap();
818 original.save(&mana_dir).unwrap();
819
820 let loaded = Index::load_or_rebuild(&mana_dir).unwrap();
822 assert_eq!(original, loaded);
823 }
824
825 #[test]
828 fn save_and_load_round_trip() {
829 let (_dir, mana_dir) = setup_mana_dir();
830
831 let index = Index::build(&mana_dir).unwrap();
832 index.save(&mana_dir).unwrap();
833
834 let loaded = Index::load(&mana_dir).unwrap();
835 assert_eq!(index, loaded);
836 }
837
838 #[test]
841 fn build_empty_directory() {
842 let dir = TempDir::new().unwrap();
843 let mana_dir = dir.path().join(".mana");
844 fs::create_dir(&mana_dir).unwrap();
845
846 let index = Index::build(&mana_dir).unwrap();
847 assert!(index.units.is_empty());
848 }
849
850 #[test]
853 fn locked_index_acquire_and_save() {
854 let (_dir, mana_dir) = setup_mana_dir();
855
856 let mut locked = LockedIndex::acquire(&mana_dir).unwrap();
857 assert_eq!(locked.index.units.len(), 4);
858
859 locked.index.units[0].title = "Modified".to_string();
861 locked.save_and_release().unwrap();
862
863 let index = Index::load(&mana_dir).unwrap();
865 assert_eq!(index.units[0].title, "Modified");
866 }
867
868 #[test]
869 fn locked_index_blocks_concurrent_access() {
870 let (_dir, mana_dir) = setup_mana_dir();
871
872 let _locked = LockedIndex::acquire(&mana_dir).unwrap();
874
875 let result = LockedIndex::acquire_with_timeout(&mana_dir, Duration::from_millis(200));
877 assert!(result.is_err());
878 let err = result.unwrap_err().to_string();
879 assert!(
880 err.contains("Timed out"),
881 "Expected timeout error, got: {}",
882 err
883 );
884 }
885
886 #[test]
887 fn locked_index_released_on_drop() {
888 let (_dir, mana_dir) = setup_mana_dir();
889
890 {
891 let _locked = LockedIndex::acquire(&mana_dir).unwrap();
892 }
894 let _locked = LockedIndex::acquire(&mana_dir).unwrap();
898 }
899
900 #[test]
901 fn locked_index_creates_lock_file() {
902 let (_dir, mana_dir) = setup_mana_dir();
903
904 let _locked = LockedIndex::acquire(&mana_dir).unwrap();
905 assert!(mana_dir.join("index.lock").exists());
906 }
907
908 #[test]
911 fn is_stale_ignores_non_yaml() {
912 let (_dir, mana_dir) = setup_mana_dir();
913
914 let index = Index::build(&mana_dir).unwrap();
915 index.save(&mana_dir).unwrap();
916
917 thread::sleep(Duration::from_millis(50));
919 fs::write(mana_dir.join("notes.txt"), "some notes").unwrap();
920
921 assert!(!Index::is_stale(&mana_dir).unwrap());
923 }
924}
925
926#[cfg(test)]
927mod archive_tests {
928 use super::*;
929 use tempfile::TempDir;
930
931 #[test]
932 fn collect_archived_finds_units() {
933 let dir = TempDir::new().unwrap();
934 let mana_dir = dir.path().join(".mana");
935 fs::create_dir(&mana_dir).unwrap();
936
937 let archive_dir = mana_dir.join("archive").join("2026").join("02");
939 fs::create_dir_all(&archive_dir).unwrap();
940
941 let mut unit = crate::unit::Unit::new("1", "Archived task");
943 unit.status = crate::unit::Status::Closed;
944 unit.to_file(archive_dir.join("1-archived-task.md"))
945 .unwrap();
946
947 let archived = Index::collect_archived(&mana_dir).unwrap();
948 assert_eq!(archived.len(), 1);
949 assert_eq!(archived[0].id, "1");
950 assert_eq!(archived[0].status, crate::unit::Status::Closed);
951 }
952
953 #[test]
954 fn collect_archived_empty_when_no_archive() {
955 let dir = TempDir::new().unwrap();
956 let mana_dir = dir.path().join(".mana");
957 fs::create_dir(&mana_dir).unwrap();
958
959 let archived = Index::collect_archived(&mana_dir).unwrap();
960 assert!(archived.is_empty());
961 }
962}
963
964#[cfg(test)]
965mod format_count_tests {
966 use super::*;
967 use tempfile::TempDir;
968
969 #[test]
970 fn count_unit_formats_only_yaml() {
971 let dir = TempDir::new().unwrap();
972 let mana_dir = dir.path().join(".mana");
973 fs::create_dir(&mana_dir).unwrap();
974
975 let unit1 = crate::unit::Unit::new("1", "Task 1");
977 let unit2 = crate::unit::Unit::new("2", "Task 2");
978 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
979 unit2.to_file(mana_dir.join("2.yaml")).unwrap();
980
981 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
982 assert_eq!(md_count, 0);
983 assert_eq!(yaml_count, 2);
984 }
985
986 #[test]
987 fn count_unit_formats_only_md() {
988 let dir = TempDir::new().unwrap();
989 let mana_dir = dir.path().join(".mana");
990 fs::create_dir(&mana_dir).unwrap();
991
992 let unit1 = crate::unit::Unit::new("1", "Task 1");
994 let unit2 = crate::unit::Unit::new("2", "Task 2");
995 unit1.to_file(mana_dir.join("1-task-1.md")).unwrap();
996 unit2.to_file(mana_dir.join("2-task-2.md")).unwrap();
997
998 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
999 assert_eq!(md_count, 2);
1000 assert_eq!(yaml_count, 0);
1001 }
1002
1003 #[test]
1004 fn count_unit_formats_mixed() {
1005 let dir = TempDir::new().unwrap();
1006 let mana_dir = dir.path().join(".mana");
1007 fs::create_dir(&mana_dir).unwrap();
1008
1009 let unit1 = crate::unit::Unit::new("1", "Task 1");
1011 let unit2 = crate::unit::Unit::new("2", "Task 2");
1012 let unit3 = crate::unit::Unit::new("3", "Task 3");
1013 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
1014 unit2.to_file(mana_dir.join("2-task-2.md")).unwrap();
1015 unit3.to_file(mana_dir.join("3-task-3.md")).unwrap();
1016
1017 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
1018 assert_eq!(md_count, 2);
1019 assert_eq!(yaml_count, 1);
1020 }
1021
1022 #[test]
1023 fn count_unit_formats_excludes_config_files() {
1024 let dir = TempDir::new().unwrap();
1025 let mana_dir = dir.path().join(".mana");
1026 fs::create_dir(&mana_dir).unwrap();
1027
1028 fs::write(mana_dir.join("config.yaml"), "project: test").unwrap();
1030 fs::write(mana_dir.join("index.yaml"), "units: []").unwrap();
1031
1032 let unit1 = crate::unit::Unit::new("1", "Task 1");
1034 unit1.to_file(mana_dir.join("1-task-1.md")).unwrap();
1035
1036 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
1037 assert_eq!(md_count, 1);
1038 assert_eq!(yaml_count, 0); }
1040
1041 #[test]
1042 fn count_unit_formats_empty_dir() {
1043 let dir = TempDir::new().unwrap();
1044 let mana_dir = dir.path().join(".mana");
1045 fs::create_dir(&mana_dir).unwrap();
1046
1047 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
1048 assert_eq!(md_count, 0);
1049 assert_eq!(yaml_count, 0);
1050 }
1051}
1052
1053#[cfg(test)]
1054mod archive_index_tests {
1055 use super::*;
1056 use tempfile::TempDir;
1057
1058 fn setup_mana_dir_with_archive() -> (TempDir, std::path::PathBuf) {
1059 let dir = TempDir::new().unwrap();
1060 let mana_dir = dir.path().join(".mana");
1061 fs::create_dir(&mana_dir).unwrap();
1062
1063 let archive_dir = mana_dir.join("archive").join("2026").join("03");
1064 fs::create_dir_all(&archive_dir).unwrap();
1065
1066 let mut unit1 = crate::unit::Unit::new("5", "Archived task five");
1067 unit1.status = crate::unit::Status::Closed;
1068 unit1.is_archived = true;
1069 unit1
1070 .to_file(archive_dir.join("5-archived-task-five.md"))
1071 .unwrap();
1072
1073 let mut unit2 = crate::unit::Unit::new("3", "Archived task three");
1074 unit2.status = crate::unit::Status::Closed;
1075 unit2.is_archived = true;
1076 unit2
1077 .to_file(archive_dir.join("3-archived-task-three.md"))
1078 .unwrap();
1079
1080 (dir, mana_dir)
1081 }
1082
1083 #[test]
1084 fn archive_index_build_from_archive_dir() {
1085 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1086 let archive = ArchiveIndex::build(&mana_dir).unwrap();
1087
1088 assert_eq!(archive.units.len(), 2);
1089 assert_eq!(archive.units[0].id, "3");
1091 assert_eq!(archive.units[1].id, "5");
1092 assert_eq!(archive.units[0].status, crate::unit::Status::Closed);
1093 assert_eq!(archive.units[1].status, crate::unit::Status::Closed);
1094 }
1095
1096 #[test]
1097 fn archive_index_build_empty_when_no_archive_dir() {
1098 let dir = TempDir::new().unwrap();
1099 let mana_dir = dir.path().join(".mana");
1100 fs::create_dir(&mana_dir).unwrap();
1101
1102 let archive = ArchiveIndex::build(&mana_dir).unwrap();
1103 assert!(archive.units.is_empty());
1104 }
1105
1106 #[test]
1107 fn archive_index_save_load_roundtrip() {
1108 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1109 let original = ArchiveIndex::build(&mana_dir).unwrap();
1110 original.save(&mana_dir).unwrap();
1111
1112 let loaded = ArchiveIndex::load(&mana_dir).unwrap();
1113 assert_eq!(original, loaded);
1114 }
1115
1116 #[test]
1117 fn archive_index_append_deduplicates() {
1118 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1119 let mut archive = ArchiveIndex::build(&mana_dir).unwrap();
1120 assert_eq!(archive.units.len(), 2);
1121
1122 let mut new_unit = crate::unit::Unit::new("7", "New archived");
1124 new_unit.status = crate::unit::Status::Closed;
1125 archive.append(IndexEntry::from(&new_unit));
1126 assert_eq!(archive.units.len(), 3);
1127
1128 let mut updated_unit = crate::unit::Unit::new("7", "Updated title");
1130 updated_unit.status = crate::unit::Status::Closed;
1131 archive.append(IndexEntry::from(&updated_unit));
1132 assert_eq!(archive.units.len(), 3);
1133
1134 let entry = archive.units.iter().find(|e| e.id == "7").unwrap();
1135 assert_eq!(entry.title, "Updated title");
1136 }
1137
1138 #[test]
1139 fn archive_index_remove() {
1140 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1141 let mut archive = ArchiveIndex::build(&mana_dir).unwrap();
1142 assert_eq!(archive.units.len(), 2);
1143
1144 archive.remove("3");
1145 assert_eq!(archive.units.len(), 1);
1146 assert_eq!(archive.units[0].id, "5");
1147
1148 archive.remove("999");
1150 assert_eq!(archive.units.len(), 1);
1151 }
1152
1153 #[test]
1154 fn archive_index_is_stale_when_no_archive_yaml() {
1155 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1156 assert!(ArchiveIndex::is_stale(&mana_dir).unwrap());
1158 }
1159
1160 #[test]
1161 fn archive_index_not_stale_when_no_archive_dir() {
1162 let dir = TempDir::new().unwrap();
1163 let mana_dir = dir.path().join(".mana");
1164 fs::create_dir(&mana_dir).unwrap();
1165 assert!(!ArchiveIndex::is_stale(&mana_dir).unwrap());
1167 }
1168
1169 #[test]
1170 fn archive_index_not_stale_after_build_and_save() {
1171 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1172 let archive = ArchiveIndex::build(&mana_dir).unwrap();
1173 archive.save(&mana_dir).unwrap();
1174 assert!(!ArchiveIndex::is_stale(&mana_dir).unwrap());
1175 }
1176
1177 #[test]
1178 fn archive_index_stale_when_file_newer() {
1179 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1180 let archive = ArchiveIndex::build(&mana_dir).unwrap();
1181 archive.save(&mana_dir).unwrap();
1182
1183 std::thread::sleep(std::time::Duration::from_millis(50));
1185 let archive_dir = mana_dir.join("archive").join("2026").join("03");
1186 let mut new_unit = crate::unit::Unit::new("9", "Newer");
1187 new_unit.status = crate::unit::Status::Closed;
1188 new_unit.is_archived = true;
1189 new_unit.to_file(archive_dir.join("9-newer.md")).unwrap();
1190
1191 assert!(ArchiveIndex::is_stale(&mana_dir).unwrap());
1192 }
1193
1194 #[test]
1195 fn archive_index_load_or_rebuild_builds_when_stale() {
1196 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1197 let archive = ArchiveIndex::load_or_rebuild(&mana_dir).unwrap();
1198 assert_eq!(archive.units.len(), 2);
1199 assert!(mana_dir.join("archive.yaml").exists());
1201 }
1202
1203 #[test]
1204 fn archive_index_load_or_rebuild_returns_empty_when_no_archive() {
1205 let dir = TempDir::new().unwrap();
1206 let mana_dir = dir.path().join(".mana");
1207 fs::create_dir(&mana_dir).unwrap();
1208
1209 let archive = ArchiveIndex::load_or_rebuild(&mana_dir).unwrap();
1210 assert!(archive.units.is_empty());
1211 assert!(!mana_dir.join("archive.yaml").exists());
1213 }
1214}