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::sqlite;
38use crate::unit::{Status, Unit, UnitType};
39use crate::util::{atomic_write, natural_cmp};
40use crate::yaml;
41
42fn default_created_at() -> DateTime<Utc> {
48 DateTime::UNIX_EPOCH
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct IndexEntry {
59 pub id: String,
60 pub title: String,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub handle: Option<String>,
63 pub status: Status,
64 pub priority: u8,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub parent: Option<String>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub dependencies: Vec<String>,
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub labels: Vec<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub assignee: Option<String>,
73 pub updated_at: DateTime<Utc>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub produces: Vec<String>,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub requires: Vec<String>,
80 #[serde(default)]
82 pub has_verify: bool,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub verify: Option<String>,
86 #[serde(default = "default_created_at")]
87 pub created_at: DateTime<Utc>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub claimed_by: Option<String>,
91 #[serde(default)]
93 pub attempts: u32,
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub paths: Vec<String>,
97 pub kind: UnitType,
99 #[serde(default)]
101 pub feature: bool,
102 #[serde(default)]
104 pub has_decisions: bool,
105}
106
107impl From<&Unit> for IndexEntry {
108 fn from(unit: &Unit) -> Self {
109 Self {
110 id: unit.id.clone(),
111 title: unit.title.clone(),
112 handle: unit.handle.clone(),
113 status: unit.status,
114 priority: unit.priority,
115 parent: unit.parent.clone(),
116 dependencies: unit.dependencies.clone(),
117 labels: unit.labels.clone(),
118 assignee: unit.assignee.clone(),
119 updated_at: unit.updated_at,
120 produces: unit.produces.clone(),
121 requires: unit.requires.clone(),
122 has_verify: unit.verify.is_some(),
123 verify: unit.verify.clone(),
124 created_at: unit.created_at,
125 claimed_by: unit.claimed_by.clone(),
126 attempts: unit.attempts,
127 paths: unit.paths.clone(),
128 kind: unit.kind,
129 feature: unit.feature,
130 has_decisions: !unit.decisions.is_empty(),
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct Index {
149 pub units: Vec<IndexEntry>,
151}
152
153const EXCLUDED_FILES: &[&str] = &["config.yaml", "index.yaml", "unit.yaml", "archive.yaml"];
155
156fn is_unit_filename(filename: &str) -> bool {
158 if EXCLUDED_FILES.contains(&filename) {
159 return false;
160 }
161 let ext = std::path::Path::new(filename)
162 .extension()
163 .and_then(|e| e.to_str());
164 match ext {
165 Some("md") => filename.contains('-'), Some("yaml") => true, _ => false,
168 }
169}
170
171pub fn count_unit_formats(mana_dir: &Path) -> Result<(usize, usize)> {
174 let mut md_count = 0;
175 let mut yaml_count = 0;
176
177 let dir_entries = fs::read_dir(mana_dir)
178 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
179
180 for entry in dir_entries {
181 let entry = entry?;
182 let path = entry.path();
183
184 let filename = path
185 .file_name()
186 .and_then(|n| n.to_str())
187 .unwrap_or_default();
188
189 if !is_unit_filename(filename) {
190 continue;
191 }
192
193 let ext = path.extension().and_then(|e| e.to_str());
194 match ext {
195 Some("md") => md_count += 1,
196 Some("yaml") => yaml_count += 1,
197 _ => {}
198 }
199 }
200
201 Ok((md_count, yaml_count))
202}
203
204impl Index {
205 pub fn build(mana_dir: &Path) -> Result<Self> {
211 let mut entries = Vec::new();
212 let mut id_to_files: HashMap<String, Vec<String>> = HashMap::new();
214
215 let dir_entries = fs::read_dir(mana_dir)
216 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
217
218 for entry in dir_entries {
219 let entry = entry?;
220 let path = entry.path();
221
222 let filename = path
223 .file_name()
224 .and_then(|n| n.to_str())
225 .unwrap_or_default();
226
227 if !is_unit_filename(filename) {
228 continue;
229 }
230
231 let unit = Unit::from_file(&path)
232 .with_context(|| format!("Failed to parse unit: {}", path.display()))?;
233
234 id_to_files
236 .entry(unit.id.clone())
237 .or_default()
238 .push(filename.to_string());
239
240 entries.push(IndexEntry::from(&unit));
241 }
242
243 let duplicates: Vec<_> = id_to_files
245 .iter()
246 .filter(|(_, files)| files.len() > 1)
247 .collect();
248
249 if !duplicates.is_empty() {
250 let mut msg = String::from("Duplicate unit IDs detected:\n");
251 for (id, files) in duplicates {
252 msg.push_str(&format!(" ID '{}' defined in: {}\n", id, files.join(", ")));
253 }
254 return Err(anyhow!(msg));
255 }
256
257 entries.sort_by(|a, b| natural_cmp(&a.id, &b.id));
258
259 Ok(Index { units: entries })
260 }
261
262 pub fn is_stale(mana_dir: &Path) -> Result<bool> {
266 let index_path = mana_dir.join("index.yaml");
267
268 if !index_path.exists() {
270 return Ok(true);
271 }
272
273 let index_mtime = fs::metadata(&index_path)
274 .with_context(|| "Failed to read index.yaml metadata")?
275 .modified()
276 .with_context(|| "Failed to get index.yaml mtime")?;
277
278 let dir_entries = fs::read_dir(mana_dir)
279 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
280
281 for entry in dir_entries {
282 let entry = entry?;
283 let path = entry.path();
284
285 let filename = path
286 .file_name()
287 .and_then(|n| n.to_str())
288 .unwrap_or_default();
289
290 if !is_unit_filename(filename) {
291 continue;
292 }
293
294 let file_mtime = fs::metadata(&path)
295 .with_context(|| format!("Failed to read metadata: {}", path.display()))?
296 .modified()
297 .with_context(|| format!("Failed to get mtime: {}", path.display()))?;
298
299 if file_mtime > index_mtime {
300 return Ok(true);
301 }
302 }
303
304 Ok(false)
305 }
306
307 pub fn load_or_rebuild(mana_dir: &Path) -> Result<Self> {
310 if Self::is_stale(mana_dir)? {
311 let index = Self::build(mana_dir)?;
312 index.save(mana_dir)?;
313 Ok(index)
314 } else {
315 match Self::load(mana_dir) {
316 Ok(index) => Ok(index),
317 Err(_) => {
318 let index = Self::build(mana_dir)?;
319 index.save(mana_dir)?;
320 Ok(index)
321 }
322 }
323 }
324 }
325
326 pub fn load(mana_dir: &Path) -> Result<Self> {
328 let index_path = mana_dir.join("index.yaml");
329 let contents = fs::read_to_string(&index_path)
330 .with_context(|| format!("Failed to read {}", index_path.display()))?;
331 let index: Index =
332 yaml::from_str(&contents).with_context(|| "Failed to parse index.yaml")?;
333 Ok(index)
334 }
335
336 pub fn save(&self, mana_dir: &Path) -> Result<()> {
338 let index_path = mana_dir.join("index.yaml");
339 let yaml = serde_yml::to_string(self).with_context(|| "Failed to serialize index")?;
340 atomic_write(&index_path, &yaml)
341 .with_context(|| format!("Failed to write {}", index_path.display()))?;
342 if let Err(error) = sqlite::Index::rebuild(mana_dir) {
343 let _ = sqlite::Index::open(mana_dir).and_then(|index| {
344 index.mark_stale(&format!("index.yaml save hook failed: {error}"))
345 });
346 }
347 Ok(())
348 }
349
350 pub fn collect_archived(mana_dir: &Path) -> Result<Vec<IndexEntry>> {
354 let mut entries = Vec::new();
355 let archive_dir = mana_dir.join("archive");
356
357 if !archive_dir.is_dir() {
358 return Ok(entries);
359 }
360
361 Self::walk_archive_dir(&archive_dir, &mut entries)?;
363
364 Ok(entries)
365 }
366
367 fn walk_archive_dir(dir: &Path, entries: &mut Vec<IndexEntry>) -> Result<()> {
371 use crate::unit::Unit;
372
373 if !dir.is_dir() {
374 return Ok(());
375 }
376
377 for entry in fs::read_dir(dir)? {
378 let entry = entry?;
379 let path = entry.path();
380
381 if path.is_dir() {
382 Self::walk_archive_dir(&path, entries)?;
383 } else if path.is_file() {
384 if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
385 if is_unit_filename(filename) {
386 if let Ok(unit) = Unit::from_file(&path) {
387 entries.push(IndexEntry::from(&unit));
388 }
389 }
390 }
391 }
392 }
393
394 Ok(())
395 }
396}
397
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403pub struct ArchiveIndex {
404 pub units: Vec<IndexEntry>,
405}
406
407impl ArchiveIndex {
408 pub fn build(mana_dir: &Path) -> Result<Self> {
412 let mut entries = Index::collect_archived(mana_dir)?;
413 entries.sort_by(|a, b| natural_cmp(&a.id, &b.id));
414 Ok(ArchiveIndex { units: entries })
415 }
416
417 pub fn load(mana_dir: &Path) -> Result<Self> {
419 let path = mana_dir.join("archive.yaml");
420 let contents = fs::read_to_string(&path)
421 .with_context(|| format!("Failed to read {}", path.display()))?;
422 let index: ArchiveIndex =
423 yaml::from_str(&contents).with_context(|| "Failed to parse archive.yaml")?;
424 Ok(index)
425 }
426
427 pub fn save(&self, mana_dir: &Path) -> Result<()> {
429 let path = mana_dir.join("archive.yaml");
430 let yaml =
431 serde_yml::to_string(self).with_context(|| "Failed to serialize archive index")?;
432 atomic_write(&path, &yaml)
433 .with_context(|| format!("Failed to write {}", path.display()))?;
434 Ok(())
435 }
436
437 pub fn load_or_rebuild(mana_dir: &Path) -> Result<Self> {
439 let archive_yaml = mana_dir.join("archive.yaml");
440 if Self::is_stale(mana_dir)? {
441 let index = Self::build(mana_dir)?;
442 if !index.units.is_empty() || archive_yaml.exists() {
445 index.save(mana_dir)?;
446 }
447 Ok(index)
448 } else if archive_yaml.exists() {
449 Self::load(mana_dir)
450 } else {
451 Ok(ArchiveIndex { units: Vec::new() })
453 }
454 }
455
456 pub fn is_stale(mana_dir: &Path) -> Result<bool> {
460 let archive_yaml = mana_dir.join("archive.yaml");
461 let archive_dir = mana_dir.join("archive");
462
463 if !archive_yaml.exists() {
464 return Ok(archive_dir.is_dir());
466 }
467
468 if !archive_dir.is_dir() {
469 return Ok(false);
470 }
471
472 let index_mtime = fs::metadata(&archive_yaml)
473 .with_context(|| "Failed to read archive.yaml metadata")?
474 .modified()
475 .with_context(|| "Failed to get archive.yaml mtime")?;
476
477 Self::any_file_newer(&archive_dir, index_mtime)
478 }
479
480 fn any_file_newer(dir: &Path, reference: std::time::SystemTime) -> Result<bool> {
482 for entry in fs::read_dir(dir)? {
483 let entry = entry?;
484 let path = entry.path();
485 if path.is_dir() {
486 if Self::any_file_newer(&path, reference)? {
487 return Ok(true);
488 }
489 } else if path.is_file() {
490 let mtime = fs::metadata(&path)?.modified()?;
491 if mtime > reference {
492 return Ok(true);
493 }
494 }
495 }
496 Ok(false)
497 }
498
499 pub fn append(&mut self, entry: IndexEntry) {
501 self.units.retain(|e| e.id != entry.id);
502 self.units.push(entry);
503 self.units.sort_by(|a, b| natural_cmp(&a.id, &b.id));
504 }
505
506 pub fn remove(&mut self, id: &str) {
508 self.units.retain(|e| e.id != id);
509 }
510}
511
512const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
518
519#[derive(Debug)]
537pub struct LockedIndex {
538 pub index: Index,
539 lock_file: fs::File,
540 mana_dir: std::path::PathBuf,
541}
542
543impl LockedIndex {
544 pub fn acquire(mana_dir: &Path) -> Result<Self> {
547 Self::acquire_with_timeout(mana_dir, LOCK_TIMEOUT)
548 }
549
550 pub fn acquire_with_timeout(mana_dir: &Path, timeout: Duration) -> Result<Self> {
552 let lock_path = mana_dir.join("index.lock");
553 let lock_file = fs::File::create(&lock_path)
554 .with_context(|| format!("Failed to create lock file: {}", lock_path.display()))?;
555
556 Self::flock_with_timeout(&lock_file, timeout)?;
557
558 let index = Index::load_or_rebuild(mana_dir)?;
559
560 Ok(Self {
561 index,
562 lock_file,
563 mana_dir: mana_dir.to_path_buf(),
564 })
565 }
566
567 pub fn save_and_release(self) -> Result<()> {
569 self.index.save(&self.mana_dir)?;
570 Ok(())
572 }
573
574 fn flock_with_timeout(file: &fs::File, timeout: Duration) -> Result<()> {
576 let start = Instant::now();
577 loop {
578 match file.try_lock_exclusive() {
579 Ok(()) => return Ok(()),
580 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
581 if start.elapsed() >= timeout {
582 return Err(anyhow!(
583 "Timed out after {}s waiting for .mana/index.lock — \
584 another mana process may be running. \
585 If no other process is active, delete .mana/index.lock and retry.",
586 timeout.as_secs()
587 ));
588 }
589 std::thread::sleep(Duration::from_millis(50));
590 }
591 Err(e) => {
592 return Err(anyhow!("Failed to acquire index lock: {}", e));
593 }
594 }
595 }
596 }
597}
598
599impl Drop for LockedIndex {
600 fn drop(&mut self) {
601 let _ = fs2::FileExt::unlock(&self.lock_file);
603 }
604}
605
606#[cfg(test)]
611mod tests {
612 use super::*;
613 use std::cmp::Ordering;
614 use std::fs;
615 use std::thread;
616 use std::time::Duration;
617 use tempfile::TempDir;
618
619 fn setup_mana_dir() -> (TempDir, std::path::PathBuf) {
621 let dir = TempDir::new().unwrap();
622 let mana_dir = dir.path().join(".mana");
623 fs::create_dir(&mana_dir).unwrap();
624
625 let unit1 = Unit::new("1", "First task");
627 let unit2 = Unit::new("2", "Second task");
628 let unit10 = Unit::new("10", "Tenth task");
629 let mut unit3_1 = Unit::new("3.1", "Subtask");
630 unit3_1.parent = Some("3".to_string());
631 unit3_1.labels = vec!["backend".to_string()];
632 unit3_1.dependencies = vec!["1".to_string()];
633
634 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
635 unit2.to_file(mana_dir.join("2.yaml")).unwrap();
636 unit10.to_file(mana_dir.join("10.yaml")).unwrap();
637 unit3_1.to_file(mana_dir.join("3.1.yaml")).unwrap();
638
639 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 11\n").unwrap();
641
642 (dir, mana_dir)
643 }
644
645 #[test]
648 fn natural_sort_basic() {
649 assert_eq!(natural_cmp("1", "2"), Ordering::Less);
650 assert_eq!(natural_cmp("2", "1"), Ordering::Greater);
651 assert_eq!(natural_cmp("1", "1"), Ordering::Equal);
652 }
653
654 #[test]
655 fn natural_sort_numeric_not_lexicographic() {
656 assert_eq!(natural_cmp("2", "10"), Ordering::Less);
658 assert_eq!(natural_cmp("10", "2"), Ordering::Greater);
659 }
660
661 #[test]
662 fn natural_sort_dotted_ids() {
663 assert_eq!(natural_cmp("3", "3.1"), Ordering::Less);
664 assert_eq!(natural_cmp("3.1", "3.2"), Ordering::Less);
665 assert_eq!(natural_cmp("3.2", "10"), Ordering::Less);
666 }
667
668 #[test]
669 fn natural_sort_full_sequence() {
670 let mut ids = vec!["10", "3.2", "1", "3", "3.1", "2"];
671 ids.sort_by(|a, b| natural_cmp(a, b));
672 assert_eq!(ids, vec!["1", "2", "3", "3.1", "3.2", "10"]);
673 }
674
675 #[test]
678 fn build_reads_all_units_and_excludes_config() {
679 let (_dir, mana_dir) = setup_mana_dir();
680 let index = Index::build(&mana_dir).unwrap();
681
682 assert_eq!(index.units.len(), 4);
684
685 let ids: Vec<&str> = index.units.iter().map(|e| e.id.as_str()).collect();
687 assert_eq!(ids, vec!["1", "2", "3.1", "10"]);
688 }
689
690 #[test]
691 fn build_extracts_fields_correctly() {
692 let (_dir, mana_dir) = setup_mana_dir();
693 let index = Index::build(&mana_dir).unwrap();
694
695 let entry = index.units.iter().find(|e| e.id == "3.1").unwrap();
696 assert_eq!(entry.title, "Subtask");
697 assert_eq!(entry.status, Status::Open);
698 assert_eq!(entry.priority, 2);
699 assert_eq!(entry.parent, Some("3".to_string()));
700 assert_eq!(entry.dependencies, vec!["1".to_string()]);
701 assert_eq!(entry.labels, vec!["backend".to_string()]);
702 }
703
704 #[test]
705 fn index_entry_preserves_kind() {
706 let mut unit = Unit::new("1", "Epic unit");
707 unit.kind = crate::unit::UnitType::Epic;
708
709 let entry = IndexEntry::from(&unit);
710 assert_eq!(entry.kind, crate::unit::UnitType::Epic);
711 }
712
713 #[test]
714 fn build_excludes_index_and_unit_yaml() {
715 let (_dir, mana_dir) = setup_mana_dir();
716
717 fs::write(mana_dir.join("index.yaml"), "units: []\n").unwrap();
719 fs::write(
720 mana_dir.join("unit.yaml"),
721 "id: template\ntitle: Template\n",
722 )
723 .unwrap();
724
725 let index = Index::build(&mana_dir).unwrap();
726 assert_eq!(index.units.len(), 4);
727 assert!(!index.units.iter().any(|e| e.id == "template"));
728 }
729
730 #[test]
731 fn build_detects_duplicate_ids() {
732 let dir = TempDir::new().unwrap();
733 let mana_dir = dir.path().join(".mana");
734 fs::create_dir(&mana_dir).unwrap();
735
736 let unit_a = Unit::new("99", "Unit A");
738 let unit_b = Unit::new("99", "Unit B");
739
740 unit_a.to_file(mana_dir.join("99-a.md")).unwrap();
741 unit_b.to_file(mana_dir.join("99-b.md")).unwrap();
742
743 let result = Index::build(&mana_dir);
744 assert!(result.is_err());
745
746 let err = result.unwrap_err().to_string();
747 assert!(err.contains("Duplicate unit IDs detected"));
748 assert!(err.contains("99"));
749 assert!(err.contains("99-a.md"));
750 assert!(err.contains("99-b.md"));
751 }
752
753 #[test]
754 fn save_rebuilds_sqlite_index() {
755 let (_dir, mana_dir) = setup_mana_dir();
756 let index = Index::build(&mana_dir).unwrap();
757
758 index.save(&mana_dir).unwrap();
759
760 let sqlite = sqlite::Index::open(&mana_dir).unwrap();
761 assert!(!sqlite.is_stale().unwrap());
762 assert!(sqlite.unit_exists("1").unwrap());
763 assert!(sqlite.unit_exists("3.1").unwrap());
764 }
765
766 #[test]
767 fn build_detects_multiple_duplicate_ids() {
768 let dir = TempDir::new().unwrap();
769 let mana_dir = dir.path().join(".mana");
770 fs::create_dir(&mana_dir).unwrap();
771
772 Unit::new("1", "First A")
774 .to_file(mana_dir.join("1-a.md"))
775 .unwrap();
776 Unit::new("1", "First B")
777 .to_file(mana_dir.join("1-b.md"))
778 .unwrap();
779 Unit::new("2", "Second A")
780 .to_file(mana_dir.join("2-a.md"))
781 .unwrap();
782 Unit::new("2", "Second B")
783 .to_file(mana_dir.join("2-b.md"))
784 .unwrap();
785
786 let result = Index::build(&mana_dir);
787 assert!(result.is_err());
788
789 let err = result.unwrap_err().to_string();
790 assert!(err.contains("ID '1'"));
791 assert!(err.contains("ID '2'"));
792 }
793
794 #[test]
797 fn is_stale_when_index_missing() {
798 let (_dir, mana_dir) = setup_mana_dir();
799 assert!(Index::is_stale(&mana_dir).unwrap());
800 }
801
802 #[test]
803 fn is_stale_when_yaml_newer_than_index() {
804 let (_dir, mana_dir) = setup_mana_dir();
805
806 let index = Index::build(&mana_dir).unwrap();
808 index.save(&mana_dir).unwrap();
809
810 thread::sleep(Duration::from_millis(50));
812
813 let unit = Unit::new("1", "Modified first task");
815 unit.to_file(mana_dir.join("1.yaml")).unwrap();
816
817 assert!(Index::is_stale(&mana_dir).unwrap());
818 }
819
820 #[test]
821 fn not_stale_when_index_is_fresh() {
822 let (_dir, mana_dir) = setup_mana_dir();
823
824 let index = Index::build(&mana_dir).unwrap();
826 index.save(&mana_dir).unwrap();
827
828 assert!(!Index::is_stale(&mana_dir).unwrap());
831 }
832
833 #[test]
836 fn load_or_rebuild_builds_when_no_index() {
837 let (_dir, mana_dir) = setup_mana_dir();
838
839 let index = Index::load_or_rebuild(&mana_dir).unwrap();
840 assert_eq!(index.units.len(), 4);
841
842 assert!(mana_dir.join("index.yaml").exists());
844 }
845
846 #[test]
847 fn load_or_rebuild_loads_when_fresh() {
848 let (_dir, mana_dir) = setup_mana_dir();
849
850 let original = Index::build(&mana_dir).unwrap();
852 original.save(&mana_dir).unwrap();
853
854 let loaded = Index::load_or_rebuild(&mana_dir).unwrap();
856 assert_eq!(original, loaded);
857 }
858
859 #[test]
860 fn load_or_rebuild_rebuilds_when_fresh_cached_index_panics_parser() {
861 let (_dir, mana_dir) = setup_mana_dir();
862
863 Index::build(&mana_dir).unwrap().save(&mana_dir).unwrap();
864 fs::write(mana_dir.join("index.yaml"), "units: *missing_alias\n").unwrap();
865
866 let loaded = Index::load_or_rebuild(&mana_dir).unwrap();
867 assert_eq!(loaded.units.len(), 4);
868 }
869
870 #[test]
873 fn save_and_load_round_trip() {
874 let (_dir, mana_dir) = setup_mana_dir();
875
876 let index = Index::build(&mana_dir).unwrap();
877 index.save(&mana_dir).unwrap();
878
879 let loaded = Index::load(&mana_dir).unwrap();
880 assert_eq!(index, loaded);
881 }
882
883 #[test]
886 fn build_empty_directory() {
887 let dir = TempDir::new().unwrap();
888 let mana_dir = dir.path().join(".mana");
889 fs::create_dir(&mana_dir).unwrap();
890
891 let index = Index::build(&mana_dir).unwrap();
892 assert!(index.units.is_empty());
893 }
894
895 #[test]
898 fn locked_index_acquire_and_save() {
899 let (_dir, mana_dir) = setup_mana_dir();
900
901 let mut locked = LockedIndex::acquire(&mana_dir).unwrap();
902 assert_eq!(locked.index.units.len(), 4);
903
904 locked.index.units[0].title = "Modified".to_string();
906 locked.save_and_release().unwrap();
907
908 let index = Index::load(&mana_dir).unwrap();
910 assert_eq!(index.units[0].title, "Modified");
911 }
912
913 #[test]
914 fn locked_index_blocks_concurrent_access() {
915 let (_dir, mana_dir) = setup_mana_dir();
916
917 let _locked = LockedIndex::acquire(&mana_dir).unwrap();
919
920 let result = LockedIndex::acquire_with_timeout(&mana_dir, Duration::from_millis(200));
922 assert!(result.is_err());
923 let err = result.unwrap_err().to_string();
924 assert!(
925 err.contains("Timed out"),
926 "Expected timeout error, got: {}",
927 err
928 );
929 }
930
931 #[test]
932 fn locked_index_released_on_drop() {
933 let (_dir, mana_dir) = setup_mana_dir();
934
935 {
936 let _locked = LockedIndex::acquire(&mana_dir).unwrap();
937 }
939 let _locked = LockedIndex::acquire(&mana_dir).unwrap();
943 }
944
945 #[test]
946 fn locked_index_creates_lock_file() {
947 let (_dir, mana_dir) = setup_mana_dir();
948
949 let _locked = LockedIndex::acquire(&mana_dir).unwrap();
950 assert!(mana_dir.join("index.lock").exists());
951 }
952
953 #[test]
956 fn is_stale_ignores_non_yaml() {
957 let (_dir, mana_dir) = setup_mana_dir();
958
959 let index = Index::build(&mana_dir).unwrap();
960 index.save(&mana_dir).unwrap();
961
962 thread::sleep(Duration::from_millis(50));
964 fs::write(mana_dir.join("notes.txt"), "some notes").unwrap();
965
966 assert!(!Index::is_stale(&mana_dir).unwrap());
968 }
969}
970
971#[cfg(test)]
972mod archive_tests {
973 use super::*;
974 use tempfile::TempDir;
975
976 #[test]
977 fn collect_archived_finds_units() {
978 let dir = TempDir::new().unwrap();
979 let mana_dir = dir.path().join(".mana");
980 fs::create_dir(&mana_dir).unwrap();
981
982 let archive_dir = mana_dir.join("archive").join("2026").join("02");
984 fs::create_dir_all(&archive_dir).unwrap();
985
986 let mut unit = crate::unit::Unit::new("1", "Archived task");
988 unit.status = crate::unit::Status::Closed;
989 unit.to_file(archive_dir.join("1-archived-task.md"))
990 .unwrap();
991
992 let archived = Index::collect_archived(&mana_dir).unwrap();
993 assert_eq!(archived.len(), 1);
994 assert_eq!(archived[0].id, "1");
995 assert_eq!(archived[0].status, crate::unit::Status::Closed);
996 }
997
998 #[test]
999 fn collect_archived_empty_when_no_archive() {
1000 let dir = TempDir::new().unwrap();
1001 let mana_dir = dir.path().join(".mana");
1002 fs::create_dir(&mana_dir).unwrap();
1003
1004 let archived = Index::collect_archived(&mana_dir).unwrap();
1005 assert!(archived.is_empty());
1006 }
1007}
1008
1009#[cfg(test)]
1010mod format_count_tests {
1011 use super::*;
1012 use tempfile::TempDir;
1013
1014 #[test]
1015 fn count_unit_formats_only_yaml() {
1016 let dir = TempDir::new().unwrap();
1017 let mana_dir = dir.path().join(".mana");
1018 fs::create_dir(&mana_dir).unwrap();
1019
1020 let unit1 = crate::unit::Unit::new("1", "Task 1");
1022 let unit2 = crate::unit::Unit::new("2", "Task 2");
1023 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
1024 unit2.to_file(mana_dir.join("2.yaml")).unwrap();
1025
1026 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
1027 assert_eq!(md_count, 0);
1028 assert_eq!(yaml_count, 2);
1029 }
1030
1031 #[test]
1032 fn count_unit_formats_only_md() {
1033 let dir = TempDir::new().unwrap();
1034 let mana_dir = dir.path().join(".mana");
1035 fs::create_dir(&mana_dir).unwrap();
1036
1037 let unit1 = crate::unit::Unit::new("1", "Task 1");
1039 let unit2 = crate::unit::Unit::new("2", "Task 2");
1040 unit1.to_file(mana_dir.join("1-task-1.md")).unwrap();
1041 unit2.to_file(mana_dir.join("2-task-2.md")).unwrap();
1042
1043 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
1044 assert_eq!(md_count, 2);
1045 assert_eq!(yaml_count, 0);
1046 }
1047
1048 #[test]
1049 fn count_unit_formats_mixed() {
1050 let dir = TempDir::new().unwrap();
1051 let mana_dir = dir.path().join(".mana");
1052 fs::create_dir(&mana_dir).unwrap();
1053
1054 let unit1 = crate::unit::Unit::new("1", "Task 1");
1056 let unit2 = crate::unit::Unit::new("2", "Task 2");
1057 let unit3 = crate::unit::Unit::new("3", "Task 3");
1058 unit1.to_file(mana_dir.join("1.yaml")).unwrap();
1059 unit2.to_file(mana_dir.join("2-task-2.md")).unwrap();
1060 unit3.to_file(mana_dir.join("3-task-3.md")).unwrap();
1061
1062 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
1063 assert_eq!(md_count, 2);
1064 assert_eq!(yaml_count, 1);
1065 }
1066
1067 #[test]
1068 fn count_unit_formats_excludes_config_files() {
1069 let dir = TempDir::new().unwrap();
1070 let mana_dir = dir.path().join(".mana");
1071 fs::create_dir(&mana_dir).unwrap();
1072
1073 fs::write(mana_dir.join("config.yaml"), "project: test").unwrap();
1075 fs::write(mana_dir.join("index.yaml"), "units: []").unwrap();
1076
1077 let unit1 = crate::unit::Unit::new("1", "Task 1");
1079 unit1.to_file(mana_dir.join("1-task-1.md")).unwrap();
1080
1081 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
1082 assert_eq!(md_count, 1);
1083 assert_eq!(yaml_count, 0); }
1085
1086 #[test]
1087 fn count_unit_formats_empty_dir() {
1088 let dir = TempDir::new().unwrap();
1089 let mana_dir = dir.path().join(".mana");
1090 fs::create_dir(&mana_dir).unwrap();
1091
1092 let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
1093 assert_eq!(md_count, 0);
1094 assert_eq!(yaml_count, 0);
1095 }
1096}
1097
1098#[cfg(test)]
1099mod archive_index_tests {
1100 use super::*;
1101 use tempfile::TempDir;
1102
1103 fn setup_mana_dir_with_archive() -> (TempDir, std::path::PathBuf) {
1104 let dir = TempDir::new().unwrap();
1105 let mana_dir = dir.path().join(".mana");
1106 fs::create_dir(&mana_dir).unwrap();
1107
1108 let archive_dir = mana_dir.join("archive").join("2026").join("03");
1109 fs::create_dir_all(&archive_dir).unwrap();
1110
1111 let mut unit1 = crate::unit::Unit::new("5", "Archived task five");
1112 unit1.status = crate::unit::Status::Closed;
1113 unit1.is_archived = true;
1114 unit1
1115 .to_file(archive_dir.join("5-archived-task-five.md"))
1116 .unwrap();
1117
1118 let mut unit2 = crate::unit::Unit::new("3", "Archived task three");
1119 unit2.status = crate::unit::Status::Closed;
1120 unit2.is_archived = true;
1121 unit2
1122 .to_file(archive_dir.join("3-archived-task-three.md"))
1123 .unwrap();
1124
1125 (dir, mana_dir)
1126 }
1127
1128 #[test]
1129 fn archive_index_build_from_archive_dir() {
1130 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1131 let archive = ArchiveIndex::build(&mana_dir).unwrap();
1132
1133 assert_eq!(archive.units.len(), 2);
1134 assert_eq!(archive.units[0].id, "3");
1136 assert_eq!(archive.units[1].id, "5");
1137 assert_eq!(archive.units[0].status, crate::unit::Status::Closed);
1138 assert_eq!(archive.units[1].status, crate::unit::Status::Closed);
1139 }
1140
1141 #[test]
1142 fn archive_index_build_empty_when_no_archive_dir() {
1143 let dir = TempDir::new().unwrap();
1144 let mana_dir = dir.path().join(".mana");
1145 fs::create_dir(&mana_dir).unwrap();
1146
1147 let archive = ArchiveIndex::build(&mana_dir).unwrap();
1148 assert!(archive.units.is_empty());
1149 }
1150
1151 #[test]
1152 fn archive_index_save_load_roundtrip() {
1153 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1154 let original = ArchiveIndex::build(&mana_dir).unwrap();
1155 original.save(&mana_dir).unwrap();
1156
1157 let loaded = ArchiveIndex::load(&mana_dir).unwrap();
1158 assert_eq!(original, loaded);
1159 }
1160
1161 #[test]
1162 fn archive_index_append_deduplicates() {
1163 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1164 let mut archive = ArchiveIndex::build(&mana_dir).unwrap();
1165 assert_eq!(archive.units.len(), 2);
1166
1167 let mut new_unit = crate::unit::Unit::new("7", "New archived");
1169 new_unit.status = crate::unit::Status::Closed;
1170 archive.append(IndexEntry::from(&new_unit));
1171 assert_eq!(archive.units.len(), 3);
1172
1173 let mut updated_unit = crate::unit::Unit::new("7", "Updated title");
1175 updated_unit.status = crate::unit::Status::Closed;
1176 archive.append(IndexEntry::from(&updated_unit));
1177 assert_eq!(archive.units.len(), 3);
1178
1179 let entry = archive.units.iter().find(|e| e.id == "7").unwrap();
1180 assert_eq!(entry.title, "Updated title");
1181 }
1182
1183 #[test]
1184 fn archive_index_remove() {
1185 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1186 let mut archive = ArchiveIndex::build(&mana_dir).unwrap();
1187 assert_eq!(archive.units.len(), 2);
1188
1189 archive.remove("3");
1190 assert_eq!(archive.units.len(), 1);
1191 assert_eq!(archive.units[0].id, "5");
1192
1193 archive.remove("999");
1195 assert_eq!(archive.units.len(), 1);
1196 }
1197
1198 #[test]
1199 fn archive_index_is_stale_when_no_archive_yaml() {
1200 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1201 assert!(ArchiveIndex::is_stale(&mana_dir).unwrap());
1203 }
1204
1205 #[test]
1206 fn archive_index_not_stale_when_no_archive_dir() {
1207 let dir = TempDir::new().unwrap();
1208 let mana_dir = dir.path().join(".mana");
1209 fs::create_dir(&mana_dir).unwrap();
1210 assert!(!ArchiveIndex::is_stale(&mana_dir).unwrap());
1212 }
1213
1214 #[test]
1215 fn archive_index_not_stale_after_build_and_save() {
1216 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1217 let archive = ArchiveIndex::build(&mana_dir).unwrap();
1218 archive.save(&mana_dir).unwrap();
1219 assert!(!ArchiveIndex::is_stale(&mana_dir).unwrap());
1220 }
1221
1222 #[test]
1223 fn archive_index_stale_when_file_newer() {
1224 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1225 let archive = ArchiveIndex::build(&mana_dir).unwrap();
1226 archive.save(&mana_dir).unwrap();
1227
1228 std::thread::sleep(std::time::Duration::from_millis(50));
1230 let archive_dir = mana_dir.join("archive").join("2026").join("03");
1231 let mut new_unit = crate::unit::Unit::new("9", "Newer");
1232 new_unit.status = crate::unit::Status::Closed;
1233 new_unit.is_archived = true;
1234 new_unit.to_file(archive_dir.join("9-newer.md")).unwrap();
1235
1236 assert!(ArchiveIndex::is_stale(&mana_dir).unwrap());
1237 }
1238
1239 #[test]
1240 fn archive_index_load_or_rebuild_builds_when_stale() {
1241 let (_dir, mana_dir) = setup_mana_dir_with_archive();
1242 let archive = ArchiveIndex::load_or_rebuild(&mana_dir).unwrap();
1243 assert_eq!(archive.units.len(), 2);
1244 assert!(mana_dir.join("archive.yaml").exists());
1246 }
1247
1248 #[test]
1249 fn archive_index_load_or_rebuild_returns_empty_when_no_archive() {
1250 let dir = TempDir::new().unwrap();
1251 let mana_dir = dir.path().join(".mana");
1252 fs::create_dir(&mana_dir).unwrap();
1253
1254 let archive = ArchiveIndex::load_or_rebuild(&mana_dir).unwrap();
1255 assert!(archive.units.is_empty());
1256 assert!(!mana_dir.join("archive.yaml").exists());
1258 }
1259}