Skip to main content

mana_core/
index.rs

1//! Fast unit index cache.
2//!
3//! The index (`index.yaml`) is a compact summary of all active units, built
4//! by scanning every unit file in the `.mana/` directory. It lets the CLI
5//! answer list/filter/graph queries without parsing every individual unit file.
6//!
7//! The index is rebuilt automatically whenever unit files are newer than the
8//! cached `index.yaml`. It is saved atomically to prevent corruption.
9//!
10//! ## Usage
11//!
12//! ```rust,no_run
13//! use mana_core::index::Index;
14//! use std::path::Path;
15//!
16//! let mana_dir = Path::new("/project/.mana");
17//!
18//! // Load from disk, rebuilding if stale
19//! let index = Index::load_or_rebuild(mana_dir).unwrap();
20//! println!("{} units", index.units.len());
21//!
22//! // Force a full rebuild from unit files
23//! let index = Index::build(mana_dir).unwrap();
24//! index.save(mana_dir).unwrap();
25//! ```
26
27use 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
40// ---------------------------------------------------------------------------
41// IndexEntry
42// ---------------------------------------------------------------------------
43
44/// Default for `created_at` when deserializing old index files that lack the field.
45fn default_created_at() -> DateTime<Utc> {
46    DateTime::UNIX_EPOCH
47}
48
49/// A lightweight summary of a single unit, stored in the index cache.
50///
51/// `IndexEntry` contains only the fields needed for list/filter/graph
52/// operations. For the full unit with description, notes, and history,
53/// load the unit file directly via [`crate::unit::Unit::from_file`] or
54/// [`crate::api::get_unit`].
55#[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    /// Artifacts this unit produces (for smart dependency inference)
71    #[serde(default, skip_serializing_if = "Vec::is_empty")]
72    pub produces: Vec<String>,
73    /// Artifacts this unit requires (for smart dependency inference)
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub requires: Vec<String>,
76    /// Whether this unit has a verify command (SPECs have verify, GOALs don't)
77    #[serde(default)]
78    pub has_verify: bool,
79    /// The actual verify command string (so agents don't need bn show per-unit)
80    #[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    /// Agent or user currently holding a claim on this unit (e.g., "spro:12345" for agent with PID)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub claimed_by: Option<String>,
87    /// Number of verify attempts so far
88    #[serde(default)]
89    pub attempts: u32,
90    /// File paths this unit touches (for scope-based blocking)
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub paths: Vec<String>,
93    /// Whether this unit is a feature (product-level goal, human-only close)
94    #[serde(default)]
95    pub feature: bool,
96    /// Whether this unit has unresolved decisions
97    #[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// ---------------------------------------------------------------------------
128// Index
129// ---------------------------------------------------------------------------
130
131/// The in-memory and on-disk unit index.
132///
133/// Holds a flat list of [`IndexEntry`] values for all active (non-archived)
134/// units in the project. Archived units are stored separately in
135/// `.mana/archive/` and are not included here.
136///
137/// Obtain an index via [`Index::load_or_rebuild`] (lazy, cached) or
138/// [`Index::build`] (always scans unit files from disk).
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct Index {
141    /// All active units, sorted by ID in natural order.
142    pub units: Vec<IndexEntry>,
143}
144
145// Files to exclude when scanning for unit YAMLs.
146const EXCLUDED_FILES: &[&str] = &["config.yaml", "index.yaml", "unit.yaml", "archive.yaml"];
147
148/// Check if a filename represents a unit file (not a config/index/template file).
149fn 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('-'), // New format: {id}-{slug}.md
158        Some("yaml") => true,                 // Legacy format: {id}.yaml
159        _ => false,
160    }
161}
162
163/// Count unit files by format in the units directory.
164/// Returns (md_count, yaml_count) tuple.
165pub 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    /// Build the index by reading all unit files from the units directory.
198    /// Supports both new format ({id}-{slug}.md) and legacy format ({id}.yaml).
199    /// Excludes config.yaml, index.yaml, and unit.yaml.
200    /// Sorts entries by ID using natural ordering.
201    /// Returns an error if duplicate unit IDs are detected.
202    pub fn build(mana_dir: &Path) -> Result<Self> {
203        let mut entries = Vec::new();
204        // Track which files define each ID to detect duplicates
205        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            // Track this ID's file for duplicate detection
227            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        // Check for duplicate IDs
236        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    /// Check whether the cached index is stale.
255    /// Returns true if the index file is missing or if any unit file (.md or .yaml)
256    /// in the units directory has been modified after the index was last written.
257    pub fn is_stale(mana_dir: &Path) -> Result<bool> {
258        let index_path = mana_dir.join("index.yaml");
259
260        // If index doesn't exist, it's stale
261        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    /// Load the cached index or rebuild it if stale.
300    /// This is the main entry point for read-heavy commands.
301    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    /// Load the index from the cached index.yaml file.
312    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    /// Save the index to .mana/index.yaml.
322    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    /// Collect all archived units from .mana/archive/ directory.
331    /// Walks through year/month subdirectories and loads all unit files.
332    /// Returns IndexEntry items for archived units.
333    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        // Walk through archive directory recursively
342        Self::walk_archive_dir(&archive_dir, &mut entries)?;
343
344        Ok(entries)
345    }
346
347    /// Recursively walk archive directory and collect unit entries.
348    /// Uses catch_unwind to survive YAML parser panics from corrupt files.
349    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(_)) => {} // normal parse error, skip silently
370                            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// ---------------------------------------------------------------------------
387// ArchiveIndex — cached index of archived units
388// ---------------------------------------------------------------------------
389
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391pub struct ArchiveIndex {
392    pub units: Vec<IndexEntry>,
393}
394
395impl ArchiveIndex {
396    /// Build the archive index by walking `.mana/archive/` recursively.
397    /// Reuses `Index::collect_archived` to find all archived unit files,
398    /// then sorts entries by ID using natural ordering.
399    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    /// Load the archive index from `.mana/archive.yaml`.
406    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    /// Save the archive index to `.mana/archive.yaml`.
416    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    /// Load cached archive index or rebuild if stale.
426    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            // Only save if there are entries or the file already exists
431            // (avoids creating archive.yaml when there's no archive dir)
432            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            // No archive dir and no archive.yaml — return empty
440            Ok(ArchiveIndex { units: Vec::new() })
441        }
442    }
443
444    /// Check whether the cached archive index is stale.
445    /// Returns true if archive.yaml is missing (and archive dir exists),
446    /// or if any file in the archive tree has been modified after archive.yaml.
447    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            // If the archive dir doesn't exist either, nothing to index
453            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    /// Check if any file in the given directory tree is newer than the reference time.
469    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    /// Append an entry, deduplicating by ID (replaces any existing entry with the same ID).
488    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    /// Remove an entry by ID.
495    pub fn remove(&mut self, id: &str) {
496        self.units.retain(|e| e.id != id);
497    }
498}
499
500// ---------------------------------------------------------------------------
501// LockedIndex — exclusive access during read-modify-write
502// ---------------------------------------------------------------------------
503
504/// Default timeout for acquiring the index lock.
505const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
506
507/// Exclusive handle to the index, backed by an advisory flock on `.mana/index.lock`.
508///
509/// Prevents concurrent read-modify-write races when multiple agents run in parallel.
510/// The lock is held from acquisition until `save_and_release` is called or the
511/// `LockedIndex` is dropped.
512///
513/// ```no_run
514/// # use anyhow::Result;
515/// # use std::path::Path;
516/// # fn example(mana_dir: &Path) -> Result<()> {
517/// use mana_core::index::LockedIndex;
518/// let mut locked = LockedIndex::acquire(mana_dir)?;
519/// locked.index.units[0].title = "Updated".to_string();
520/// locked.save_and_release()?;
521/// # Ok(())
522/// # }
523/// ```
524#[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    /// Acquire an exclusive lock on the index, then load or rebuild it.
533    /// Uses the default 5-second timeout.
534    pub fn acquire(mana_dir: &Path) -> Result<Self> {
535        Self::acquire_with_timeout(mana_dir, LOCK_TIMEOUT)
536    }
537
538    /// Acquire an exclusive lock with a custom timeout.
539    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    /// Save the modified index and release the lock.
556    pub fn save_and_release(self) -> Result<()> {
557        self.index.save(&self.mana_dir)?;
558        // self drops here, releasing the flock via Drop
559        Ok(())
560    }
561
562    /// Poll for an exclusive flock with timeout.
563    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        // Use fs2's unlock explicitly (std's File::unlock stabilized in 1.89, above our MSRV)
590        let _ = fs2::FileExt::unlock(&self.lock_file);
591    }
592}
593
594// ---------------------------------------------------------------------------
595// Tests
596// ---------------------------------------------------------------------------
597
598#[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    /// Helper: create a .mana directory with some unit YAML files.
608    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        // Create a few units
614        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        // Create files that should be excluded
628        fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 11\n").unwrap();
629
630        (dir, mana_dir)
631    }
632
633    // -- natural_cmp tests --
634
635    #[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        // Lexicographic: "10" < "2", but natural: "10" > "2"
645        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    // -- build tests --
664
665    #[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        // Should have 4 units: 1, 2, 3.1, 10
671        assert_eq!(index.units.len(), 4);
672
673        // Should be naturally sorted
674        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        // Create index.yaml and unit.yaml — these should be excluded
697        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        // Create two units with the same ID in different files
716        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        // Create duplicates for two different IDs
739        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    // -- is_stale tests --
761
762    #[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        // Build and save the index first
773        let index = Index::build(&mana_dir).unwrap();
774        index.save(&mana_dir).unwrap();
775
776        // Wait a moment to ensure distinct mtimes
777        thread::sleep(Duration::from_millis(50));
778
779        // Modify a unit file — this makes it newer than the index
780        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        // Build and save
791        let index = Index::build(&mana_dir).unwrap();
792        index.save(&mana_dir).unwrap();
793
794        // The index was just written, so it should not be stale
795        // (index.yaml mtime >= all other yaml mtimes)
796        assert!(!Index::is_stale(&mana_dir).unwrap());
797    }
798
799    // -- load_or_rebuild tests --
800
801    #[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        // Should have created index.yaml
809        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        // Build + save
817        let original = Index::build(&mana_dir).unwrap();
818        original.save(&mana_dir).unwrap();
819
820        // load_or_rebuild should load without rebuilding
821        let loaded = Index::load_or_rebuild(&mana_dir).unwrap();
822        assert_eq!(original, loaded);
823    }
824
825    // -- save / load round-trip --
826
827    #[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    // -- empty directory --
839
840    #[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    // -- LockedIndex tests --
851
852    #[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        // Modify a title
860        locked.index.units[0].title = "Modified".to_string();
861        locked.save_and_release().unwrap();
862
863        // Verify the change persisted
864        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        // First lock
873        let _locked = LockedIndex::acquire(&mana_dir).unwrap();
874
875        // Second lock should fail with timeout
876        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            // lock held in this scope
893        }
894        // lock released on drop
895
896        // Should be able to acquire again
897        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    // -- is_stale ignores non-yaml files --
909
910    #[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        // Create a non-yaml file after the index
918        thread::sleep(Duration::from_millis(50));
919        fs::write(mana_dir.join("notes.txt"), "some notes").unwrap();
920
921        // Should NOT be stale — non-yaml files don't count
922        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        // Create archive structure
938        let archive_dir = mana_dir.join("archive").join("2026").join("02");
939        fs::create_dir_all(&archive_dir).unwrap();
940
941        // Create an archived unit
942        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        // Create only yaml files
976        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        // Create only md files
993        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        // Create mixed formats
1010        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        // Create excluded yaml files (config.yaml, index.yaml)
1029        fs::write(mana_dir.join("config.yaml"), "project: test").unwrap();
1030        fs::write(mana_dir.join("index.yaml"), "units: []").unwrap();
1031
1032        // Create one actual unit
1033        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); // config.yaml and index.yaml are excluded
1039    }
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        // Should be sorted by natural ordering: "3" before "5"
1090        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        // Append a new entry
1123        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        // Append again with same ID — should replace, not duplicate
1129        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        // Removing non-existent ID is a no-op
1149        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        // Archive dir exists but archive.yaml doesn't
1157        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        // Neither archive dir nor archive.yaml exist
1166        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        // Wait and add a new file to the archive
1184        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        // Should have created archive.yaml
1200        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        // Should NOT create archive.yaml when there's nothing to index
1212        assert!(!mana_dir.join("archive.yaml").exists());
1213    }
1214}