Skip to main content

bn/commands/
show.rs

1use std::path::Path;
2
3use anyhow::Result;
4use termimad::MadSkin;
5
6use crate::bean::{Bean, RunRecord};
7use crate::discovery::find_bean_file;
8
9/// Default number of history entries to show without `--history`.
10const DEFAULT_HISTORY_LIMIT: usize = 10;
11
12/// Maximum lines of outputs JSON to display before truncating.
13const MAX_OUTPUT_LINES: usize = 50;
14
15/// Handle `bn show <id>` command
16/// - Default: render beautifully with metadata header and markdown formatting
17/// - --json: deserialize and re-serialize as JSON
18/// - --short: one-line summary "{id}. {title} [{status}]"
19/// - --history: show all history entries (default: last 10)
20pub fn cmd_show(id: &str, json: bool, short: bool, history: bool, beans_dir: &Path) -> Result<()> {
21    let bean_path = find_bean_file(beans_dir, id)?;
22
23    let bean = Bean::from_file(&bean_path)?;
24
25    if short {
26        println!("{}", format_short(&bean));
27    } else if json {
28        let json_str = serde_json::to_string_pretty(&bean)?;
29        println!("{}", json_str);
30    } else {
31        // Default: beautiful markdown rendering
32        render_bean(&bean, history)?;
33    }
34
35    Ok(())
36}
37
38/// Render a bean beautifully with metadata header and formatted markdown body
39fn render_bean(bean: &Bean, show_all_history: bool) -> Result<()> {
40    let skin = MadSkin::default();
41
42    // Print metadata header
43    println!("{}", render_metadata_header(bean));
44
45    // Print title as emphasized header
46    println!("\n*{}*\n", bean.title);
47
48    // Print description with markdown formatting if it exists
49    if let Some(description) = &bean.description {
50        let formatted = skin.term_text(description);
51        println!("{}", formatted);
52    }
53
54    // Print acceptance criteria
55    if let Some(acceptance) = &bean.acceptance {
56        println!("\n**Acceptance Criteria**");
57        let formatted = skin.term_text(acceptance);
58        println!("{}", formatted);
59    }
60
61    // Print verify command
62    if let Some(verify) = &bean.verify {
63        println!("\n**Verify Command**");
64        println!("```");
65        println!("{}", verify);
66        println!("```");
67    }
68
69    // Print design notes
70    if let Some(design) = &bean.design {
71        println!("\n**Design**");
72        let formatted = skin.term_text(design);
73        println!("{}", formatted);
74    }
75
76    // Print notes
77    if let Some(notes) = &bean.notes {
78        println!("\n**Notes**");
79        let formatted = skin.term_text(notes);
80        println!("{}", formatted);
81    }
82
83    // Print outputs
84    if let Some(outputs) = &bean.outputs {
85        println!("\n**Outputs**");
86        println!("```");
87        let pretty = serde_json::to_string_pretty(outputs).unwrap_or_else(|_| outputs.to_string());
88        let lines: Vec<&str> = pretty.lines().collect();
89        if lines.len() > MAX_OUTPUT_LINES {
90            for line in &lines[..MAX_OUTPUT_LINES] {
91                println!("{}", line);
92            }
93            println!("... (truncated)");
94        } else {
95            print!("{}", pretty);
96            if !pretty.ends_with('\n') {
97                println!();
98            }
99        }
100        println!("```");
101    }
102
103    // Print history section if non-empty
104    if !bean.history.is_empty() {
105        let limit = if show_all_history {
106            bean.history.len()
107        } else {
108            DEFAULT_HISTORY_LIMIT
109        };
110        println!("\n{}", render_history(&bean.history, limit));
111    }
112
113    Ok(())
114}
115
116/// Render metadata header with ID, status, priority, and dates
117fn render_metadata_header(bean: &Bean) -> String {
118    let separator = "━".repeat(40);
119    let status_str = format!("Status: {}", bean.status);
120    let priority_str = format!("Priority: P{}", bean.priority);
121
122    let header_line = format!("  ID: {}  |  {}  |  {}", bean.id, status_str, priority_str);
123
124    // Build metadata details with optional fields
125    let mut details = Vec::new();
126
127    if let Some(parent) = &bean.parent {
128        details.push(format!("Parent: {}", parent));
129    }
130
131    if !bean.dependencies.is_empty() {
132        details.push(format!("Dependencies: {}", bean.dependencies.join(", ")));
133    }
134
135    if let Some(assignee) = &bean.assignee {
136        details.push(format!("Assignee: {}", assignee));
137    }
138
139    if !bean.labels.is_empty() {
140        details.push(format!("Labels: {}", bean.labels.join(", ")));
141    }
142
143    // Format dates nicely
144    let created = bean.created_at.format("%Y-%m-%d %H:%M:%S UTC");
145    let updated = bean.updated_at.format("%Y-%m-%d %H:%M:%S UTC");
146    details.push(format!("Created: {}", created));
147    details.push(format!("Updated: {}", updated));
148
149    if let Some(closed_at) = bean.closed_at {
150        let closed = closed_at.format("%Y-%m-%d %H:%M:%S UTC");
151        details.push(format!("Closed: {}", closed));
152    }
153
154    if let Some(reason) = &bean.close_reason {
155        details.push(format!("Close reason: {}", reason));
156    }
157
158    // Show claim information
159    if let Some(claimed_by) = &bean.claimed_by {
160        details.push(format!("Claimed by: {}", claimed_by));
161    }
162    if let Some(claimed_at) = bean.claimed_at {
163        let claimed = claimed_at.format("%Y-%m-%d %H:%M:%S UTC");
164        details.push(format!("Claimed at: {}", claimed));
165    }
166
167    // Show token count if available
168    if let Some(tokens) = bean.tokens {
169        details.push(format!("tokens: {}", tokens));
170    }
171
172    let mut output = String::new();
173    output.push_str(&separator);
174    output.push('\n');
175    output.push_str(&header_line);
176    output.push('\n');
177    output.push_str(&separator);
178
179    if !details.is_empty() {
180        output.push_str("\n\n");
181        output.push_str(&details.join("\n"));
182    }
183
184    output
185}
186
187/// Format a duration in seconds to a human-readable string.
188///
189/// - Under 60s: `12.3s`
190/// - Under 3600s: `2m 15s`
191/// - 3600s+: `1h 5m`
192fn format_duration(secs: f64) -> String {
193    if secs < 60.0 {
194        format!("{:.1}s", secs)
195    } else if secs < 3600.0 {
196        let mins = (secs / 60.0).floor() as u64;
197        let remainder = (secs % 60.0).round() as u64;
198        format!("{}m {}s", mins, remainder)
199    } else {
200        let hours = (secs / 3600.0).floor() as u64;
201        let remainder_mins = ((secs % 3600.0) / 60.0).round() as u64;
202        format!("{}h {}m", hours, remainder_mins)
203    }
204}
205
206/// Format a token count with `k` suffix for thousands.
207///
208/// - Under 1000: `500`
209/// - Exact thousands (e.g. 12000): `12k`
210/// - Otherwise: `8.2k`
211fn format_tokens(tokens: u64) -> String {
212    if tokens < 1000 {
213        tokens.to_string()
214    } else if tokens % 1000 == 0 {
215        format!("{}k", tokens / 1000)
216    } else {
217        // Round to nearest hundred for one decimal place
218        let k = tokens as f64 / 1000.0;
219        format!("{:.1}k", k)
220    }
221}
222
223/// Format a cost as `$X.XX`, or empty string if `None`.
224fn format_cost(cost: f64) -> String {
225    format!("${:.2}", cost)
226}
227
228/// Truncate a string to `max_len` characters, appending `…` if truncated.
229fn truncate_agent(agent: &str, max_len: usize) -> String {
230    if agent.len() <= max_len {
231        agent.to_string()
232    } else {
233        let mut s = agent[..max_len - 1].to_string();
234        s.push('…');
235        s
236    }
237}
238
239/// Render the history table from a slice of `RunRecord`.
240///
241/// Shows the most recent `limit` entries. Includes a totals line at the bottom.
242fn render_history(history: &[RunRecord], limit: usize) -> String {
243    let total = history.len();
244    let entries: &[RunRecord] = if total > limit {
245        &history[total - limit..]
246    } else {
247        history
248    };
249
250    let mut out = String::from("**History**\n");
251
252    // Table header
253    out.push_str("  #  Result     Duration  Agent         Exit  Tokens  Cost\n");
254
255    for record in entries {
256        let attempt = format!("{:>3}", record.attempt);
257        let result = format!("{:<9}", format!("{:?}", record.result).to_lowercase());
258        let duration = record
259            .duration_secs
260            .map(format_duration)
261            .unwrap_or_else(|| "-".to_string());
262        let duration_col = format!("{:<8}", duration);
263        let agent = record
264            .agent
265            .as_deref()
266            .map(|a| truncate_agent(a, 12))
267            .unwrap_or_else(|| "-".to_string());
268        let agent_col = format!("{:<12}", agent);
269        let exit = record
270            .exit_code
271            .map(|c| c.to_string())
272            .unwrap_or_else(|| "-".to_string());
273        let exit_col = format!("{:<4}", exit);
274        let tokens = record
275            .tokens
276            .map(format_tokens)
277            .unwrap_or_else(|| "-".to_string());
278        let tokens_col = format!("{:<6}", tokens);
279        let cost = record
280            .cost
281            .map(format_cost)
282            .unwrap_or_else(|| "-".to_string());
283
284        out.push_str(&format!(
285            "  {} {}  {}  {}  {}  {}  {}\n",
286            attempt, result, duration_col, agent_col, exit_col, tokens_col, cost
287        ));
288    }
289
290    // Totals
291    let total_duration: f64 = history.iter().filter_map(|r| r.duration_secs).sum();
292    let total_tokens: u64 = history.iter().filter_map(|r| r.tokens).sum();
293    let total_cost: f64 = history.iter().filter_map(|r| r.cost).sum();
294
295    let mut totals_parts = vec![format!("{} attempts", total)];
296    if total_duration > 0.0 {
297        totals_parts.push(format_duration(total_duration));
298    }
299    if total_tokens > 0 {
300        totals_parts.push(format!("{} tokens", format_tokens(total_tokens)));
301    }
302    if total_cost > 0.0 {
303        totals_parts.push(format_cost(total_cost));
304    }
305
306    if total > limit {
307        out.push_str(&format!(
308            "  ... ({} earlier entries hidden)\n",
309            total - limit
310        ));
311    }
312    out.push_str(&format!("  Total: {}", totals_parts.join(", ")));
313
314    out
315}
316
317/// Format a bean as a one-line summary
318fn format_short(bean: &Bean) -> String {
319    format!("{}. {} [{}]", bean.id, bean.title, bean.status)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::bean::{RunRecord, RunResult};
326    use crate::util::title_to_slug;
327    use chrono::Utc;
328    use tempfile::TempDir;
329
330    // ------------------------------------------------------------------
331    // cmd_show integration tests
332    // ------------------------------------------------------------------
333
334    #[test]
335    fn show_renders_beautifully_default() {
336        let dir = TempDir::new().unwrap();
337        let beans_dir = dir.path().join(".beans");
338        std::fs::create_dir(&beans_dir).unwrap();
339
340        let bean = Bean::new("1", "Test bean");
341        let slug = title_to_slug(&bean.title);
342        let bean_path = beans_dir.join(format!("1-{}.md", slug));
343        bean.to_file(&bean_path).unwrap();
344
345        let result = cmd_show("1", false, false, false, &beans_dir);
346        assert!(result.is_ok());
347    }
348
349    #[test]
350    fn show_json() {
351        let dir = TempDir::new().unwrap();
352        let beans_dir = dir.path().join(".beans");
353        std::fs::create_dir(&beans_dir).unwrap();
354
355        let bean = Bean::new("1", "Test bean");
356        let slug = title_to_slug(&bean.title);
357        let bean_path = beans_dir.join(format!("1-{}.md", slug));
358        bean.to_file(&bean_path).unwrap();
359
360        let result = cmd_show("1", true, false, false, &beans_dir);
361        assert!(result.is_ok());
362    }
363
364    #[test]
365    fn show_short() {
366        let dir = TempDir::new().unwrap();
367        let beans_dir = dir.path().join(".beans");
368        std::fs::create_dir(&beans_dir).unwrap();
369
370        let bean = Bean::new("1", "Test bean");
371        let slug = title_to_slug(&bean.title);
372        let bean_path = beans_dir.join(format!("1-{}.md", slug));
373        bean.to_file(&bean_path).unwrap();
374
375        let result = cmd_show("1", false, true, false, &beans_dir);
376        assert!(result.is_ok());
377    }
378
379    #[test]
380    fn show_not_found() {
381        let dir = TempDir::new().unwrap();
382        let beans_dir = dir.path().join(".beans");
383        std::fs::create_dir(&beans_dir).unwrap();
384
385        let result = cmd_show("999", false, false, false, &beans_dir);
386        assert!(result.is_err());
387    }
388
389    #[test]
390    fn format_short_test() {
391        let bean = Bean::new("42", "My task");
392        let formatted = format_short(&bean);
393        assert_eq!(formatted, "42. My task [open]");
394    }
395
396    #[test]
397    fn metadata_header_includes_id_and_status() {
398        let bean = Bean::new("1", "Test");
399        let header = render_metadata_header(&bean);
400        assert!(header.contains("ID: 1"));
401        assert!(header.contains("Status: open"));
402    }
403
404    #[test]
405    fn metadata_header_includes_parent_when_set() {
406        let mut bean = Bean::new("1.1", "Child task");
407        bean.parent = Some("1".to_string());
408        let header = render_metadata_header(&bean);
409        assert!(header.contains("Parent: 1"));
410    }
411
412    #[test]
413    fn metadata_header_includes_dependencies() {
414        let mut bean = Bean::new("2", "Task");
415        bean.dependencies = vec!["1".to_string(), "1.1".to_string()];
416        let header = render_metadata_header(&bean);
417        assert!(header.contains("Dependencies: 1, 1.1"));
418    }
419
420    #[test]
421    fn render_bean_with_description() {
422        let dir = TempDir::new().unwrap();
423        let beans_dir = dir.path().join(".beans");
424        std::fs::create_dir(&beans_dir).unwrap();
425
426        let mut bean = Bean::new("1", "Test bean");
427        bean.description = Some("# Description\n\nThis is test markdown.".to_string());
428        let slug = title_to_slug(&bean.title);
429        let bean_path = beans_dir.join(format!("1-{}.md", slug));
430        bean.to_file(&bean_path).unwrap();
431
432        let result = cmd_show("1", false, false, false, &beans_dir);
433        assert!(result.is_ok());
434    }
435
436    #[test]
437    fn show_works_with_hierarchical_ids() {
438        let dir = TempDir::new().unwrap();
439        let beans_dir = dir.path().join(".beans");
440        std::fs::create_dir(&beans_dir).unwrap();
441
442        let bean = Bean::new("11.1", "Hierarchical bean");
443        let slug = title_to_slug(&bean.title);
444        let bean_path = beans_dir.join(format!("11.1-{}.md", slug));
445        bean.to_file(&bean_path).unwrap();
446
447        let result = cmd_show("11.1", false, false, false, &beans_dir);
448        assert!(result.is_ok());
449    }
450
451    // ------------------------------------------------------------------
452    // Duration formatting
453    // ------------------------------------------------------------------
454
455    #[test]
456    fn history_format_duration_seconds() {
457        assert_eq!(format_duration(0.0), "0.0s");
458        assert_eq!(format_duration(12.3), "12.3s");
459        assert_eq!(format_duration(59.9), "59.9s");
460    }
461
462    #[test]
463    fn history_format_duration_minutes() {
464        assert_eq!(format_duration(60.0), "1m 0s");
465        assert_eq!(format_duration(135.0), "2m 15s");
466        assert_eq!(format_duration(3599.0), "59m 59s");
467    }
468
469    #[test]
470    fn history_format_duration_hours() {
471        assert_eq!(format_duration(3600.0), "1h 0m");
472        assert_eq!(format_duration(3900.0), "1h 5m");
473        assert_eq!(format_duration(7200.0), "2h 0m");
474    }
475
476    // ------------------------------------------------------------------
477    // Token formatting
478    // ------------------------------------------------------------------
479
480    #[test]
481    fn history_format_tokens_small() {
482        assert_eq!(format_tokens(0), "0");
483        assert_eq!(format_tokens(500), "500");
484        assert_eq!(format_tokens(999), "999");
485    }
486
487    #[test]
488    fn history_format_tokens_thousands() {
489        assert_eq!(format_tokens(1000), "1k");
490        assert_eq!(format_tokens(8200), "8.2k");
491        assert_eq!(format_tokens(12400), "12.4k");
492        assert_eq!(format_tokens(12000), "12k");
493    }
494
495    // ------------------------------------------------------------------
496    // Cost formatting
497    // ------------------------------------------------------------------
498
499    #[test]
500    fn history_format_cost() {
501        assert_eq!(format_cost(0.0), "$0.00");
502        assert_eq!(format_cost(0.03), "$0.03");
503        assert_eq!(format_cost(1.5), "$1.50");
504    }
505
506    // ------------------------------------------------------------------
507    // Agent truncation
508    // ------------------------------------------------------------------
509
510    #[test]
511    fn history_truncate_agent_short() {
512        assert_eq!(truncate_agent("pi-abc123", 12), "pi-abc123");
513        assert_eq!(truncate_agent("exactly12chr", 12), "exactly12chr");
514    }
515
516    #[test]
517    fn history_truncate_agent_long() {
518        assert_eq!(
519            truncate_agent("pi-very-long-agent-name", 12),
520            "pi-very-lon…"
521        );
522    }
523
524    // ------------------------------------------------------------------
525    // History rendering
526    // ------------------------------------------------------------------
527
528    fn make_record(
529        attempt: u32,
530        result: RunResult,
531        duration: f64,
532        agent: &str,
533        exit: i32,
534        tokens: u64,
535        cost: f64,
536    ) -> RunRecord {
537        RunRecord {
538            attempt,
539            started_at: Utc::now(),
540            finished_at: Some(Utc::now()),
541            duration_secs: Some(duration),
542            agent: Some(agent.to_string()),
543            result,
544            exit_code: Some(exit),
545            tokens: Some(tokens),
546            cost: Some(cost),
547            output_snippet: None,
548        }
549    }
550
551    #[test]
552    fn history_not_shown_when_empty() {
553        let bean = Bean::new("1", "No history");
554        assert!(bean.history.is_empty());
555        // render_history is never called when history is empty, but verify it
556        // produces a sensible output anyway
557        let rendered = render_history(&[], 10);
558        assert!(rendered.contains("0 attempts"));
559    }
560
561    #[test]
562    fn history_displays_formatted_table() {
563        let records = vec![
564            make_record(1, RunResult::Fail, 12.3, "pi-abc123", 1, 8200, 0.04),
565            make_record(2, RunResult::Fail, 8.1, "pi-def456", 1, 6100, 0.03),
566            make_record(3, RunResult::Pass, 15.7, "pi-ghi789", 0, 12400, 0.05),
567        ];
568
569        let rendered = render_history(&records, 10);
570
571        // Header present
572        assert!(rendered.contains("**History**"));
573        assert!(rendered.contains("Result"));
574        assert!(rendered.contains("Duration"));
575        assert!(rendered.contains("Agent"));
576        assert!(rendered.contains("Tokens"));
577
578        // Row content
579        assert!(rendered.contains("fail"));
580        assert!(rendered.contains("pass"));
581        assert!(rendered.contains("12.3s"));
582        assert!(rendered.contains("8.1s"));
583        assert!(rendered.contains("15.7s"));
584        assert!(rendered.contains("pi-abc123"));
585        assert!(rendered.contains("8.2k"));
586        assert!(rendered.contains("6.1k"));
587        assert!(rendered.contains("12.4k"));
588    }
589
590    #[test]
591    fn history_totals_sum_correctly() {
592        let records = vec![
593            make_record(1, RunResult::Fail, 12.3, "a", 1, 8200, 0.04),
594            make_record(2, RunResult::Fail, 8.1, "b", 1, 6100, 0.03),
595            make_record(3, RunResult::Pass, 15.7, "c", 0, 12400, 0.05),
596        ];
597
598        let rendered = render_history(&records, 10);
599
600        assert!(rendered.contains("3 attempts"));
601        // Total duration: 36.1s
602        assert!(rendered.contains("36.1s"));
603        // Total tokens: 26700 → 26.7k
604        assert!(rendered.contains("26.7k tokens"));
605        // Total cost: $0.12
606        assert!(rendered.contains("$0.12"));
607    }
608
609    #[test]
610    fn history_limits_entries_default() {
611        // Create 15 records, limit to 10. Use exit code 0 to avoid
612        // ambiguous substring matches with attempt numbers.
613        let records: Vec<RunRecord> = (1..=15)
614            .map(|i| make_record(i, RunResult::Fail, 1.0, "agent", 0, 1000, 0.01))
615            .collect();
616
617        let rendered = render_history(&records, 10);
618
619        // Should mention hidden entries
620        assert!(rendered.contains("5 earlier entries hidden"));
621        // Totals are over ALL 15
622        assert!(rendered.contains("15 attempts"));
623
624        // Entries 1-5 hidden, 6-15 shown.
625        // Check attempt column (right-aligned 3 chars) at line start.
626        let data_lines: Vec<&str> = rendered
627            .lines()
628            .filter(|l| {
629                l.starts_with("  ")
630                    && !l.starts_with("  #")
631                    && !l.starts_with("  Total")
632                    && !l.starts_with("  ...")
633            })
634            .collect();
635        assert_eq!(data_lines.len(), 10);
636        // First visible attempt is 6
637        assert!(data_lines[0].contains("  6 "));
638        // Last visible attempt is 15
639        assert!(data_lines[9].contains(" 15 "));
640    }
641
642    #[test]
643    fn history_show_all_flag() {
644        let records: Vec<RunRecord> = (1..=15)
645            .map(|i| make_record(i, RunResult::Fail, 1.0, "agent", 0, 1000, 0.01))
646            .collect();
647
648        // With limit = total, all shown
649        let rendered = render_history(&records, 15);
650        assert!(!rendered.contains("hidden"));
651
652        let data_lines: Vec<&str> = rendered
653            .lines()
654            .filter(|l| l.starts_with("  ") && !l.starts_with("  #") && !l.starts_with("  Total"))
655            .collect();
656        assert_eq!(data_lines.len(), 15);
657    }
658
659    #[test]
660    fn history_handles_missing_optional_fields() {
661        let record = RunRecord {
662            attempt: 1,
663            started_at: Utc::now(),
664            finished_at: None,
665            duration_secs: None,
666            agent: None,
667            result: RunResult::Timeout,
668            exit_code: None,
669            tokens: None,
670            cost: None,
671            output_snippet: None,
672        };
673
674        let rendered = render_history(&[record], 10);
675        assert!(rendered.contains("timeout"));
676        // Missing fields show as "-"
677        // Count dashes in the row (duration, agent, exit, tokens, cost)
678        let row_line = rendered.lines().nth(2).unwrap(); // first data row
679        let dashes = row_line.matches(" - ").count() + row_line.matches(" -\n").count();
680        assert!(
681            dashes >= 3,
682            "Expected dashes for missing fields, got line: {}",
683            row_line
684        );
685    }
686
687    #[test]
688    fn history_cmd_show_with_history() {
689        let dir = TempDir::new().unwrap();
690        let beans_dir = dir.path().join(".beans");
691        std::fs::create_dir(&beans_dir).unwrap();
692
693        let mut bean = Bean::new("1", "Bean with history");
694        bean.history = vec![
695            make_record(1, RunResult::Fail, 5.0, "pi-test", 1, 3000, 0.02),
696            make_record(2, RunResult::Pass, 3.0, "pi-test", 0, 2000, 0.01),
697        ];
698        let slug = title_to_slug(&bean.title);
699        let bean_path = beans_dir.join(format!("1-{}.md", slug));
700        bean.to_file(&bean_path).unwrap();
701
702        // Without --history flag (still shows, just limited to 10)
703        let result = cmd_show("1", false, false, false, &beans_dir);
704        assert!(result.is_ok());
705
706        // With --history flag
707        let result = cmd_show("1", false, false, true, &beans_dir);
708        assert!(result.is_ok());
709    }
710
711    // ------------------------------------------------------------------
712    // Outputs display
713    // ------------------------------------------------------------------
714
715    #[test]
716    fn outputs_not_shown_when_none() {
717        let bean = Bean::new("1", "No outputs");
718        // render_bean prints to stdout; just verify it doesn't panic
719        let result = render_bean(&bean, false);
720        assert!(result.is_ok());
721    }
722
723    #[test]
724    fn outputs_shows_pretty_printed_json() {
725        let dir = TempDir::new().unwrap();
726        let beans_dir = dir.path().join(".beans");
727        std::fs::create_dir(&beans_dir).unwrap();
728
729        let mut bean = Bean::new("1", "With outputs");
730        bean.outputs = Some(serde_json::json!({
731            "coverage": 85.5,
732            "files": ["a.rs", "b.rs"]
733        }));
734        let slug = title_to_slug(&bean.title);
735        let bean_path = beans_dir.join(format!("1-{}.md", slug));
736        bean.to_file(&bean_path).unwrap();
737
738        let result = cmd_show("1", false, false, false, &beans_dir);
739        assert!(result.is_ok());
740    }
741
742    #[test]
743    fn outputs_long_truncated_at_50_lines() {
744        // Build a JSON value that pretty-prints to >50 lines
745        let map: serde_json::Map<String, serde_json::Value> = (0..60)
746            .map(|i| (format!("key_{}", i), serde_json::json!(i)))
747            .collect();
748        let big_obj = serde_json::Value::Object(map);
749        let pretty = serde_json::to_string_pretty(&big_obj).unwrap();
750        let lines: Vec<&str> = pretty.lines().collect();
751        assert!(
752            lines.len() > MAX_OUTPUT_LINES,
753            "test setup: need >50 lines, got {}",
754            lines.len()
755        );
756
757        let mut bean = Bean::new("1", "Big outputs");
758        bean.outputs = Some(big_obj);
759        // Just verify render_bean doesn't panic and works
760        let result = render_bean(&bean, false);
761        assert!(result.is_ok());
762    }
763}