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#[derive(Debug, Clone)]
13pub struct TidiedUnit {
14 pub id: String,
15 pub title: String,
16 pub archive_path: String,
17}
18
19#[derive(Debug, Clone)]
21pub struct ReleasedUnit {
22 pub id: String,
23 pub title: String,
24 pub reason: String,
25}
26
27pub 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
37fn 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
58pub 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 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 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 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}