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::sqlite;
38use crate::unit::{Status, Unit, UnitType};
39use crate::util::{atomic_write, natural_cmp};
40use crate::yaml;
41
42// ---------------------------------------------------------------------------
43// IndexEntry
44// ---------------------------------------------------------------------------
45
46/// Default for `created_at` when deserializing old index files that lack the field.
47fn default_created_at() -> DateTime<Utc> {
48    DateTime::UNIX_EPOCH
49}
50
51/// A lightweight summary of a single unit, stored in the index cache.
52///
53/// `IndexEntry` contains only the fields needed for list/filter/graph
54/// operations. For the full unit with description, notes, and history,
55/// load the unit file directly via [`crate::unit::Unit::from_file`] or
56/// [`crate::api::get_unit`].
57#[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    /// Artifacts this unit produces (for smart dependency inference)
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub produces: Vec<String>,
77    /// Artifacts this unit requires (for smart dependency inference)
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub requires: Vec<String>,
80    /// Whether this unit has a verify command (SPECs have verify, GOALs don't)
81    #[serde(default)]
82    pub has_verify: bool,
83    /// The actual verify command string (so agents don't need bn show per-unit)
84    #[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    /// Agent or user currently holding a claim on this unit (e.g., "spro:12345" for agent with PID)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub claimed_by: Option<String>,
91    /// Number of verify attempts so far
92    #[serde(default)]
93    pub attempts: u32,
94    /// File paths this unit touches (for scope-based blocking)
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub paths: Vec<String>,
97    /// Explicit unit type.
98    pub kind: UnitType,
99    /// Whether this unit is a feature (product-level goal, human-only close)
100    #[serde(default)]
101    pub feature: bool,
102    /// Whether this unit has unresolved decisions
103    #[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// ---------------------------------------------------------------------------
136// Index
137// ---------------------------------------------------------------------------
138
139/// The in-memory and on-disk unit index.
140///
141/// Holds a flat list of [`IndexEntry`] values for all active (non-archived)
142/// units in the project. Archived units are stored separately in
143/// `.mana/archive/` and are not included here.
144///
145/// Obtain an index via [`Index::load_or_rebuild`] (lazy, cached) or
146/// [`Index::build`] (always scans unit files from disk).
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct Index {
149    /// All active units, sorted by ID in natural order.
150    pub units: Vec<IndexEntry>,
151}
152
153// Files to exclude when scanning for unit YAMLs.
154const EXCLUDED_FILES: &[&str] = &["config.yaml", "index.yaml", "unit.yaml", "archive.yaml"];
155
156/// Check if a filename represents a unit file (not a config/index/template file).
157fn 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('-'), // New format: {id}-{slug}.md
166        Some("yaml") => true,                 // Legacy format: {id}.yaml
167        _ => false,
168    }
169}
170
171/// Count unit files by format in the units directory.
172/// Returns (md_count, yaml_count) tuple.
173pub 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    /// Build the index by reading all unit files from the units directory.
206    /// Supports both new format ({id}-{slug}.md) and legacy format ({id}.yaml).
207    /// Excludes config.yaml, index.yaml, and unit.yaml.
208    /// Sorts entries by ID using natural ordering.
209    /// Returns an error if duplicate unit IDs are detected.
210    pub fn build(mana_dir: &Path) -> Result<Self> {
211        let mut entries = Vec::new();
212        // Track which files define each ID to detect duplicates
213        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            // Track this ID's file for duplicate detection
235            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        // Check for duplicate IDs
244        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    /// Check whether the cached index is stale.
263    /// Returns true if the index file is missing or if any unit file (.md or .yaml)
264    /// in the units directory has been modified after the index was last written.
265    pub fn is_stale(mana_dir: &Path) -> Result<bool> {
266        let index_path = mana_dir.join("index.yaml");
267
268        // If index doesn't exist, it's stale
269        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    /// Load the cached index or rebuild it if stale.
308    /// This is the main entry point for read-heavy commands.
309    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    /// Load the index from the cached index.yaml file.
327    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    /// Save the index to .mana/index.yaml.
337    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    /// Collect all archived units from .mana/archive/ directory.
351    /// Walks through year/month subdirectories and loads all unit files.
352    /// Returns IndexEntry items for archived units.
353    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        // Walk through archive directory recursively
362        Self::walk_archive_dir(&archive_dir, &mut entries)?;
363
364        Ok(entries)
365    }
366
367    /// Recursively walk archive directory and collect unit entries.
368    /// Unit::from_file now guards against YAML parser panics from corrupt files,
369    /// so normal Result handling is enough here.
370    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// ---------------------------------------------------------------------------
399// ArchiveIndex — cached index of archived units
400// ---------------------------------------------------------------------------
401
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403pub struct ArchiveIndex {
404    pub units: Vec<IndexEntry>,
405}
406
407impl ArchiveIndex {
408    /// Build the archive index by walking `.mana/archive/` recursively.
409    /// Reuses `Index::collect_archived` to find all archived unit files,
410    /// then sorts entries by ID using natural ordering.
411    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    /// Load the archive index from `.mana/archive.yaml`.
418    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    /// Save the archive index to `.mana/archive.yaml`.
428    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    /// Load cached archive index or rebuild if stale.
438    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            // Only save if there are entries or the file already exists
443            // (avoids creating archive.yaml when there's no archive dir)
444            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            // No archive dir and no archive.yaml — return empty
452            Ok(ArchiveIndex { units: Vec::new() })
453        }
454    }
455
456    /// Check whether the cached archive index is stale.
457    /// Returns true if archive.yaml is missing (and archive dir exists),
458    /// or if any file in the archive tree has been modified after archive.yaml.
459    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            // If the archive dir doesn't exist either, nothing to index
465            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    /// Check if any file in the given directory tree is newer than the reference time.
481    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    /// Append an entry, deduplicating by ID (replaces any existing entry with the same ID).
500    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    /// Remove an entry by ID.
507    pub fn remove(&mut self, id: &str) {
508        self.units.retain(|e| e.id != id);
509    }
510}
511
512// ---------------------------------------------------------------------------
513// LockedIndex — exclusive access during read-modify-write
514// ---------------------------------------------------------------------------
515
516/// Default timeout for acquiring the index lock.
517const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
518
519/// Exclusive handle to the index, backed by an advisory flock on `.mana/index.lock`.
520///
521/// Prevents concurrent read-modify-write races when multiple agents run in parallel.
522/// The lock is held from acquisition until `save_and_release` is called or the
523/// `LockedIndex` is dropped.
524///
525/// ```no_run
526/// # use anyhow::Result;
527/// # use std::path::Path;
528/// # fn example(mana_dir: &Path) -> Result<()> {
529/// use mana_core::index::LockedIndex;
530/// let mut locked = LockedIndex::acquire(mana_dir)?;
531/// locked.index.units[0].title = "Updated".to_string();
532/// locked.save_and_release()?;
533/// # Ok(())
534/// # }
535/// ```
536#[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    /// Acquire an exclusive lock on the index, then load or rebuild it.
545    /// Uses the default 5-second timeout.
546    pub fn acquire(mana_dir: &Path) -> Result<Self> {
547        Self::acquire_with_timeout(mana_dir, LOCK_TIMEOUT)
548    }
549
550    /// Acquire an exclusive lock with a custom timeout.
551    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    /// Save the modified index and release the lock.
568    pub fn save_and_release(self) -> Result<()> {
569        self.index.save(&self.mana_dir)?;
570        // self drops here, releasing the flock via Drop
571        Ok(())
572    }
573
574    /// Poll for an exclusive flock with timeout.
575    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        // Use fs2's unlock explicitly (std's File::unlock stabilized in 1.89, above our MSRV)
602        let _ = fs2::FileExt::unlock(&self.lock_file);
603    }
604}
605
606// ---------------------------------------------------------------------------
607// Tests
608// ---------------------------------------------------------------------------
609
610#[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    /// Helper: create a .mana directory with some unit YAML files.
620    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        // Create a few units
626        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        // Create files that should be excluded
640        fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 11\n").unwrap();
641
642        (dir, mana_dir)
643    }
644
645    // -- natural_cmp tests --
646
647    #[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        // Lexicographic: "10" < "2", but natural: "10" > "2"
657        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    // -- build tests --
676
677    #[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        // Should have 4 units: 1, 2, 3.1, 10
683        assert_eq!(index.units.len(), 4);
684
685        // Should be naturally sorted
686        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        // Create index.yaml and unit.yaml — these should be excluded
718        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        // Create two units with the same ID in different files
737        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        // Create duplicates for two different IDs
773        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    // -- is_stale tests --
795
796    #[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        // Build and save the index first
807        let index = Index::build(&mana_dir).unwrap();
808        index.save(&mana_dir).unwrap();
809
810        // Wait a moment to ensure distinct mtimes
811        thread::sleep(Duration::from_millis(50));
812
813        // Modify a unit file — this makes it newer than the index
814        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        // Build and save
825        let index = Index::build(&mana_dir).unwrap();
826        index.save(&mana_dir).unwrap();
827
828        // The index was just written, so it should not be stale
829        // (index.yaml mtime >= all other yaml mtimes)
830        assert!(!Index::is_stale(&mana_dir).unwrap());
831    }
832
833    // -- load_or_rebuild tests --
834
835    #[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        // Should have created index.yaml
843        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        // Build + save
851        let original = Index::build(&mana_dir).unwrap();
852        original.save(&mana_dir).unwrap();
853
854        // load_or_rebuild should load without rebuilding
855        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    // -- save / load round-trip --
871
872    #[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    // -- empty directory --
884
885    #[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    // -- LockedIndex tests --
896
897    #[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        // Modify a title
905        locked.index.units[0].title = "Modified".to_string();
906        locked.save_and_release().unwrap();
907
908        // Verify the change persisted
909        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        // First lock
918        let _locked = LockedIndex::acquire(&mana_dir).unwrap();
919
920        // Second lock should fail with timeout
921        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            // lock held in this scope
938        }
939        // lock released on drop
940
941        // Should be able to acquire again
942        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    // -- is_stale ignores non-yaml files --
954
955    #[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        // Create a non-yaml file after the index
963        thread::sleep(Duration::from_millis(50));
964        fs::write(mana_dir.join("notes.txt"), "some notes").unwrap();
965
966        // Should NOT be stale — non-yaml files don't count
967        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        // Create archive structure
983        let archive_dir = mana_dir.join("archive").join("2026").join("02");
984        fs::create_dir_all(&archive_dir).unwrap();
985
986        // Create an archived unit
987        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        // Create only yaml files
1021        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        // Create only md files
1038        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        // Create mixed formats
1055        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        // Create excluded yaml files (config.yaml, index.yaml)
1074        fs::write(mana_dir.join("config.yaml"), "project: test").unwrap();
1075        fs::write(mana_dir.join("index.yaml"), "units: []").unwrap();
1076
1077        // Create one actual unit
1078        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); // config.yaml and index.yaml are excluded
1084    }
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        // Should be sorted by natural ordering: "3" before "5"
1135        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        // Append a new entry
1168        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        // Append again with same ID — should replace, not duplicate
1174        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        // Removing non-existent ID is a no-op
1194        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        // Archive dir exists but archive.yaml doesn't
1202        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        // Neither archive dir nor archive.yaml exist
1211        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        // Wait and add a new file to the archive
1229        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        // Should have created archive.yaml
1245        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        // Should NOT create archive.yaml when there's nothing to index
1257        assert!(!mana_dir.join("archive.yaml").exists());
1258    }
1259}