1use std::collections::HashSet;
2use std::path::Path;
3
4use anyhow::{anyhow, Result};
5use serde::Serialize;
6
7use crate::bean::{AttemptOutcome, Bean, Status};
8use crate::discovery::find_bean_file;
9use crate::index::Index;
10
11#[derive(Debug, Serialize)]
16pub struct TraceOutput {
17 pub bean: BeanSummary,
18 pub parent_chain: Vec<BeanSummary>,
19 pub children: Vec<BeanSummary>,
20 pub dependencies: Vec<BeanSummary>,
21 pub dependents: Vec<BeanSummary>,
22 pub produces: Vec<String>,
23 pub requires: Vec<String>,
24 pub attempts: AttemptSummary,
25}
26
27#[derive(Debug, Serialize)]
28pub struct BeanSummary {
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 pub tokens: Option<u64>,
41}
42
43pub fn cmd_trace(id: &str, json: bool, beans_dir: &Path) -> Result<()> {
53 let index = Index::load_or_rebuild(beans_dir)?;
54
55 let entry = index
56 .beans
57 .iter()
58 .find(|e| e.id == id)
59 .ok_or_else(|| anyhow!("Bean {} not found", id))?;
60
61 let bean_path = find_bean_file(beans_dir, id)?;
63 let bean = Bean::from_file(&bean_path)?;
64
65 let mut dependents_map: std::collections::HashMap<String, Vec<String>> =
67 std::collections::HashMap::new();
68 for e in &index.beans {
69 for dep in &e.dependencies {
70 dependents_map
71 .entry(dep.clone())
72 .or_default()
73 .push(e.id.clone());
74 }
75 }
76
77 let parent_chain = collect_parent_chain(&index, &entry.parent, &mut HashSet::new());
79
80 let children: Vec<BeanSummary> = index
82 .beans
83 .iter()
84 .filter(|e| e.parent.as_deref() == Some(id))
85 .map(|e| bean_summary(e.id.clone(), e.title.clone(), &e.status))
86 .collect();
87
88 let dependencies: Vec<BeanSummary> = entry
90 .dependencies
91 .iter()
92 .filter_map(|dep_id| {
93 index
94 .beans
95 .iter()
96 .find(|e| &e.id == dep_id)
97 .map(|e| bean_summary(e.id.clone(), e.title.clone(), &e.status))
98 })
99 .collect();
100
101 let dependents: Vec<BeanSummary> = dependents_map
103 .get(id)
104 .map(|ids| {
105 ids.iter()
106 .filter_map(|dep_id| {
107 index
108 .beans
109 .iter()
110 .find(|e| &e.id == dep_id)
111 .map(|e| bean_summary(e.id.clone(), e.title.clone(), &e.status))
112 })
113 .collect()
114 })
115 .unwrap_or_default();
116
117 let attempts = build_attempt_summary(&bean);
119
120 let this_summary = bean_summary(entry.id.clone(), entry.title.clone(), &entry.status);
122
123 let output = TraceOutput {
124 bean: this_summary,
125 parent_chain,
126 children,
127 dependencies,
128 dependents,
129 produces: entry.produces.clone(),
130 requires: entry.requires.clone(),
131 attempts,
132 };
133
134 if json {
135 println!("{}", serde_json::to_string_pretty(&output)?);
136 } else {
137 print_trace(&output);
138 }
139
140 Ok(())
141}
142
143fn collect_parent_chain(
148 index: &Index,
149 parent_id: &Option<String>,
150 visited: &mut HashSet<String>,
151) -> Vec<BeanSummary> {
152 let Some(pid) = parent_id else {
153 return vec![];
154 };
155
156 if visited.contains(pid) {
158 return vec![];
159 }
160 visited.insert(pid.clone());
161
162 if let Some(entry) = index.beans.iter().find(|e| &e.id == pid) {
163 let mut chain = vec![bean_summary(
164 entry.id.clone(),
165 entry.title.clone(),
166 &entry.status,
167 )];
168 chain.extend(collect_parent_chain(index, &entry.parent, visited));
169 chain
170 } else {
171 vec![]
172 }
173}
174
175fn bean_summary(id: String, title: String, status: &Status) -> BeanSummary {
176 BeanSummary {
177 id,
178 title,
179 status: status.to_string(),
180 }
181}
182
183fn build_attempt_summary(bean: &Bean) -> AttemptSummary {
184 let total = bean.attempt_log.len();
185 let successful = bean
186 .attempt_log
187 .iter()
188 .filter(|a| matches!(a.outcome, AttemptOutcome::Success))
189 .count();
190 let failed = bean
191 .attempt_log
192 .iter()
193 .filter(|a| matches!(a.outcome, AttemptOutcome::Failed))
194 .count();
195 let abandoned = bean
196 .attempt_log
197 .iter()
198 .filter(|a| matches!(a.outcome, AttemptOutcome::Abandoned))
199 .count();
200
201 AttemptSummary {
202 total,
203 successful,
204 failed,
205 abandoned,
206 tokens: bean.tokens,
207 }
208}
209
210fn status_indicator(status: &str) -> &str {
211 match status {
212 "closed" => "✓",
213 "in_progress" => "⚡",
214 _ => "○",
215 }
216}
217
218fn print_trace(output: &TraceOutput) {
219 let b = &output.bean;
220 println!("Bean {}: \"{}\" [{}]", b.id, b.title, b.status);
221
222 if output.parent_chain.is_empty() {
224 println!(" Parent: (root)");
225 } else {
226 let mut indent = " ".to_string();
227 for parent in &output.parent_chain {
228 println!(
229 "{}Parent: {} {} \"{}\" [{}]",
230 indent,
231 status_indicator(&parent.status),
232 parent.id,
233 parent.title,
234 parent.status
235 );
236 indent.push_str(" ");
237 }
238 println!("{}Parent: (root)", indent);
239 }
240
241 if !output.children.is_empty() {
243 println!(" Children:");
244 for child in &output.children {
245 println!(
246 " {} {} \"{}\" [{}]",
247 status_indicator(&child.status),
248 child.id,
249 child.title,
250 child.status
251 );
252 }
253 }
254
255 if output.dependencies.is_empty() {
257 println!(" Dependencies: (none)");
258 } else {
259 println!(" Dependencies:");
260 for dep in &output.dependencies {
261 println!(
262 " → {} {} \"{}\" [{}]",
263 status_indicator(&dep.status),
264 dep.id,
265 dep.title,
266 dep.status
267 );
268 }
269 }
270
271 if output.dependents.is_empty() {
273 println!(" Dependents: (none)");
274 } else {
275 println!(" Dependents:");
276 for dep in &output.dependents {
277 println!(
278 " ← {} {} \"{}\" [{}]",
279 status_indicator(&dep.status),
280 dep.id,
281 dep.title,
282 dep.status
283 );
284 }
285 }
286
287 if output.produces.is_empty() {
289 println!(" Produces: (none)");
290 } else {
291 println!(" Produces: {}", output.produces.join(", "));
292 }
293
294 if output.requires.is_empty() {
295 println!(" Requires: (none)");
296 } else {
297 println!(" Requires: {}", output.requires.join(", "));
298 }
299
300 let a = &output.attempts;
302 if a.total == 0 {
303 println!(" Attempts: (none)");
304 } else {
305 let tokens_str = match a.tokens {
306 Some(t) if t >= 1000 => format!(", {}K tokens", t / 1000),
307 Some(t) => format!(", {} tokens", t),
308 None => String::new(),
309 };
310 println!(
311 " Attempts: {} total ({} success, {} failed, {} abandoned{})",
312 a.total, a.successful, a.failed, a.abandoned, tokens_str
313 );
314 }
315}
316
317#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::bean::{AttemptOutcome, AttemptRecord, Bean};
325 use tempfile::TempDir;
326
327 fn write_bean(beans_dir: &Path, bean: &Bean) {
329 let path = beans_dir.join(format!("{}.yaml", bean.id));
330 bean.to_file(&path).expect("write bean file");
331 }
332
333 #[test]
334 fn test_trace_no_parent_no_deps() {
335 let tmp = TempDir::new().unwrap();
336 let beans_dir = tmp.path();
337
338 let mut bean = Bean::new("42", "test bean");
339 bean.produces = vec!["artifact-a".to_string()];
340 bean.tokens = Some(5000);
341 bean.attempt_log = vec![AttemptRecord {
342 num: 1,
343 outcome: AttemptOutcome::Abandoned,
344 notes: None,
345 agent: None,
346 started_at: None,
347 finished_at: None,
348 }];
349 write_bean(beans_dir, &bean);
350
351 let result = cmd_trace("42", false, beans_dir);
353 assert!(result.is_ok(), "cmd_trace failed: {:?}", result);
354 }
355
356 #[test]
357 fn test_trace_json_output() {
358 let tmp = TempDir::new().unwrap();
359 let beans_dir = tmp.path();
360
361 let bean = Bean::new("1", "root bean");
362 write_bean(beans_dir, &bean);
363
364 let result = cmd_trace("1", true, beans_dir);
365 assert!(result.is_ok(), "cmd_trace --json failed: {:?}", result);
366 }
367
368 #[test]
369 fn test_trace_with_parent_and_deps() {
370 let tmp = TempDir::new().unwrap();
371 let beans_dir = tmp.path();
372
373 let parent_bean = Bean::new("10", "parent task");
375 write_bean(beans_dir, &parent_bean);
376
377 let mut dep_bean = Bean::new("11", "dep task");
379 dep_bean.status = Status::Closed;
380 write_bean(beans_dir, &dep_bean);
381
382 let mut main_bean = Bean::new("12", "main task");
384 main_bean.parent = Some("10".to_string());
385 main_bean.dependencies = vec!["11".to_string()];
386 main_bean.produces = vec!["api.rs".to_string()];
387 main_bean.requires = vec!["Config".to_string()];
388 main_bean.tokens = Some(12000);
389 main_bean.attempt_log = vec![
390 AttemptRecord {
391 num: 1,
392 outcome: AttemptOutcome::Failed,
393 notes: None,
394 agent: None,
395 started_at: None,
396 finished_at: None,
397 },
398 AttemptRecord {
399 num: 2,
400 outcome: AttemptOutcome::Success,
401 notes: None,
402 agent: None,
403 started_at: None,
404 finished_at: None,
405 },
406 ];
407 write_bean(beans_dir, &main_bean);
408
409 let result = cmd_trace("12", false, beans_dir);
410 assert!(
411 result.is_ok(),
412 "cmd_trace with parent/deps failed: {:?}",
413 result
414 );
415 }
416
417 #[test]
418 fn test_trace_not_found() {
419 let tmp = TempDir::new().unwrap();
420 let beans_dir = tmp.path();
421
422 let result = cmd_trace("999", false, beans_dir);
424 assert!(result.is_err(), "Should error for missing bean");
425 }
426}