Skip to main content

mana_core/ops/
tidy.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use chrono::Utc;
5
6use crate::discovery::{archive_path_for_unit, find_unit_file};
7use crate::index::{ArchiveIndex, Index};
8use crate::unit::{Status, Unit};
9use crate::util::title_to_slug;
10
11/// A record of one unit that was archived during tidy.
12#[derive(Debug, Clone)]
13pub struct TidiedUnit {
14    pub id: String,
15    pub title: String,
16    pub archive_path: String,
17}
18
19/// A record of one unit that was released during tidy.
20#[derive(Debug, Clone)]
21pub struct ReleasedUnit {
22    pub id: String,
23    pub title: String,
24    pub reason: String,
25}
26
27/// Result of a tidy operation.
28pub struct TidyResult {
29    pub tidied: Vec<TidiedUnit>,
30    pub released: Vec<ReleasedUnit>,
31    pub skipped_parent_ids: Vec<String>,
32    pub index_count: usize,
33    pub agents_running: bool,
34    pub in_progress_count: usize,
35}
36
37/// Format a chrono Duration as a human-readable string like "3 days ago".
38fn format_duration(duration: chrono::Duration) -> String {
39    let secs = duration.num_seconds();
40    if secs < 0 {
41        return "just now".to_string();
42    }
43    let minutes = secs / 60;
44    let hours = minutes / 60;
45    let days = hours / 24;
46
47    if days > 0 {
48        format!("claimed {} day(s) ago", days)
49    } else if hours > 0 {
50        format!("claimed {} hour(s) ago", hours)
51    } else if minutes > 0 {
52        format!("claimed {} minute(s) ago", minutes)
53    } else {
54        "claimed just now".to_string()
55    }
56}
57
58/// Tidy the units directory: archive closed units, release stale in-progress
59/// units, and rebuild the index.
60///
61/// The `check_agents` function is injectable for testability. It returns true
62/// if agent processes are currently running.
63///
64/// With `dry_run = true`, reports what would change without touching any files.
65pub fn tidy(mana_dir: &Path, dry_run: bool, check_agents: fn() -> bool) -> Result<TidyResult> {
66    let index = Index::build(mana_dir).context("Failed to build index")?;
67
68    let closed: Vec<&crate::index::IndexEntry> = index
69        .units
70        .iter()
71        .filter(|entry| entry.status == Status::Closed)
72        .collect();
73
74    let mut tidied: Vec<TidiedUnit> = Vec::new();
75    let mut skipped_parent_ids: Vec<String> = Vec::new();
76
77    for entry in &closed {
78        let unit_path = match find_unit_file(mana_dir, &entry.id) {
79            Ok(path) => path,
80            Err(_) => continue,
81        };
82
83        let mut unit = Unit::from_file(&unit_path)
84            .with_context(|| format!("Failed to load unit: {}", entry.id))?;
85
86        if unit.is_archived {
87            continue;
88        }
89
90        let has_open_children = index
91            .units
92            .iter()
93            .any(|b| b.parent.as_deref() == Some(entry.id.as_str()) && b.status != Status::Closed);
94
95        if has_open_children {
96            skipped_parent_ids.push(entry.id.clone());
97            continue;
98        }
99
100        let archive_date = unit
101            .closed_at
102            .unwrap_or(unit.updated_at)
103            .with_timezone(&chrono::Local)
104            .date_naive();
105
106        let slug = unit
107            .slug
108            .clone()
109            .unwrap_or_else(|| title_to_slug(&unit.title));
110        let ext = unit_path
111            .extension()
112            .and_then(|e| e.to_str())
113            .unwrap_or("md");
114        let archive_path = archive_path_for_unit(mana_dir, &entry.id, &slug, ext, archive_date);
115
116        let relative = archive_path.strip_prefix(mana_dir).unwrap_or(&archive_path);
117        tidied.push(TidiedUnit {
118            id: entry.id.clone(),
119            title: entry.title.clone(),
120            archive_path: relative.display().to_string(),
121        });
122
123        if dry_run {
124            continue;
125        }
126
127        if let Some(parent) = archive_path.parent() {
128            std::fs::create_dir_all(parent).with_context(|| {
129                format!("Failed to create archive directory for unit {}", entry.id)
130            })?;
131        }
132
133        std::fs::rename(&unit_path, &archive_path)
134            .with_context(|| format!("Failed to move unit {} to archive", entry.id))?;
135
136        unit.is_archived = true;
137        unit.to_file(&archive_path)
138            .with_context(|| format!("Failed to save archived unit: {}", entry.id))?;
139    }
140
141    // Release stale in-progress units
142    let in_progress: Vec<&crate::index::IndexEntry> = index
143        .units
144        .iter()
145        .filter(|entry| entry.status == Status::InProgress)
146        .collect();
147
148    let mut released: Vec<ReleasedUnit> = Vec::new();
149    let agents_running;
150    let in_progress_count = in_progress.len();
151
152    if !in_progress.is_empty() {
153        agents_running = check_agents();
154
155        if !agents_running {
156            for entry in &in_progress {
157                let unit_path = match find_unit_file(mana_dir, &entry.id) {
158                    Ok(path) => path,
159                    Err(_) => continue,
160                };
161
162                let mut unit = match Unit::from_file(&unit_path) {
163                    Ok(b) => b,
164                    Err(_) => continue,
165                };
166
167                let reason = if let Some(claimed_at) = unit.claimed_at {
168                    let age = Utc::now().signed_duration_since(claimed_at);
169                    format_duration(age)
170                } else {
171                    "never properly claimed".to_string()
172                };
173
174                released.push(ReleasedUnit {
175                    id: entry.id.clone(),
176                    title: entry.title.clone(),
177                    reason,
178                });
179
180                if dry_run {
181                    continue;
182                }
183
184                let now = Utc::now();
185                unit.status = Status::Open;
186                unit.claimed_by = None;
187                unit.claimed_at = None;
188                unit.updated_at = now;
189
190                unit.to_file(&unit_path)
191                    .with_context(|| format!("Failed to release stale unit: {}", entry.id))?;
192            }
193        }
194    } else {
195        agents_running = false;
196    }
197
198    // Rebuild the index
199    let final_index = Index::build(mana_dir).context("Failed to rebuild index after tidy")?;
200    final_index.save(mana_dir).context("Failed to save index")?;
201
202    // Rebuild archive index if units were archived
203    if !dry_run && !tidied.is_empty() {
204        let archive_index =
205            ArchiveIndex::build(mana_dir).context("Failed to rebuild archive index after tidy")?;
206        archive_index
207            .save(mana_dir)
208            .context("Failed to save archive index")?;
209    }
210
211    Ok(TidyResult {
212        tidied,
213        released,
214        skipped_parent_ids,
215        index_count: final_index.units.len(),
216        agents_running,
217        in_progress_count,
218    })
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use std::fs;
225    use tempfile::TempDir;
226
227    fn setup() -> (TempDir, std::path::PathBuf) {
228        let dir = TempDir::new().unwrap();
229        let mana_dir = dir.path().join(".mana");
230        fs::create_dir(&mana_dir).unwrap();
231        (dir, mana_dir)
232    }
233
234    fn no_agents() -> bool {
235        false
236    }
237
238    fn agents_running_fn() -> bool {
239        true
240    }
241
242    fn write_unit(mana_dir: &Path, unit: &Unit) {
243        let slug = title_to_slug(&unit.title);
244        let path = mana_dir.join(format!("{}-{}.md", unit.id, slug));
245        unit.to_file(path).unwrap();
246    }
247
248    #[test]
249    fn tidy_archives_closed_units() {
250        let (_dir, mana_dir) = setup();
251
252        let mut unit = Unit::new("1", "Done task");
253        unit.status = Status::Closed;
254        unit.closed_at = Some(chrono::Utc::now());
255        write_unit(&mana_dir, &unit);
256
257        let result = tidy(&mana_dir, false, no_agents).unwrap();
258
259        assert_eq!(result.tidied.len(), 1);
260        assert_eq!(result.tidied[0].id, "1");
261        assert!(find_unit_file(&mana_dir, "1").is_err());
262        let archived = crate::discovery::find_archived_unit(&mana_dir, "1");
263        assert!(archived.is_ok());
264    }
265
266    #[test]
267    fn tidy_leaves_open_units_alone() {
268        let (_dir, mana_dir) = setup();
269
270        let unit = Unit::new("1", "Open task");
271        write_unit(&mana_dir, &unit);
272
273        let result = tidy(&mana_dir, false, no_agents).unwrap();
274
275        assert!(result.tidied.is_empty());
276        assert!(find_unit_file(&mana_dir, "1").is_ok());
277    }
278
279    #[test]
280    fn tidy_dry_run_does_not_move_files() {
281        let (_dir, mana_dir) = setup();
282
283        let mut unit = Unit::new("1", "Done task");
284        unit.status = Status::Closed;
285        unit.closed_at = Some(chrono::Utc::now());
286        write_unit(&mana_dir, &unit);
287
288        let result = tidy(&mana_dir, true, no_agents).unwrap();
289
290        assert_eq!(result.tidied.len(), 1);
291        assert!(find_unit_file(&mana_dir, "1").is_ok());
292    }
293
294    #[test]
295    fn tidy_skips_closed_parent_with_open_children() {
296        let (_dir, mana_dir) = setup();
297
298        let mut parent = Unit::new("1", "Parent");
299        parent.status = Status::Closed;
300        parent.closed_at = Some(chrono::Utc::now());
301        write_unit(&mana_dir, &parent);
302
303        let mut child = Unit::new("1.1", "Child");
304        child.parent = Some("1".to_string());
305        write_unit(&mana_dir, &child);
306
307        let result = tidy(&mana_dir, false, no_agents).unwrap();
308
309        assert!(result.tidied.is_empty());
310        assert_eq!(result.skipped_parent_ids, vec!["1"]);
311        assert!(find_unit_file(&mana_dir, "1").is_ok());
312    }
313
314    #[test]
315    fn tidy_releases_stale_in_progress_units() {
316        let (_dir, mana_dir) = setup();
317
318        let mut unit = Unit::new("1", "Stale WIP");
319        unit.status = Status::InProgress;
320        unit.claimed_at = Some(
321            chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z")
322                .unwrap()
323                .with_timezone(&chrono::Utc),
324        );
325        write_unit(&mana_dir, &unit);
326
327        let result = tidy(&mana_dir, false, no_agents).unwrap();
328
329        assert_eq!(result.released.len(), 1);
330        let updated = Unit::from_file(find_unit_file(&mana_dir, "1").unwrap()).unwrap();
331        assert_eq!(updated.status, Status::Open);
332        assert!(updated.claimed_by.is_none());
333    }
334
335    #[test]
336    fn tidy_skips_in_progress_when_agents_running() {
337        let (_dir, mana_dir) = setup();
338
339        let mut unit = Unit::new("1", "Active WIP");
340        unit.status = Status::InProgress;
341        unit.claimed_at = Some(chrono::Utc::now());
342        write_unit(&mana_dir, &unit);
343
344        let result = tidy(&mana_dir, false, agents_running_fn).unwrap();
345
346        assert!(result.released.is_empty());
347        assert!(result.agents_running);
348        let updated = Unit::from_file(find_unit_file(&mana_dir, "1").unwrap()).unwrap();
349        assert_eq!(updated.status, Status::InProgress);
350    }
351
352    #[test]
353    fn tidy_empty_project() {
354        let (_dir, mana_dir) = setup();
355        let result = tidy(&mana_dir, false, no_agents).unwrap();
356        assert!(result.tidied.is_empty());
357        assert!(result.released.is_empty());
358    }
359}