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#[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
42pub 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 let unit_path = find_unit_file(mana_dir, id)?;
62 let unit = Unit::from_file(&unit_path)?;
63
64 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 let parent_chain = collect_parent_chain(&index, &entry.parent, &mut HashSet::new());
78
79 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 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 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 let attempts = build_attempt_summary(&unit);
118
119 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
142fn 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 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 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 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 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 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 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 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#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::unit::{AttemptOutcome, AttemptRecord, Unit};
318 use tempfile::TempDir;
319
320 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 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 let parent_unit = Unit::new("10", "parent task");
367 write_unit(mana_dir, &parent_unit);
368
369 let mut dep_unit = Unit::new("11", "dep task");
371 dep_unit.status = Status::Closed;
372 write_unit(mana_dir, &dep_unit);
373
374 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 let result = cmd_trace("999", false, mana_dir);
415 assert!(result.is_err(), "Should error for missing unit");
416 }
417}