1use std::fmt::Write;
23
24use owo_colors::OwoColorize;
25
26pub fn color_enabled() -> bool {
33 std::env::var_os("NO_COLOR").is_none()
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Verbosity {
41 Quiet,
43 Default,
45 Verbose,
47}
48
49impl Verbosity {
50 pub fn from_flags(verbose: bool, quiet: bool) -> Self {
54 if quiet {
55 Self::Quiet
56 } else if verbose {
57 Self::Verbose
58 } else {
59 Self::Default
60 }
61 }
62
63 pub fn show_warnings(self) -> bool {
65 self != Self::Quiet
66 }
67
68 pub fn show_findings(self) -> bool {
70 self != Self::Quiet
71 }
72
73 pub fn show_verbose(self) -> bool {
75 self == Self::Verbose
76 }
77}
78
79const HEADER_WIDTH: usize = 60;
83
84pub fn format_section_header(title: &str, color: bool) -> String {
91 let prefix = "── ";
92 let separator = " ";
93 let used = prefix.chars().count() + title.chars().count() + separator.chars().count();
95 let remaining = HEADER_WIDTH.saturating_sub(used);
96 let dashes: String = "─".repeat(remaining);
97
98 if color {
99 format!(
100 "{}{}{}{}",
101 "── ".dimmed(),
102 title.bold(),
103 " ".dimmed(),
104 dashes.dimmed()
105 )
106 } else {
107 format!("{prefix}{title}{separator}{dashes}")
108 }
109}
110
111const BAR_WIDTH: usize = 20;
115
116pub fn format_bar_chart(
122 label: &str,
123 count: usize,
124 fraction: f64,
125 unit: &str,
126 color: bool,
127) -> String {
128 let filled = (fraction * BAR_WIDTH as f64).round() as usize;
129 let empty = BAR_WIDTH.saturating_sub(filled);
130
131 let bar_filled: String = "\u{2593}".repeat(filled); let bar_empty: String = "\u{2591}".repeat(empty); let pct = fraction * 100.0;
134
135 if color {
136 format!(
137 " {}{} {:>5.1}% {} ({} {})",
138 bar_filled.cyan(),
139 bar_empty.dimmed(),
140 pct,
141 label.bold(),
142 count,
143 unit,
144 )
145 } else {
146 format!(" {bar_filled}{bar_empty} {pct:>5.1}% {label} ({count} {unit})",)
147 }
148}
149
150pub fn format_tier_bullet(label: &str, count: usize, tier: ConfidenceTier, color: bool) -> String {
160 let bullet = styled_tier_bullet(tier, color);
161 format!("{bullet} {label} ({count})")
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum ConfidenceTier {
167 High,
169 Medium,
171 Low,
173}
174
175impl ConfidenceTier {
176 pub fn from_confidence(confidence: f64) -> Self {
178 if confidence > 85.0 {
179 Self::High
180 } else if confidence >= 50.0 {
181 Self::Medium
182 } else {
183 Self::Low
184 }
185 }
186}
187
188pub fn tier_bullet_char(tier: ConfidenceTier) -> &'static str {
194 match tier {
195 ConfidenceTier::High => "\u{25CF}", ConfidenceTier::Medium => "\u{25D0}", ConfidenceTier::Low => "\u{25CB}", }
199}
200
201pub fn styled_tier_bullet(tier: ConfidenceTier, color: bool) -> String {
206 let bullet = tier_bullet_char(tier);
207 if color {
208 match tier {
209 ConfidenceTier::High => bullet.green().to_string(),
210 ConfidenceTier::Medium => bullet.yellow().to_string(),
211 ConfidenceTier::Low => bullet.red().to_string(),
212 }
213 } else {
214 bullet.to_owned()
215 }
216}
217
218pub fn format_human_size(bytes: u64) -> String {
231 const KB: f64 = 1_000.0;
232 const MB: f64 = 1_000_000.0;
233 const GB: f64 = 1_000_000_000.0;
234
235 let b = bytes as f64;
236 if b < KB {
237 format!("{bytes} B")
238 } else if b < MB {
239 format!("{:.1} KB", b / KB)
240 } else if b < GB {
241 format!("{:.1} MB", b / MB)
242 } else {
243 format!("{:.1} GB", b / GB)
244 }
245}
246
247pub fn format_number(n: u64) -> String {
253 let s = n.to_string();
254 let bytes = s.as_bytes();
255 let len = bytes.len();
256 if len <= 3 {
257 return s;
258 }
259
260 let mut result = String::with_capacity(len + (len - 1) / 3);
261 for (i, &b) in bytes.iter().enumerate() {
262 if i > 0 && (len - i) % 3 == 0 {
263 result.push(',');
264 }
265 result.push(b as char);
266 }
267 result
268}
269
270pub fn format_error_hint(message: &str, hints: &[&str], color: bool) -> String {
282 let mut buf = String::new();
283
284 if color {
285 let _ = write!(buf, "{} {message}", "error:".red().bold());
286 } else {
287 let _ = write!(buf, "error: {message}");
288 }
289
290 if !hints.is_empty() {
291 buf.push('\n');
292 for hint in hints {
293 buf.push('\n');
294 if color {
295 let _ = write!(buf, "{} {hint}", "hint:".cyan());
296 } else {
297 let _ = write!(buf, "hint: {hint}");
298 }
299 }
300 }
301
302 buf
303}
304
305pub fn format_bordered_box(lines: &[&str], color: bool) -> String {
318 let max_width = lines.iter().map(|l| l.len()).max().unwrap_or(0);
319 let inner = max_width.max(20);
321
322 let mut buf = String::new();
323
324 let top = format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(inner + 2)); if color {
327 let _ = writeln!(buf, "{}", top.dimmed());
328 } else {
329 let _ = writeln!(buf, "{top}");
330 }
331
332 for line in lines {
334 let padded = format!("{line:<width$}", width = inner);
335 if color {
336 let _ = writeln!(
337 buf,
338 "{} {padded} {}",
339 "\u{2502}".dimmed(), "\u{2502}".dimmed(),
341 );
342 } else {
343 let _ = writeln!(buf, "\u{2502} {padded} \u{2502}");
344 }
345 }
346
347 let bottom = format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(inner + 2)); if color {
350 let _ = write!(buf, "{}", bottom.dimmed());
351 } else {
352 let _ = write!(buf, "{bottom}");
353 }
354
355 buf
356}
357
358pub fn format_copy_block(lines: &[&str], color: bool) -> String {
377 let rule_inner = HEADER_WIDTH - 2; let copy_label = "── copy ";
382 let top_dashes = "─".repeat(rule_inner.saturating_sub(copy_label.chars().count()));
383 let top_rule = format!(" {copy_label}{top_dashes}");
384
385 let bottom_dashes = "─".repeat(rule_inner);
387 let bottom_rule = format!(" {bottom_dashes}");
388
389 let mut buf = String::new();
390
391 if color {
392 buf.push_str(&top_rule.dimmed().to_string());
393 } else {
394 buf.push_str(&top_rule);
395 }
396 buf.push('\n');
397
398 for line in lines {
399 buf.push_str(" "); buf.push_str(line);
401 buf.push('\n');
402 }
403
404 if color {
405 buf.push_str(&bottom_rule.dimmed().to_string());
406 } else {
407 buf.push_str(&bottom_rule);
408 }
409 buf.push('\n');
410
411 buf
412}
413
414pub fn format_warn(message: &str, color: bool) -> String {
418 if color {
419 format!("{} {message}", "warn:".yellow().bold())
420 } else {
421 format!("warn: {message}")
422 }
423}
424
425pub fn format_info(message: &str, color: bool) -> String {
427 if color {
428 format!("{} {message}", "info:".blue())
429 } else {
430 format!("info: {message}")
431 }
432}
433
434#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
445 fn test_no_color_respected() {
446 let _ = color_enabled(); }
452
453 #[test]
456 fn test_section_header_no_color() {
457 let h = format_section_header("Project Overview", false);
458 assert!(h.starts_with("── Project Overview "));
459 assert!(h.contains("─────"));
460 assert_eq!(h.chars().count(), HEADER_WIDTH);
462 }
463
464 #[test]
465 fn test_section_header_with_color_contains_title() {
466 let h = format_section_header("Project Overview", true);
467 assert!(h.contains("Project Overview"));
469 }
470
471 #[test]
472 fn test_section_header_long_title() {
473 let title = "A".repeat(70);
474 let h = format_section_header(&title, false);
475 assert!(h.contains(&title));
477 assert!(!h.ends_with("──")); }
479
480 #[test]
483 fn test_bar_chart_full() {
484 let b = format_bar_chart("Rust", 42, 1.0, "files", false);
485 assert!(b.contains("▓".repeat(BAR_WIDTH).as_str()));
486 assert!(!b.contains('░'));
487 assert!(b.contains("100.0%"));
488 assert!(b.contains("Rust"));
489 assert!(b.contains("42 files"));
490 }
491
492 #[test]
493 fn test_bar_chart_empty() {
494 let b = format_bar_chart("Python", 0, 0.0, "files", false);
495 assert!(b.contains("░".repeat(BAR_WIDTH).as_str()));
496 assert!(b.contains("0.0%"));
497 }
498
499 #[test]
500 fn test_bar_chart_half() {
501 let b = format_bar_chart("TypeScript", 10, 0.5, "files", false);
502 let filled = "▓".repeat(BAR_WIDTH / 2);
503 let empty = "░".repeat(BAR_WIDTH / 2);
504 assert!(b.contains(&filled));
505 assert!(b.contains(&empty));
506 assert!(b.contains("50.0%"));
507 }
508
509 #[test]
510 fn test_bar_chart_with_color() {
511 let b = format_bar_chart("Rust", 42, 0.75, "files", true);
512 assert!(b.contains("Rust"));
514 assert!(b.contains("42 files"));
515 assert!(b.contains("75.0%"));
516 }
517
518 #[test]
521 fn test_tier_bullet_high() {
522 let b = format_tier_bullet("High", 12, ConfidenceTier::High, false);
523 assert!(b.contains('●'));
524 assert!(b.contains("High (12)"));
525 }
526
527 #[test]
528 fn test_tier_bullet_medium() {
529 let b = format_tier_bullet("Medium", 5, ConfidenceTier::Medium, false);
530 assert!(b.contains('◐'));
531 assert!(b.contains("Medium (5)"));
532 }
533
534 #[test]
535 fn test_tier_bullet_low() {
536 let b = format_tier_bullet("Low", 3, ConfidenceTier::Low, false);
537 assert!(b.contains('○'));
538 assert!(b.contains("Low (3)"));
539 }
540
541 #[test]
542 fn test_tier_bullet_with_color() {
543 let b = format_tier_bullet("High", 12, ConfidenceTier::High, true);
544 assert!(b.contains("High (12)"));
546 }
547
548 #[test]
551 fn test_confidence_tier_boundaries() {
552 assert_eq!(ConfidenceTier::from_confidence(100.0), ConfidenceTier::High);
553 assert_eq!(ConfidenceTier::from_confidence(86.0), ConfidenceTier::High);
554 assert_eq!(
555 ConfidenceTier::from_confidence(85.0),
556 ConfidenceTier::Medium,
557 );
558 assert_eq!(
559 ConfidenceTier::from_confidence(50.0),
560 ConfidenceTier::Medium,
561 );
562 assert_eq!(ConfidenceTier::from_confidence(49.9), ConfidenceTier::Low);
563 assert_eq!(ConfidenceTier::from_confidence(0.0), ConfidenceTier::Low);
564 }
565
566 #[test]
569 fn test_human_size_bytes() {
570 assert_eq!(format_human_size(0), "0 B");
571 assert_eq!(format_human_size(999), "999 B");
572 }
573
574 #[test]
575 fn test_human_size_kilobytes() {
576 assert_eq!(format_human_size(1_000), "1.0 KB");
577 assert_eq!(format_human_size(1_500), "1.5 KB");
578 assert_eq!(format_human_size(999_999), "1000.0 KB");
579 }
580
581 #[test]
582 fn test_human_size_megabytes() {
583 assert_eq!(format_human_size(1_000_000), "1.0 MB");
584 assert_eq!(format_human_size(12_400_000), "12.4 MB");
585 }
586
587 #[test]
588 fn test_human_size_gigabytes() {
589 assert_eq!(format_human_size(2_500_000_000), "2.5 GB");
590 }
591
592 #[test]
595 fn test_format_number_no_separator() {
596 assert_eq!(format_number(0), "0");
597 assert_eq!(format_number(999), "999");
598 }
599
600 #[test]
601 fn test_format_number_with_separators() {
602 assert_eq!(format_number(1_234), "1,234");
603 assert_eq!(format_number(1_234_567), "1,234,567");
604 assert_eq!(format_number(1_000_000_000), "1,000,000,000");
605 }
606
607 #[test]
610 fn test_error_hint_no_hints() {
611 let e = format_error_hint("something broke", &[], false);
612 assert_eq!(e, "error: something broke");
613 }
614
615 #[test]
616 fn test_error_hint_with_hints() {
617 let e = format_error_hint("bad path", &["check the path", "try again"], false);
618 assert!(e.contains("error: bad path"));
619 assert!(e.contains("hint: check the path"));
620 assert!(e.contains("hint: try again"));
621 }
622
623 #[test]
624 fn test_error_hint_with_color() {
625 let e = format_error_hint("something broke", &["try X"], true);
626 assert!(e.contains("something broke"));
627 assert!(e.contains("try X"));
628 }
629
630 #[test]
633 fn test_bordered_box_basic() {
634 let b = format_bordered_box(&["hello", "world"], false);
635 assert!(b.contains('┌'));
636 assert!(b.contains('┐'));
637 assert!(b.contains('│'));
638 assert!(b.contains('└'));
639 assert!(b.contains('┘'));
640 assert!(b.contains("hello"));
641 assert!(b.contains("world"));
642 }
643
644 #[test]
645 fn test_bordered_box_empty() {
646 let b = format_bordered_box(&[], false);
647 assert!(b.contains('┌'));
649 assert!(b.contains('└'));
650 }
651
652 #[test]
653 fn test_bordered_box_with_color() {
654 let b = format_bordered_box(&["test"], true);
655 assert!(b.contains("test"));
656 }
657
658 #[test]
661 fn test_warn_no_color() {
662 assert_eq!(format_warn("oops", false), "warn: oops");
663 }
664
665 #[test]
666 fn test_info_no_color() {
667 assert_eq!(format_info("hello", false), "info: hello");
668 }
669
670 #[test]
671 fn test_warn_with_color() {
672 let w = format_warn("oops", true);
673 assert!(w.contains("oops"));
674 }
675
676 #[test]
679 fn test_verbosity_from_flags_default() {
680 assert_eq!(Verbosity::from_flags(false, false), Verbosity::Default);
681 }
682
683 #[test]
684 fn test_verbosity_from_flags_verbose() {
685 assert_eq!(Verbosity::from_flags(true, false), Verbosity::Verbose);
686 }
687
688 #[test]
689 fn test_verbosity_from_flags_quiet() {
690 assert_eq!(Verbosity::from_flags(false, true), Verbosity::Quiet);
691 }
692
693 #[test]
694 fn test_verbosity_from_flags_both_quiet_wins() {
695 assert_eq!(Verbosity::from_flags(true, true), Verbosity::Quiet);
697 }
698
699 #[test]
700 fn test_verbosity_show_warnings() {
701 assert!(!Verbosity::Quiet.show_warnings());
702 assert!(Verbosity::Default.show_warnings());
703 assert!(Verbosity::Verbose.show_warnings());
704 }
705
706 #[test]
707 fn test_verbosity_show_findings() {
708 assert!(!Verbosity::Quiet.show_findings());
709 assert!(Verbosity::Default.show_findings());
710 assert!(Verbosity::Verbose.show_findings());
711 }
712
713 #[test]
714 fn test_verbosity_show_verbose() {
715 assert!(!Verbosity::Quiet.show_verbose());
716 assert!(!Verbosity::Default.show_verbose());
717 assert!(Verbosity::Verbose.show_verbose());
718 }
719
720 #[test]
723 fn test_copy_block_contains_content() {
724 let b = format_copy_block(&[r#""seshat": {"#, r#" "command": "seshat""#, "}"], false);
725 assert!(b.contains(r#""seshat": {"#));
726 assert!(b.contains(r#""command": "seshat""#));
727 assert!(b.contains('}'));
728 }
729
730 #[test]
731 fn test_copy_block_has_copy_label() {
732 let b = format_copy_block(&["line"], false);
733 assert!(b.contains("── copy"));
734 }
735
736 #[test]
737 fn test_copy_block_no_vertical_bars() {
738 let b = format_copy_block(&[r#""key": "value""#], false);
739 assert!(!b.contains('│'));
741 }
742
743 #[test]
744 fn test_copy_block_four_space_indent() {
745 let b = format_copy_block(&["hello"], false);
746 let content_line = b.lines().find(|l| l.contains("hello")).unwrap();
748 assert!(content_line.starts_with(" hello"));
749 }
750
751 #[test]
752 fn test_copy_block_empty() {
753 let b = format_copy_block(&[], false);
754 assert!(b.contains("── copy"));
756 assert!(b.lines().count() == 2); }
758
759 #[test]
760 fn test_copy_block_with_color() {
761 let b = format_copy_block(&["test"], true);
762 assert!(b.contains("test"));
763 assert!(!b.contains('│'));
764 }
765
766 #[test]
774 fn test_no_color_produces_different_output() {
775 let with_color = format_section_header("Test", true);
776 let without_color = format_section_header("Test", false);
777 assert_ne!(with_color.len(), without_color.len());
779 }
780
781 #[test]
782 fn test_no_color_bar_chart_different() {
783 let with_color = format_bar_chart("Rust", 10, 0.5, "files", true);
784 let without_color = format_bar_chart("Rust", 10, 0.5, "files", false);
785 assert_ne!(with_color.len(), without_color.len());
786 }
787
788 #[test]
789 fn test_no_color_error_hint_different() {
790 let with_color = format_error_hint("fail", &["hint1"], true);
791 let without_color = format_error_hint("fail", &["hint1"], false);
792 assert_ne!(with_color.len(), without_color.len());
793 }
794}