1use std::collections::{HashMap, HashSet};
31use std::path::Path;
32
33use anyhow::{anyhow, Result};
34
35use crate::index::Index;
36
37pub fn detect_cycle(index: &Index, from_id: &str, to_id: &str) -> Result<bool> {
42 if from_id == to_id {
44 return Ok(true);
45 }
46
47 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
49 for entry in &index.units {
50 graph.insert(entry.id.clone(), entry.dependencies.clone());
51 }
52
53 let mut visited = HashSet::new();
55 let mut stack = vec![to_id.to_string()];
56
57 while let Some(current) = stack.pop() {
58 if current == from_id {
59 return Ok(true);
60 }
61
62 if visited.contains(¤t) {
63 continue;
64 }
65 visited.insert(current.clone());
66
67 if let Some(deps) = graph.get(¤t) {
68 for dep in deps {
69 if !visited.contains(dep) {
70 stack.push(dep.clone());
71 }
72 }
73 }
74 }
75
76 Ok(false)
77}
78
79pub fn build_dependency_tree(index: &Index, id: &str) -> Result<String> {
82 let root_entry = index
84 .units
85 .iter()
86 .find(|e| e.id == id)
87 .ok_or_else(|| anyhow!("Unit {} not found", id))?;
88
89 let mut output = String::new();
90 output.push_str(&format!("{} {}\n", root_entry.id, root_entry.title));
91
92 let graph: HashMap<String, Vec<String>> = index
94 .units
95 .iter()
96 .map(|e| (e.id.clone(), e.dependencies.clone()))
97 .collect();
98
99 let mut reverse_graph: HashMap<String, Vec<String>> = HashMap::new();
101 for (id, deps) in &graph {
102 for dep in deps {
103 reverse_graph
104 .entry(dep.clone())
105 .or_default()
106 .push(id.clone());
107 }
108 }
109
110 let id_map: HashMap<String, &crate::index::IndexEntry> =
112 index.units.iter().map(|e| (e.id.clone(), e)).collect();
113
114 let mut visited = HashSet::new();
116 build_tree_recursive(&mut output, id, &reverse_graph, &id_map, &mut visited, "");
117
118 Ok(output)
119}
120
121fn build_tree_recursive(
122 output: &mut String,
123 current_id: &str,
124 reverse_graph: &HashMap<String, Vec<String>>,
125 id_map: &HashMap<String, &crate::index::IndexEntry>,
126 visited: &mut HashSet<String>,
127 prefix: &str,
128) {
129 if visited.contains(current_id) {
130 return;
131 }
132 visited.insert(current_id.to_string());
133
134 if let Some(dependents) = reverse_graph.get(current_id) {
135 for (i, dependent_id) in dependents.iter().enumerate() {
136 let is_last_dependent = i == dependents.len() - 1;
137
138 let connector = if is_last_dependent {
139 "└── "
140 } else {
141 "├── "
142 };
143 output.push_str(prefix);
144 output.push_str(connector);
145
146 if let Some(entry) = id_map.get(dependent_id) {
147 output.push_str(&format!("{} {}\n", entry.id, entry.title));
148 } else {
149 output.push_str(&format!("{}\n", dependent_id));
150 }
151
152 let new_prefix = if is_last_dependent {
153 format!("{} ", prefix)
154 } else {
155 format!("{}│ ", prefix)
156 };
157
158 build_tree_recursive(
159 output,
160 dependent_id,
161 reverse_graph,
162 id_map,
163 visited,
164 &new_prefix,
165 );
166 }
167 }
168}
169
170pub fn build_full_graph(index: &Index) -> Result<String> {
173 let root_units: Vec<_> = index.units.iter().filter(|e| e.parent.is_none()).collect();
175
176 if root_units.is_empty() {
177 return Ok("No units found.".to_string());
178 }
179
180 let mut output = String::new();
181
182 let mut reverse_graph: HashMap<String, Vec<String>> = HashMap::new();
184 for entry in &index.units {
185 for dep in &entry.dependencies {
186 reverse_graph
187 .entry(dep.clone())
188 .or_default()
189 .push(entry.id.clone());
190 }
191 }
192
193 let id_map: HashMap<String, &crate::index::IndexEntry> =
195 index.units.iter().map(|e| (e.id.clone(), e)).collect();
196
197 let mut visited = HashSet::new();
198 for root in root_units {
199 output.push_str(&format!("{} {}\n", root.id, root.title));
200 build_tree_recursive(
201 &mut output,
202 &root.id,
203 &reverse_graph,
204 &id_map,
205 &mut visited,
206 "",
207 );
208 }
209
210 Ok(output)
211}
212
213#[must_use = "returns the total attempt count"]
218pub fn count_subtree_attempts(mana_dir: &Path, root_id: &str) -> Result<u32> {
219 let index = Index::build(mana_dir)?;
220 let archived = Index::collect_archived(mana_dir).unwrap_or_default();
221
222 let mut all_units = index.units;
224 all_units.extend(archived);
225
226 let mut total = 0u32;
227 let mut stack = vec![root_id.to_string()];
228 let mut visited = HashSet::new();
229
230 while let Some(id) = stack.pop() {
231 if !visited.insert(id.clone()) {
232 continue;
233 }
234 if let Some(entry) = all_units.iter().find(|b| b.id == id) {
235 total += entry.attempts;
236 for child in all_units
238 .iter()
239 .filter(|b| b.parent.as_deref() == Some(id.as_str()))
240 {
241 if !visited.contains(&child.id) {
242 stack.push(child.id.clone());
243 }
244 }
245 }
246 }
247 Ok(total)
248}
249
250pub fn find_all_cycles(index: &Index) -> Result<Vec<Vec<String>>> {
253 let mut cycles = Vec::new();
254
255 let graph: HashMap<String, Vec<String>> = index
257 .units
258 .iter()
259 .map(|e| (e.id.clone(), e.dependencies.clone()))
260 .collect();
261
262 let mut visited = HashSet::new();
263
264 for start_id in graph.keys() {
266 if !visited.contains(start_id) {
267 let mut path = Vec::new();
268 find_cycle_dfs(&graph, start_id, &mut visited, &mut path, &mut cycles);
269 }
270 }
271
272 Ok(cycles)
273}
274
275fn find_cycle_dfs(
276 graph: &HashMap<String, Vec<String>>,
277 current: &str,
278 visited: &mut HashSet<String>,
279 path: &mut Vec<String>,
280 cycles: &mut Vec<Vec<String>>,
281) {
282 if let Some(pos) = path.iter().position(|id| id == current) {
284 let cycle = path[pos..].to_vec();
285 if !cycles.contains(&cycle) {
286 cycles.push(cycle);
287 }
288 return;
289 }
290
291 if visited.contains(current) {
293 return;
294 }
295
296 path.push(current.to_string());
297
298 if let Some(deps) = graph.get(current) {
299 for dep in deps {
300 find_cycle_dfs(graph, dep, visited, path, cycles);
301 }
302 }
303
304 path.pop();
305 visited.insert(current.to_string());
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::unit::Unit;
312 use std::fs;
313 use tempfile::TempDir;
314
315 fn setup_test_units(specs: Vec<(&str, Vec<&str>)>) -> (TempDir, std::path::PathBuf) {
316 let dir = TempDir::new().unwrap();
317 let mana_dir = dir.path().join(".mana");
318 fs::create_dir(&mana_dir).unwrap();
319
320 for (id, deps) in specs {
321 let mut unit = Unit::new(id, format!("Task {}", id));
322 unit.dependencies = deps.iter().map(|s| s.to_string()).collect();
323 unit.to_file(mana_dir.join(format!("{}.yaml", id))).unwrap();
324 }
325
326 (dir, mana_dir)
327 }
328
329 #[test]
330 fn detect_self_cycle() {
331 let (_dir, mana_dir) = setup_test_units(vec![("1", vec![])]);
332 let index = Index::build(&mana_dir).unwrap();
333 assert!(detect_cycle(&index, "1", "1").unwrap());
334 }
335
336 #[test]
337 fn detect_two_node_cycle() {
338 let (_dir, mana_dir) = setup_test_units(vec![("1", vec!["2"]), ("2", vec![])]);
339 let index = Index::build(&mana_dir).unwrap();
340 assert!(detect_cycle(&index, "2", "1").unwrap());
341 assert!(!detect_cycle(&index, "1", "2").unwrap());
342 }
343
344 #[test]
345 fn detect_three_node_cycle() {
346 let (_dir, mana_dir) =
347 setup_test_units(vec![("1", vec!["2"]), ("2", vec!["3"]), ("3", vec![])]);
348 let index = Index::build(&mana_dir).unwrap();
349 assert!(detect_cycle(&index, "3", "1").unwrap());
351 assert!(!detect_cycle(&index, "1", "3").unwrap());
352 }
353
354 #[test]
355 fn no_cycle_linear_chain() {
356 let (_dir, mana_dir) =
357 setup_test_units(vec![("1", vec!["2"]), ("2", vec!["3"]), ("3", vec![])]);
358 let index = Index::build(&mana_dir).unwrap();
359 assert!(!detect_cycle(&index, "1", "2").unwrap());
360 assert!(!detect_cycle(&index, "2", "3").unwrap());
361 }
362
363 fn setup_subtree_units(specs: Vec<(&str, Option<&str>, u32)>) -> (TempDir, std::path::PathBuf) {
370 let dir = TempDir::new().unwrap();
371 let mana_dir = dir.path().join(".mana");
372 fs::create_dir(&mana_dir).unwrap();
373
374 for (id, parent, attempts) in specs {
375 let mut unit = Unit::new(id, format!("Task {}", id));
376 unit.parent = parent.map(|s| s.to_string());
377 unit.attempts = attempts;
378 let slug = crate::util::title_to_slug(&unit.title);
379 unit.to_file(mana_dir.join(format!("{}-{}.md", id, slug)))
380 .unwrap();
381 }
382
383 (dir, mana_dir)
384 }
385
386 #[test]
387 fn subtree_attempts_single_unit_no_children() {
388 let (_dir, mana_dir) = setup_subtree_units(vec![("1", None, 5)]);
389 let total = count_subtree_attempts(&mana_dir, "1").unwrap();
390 assert_eq!(total, 5);
391 }
392
393 #[test]
394 fn subtree_attempts_includes_root() {
395 let (_dir, mana_dir) = setup_subtree_units(vec![
396 ("1", None, 3),
397 ("1.1", Some("1"), 2),
398 ("1.2", Some("1"), 1),
399 ]);
400 let total = count_subtree_attempts(&mana_dir, "1").unwrap();
401 assert_eq!(total, 6);
403 }
404
405 #[test]
406 fn subtree_attempts_sums_all_descendants() {
407 let (_dir, mana_dir) = setup_subtree_units(vec![
408 ("1", None, 0),
409 ("1.1", Some("1"), 2),
410 ("1.2", Some("1"), 3),
411 ("1.1.1", Some("1.1"), 1),
412 ("1.1.2", Some("1.1"), 4),
413 ]);
414 let total = count_subtree_attempts(&mana_dir, "1").unwrap();
415 assert_eq!(total, 10);
417 }
418
419 #[test]
420 fn subtree_attempts_subtree_only() {
421 let (_dir, mana_dir) = setup_subtree_units(vec![
423 ("1", None, 1),
424 ("1.1", Some("1"), 5),
425 ("2", None, 10),
426 ("2.1", Some("2"), 20),
427 ]);
428 let total = count_subtree_attempts(&mana_dir, "1").unwrap();
429 assert_eq!(total, 6);
431 }
432
433 #[test]
434 fn subtree_attempts_unknown_root_returns_zero() {
435 let (_dir, mana_dir) = setup_subtree_units(vec![("1", None, 5)]);
436 let total = count_subtree_attempts(&mana_dir, "999").unwrap();
437 assert_eq!(total, 0);
438 }
439
440 #[test]
441 fn subtree_attempts_zero_attempts_everywhere() {
442 let (_dir, mana_dir) = setup_subtree_units(vec![
443 ("1", None, 0),
444 ("1.1", Some("1"), 0),
445 ("1.2", Some("1"), 0),
446 ]);
447 let total = count_subtree_attempts(&mana_dir, "1").unwrap();
448 assert_eq!(total, 0);
449 }
450
451 #[test]
452 fn subtree_attempts_includes_archived_units() {
453 let (_dir, mana_dir) = setup_subtree_units(vec![("1", None, 1), ("1.2", Some("1"), 2)]);
454
455 let archive_dir = mana_dir.join("archive").join("2026").join("02");
457 fs::create_dir_all(&archive_dir).unwrap();
458 let mut archived_unit = Unit::new("1.1", "Archived Child");
459 archived_unit.parent = Some("1".to_string());
460 archived_unit.attempts = 3;
461 archived_unit.status = crate::unit::Status::Closed;
462 archived_unit.is_archived = true;
463 archived_unit
464 .to_file(archive_dir.join("1.1-archived-child.md"))
465 .unwrap();
466
467 let total = count_subtree_attempts(&mana_dir, "1").unwrap();
468 assert_eq!(total, 6);
470 }
471}