Skip to main content

bn/commands/
trace.rs

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// ---------------------------------------------------------------------------
12// Output types (text + JSON)
13// ---------------------------------------------------------------------------
14
15#[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
43// ---------------------------------------------------------------------------
44// Main entry point
45// ---------------------------------------------------------------------------
46
47/// Handle `bn trace <id>` command.
48///
49/// Walks the bean graph from the given bean: parent chain up to root,
50/// direct children, dependencies (what this bean waits on), dependents
51/// (what waits on this bean), produces/requires artifacts, and attempt history.
52pub 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    // Load full bean for attempt log and tokens
62    let bean_path = find_bean_file(beans_dir, id)?;
63    let bean = Bean::from_file(&bean_path)?;
64
65    // Build reverse graph: dep_id -> list of bean IDs that depend on it
66    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    // --- Parent chain (up to root, cycle-safe) ---
78    let parent_chain = collect_parent_chain(&index, &entry.parent, &mut HashSet::new());
79
80    // --- Direct children ---
81    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    // --- Dependencies (what this bean waits on) ---
89    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    // --- Dependents (what waits on this bean) ---
102    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    // --- Attempt summary ---
118    let attempts = build_attempt_summary(&bean);
119
120    // --- Build output ---
121    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
143// ---------------------------------------------------------------------------
144// Helpers
145// ---------------------------------------------------------------------------
146
147fn 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    // Guard against circular references (shouldn't happen but don't crash)
157    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    // Parent chain
223    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    // Children
242    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    // Dependencies
256    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    // Dependents
272    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    // Produces / Requires
288    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    // Attempts
301    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// ---------------------------------------------------------------------------
318// Tests
319// ---------------------------------------------------------------------------
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::bean::{AttemptOutcome, AttemptRecord, Bean};
325    use tempfile::TempDir;
326
327    /// Write a bean as a legacy `.yaml` file so `find_bean_file` can locate it.
328    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        // Index is rebuilt from bean files
352        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        // Parent bean
374        let parent_bean = Bean::new("10", "parent task");
375        write_bean(beans_dir, &parent_bean);
376
377        // Dependency bean
378        let mut dep_bean = Bean::new("11", "dep task");
379        dep_bean.status = Status::Closed;
380        write_bean(beans_dir, &dep_bean);
381
382        // Main bean with parent, deps, produces, requires, attempts
383        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        // Empty directory — no beans
423        let result = cmd_trace("999", false, beans_dir);
424        assert!(result.is_err(), "Should error for missing bean");
425    }
426}