1use std::path::Path;
2
3use anyhow::Result;
4use termimad::MadSkin;
5
6use crate::bean::{Bean, RunRecord};
7use crate::discovery::find_bean_file;
8
9const DEFAULT_HISTORY_LIMIT: usize = 10;
11
12const MAX_OUTPUT_LINES: usize = 50;
14
15pub 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 render_bean(&bean, history)?;
33 }
34
35 Ok(())
36}
37
38fn render_bean(bean: &Bean, show_all_history: bool) -> Result<()> {
40 let skin = MadSkin::default();
41
42 println!("{}", render_metadata_header(bean));
44
45 println!("\n*{}*\n", bean.title);
47
48 if let Some(description) = &bean.description {
50 let formatted = skin.term_text(description);
51 println!("{}", formatted);
52 }
53
54 if let Some(acceptance) = &bean.acceptance {
56 println!("\n**Acceptance Criteria**");
57 let formatted = skin.term_text(acceptance);
58 println!("{}", formatted);
59 }
60
61 if let Some(verify) = &bean.verify {
63 println!("\n**Verify Command**");
64 println!("```");
65 println!("{}", verify);
66 println!("```");
67 }
68
69 if let Some(design) = &bean.design {
71 println!("\n**Design**");
72 let formatted = skin.term_text(design);
73 println!("{}", formatted);
74 }
75
76 if let Some(notes) = &bean.notes {
78 println!("\n**Notes**");
79 let formatted = skin.term_text(notes);
80 println!("{}", formatted);
81 }
82
83 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 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
116fn 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 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 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 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 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
187fn 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
206fn 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 let k = tokens as f64 / 1000.0;
219 format!("{:.1}k", k)
220 }
221}
222
223fn format_cost(cost: f64) -> String {
225 format!("${:.2}", cost)
226}
227
228fn 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
239fn 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 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 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
317fn 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 assert!(rendered.contains("36.1s"));
603 assert!(rendered.contains("26.7k tokens"));
605 assert!(rendered.contains("$0.12"));
607 }
608
609 #[test]
610 fn history_limits_entries_default() {
611 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 assert!(rendered.contains("5 earlier entries hidden"));
621 assert!(rendered.contains("15 attempts"));
623
624 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 assert!(data_lines[0].contains(" 6 "));
638 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 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 let row_line = rendered.lines().nth(2).unwrap(); 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 let result = cmd_show("1", false, false, false, &beans_dir);
704 assert!(result.is_ok());
705
706 let result = cmd_show("1", false, false, true, &beans_dir);
708 assert!(result.is_ok());
709 }
710
711 #[test]
716 fn outputs_not_shown_when_none() {
717 let bean = Bean::new("1", "No outputs");
718 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 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 let result = render_bean(&bean, false);
761 assert!(result.is_ok());
762 }
763}