use std::path::Path;
use anyhow::{Context, Result};
use chrono::Utc;
use crate::discovery::{archive_path_for_unit, find_unit_file};
use crate::index::{ArchiveIndex, Index};
use crate::unit::{Status, Unit};
use crate::util::title_to_slug;
#[derive(Debug, Clone)]
pub struct TidiedUnit {
pub id: String,
pub title: String,
pub archive_path: String,
}
#[derive(Debug, Clone)]
pub struct ReleasedUnit {
pub id: String,
pub title: String,
pub reason: String,
}
pub struct TidyResult {
pub tidied: Vec<TidiedUnit>,
pub released: Vec<ReleasedUnit>,
pub skipped_parent_ids: Vec<String>,
pub index_count: usize,
pub agents_running: bool,
pub in_progress_count: usize,
}
fn format_duration(duration: chrono::Duration) -> String {
let secs = duration.num_seconds();
if secs < 0 {
return "just now".to_string();
}
let minutes = secs / 60;
let hours = minutes / 60;
let days = hours / 24;
if days > 0 {
format!("claimed {} day(s) ago", days)
} else if hours > 0 {
format!("claimed {} hour(s) ago", hours)
} else if minutes > 0 {
format!("claimed {} minute(s) ago", minutes)
} else {
"claimed just now".to_string()
}
}
pub fn tidy(mana_dir: &Path, dry_run: bool, check_agents: fn() -> bool) -> Result<TidyResult> {
let index = Index::build(mana_dir).context("Failed to build index")?;
let closed: Vec<&crate::index::IndexEntry> = index
.units
.iter()
.filter(|entry| entry.status == Status::Closed)
.collect();
let mut tidied: Vec<TidiedUnit> = Vec::new();
let mut skipped_parent_ids: Vec<String> = Vec::new();
for entry in &closed {
let unit_path = match find_unit_file(mana_dir, &entry.id) {
Ok(path) => path,
Err(_) => continue,
};
let mut unit = Unit::from_file(&unit_path)
.with_context(|| format!("Failed to load unit: {}", entry.id))?;
if unit.is_archived {
continue;
}
let has_open_children = index
.units
.iter()
.any(|b| b.parent.as_deref() == Some(entry.id.as_str()) && b.status != Status::Closed);
if has_open_children {
skipped_parent_ids.push(entry.id.clone());
continue;
}
let archive_date = unit
.closed_at
.unwrap_or(unit.updated_at)
.with_timezone(&chrono::Local)
.date_naive();
let slug = unit
.slug
.clone()
.unwrap_or_else(|| title_to_slug(&unit.title));
let ext = unit_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("md");
let archive_path = archive_path_for_unit(mana_dir, &entry.id, &slug, ext, archive_date);
let relative = archive_path.strip_prefix(mana_dir).unwrap_or(&archive_path);
tidied.push(TidiedUnit {
id: entry.id.clone(),
title: entry.title.clone(),
archive_path: relative.display().to_string(),
});
if dry_run {
continue;
}
if let Some(parent) = archive_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create archive directory for unit {}", entry.id)
})?;
}
std::fs::rename(&unit_path, &archive_path)
.with_context(|| format!("Failed to move unit {} to archive", entry.id))?;
unit.is_archived = true;
unit.to_file(&archive_path)
.with_context(|| format!("Failed to save archived unit: {}", entry.id))?;
}
let in_progress: Vec<&crate::index::IndexEntry> = index
.units
.iter()
.filter(|entry| entry.status == Status::InProgress)
.collect();
let mut released: Vec<ReleasedUnit> = Vec::new();
let agents_running;
let in_progress_count = in_progress.len();
if !in_progress.is_empty() {
agents_running = check_agents();
if !agents_running {
for entry in &in_progress {
let unit_path = match find_unit_file(mana_dir, &entry.id) {
Ok(path) => path,
Err(_) => continue,
};
let mut unit = match Unit::from_file(&unit_path) {
Ok(b) => b,
Err(_) => continue,
};
let reason = if let Some(claimed_at) = unit.claimed_at {
let age = Utc::now().signed_duration_since(claimed_at);
format_duration(age)
} else {
"never properly claimed".to_string()
};
released.push(ReleasedUnit {
id: entry.id.clone(),
title: entry.title.clone(),
reason,
});
if dry_run {
continue;
}
let now = Utc::now();
unit.status = Status::Open;
unit.claimed_by = None;
unit.claimed_at = None;
unit.updated_at = now;
unit.to_file(&unit_path)
.with_context(|| format!("Failed to release stale unit: {}", entry.id))?;
}
}
} else {
agents_running = false;
}
let final_index = Index::build(mana_dir).context("Failed to rebuild index after tidy")?;
final_index.save(mana_dir).context("Failed to save index")?;
if !dry_run && !tidied.is_empty() {
let archive_index =
ArchiveIndex::build(mana_dir).context("Failed to rebuild archive index after tidy")?;
archive_index
.save(mana_dir)
.context("Failed to save archive index")?;
}
Ok(TidyResult {
tidied,
released,
skipped_parent_ids,
index_count: final_index.units.len(),
agents_running,
in_progress_count,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup() -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().unwrap();
let mana_dir = dir.path().join(".mana");
fs::create_dir(&mana_dir).unwrap();
(dir, mana_dir)
}
fn no_agents() -> bool {
false
}
fn agents_running_fn() -> bool {
true
}
fn write_unit(mana_dir: &Path, unit: &Unit) {
let slug = title_to_slug(&unit.title);
let path = mana_dir.join(format!("{}-{}.md", unit.id, slug));
unit.to_file(path).unwrap();
}
#[test]
fn tidy_archives_closed_units() {
let (_dir, mana_dir) = setup();
let mut unit = Unit::new("1", "Done task");
unit.status = Status::Closed;
unit.closed_at = Some(chrono::Utc::now());
write_unit(&mana_dir, &unit);
let result = tidy(&mana_dir, false, no_agents).unwrap();
assert_eq!(result.tidied.len(), 1);
assert_eq!(result.tidied[0].id, "1");
assert!(find_unit_file(&mana_dir, "1").is_err());
let archived = crate::discovery::find_archived_unit(&mana_dir, "1");
assert!(archived.is_ok());
}
#[test]
fn tidy_leaves_open_units_alone() {
let (_dir, mana_dir) = setup();
let unit = Unit::new("1", "Open task");
write_unit(&mana_dir, &unit);
let result = tidy(&mana_dir, false, no_agents).unwrap();
assert!(result.tidied.is_empty());
assert!(find_unit_file(&mana_dir, "1").is_ok());
}
#[test]
fn tidy_dry_run_does_not_move_files() {
let (_dir, mana_dir) = setup();
let mut unit = Unit::new("1", "Done task");
unit.status = Status::Closed;
unit.closed_at = Some(chrono::Utc::now());
write_unit(&mana_dir, &unit);
let result = tidy(&mana_dir, true, no_agents).unwrap();
assert_eq!(result.tidied.len(), 1);
assert!(find_unit_file(&mana_dir, "1").is_ok());
}
#[test]
fn tidy_skips_closed_parent_with_open_children() {
let (_dir, mana_dir) = setup();
let mut parent = Unit::new("1", "Parent");
parent.status = Status::Closed;
parent.closed_at = Some(chrono::Utc::now());
write_unit(&mana_dir, &parent);
let mut child = Unit::new("1.1", "Child");
child.parent = Some("1".to_string());
write_unit(&mana_dir, &child);
let result = tidy(&mana_dir, false, no_agents).unwrap();
assert!(result.tidied.is_empty());
assert_eq!(result.skipped_parent_ids, vec!["1"]);
assert!(find_unit_file(&mana_dir, "1").is_ok());
}
#[test]
fn tidy_releases_stale_in_progress_units() {
let (_dir, mana_dir) = setup();
let mut unit = Unit::new("1", "Stale WIP");
unit.status = Status::InProgress;
unit.claimed_at = Some(
chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
);
write_unit(&mana_dir, &unit);
let result = tidy(&mana_dir, false, no_agents).unwrap();
assert_eq!(result.released.len(), 1);
let updated = Unit::from_file(find_unit_file(&mana_dir, "1").unwrap()).unwrap();
assert_eq!(updated.status, Status::Open);
assert!(updated.claimed_by.is_none());
}
#[test]
fn tidy_skips_in_progress_when_agents_running() {
let (_dir, mana_dir) = setup();
let mut unit = Unit::new("1", "Active WIP");
unit.status = Status::InProgress;
unit.claimed_at = Some(chrono::Utc::now());
write_unit(&mana_dir, &unit);
let result = tidy(&mana_dir, false, agents_running_fn).unwrap();
assert!(result.released.is_empty());
assert!(result.agents_running);
let updated = Unit::from_file(find_unit_file(&mana_dir, "1").unwrap()).unwrap();
assert_eq!(updated.status, Status::InProgress);
}
#[test]
fn tidy_empty_project() {
let (_dir, mana_dir) = setup();
let result = tidy(&mana_dir, false, no_agents).unwrap();
assert!(result.tidied.is_empty());
assert!(result.released.is_empty());
}
}