Skip to main content

mana/commands/
trace.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use anyhow::{anyhow, Result};
5use serde::Serialize;
6
7use crate::discovery::find_unit_file;
8use crate::index::Index;
9use crate::unit::{AttemptOutcome, Status, Unit};
10
11// ---------------------------------------------------------------------------
12// Output types (text + JSON)
13// ---------------------------------------------------------------------------
14
15#[derive(Debug, Serialize)]
16pub struct TraceOutput {
17    pub unit: UnitSummary,
18    pub parent_chain: Vec<UnitSummary>,
19    pub children: Vec<UnitSummary>,
20    pub dependencies: Vec<UnitSummary>,
21    pub dependents: Vec<UnitSummary>,
22    pub produces: Vec<String>,
23    pub requires: Vec<String>,
24    pub attempts: AttemptSummary,
25}
26
27#[derive(Debug, Serialize)]
28pub struct UnitSummary {
29    pub id: String,
30    pub title: String,
31    pub status: String,
32}
33
34#[derive(Debug, Serialize)]
35pub struct AttemptSummary {
36    pub total: usize,
37    pub successful: usize,
38    pub failed: usize,
39    pub abandoned: usize,
40}
41
42// ---------------------------------------------------------------------------
43// Main entry point
44// ---------------------------------------------------------------------------
45
46/// Handle `mana trace <id>` command.
47///
48/// Walks the unit graph from the given unit: parent chain up to root,
49/// direct children, dependencies (what this unit waits on), dependents
50/// (what waits on this unit), produces/requires artifacts, and attempt history.
51pub fn cmd_trace(id: &str, json: bool, mana_dir: &Path) -> Result<()> {
52    let index = Index::load_or_rebuild(mana_dir)?;
53
54    let entry = index
55        .units
56        .iter()
57        .find(|e| e.id == id)
58        .ok_or_else(|| anyhow!("Unit {} not found", id))?;
59
60    // Load full unit for attempt log and tokens
61    let unit_path = find_unit_file(mana_dir, id)?;
62    let unit = Unit::from_file(&unit_path)?;
63
64    // Build reverse graph: dep_id -> list of unit IDs that depend on it
65    let mut dependents_map: std::collections::HashMap<String, Vec<String>> =
66        std::collections::HashMap::new();
67    for e in &index.units {
68        for dep in &e.dependencies {
69            dependents_map
70                .entry(dep.clone())
71                .or_default()
72                .push(e.id.clone());
73        }
74    }
75
76    // --- Parent chain (up to root, cycle-safe) ---
77    let parent_chain = collect_parent_chain(&index, &entry.parent, &mut HashSet::new());
78
79    // --- Direct children ---
80    let children: Vec<UnitSummary> = index
81        .units
82        .iter()
83        .filter(|e| e.parent.as_deref() == Some(id))
84        .map(|e| unit_summary(e.id.clone(), e.title.clone(), &e.status))
85        .collect();
86
87    // --- Dependencies (what this unit waits on) ---
88    let dependencies: Vec<UnitSummary> = entry
89        .dependencies
90        .iter()
91        .filter_map(|dep_id| {
92            index
93                .units
94                .iter()
95                .find(|e| &e.id == dep_id)
96                .map(|e| unit_summary(e.id.clone(), e.title.clone(), &e.status))
97        })
98        .collect();
99
100    // --- Dependents (what waits on this unit) ---
101    let dependents: Vec<UnitSummary> = dependents_map
102        .get(id)
103        .map(|ids| {
104            ids.iter()
105                .filter_map(|dep_id| {
106                    index
107                        .units
108                        .iter()
109                        .find(|e| &e.id == dep_id)
110                        .map(|e| unit_summary(e.id.clone(), e.title.clone(), &e.status))
111                })
112                .collect()
113        })
114        .unwrap_or_default();
115
116    // --- Attempt summary ---
117    let attempts = build_attempt_summary(&unit);
118
119    // --- Build output ---
120    let this_summary = unit_summary(entry.id.clone(), entry.title.clone(), &entry.status);
121
122    let output = TraceOutput {
123        unit: this_summary,
124        parent_chain,
125        children,
126        dependencies,
127        dependents,
128        produces: entry.produces.clone(),
129        requires: entry.requires.clone(),
130        attempts,
131    };
132
133    if json {
134        println!("{}", serde_json::to_string_pretty(&output)?);
135    } else {
136        print_trace(&output);
137    }
138
139    Ok(())
140}
141
142// ---------------------------------------------------------------------------
143// Helpers
144// ---------------------------------------------------------------------------
145
146fn collect_parent_chain(
147    index: &Index,
148    parent_id: &Option<String>,
149    visited: &mut HashSet<String>,
150) -> Vec<UnitSummary> {
151    let Some(pid) = parent_id else {
152        return vec![];
153    };
154
155    // Guard against circular references (shouldn't happen but don't crash)
156    if visited.contains(pid) {
157        return vec![];
158    }
159    visited.insert(pid.clone());
160
161    if let Some(entry) = index.units.iter().find(|e| &e.id == pid) {
162        let mut chain = vec![unit_summary(
163            entry.id.clone(),
164            entry.title.clone(),
165            &entry.status,
166        )];
167        chain.extend(collect_parent_chain(index, &entry.parent, visited));
168        chain
169    } else {
170        vec![]
171    }
172}
173
174fn unit_summary(id: String, title: String, status: &Status) -> UnitSummary {
175    UnitSummary {
176        id,
177        title,
178        status: status.to_string(),
179    }
180}
181
182fn build_attempt_summary(unit: &Unit) -> AttemptSummary {
183    let total = unit.attempt_log.len();
184    let successful = unit
185        .attempt_log
186        .iter()
187        .filter(|a| matches!(a.outcome, AttemptOutcome::Success))
188        .count();
189    let failed = unit
190        .attempt_log
191        .iter()
192        .filter(|a| matches!(a.outcome, AttemptOutcome::Failed))
193        .count();
194    let abandoned = unit
195        .attempt_log
196        .iter()
197        .filter(|a| matches!(a.outcome, AttemptOutcome::Abandoned))
198        .count();
199
200    AttemptSummary {
201        total,
202        successful,
203        failed,
204        abandoned,
205    }
206}
207
208fn status_indicator(status: &str) -> &str {
209    match status {
210        "closed" => "✓",
211        "in_progress" => "⚡",
212        _ => "○",
213    }
214}
215
216fn print_trace(output: &TraceOutput) {
217    let b = &output.unit;
218    println!("Unit {}: \"{}\" [{}]", b.id, b.title, b.status);
219
220    // Parent chain
221    if output.parent_chain.is_empty() {
222        println!("  Parent: (root)");
223    } else {
224        let mut indent = "  ".to_string();
225        for parent in &output.parent_chain {
226            println!(
227                "{}Parent: {} {} \"{}\" [{}]",
228                indent,
229                status_indicator(&parent.status),
230                parent.id,
231                parent.title,
232                parent.status
233            );
234            indent.push_str("  ");
235        }
236        println!("{}Parent: (root)", indent);
237    }
238
239    // Children
240    if !output.children.is_empty() {
241        println!("  Children:");
242        for child in &output.children {
243            println!(
244                "    {} {} \"{}\" [{}]",
245                status_indicator(&child.status),
246                child.id,
247                child.title,
248                child.status
249            );
250        }
251    }
252
253    // Dependencies
254    if output.dependencies.is_empty() {
255        println!("  Dependencies: (none)");
256    } else {
257        println!("  Dependencies:");
258        for dep in &output.dependencies {
259            println!(
260                "    → {} {} \"{}\" [{}]",
261                status_indicator(&dep.status),
262                dep.id,
263                dep.title,
264                dep.status
265            );
266        }
267    }
268
269    // Dependents
270    if output.dependents.is_empty() {
271        println!("  Dependents: (none)");
272    } else {
273        println!("  Dependents:");
274        for dep in &output.dependents {
275            println!(
276                "    ← {} {} \"{}\" [{}]",
277                status_indicator(&dep.status),
278                dep.id,
279                dep.title,
280                dep.status
281            );
282        }
283    }
284
285    // Produces / Requires
286    if output.produces.is_empty() {
287        println!("  Produces: (none)");
288    } else {
289        println!("  Produces: {}", output.produces.join(", "));
290    }
291
292    if output.requires.is_empty() {
293        println!("  Requires: (none)");
294    } else {
295        println!("  Requires: {}", output.requires.join(", "));
296    }
297
298    // Attempts
299    let a = &output.attempts;
300    if a.total == 0 {
301        println!("  Attempts: (none)");
302    } else {
303        println!(
304            "  Attempts: {} total ({} success, {} failed, {} abandoned)",
305            a.total, a.successful, a.failed, a.abandoned
306        );
307    }
308}
309
310// ---------------------------------------------------------------------------
311// Tests
312// ---------------------------------------------------------------------------
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::unit::{AttemptOutcome, AttemptRecord, Unit};
318    use tempfile::TempDir;
319
320    /// Write a unit as a legacy `.yaml` file so `find_unit_file` can locate it.
321    fn write_unit(mana_dir: &Path, unit: &Unit) {
322        let path = mana_dir.join(format!("{}.yaml", unit.id));
323        unit.to_file(&path).expect("write unit file");
324    }
325
326    #[test]
327    fn test_trace_no_parent_no_deps() {
328        let tmp = TempDir::new().unwrap();
329        let mana_dir = tmp.path();
330
331        let mut unit = Unit::new("42", "test unit");
332        unit.produces = vec!["artifact-a".to_string()];
333        unit.attempt_log = vec![AttemptRecord {
334            num: 1,
335            outcome: AttemptOutcome::Abandoned,
336            notes: None,
337            agent: None,
338            started_at: None,
339            finished_at: None,
340        }];
341        write_unit(mana_dir, &unit);
342
343        // Index is rebuilt from unit files
344        let result = cmd_trace("42", false, mana_dir);
345        assert!(result.is_ok(), "cmd_trace failed: {:?}", result);
346    }
347
348    #[test]
349    fn test_trace_json_output() {
350        let tmp = TempDir::new().unwrap();
351        let mana_dir = tmp.path();
352
353        let unit = Unit::new("1", "root unit");
354        write_unit(mana_dir, &unit);
355
356        let result = cmd_trace("1", true, mana_dir);
357        assert!(result.is_ok(), "cmd_trace --json failed: {:?}", result);
358    }
359
360    #[test]
361    fn test_trace_with_parent_and_deps() {
362        let tmp = TempDir::new().unwrap();
363        let mana_dir = tmp.path();
364
365        // Parent unit
366        let parent_unit = Unit::new("10", "parent task");
367        write_unit(mana_dir, &parent_unit);
368
369        // Dependency unit
370        let mut dep_unit = Unit::new("11", "dep task");
371        dep_unit.status = Status::Closed;
372        write_unit(mana_dir, &dep_unit);
373
374        // Main unit with parent, deps, produces, requires, attempts
375        let mut main_unit = Unit::new("12", "main task");
376        main_unit.parent = Some("10".to_string());
377        main_unit.dependencies = vec!["11".to_string()];
378        main_unit.produces = vec!["api.rs".to_string()];
379        main_unit.requires = vec!["Config".to_string()];
380        main_unit.attempt_log = vec![
381            AttemptRecord {
382                num: 1,
383                outcome: AttemptOutcome::Failed,
384                notes: None,
385                agent: None,
386                started_at: None,
387                finished_at: None,
388            },
389            AttemptRecord {
390                num: 2,
391                outcome: AttemptOutcome::Success,
392                notes: None,
393                agent: None,
394                started_at: None,
395                finished_at: None,
396            },
397        ];
398        write_unit(mana_dir, &main_unit);
399
400        let result = cmd_trace("12", false, mana_dir);
401        assert!(
402            result.is_ok(),
403            "cmd_trace with parent/deps failed: {:?}",
404            result
405        );
406    }
407
408    #[test]
409    fn test_trace_not_found() {
410        let tmp = TempDir::new().unwrap();
411        let mana_dir = tmp.path();
412
413        // Empty directory — no units
414        let result = cmd_trace("999", false, mana_dir);
415        assert!(result.is_err(), "Should error for missing unit");
416    }
417}