use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use crate::sqlite;
use crate::unit::{Status, Unit, UnitType};
use crate::util::{atomic_write, natural_cmp};
use crate::yaml;
fn default_created_at() -> DateTime<Utc> {
DateTime::UNIX_EPOCH
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IndexEntry {
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
pub status: Status,
pub priority: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignee: Option<String>,
pub updated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub produces: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<String>,
#[serde(default)]
pub has_verify: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify: Option<String>,
#[serde(default = "default_created_at")]
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claimed_by: Option<String>,
#[serde(default)]
pub attempts: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paths: Vec<String>,
pub kind: UnitType,
#[serde(default)]
pub feature: bool,
#[serde(default)]
pub has_decisions: bool,
}
impl From<&Unit> for IndexEntry {
fn from(unit: &Unit) -> Self {
Self {
id: unit.id.clone(),
title: unit.title.clone(),
handle: unit.handle.clone(),
status: unit.status,
priority: unit.priority,
parent: unit.parent.clone(),
dependencies: unit.dependencies.clone(),
labels: unit.labels.clone(),
assignee: unit.assignee.clone(),
updated_at: unit.updated_at,
produces: unit.produces.clone(),
requires: unit.requires.clone(),
has_verify: unit.verify.is_some(),
verify: unit.verify.clone(),
created_at: unit.created_at,
claimed_by: unit.claimed_by.clone(),
attempts: unit.attempts,
paths: unit.paths.clone(),
kind: unit.kind,
feature: unit.feature,
has_decisions: !unit.decisions.is_empty(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Index {
pub units: Vec<IndexEntry>,
}
const EXCLUDED_FILES: &[&str] = &["config.yaml", "index.yaml", "unit.yaml", "archive.yaml"];
fn is_unit_filename(filename: &str) -> bool {
if EXCLUDED_FILES.contains(&filename) {
return false;
}
let ext = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str());
match ext {
Some("md") => filename.contains('-'), Some("yaml") => true, _ => false,
}
}
pub fn count_unit_formats(mana_dir: &Path) -> Result<(usize, usize)> {
let mut md_count = 0;
let mut yaml_count = 0;
let dir_entries = fs::read_dir(mana_dir)
.with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
for entry in dir_entries {
let entry = entry?;
let path = entry.path();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if !is_unit_filename(filename) {
continue;
}
let ext = path.extension().and_then(|e| e.to_str());
match ext {
Some("md") => md_count += 1,
Some("yaml") => yaml_count += 1,
_ => {}
}
}
Ok((md_count, yaml_count))
}
impl Index {
pub fn build(mana_dir: &Path) -> Result<Self> {
let mut entries = Vec::new();
let mut id_to_files: HashMap<String, Vec<String>> = HashMap::new();
let dir_entries = fs::read_dir(mana_dir)
.with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
for entry in dir_entries {
let entry = entry?;
let path = entry.path();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if !is_unit_filename(filename) {
continue;
}
let unit = Unit::from_file(&path)
.with_context(|| format!("Failed to parse unit: {}", path.display()))?;
id_to_files
.entry(unit.id.clone())
.or_default()
.push(filename.to_string());
entries.push(IndexEntry::from(&unit));
}
let duplicates: Vec<_> = id_to_files
.iter()
.filter(|(_, files)| files.len() > 1)
.collect();
if !duplicates.is_empty() {
let mut msg = String::from("Duplicate unit IDs detected:\n");
for (id, files) in duplicates {
msg.push_str(&format!(" ID '{}' defined in: {}\n", id, files.join(", ")));
}
return Err(anyhow!(msg));
}
entries.sort_by(|a, b| natural_cmp(&a.id, &b.id));
Ok(Index { units: entries })
}
pub fn is_stale(mana_dir: &Path) -> Result<bool> {
let index_path = mana_dir.join("index.yaml");
if !index_path.exists() {
return Ok(true);
}
let index_mtime = fs::metadata(&index_path)
.with_context(|| "Failed to read index.yaml metadata")?
.modified()
.with_context(|| "Failed to get index.yaml mtime")?;
let dir_entries = fs::read_dir(mana_dir)
.with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
for entry in dir_entries {
let entry = entry?;
let path = entry.path();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if !is_unit_filename(filename) {
continue;
}
let file_mtime = fs::metadata(&path)
.with_context(|| format!("Failed to read metadata: {}", path.display()))?
.modified()
.with_context(|| format!("Failed to get mtime: {}", path.display()))?;
if file_mtime > index_mtime {
return Ok(true);
}
}
Ok(false)
}
pub fn load_or_rebuild(mana_dir: &Path) -> Result<Self> {
if Self::is_stale(mana_dir)? {
let index = Self::build(mana_dir)?;
index.save(mana_dir)?;
Ok(index)
} else {
match Self::load(mana_dir) {
Ok(index) => Ok(index),
Err(_) => {
let index = Self::build(mana_dir)?;
index.save(mana_dir)?;
Ok(index)
}
}
}
}
pub fn load(mana_dir: &Path) -> Result<Self> {
let index_path = mana_dir.join("index.yaml");
let contents = fs::read_to_string(&index_path)
.with_context(|| format!("Failed to read {}", index_path.display()))?;
let index: Index =
yaml::from_str(&contents).with_context(|| "Failed to parse index.yaml")?;
Ok(index)
}
pub fn save(&self, mana_dir: &Path) -> Result<()> {
let index_path = mana_dir.join("index.yaml");
let yaml = serde_yml::to_string(self).with_context(|| "Failed to serialize index")?;
atomic_write(&index_path, &yaml)
.with_context(|| format!("Failed to write {}", index_path.display()))?;
if let Err(error) = sqlite::Index::rebuild(mana_dir) {
let _ = sqlite::Index::open(mana_dir).and_then(|index| {
index.mark_stale(&format!("index.yaml save hook failed: {error}"))
});
}
Ok(())
}
pub fn collect_archived(mana_dir: &Path) -> Result<Vec<IndexEntry>> {
let mut entries = Vec::new();
let archive_dir = mana_dir.join("archive");
if !archive_dir.is_dir() {
return Ok(entries);
}
Self::walk_archive_dir(&archive_dir, &mut entries)?;
Ok(entries)
}
fn walk_archive_dir(dir: &Path, entries: &mut Vec<IndexEntry>) -> Result<()> {
use crate::unit::Unit;
if !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
Self::walk_archive_dir(&path, entries)?;
} else if path.is_file() {
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if is_unit_filename(filename) {
if let Ok(unit) = Unit::from_file(&path) {
entries.push(IndexEntry::from(&unit));
}
}
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArchiveIndex {
pub units: Vec<IndexEntry>,
}
impl ArchiveIndex {
pub fn build(mana_dir: &Path) -> Result<Self> {
let mut entries = Index::collect_archived(mana_dir)?;
entries.sort_by(|a, b| natural_cmp(&a.id, &b.id));
Ok(ArchiveIndex { units: entries })
}
pub fn load(mana_dir: &Path) -> Result<Self> {
let path = mana_dir.join("archive.yaml");
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let index: ArchiveIndex =
yaml::from_str(&contents).with_context(|| "Failed to parse archive.yaml")?;
Ok(index)
}
pub fn save(&self, mana_dir: &Path) -> Result<()> {
let path = mana_dir.join("archive.yaml");
let yaml =
serde_yml::to_string(self).with_context(|| "Failed to serialize archive index")?;
atomic_write(&path, &yaml)
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub fn load_or_rebuild(mana_dir: &Path) -> Result<Self> {
let archive_yaml = mana_dir.join("archive.yaml");
if Self::is_stale(mana_dir)? {
let index = Self::build(mana_dir)?;
if !index.units.is_empty() || archive_yaml.exists() {
index.save(mana_dir)?;
}
Ok(index)
} else if archive_yaml.exists() {
Self::load(mana_dir)
} else {
Ok(ArchiveIndex { units: Vec::new() })
}
}
pub fn is_stale(mana_dir: &Path) -> Result<bool> {
let archive_yaml = mana_dir.join("archive.yaml");
let archive_dir = mana_dir.join("archive");
if !archive_yaml.exists() {
return Ok(archive_dir.is_dir());
}
if !archive_dir.is_dir() {
return Ok(false);
}
let index_mtime = fs::metadata(&archive_yaml)
.with_context(|| "Failed to read archive.yaml metadata")?
.modified()
.with_context(|| "Failed to get archive.yaml mtime")?;
Self::any_file_newer(&archive_dir, index_mtime)
}
fn any_file_newer(dir: &Path, reference: std::time::SystemTime) -> Result<bool> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if Self::any_file_newer(&path, reference)? {
return Ok(true);
}
} else if path.is_file() {
let mtime = fs::metadata(&path)?.modified()?;
if mtime > reference {
return Ok(true);
}
}
}
Ok(false)
}
pub fn append(&mut self, entry: IndexEntry) {
self.units.retain(|e| e.id != entry.id);
self.units.push(entry);
self.units.sort_by(|a, b| natural_cmp(&a.id, &b.id));
}
pub fn remove(&mut self, id: &str) {
self.units.retain(|e| e.id != id);
}
}
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug)]
pub struct LockedIndex {
pub index: Index,
lock_file: fs::File,
mana_dir: std::path::PathBuf,
}
impl LockedIndex {
pub fn acquire(mana_dir: &Path) -> Result<Self> {
Self::acquire_with_timeout(mana_dir, LOCK_TIMEOUT)
}
pub fn acquire_with_timeout(mana_dir: &Path, timeout: Duration) -> Result<Self> {
let lock_path = mana_dir.join("index.lock");
let lock_file = fs::File::create(&lock_path)
.with_context(|| format!("Failed to create lock file: {}", lock_path.display()))?;
Self::flock_with_timeout(&lock_file, timeout)?;
let index = Index::load_or_rebuild(mana_dir)?;
Ok(Self {
index,
lock_file,
mana_dir: mana_dir.to_path_buf(),
})
}
pub fn save_and_release(self) -> Result<()> {
self.index.save(&self.mana_dir)?;
Ok(())
}
fn flock_with_timeout(file: &fs::File, timeout: Duration) -> Result<()> {
let start = Instant::now();
loop {
match file.try_lock_exclusive() {
Ok(()) => return Ok(()),
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if start.elapsed() >= timeout {
return Err(anyhow!(
"Timed out after {}s waiting for .mana/index.lock — \
another mana process may be running. \
If no other process is active, delete .mana/index.lock and retry.",
timeout.as_secs()
));
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
return Err(anyhow!("Failed to acquire index lock: {}", e));
}
}
}
}
}
impl Drop for LockedIndex {
fn drop(&mut self) {
let _ = fs2::FileExt::unlock(&self.lock_file);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cmp::Ordering;
use std::fs;
use std::thread;
use std::time::Duration;
use tempfile::TempDir;
fn setup_mana_dir() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let unit1 = Unit::new("1", "First task");
let unit2 = Unit::new("2", "Second task");
let unit10 = Unit::new("10", "Tenth task");
let mut unit3_1 = Unit::new("3.1", "Subtask");
unit3_1.parent = Some("3".to_string());
unit3_1.labels = vec!["backend".to_string()];
unit3_1.dependencies = vec!["1".to_string()];
unit1.to_file(mana_dir.join("1.yaml")).unwrap();
unit2.to_file(mana_dir.join("2.yaml")).unwrap();
unit10.to_file(mana_dir.join("10.yaml")).unwrap();
unit3_1.to_file(mana_dir.join("3.1.yaml")).unwrap();
fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 11\n").unwrap();
(dir, mana_dir)
}
#[test]
fn natural_sort_basic() {
assert_eq!(natural_cmp("1", "2"), Ordering::Less);
assert_eq!(natural_cmp("2", "1"), Ordering::Greater);
assert_eq!(natural_cmp("1", "1"), Ordering::Equal);
}
#[test]
fn natural_sort_numeric_not_lexicographic() {
assert_eq!(natural_cmp("2", "10"), Ordering::Less);
assert_eq!(natural_cmp("10", "2"), Ordering::Greater);
}
#[test]
fn natural_sort_dotted_ids() {
assert_eq!(natural_cmp("3", "3.1"), Ordering::Less);
assert_eq!(natural_cmp("3.1", "3.2"), Ordering::Less);
assert_eq!(natural_cmp("3.2", "10"), Ordering::Less);
}
#[test]
fn natural_sort_full_sequence() {
let mut ids = vec!["10", "3.2", "1", "3", "3.1", "2"];
ids.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(ids, vec!["1", "2", "3", "3.1", "3.2", "10"]);
}
#[test]
fn build_reads_all_units_and_excludes_config() {
let (_dir, mana_dir) = setup_mana_dir();
let index = Index::build(&mana_dir).unwrap();
assert_eq!(index.units.len(), 4);
let ids: Vec<&str> = index.units.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, vec!["1", "2", "3.1", "10"]);
}
#[test]
fn build_extracts_fields_correctly() {
let (_dir, mana_dir) = setup_mana_dir();
let index = Index::build(&mana_dir).unwrap();
let entry = index.units.iter().find(|e| e.id == "3.1").unwrap();
assert_eq!(entry.title, "Subtask");
assert_eq!(entry.status, Status::Open);
assert_eq!(entry.priority, 2);
assert_eq!(entry.parent, Some("3".to_string()));
assert_eq!(entry.dependencies, vec!["1".to_string()]);
assert_eq!(entry.labels, vec!["backend".to_string()]);
}
#[test]
fn index_entry_preserves_kind() {
let mut unit = Unit::new("1", "Epic unit");
unit.kind = crate::unit::UnitType::Epic;
let entry = IndexEntry::from(&unit);
assert_eq!(entry.kind, crate::unit::UnitType::Epic);
}
#[test]
fn build_excludes_index_and_unit_yaml() {
let (_dir, mana_dir) = setup_mana_dir();
fs::write(mana_dir.join("index.yaml"), "units: []\n").unwrap();
fs::write(
mana_dir.join("unit.yaml"),
"id: template\ntitle: Template\n",
)
.unwrap();
let index = Index::build(&mana_dir).unwrap();
assert_eq!(index.units.len(), 4);
assert!(!index.units.iter().any(|e| e.id == "template"));
}
#[test]
fn build_detects_duplicate_ids() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let unit_a = Unit::new("99", "Unit A");
let unit_b = Unit::new("99", "Unit B");
unit_a.to_file(mana_dir.join("99-a.md")).unwrap();
unit_b.to_file(mana_dir.join("99-b.md")).unwrap();
let result = Index::build(&mana_dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Duplicate unit IDs detected"));
assert!(err.contains("99"));
assert!(err.contains("99-a.md"));
assert!(err.contains("99-b.md"));
}
#[test]
fn save_rebuilds_sqlite_index() {
let (_dir, mana_dir) = setup_mana_dir();
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
let sqlite = sqlite::Index::open(&mana_dir).unwrap();
assert!(!sqlite.is_stale().unwrap());
assert!(sqlite.unit_exists("1").unwrap());
assert!(sqlite.unit_exists("3.1").unwrap());
}
#[test]
fn build_detects_multiple_duplicate_ids() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
Unit::new("1", "First A")
.to_file(mana_dir.join("1-a.md"))
.unwrap();
Unit::new("1", "First B")
.to_file(mana_dir.join("1-b.md"))
.unwrap();
Unit::new("2", "Second A")
.to_file(mana_dir.join("2-a.md"))
.unwrap();
Unit::new("2", "Second B")
.to_file(mana_dir.join("2-b.md"))
.unwrap();
let result = Index::build(&mana_dir);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("ID '1'"));
assert!(err.contains("ID '2'"));
}
#[test]
fn is_stale_when_index_missing() {
let (_dir, mana_dir) = setup_mana_dir();
assert!(Index::is_stale(&mana_dir).unwrap());
}
#[test]
fn is_stale_when_yaml_newer_than_index() {
let (_dir, mana_dir) = setup_mana_dir();
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
thread::sleep(Duration::from_millis(50));
let unit = Unit::new("1", "Modified first task");
unit.to_file(mana_dir.join("1.yaml")).unwrap();
assert!(Index::is_stale(&mana_dir).unwrap());
}
#[test]
fn not_stale_when_index_is_fresh() {
let (_dir, mana_dir) = setup_mana_dir();
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
assert!(!Index::is_stale(&mana_dir).unwrap());
}
#[test]
fn load_or_rebuild_builds_when_no_index() {
let (_dir, mana_dir) = setup_mana_dir();
let index = Index::load_or_rebuild(&mana_dir).unwrap();
assert_eq!(index.units.len(), 4);
assert!(mana_dir.join("index.yaml").exists());
}
#[test]
fn load_or_rebuild_loads_when_fresh() {
let (_dir, mana_dir) = setup_mana_dir();
let original = Index::build(&mana_dir).unwrap();
original.save(&mana_dir).unwrap();
let loaded = Index::load_or_rebuild(&mana_dir).unwrap();
assert_eq!(original, loaded);
}
#[test]
fn load_or_rebuild_rebuilds_when_fresh_cached_index_panics_parser() {
let (_dir, mana_dir) = setup_mana_dir();
Index::build(&mana_dir).unwrap().save(&mana_dir).unwrap();
fs::write(mana_dir.join("index.yaml"), "units: *missing_alias\n").unwrap();
let loaded = Index::load_or_rebuild(&mana_dir).unwrap();
assert_eq!(loaded.units.len(), 4);
}
#[test]
fn save_and_load_round_trip() {
let (_dir, mana_dir) = setup_mana_dir();
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
let loaded = Index::load(&mana_dir).unwrap();
assert_eq!(index, loaded);
}
#[test]
fn build_empty_directory() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let index = Index::build(&mana_dir).unwrap();
assert!(index.units.is_empty());
}
#[test]
fn locked_index_acquire_and_save() {
let (_dir, mana_dir) = setup_mana_dir();
let mut locked = LockedIndex::acquire(&mana_dir).unwrap();
assert_eq!(locked.index.units.len(), 4);
locked.index.units[0].title = "Modified".to_string();
locked.save_and_release().unwrap();
let index = Index::load(&mana_dir).unwrap();
assert_eq!(index.units[0].title, "Modified");
}
#[test]
fn locked_index_blocks_concurrent_access() {
let (_dir, mana_dir) = setup_mana_dir();
let _locked = LockedIndex::acquire(&mana_dir).unwrap();
let result = LockedIndex::acquire_with_timeout(&mana_dir, Duration::from_millis(200));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Timed out"),
"Expected timeout error, got: {}",
err
);
}
#[test]
fn locked_index_released_on_drop() {
let (_dir, mana_dir) = setup_mana_dir();
{
let _locked = LockedIndex::acquire(&mana_dir).unwrap();
}
let _locked = LockedIndex::acquire(&mana_dir).unwrap();
}
#[test]
fn locked_index_creates_lock_file() {
let (_dir, mana_dir) = setup_mana_dir();
let _locked = LockedIndex::acquire(&mana_dir).unwrap();
assert!(mana_dir.join("index.lock").exists());
}
#[test]
fn is_stale_ignores_non_yaml() {
let (_dir, mana_dir) = setup_mana_dir();
let index = Index::build(&mana_dir).unwrap();
index.save(&mana_dir).unwrap();
thread::sleep(Duration::from_millis(50));
fs::write(mana_dir.join("notes.txt"), "some notes").unwrap();
assert!(!Index::is_stale(&mana_dir).unwrap());
}
}
#[cfg(test)]
mod archive_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn collect_archived_finds_units() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let archive_dir = mana_dir.join("archive").join("2026").join("02");
fs::create_dir_all(&archive_dir).unwrap();
let mut unit = crate::unit::Unit::new("1", "Archived task");
unit.status = crate::unit::Status::Closed;
unit.to_file(archive_dir.join("1-archived-task.md"))
.unwrap();
let archived = Index::collect_archived(&mana_dir).unwrap();
assert_eq!(archived.len(), 1);
assert_eq!(archived[0].id, "1");
assert_eq!(archived[0].status, crate::unit::Status::Closed);
}
#[test]
fn collect_archived_empty_when_no_archive() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let archived = Index::collect_archived(&mana_dir).unwrap();
assert!(archived.is_empty());
}
}
#[cfg(test)]
mod format_count_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn count_unit_formats_only_yaml() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let unit1 = crate::unit::Unit::new("1", "Task 1");
let unit2 = crate::unit::Unit::new("2", "Task 2");
unit1.to_file(mana_dir.join("1.yaml")).unwrap();
unit2.to_file(mana_dir.join("2.yaml")).unwrap();
let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
assert_eq!(md_count, 0);
assert_eq!(yaml_count, 2);
}
#[test]
fn count_unit_formats_only_md() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let unit1 = crate::unit::Unit::new("1", "Task 1");
let unit2 = crate::unit::Unit::new("2", "Task 2");
unit1.to_file(mana_dir.join("1-task-1.md")).unwrap();
unit2.to_file(mana_dir.join("2-task-2.md")).unwrap();
let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
assert_eq!(md_count, 2);
assert_eq!(yaml_count, 0);
}
#[test]
fn count_unit_formats_mixed() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let unit1 = crate::unit::Unit::new("1", "Task 1");
let unit2 = crate::unit::Unit::new("2", "Task 2");
let unit3 = crate::unit::Unit::new("3", "Task 3");
unit1.to_file(mana_dir.join("1.yaml")).unwrap();
unit2.to_file(mana_dir.join("2-task-2.md")).unwrap();
unit3.to_file(mana_dir.join("3-task-3.md")).unwrap();
let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
assert_eq!(md_count, 2);
assert_eq!(yaml_count, 1);
}
#[test]
fn count_unit_formats_excludes_config_files() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
fs::write(mana_dir.join("config.yaml"), "project: test").unwrap();
fs::write(mana_dir.join("index.yaml"), "units: []").unwrap();
let unit1 = crate::unit::Unit::new("1", "Task 1");
unit1.to_file(mana_dir.join("1-task-1.md")).unwrap();
let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
assert_eq!(md_count, 1);
assert_eq!(yaml_count, 0); }
#[test]
fn count_unit_formats_empty_dir() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let (md_count, yaml_count) = count_unit_formats(&mana_dir).unwrap();
assert_eq!(md_count, 0);
assert_eq!(yaml_count, 0);
}
}
#[cfg(test)]
mod archive_index_tests {
use super::*;
use tempfile::TempDir;
fn setup_mana_dir_with_archive() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let archive_dir = mana_dir.join("archive").join("2026").join("03");
fs::create_dir_all(&archive_dir).unwrap();
let mut unit1 = crate::unit::Unit::new("5", "Archived task five");
unit1.status = crate::unit::Status::Closed;
unit1.is_archived = true;
unit1
.to_file(archive_dir.join("5-archived-task-five.md"))
.unwrap();
let mut unit2 = crate::unit::Unit::new("3", "Archived task three");
unit2.status = crate::unit::Status::Closed;
unit2.is_archived = true;
unit2
.to_file(archive_dir.join("3-archived-task-three.md"))
.unwrap();
(dir, mana_dir)
}
#[test]
fn archive_index_build_from_archive_dir() {
let (_dir, mana_dir) = setup_mana_dir_with_archive();
let archive = ArchiveIndex::build(&mana_dir).unwrap();
assert_eq!(archive.units.len(), 2);
assert_eq!(archive.units[0].id, "3");
assert_eq!(archive.units[1].id, "5");
assert_eq!(archive.units[0].status, crate::unit::Status::Closed);
assert_eq!(archive.units[1].status, crate::unit::Status::Closed);
}
#[test]
fn archive_index_build_empty_when_no_archive_dir() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let archive = ArchiveIndex::build(&mana_dir).unwrap();
assert!(archive.units.is_empty());
}
#[test]
fn archive_index_save_load_roundtrip() {
let (_dir, mana_dir) = setup_mana_dir_with_archive();
let original = ArchiveIndex::build(&mana_dir).unwrap();
original.save(&mana_dir).unwrap();
let loaded = ArchiveIndex::load(&mana_dir).unwrap();
assert_eq!(original, loaded);
}
#[test]
fn archive_index_append_deduplicates() {
let (_dir, mana_dir) = setup_mana_dir_with_archive();
let mut archive = ArchiveIndex::build(&mana_dir).unwrap();
assert_eq!(archive.units.len(), 2);
let mut new_unit = crate::unit::Unit::new("7", "New archived");
new_unit.status = crate::unit::Status::Closed;
archive.append(IndexEntry::from(&new_unit));
assert_eq!(archive.units.len(), 3);
let mut updated_unit = crate::unit::Unit::new("7", "Updated title");
updated_unit.status = crate::unit::Status::Closed;
archive.append(IndexEntry::from(&updated_unit));
assert_eq!(archive.units.len(), 3);
let entry = archive.units.iter().find(|e| e.id == "7").unwrap();
assert_eq!(entry.title, "Updated title");
}
#[test]
fn archive_index_remove() {
let (_dir, mana_dir) = setup_mana_dir_with_archive();
let mut archive = ArchiveIndex::build(&mana_dir).unwrap();
assert_eq!(archive.units.len(), 2);
archive.remove("3");
assert_eq!(archive.units.len(), 1);
assert_eq!(archive.units[0].id, "5");
archive.remove("999");
assert_eq!(archive.units.len(), 1);
}
#[test]
fn archive_index_is_stale_when_no_archive_yaml() {
let (_dir, mana_dir) = setup_mana_dir_with_archive();
assert!(ArchiveIndex::is_stale(&mana_dir).unwrap());
}
#[test]
fn archive_index_not_stale_when_no_archive_dir() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
assert!(!ArchiveIndex::is_stale(&mana_dir).unwrap());
}
#[test]
fn archive_index_not_stale_after_build_and_save() {
let (_dir, mana_dir) = setup_mana_dir_with_archive();
let archive = ArchiveIndex::build(&mana_dir).unwrap();
archive.save(&mana_dir).unwrap();
assert!(!ArchiveIndex::is_stale(&mana_dir).unwrap());
}
#[test]
fn archive_index_stale_when_file_newer() {
let (_dir, mana_dir) = setup_mana_dir_with_archive();
let archive = ArchiveIndex::build(&mana_dir).unwrap();
archive.save(&mana_dir).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
let archive_dir = mana_dir.join("archive").join("2026").join("03");
let mut new_unit = crate::unit::Unit::new("9", "Newer");
new_unit.status = crate::unit::Status::Closed;
new_unit.is_archived = true;
new_unit.to_file(archive_dir.join("9-newer.md")).unwrap();
assert!(ArchiveIndex::is_stale(&mana_dir).unwrap());
}
#[test]
fn archive_index_load_or_rebuild_builds_when_stale() {
let (_dir, mana_dir) = setup_mana_dir_with_archive();
let archive = ArchiveIndex::load_or_rebuild(&mana_dir).unwrap();
assert_eq!(archive.units.len(), 2);
assert!(mana_dir.join("archive.yaml").exists());
}
#[test]
fn archive_index_load_or_rebuild_returns_empty_when_no_archive() {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
let archive = ArchiveIndex::load_or_rebuild(&mana_dir).unwrap();
assert!(archive.units.is_empty());
assert!(!mana_dir.join("archive.yaml").exists());
}
}