Skip to main content

cargo_bless/
output.rs

1//! Output layer — renders the modernization report to the terminal
2//! with colored output, emojis, and actionable links.
3
4use std::collections::HashMap;
5
6use colored::*;
7use serde::Serialize;
8
9use crate::code_audit::{kind_label, CodeAuditReport};
10use crate::intel::CrateIntel;
11use crate::suggestions::{AutofixSafety, Confidence, Impact, MigrationRisk, SuggestionKind};
12use crate::suggestions::{EvidenceSource, Suggestion};
13
14/// Render the full modernization report to stdout.
15///
16/// `intel` provides optional live metadata for enriching suggestions
17/// with version info, downloads, and freshness (can be empty).
18pub fn render_report(
19    project_name: &str,
20    version: &str,
21    suggestions: &[Suggestion],
22    intel: &HashMap<String, CrateIntel>,
23) {
24    if suggestions.is_empty() {
25        println!(
26            "{}",
27            "✅ Your dependencies are already blessed! Nothing to modernize.".green()
28        );
29        return;
30    }
31
32    println!(
33        "{}",
34        format!("🚀 Modernization report for {} v{}", project_name, version).bold()
35    );
36    println!();
37
38    for suggestion in suggestions {
39        let icon = match suggestion.kind {
40            SuggestionKind::ModernAlternative => "•",
41            SuggestionKind::FeatureOptimization => "•",
42            SuggestionKind::StdReplacement => "•",
43            SuggestionKind::ComboWin => "•",
44            SuggestionKind::Unmaintained => "⚠️",
45        };
46
47        let impact_tag = match suggestion.impact {
48            Impact::High => "[HIGH]".red().bold(),
49            Impact::Medium => "[MED]".yellow().bold(),
50            Impact::Low => "[LOW]".dimmed(),
51        };
52        let confidence_tag = match suggestion.confidence {
53            Confidence::High => "[HIGH confidence]".green().bold(),
54            Confidence::Medium => "[MED confidence]".yellow(),
55            Confidence::Low => "[LOW confidence]".red(),
56        };
57        let risk_tag = match suggestion.migration_risk {
58            MigrationRisk::High => "[HIGH risk]".red().bold(),
59            MigrationRisk::Medium => "[MED risk]".yellow(),
60            MigrationRisk::Low => "[LOW risk]".green(),
61        };
62        let autofix_tag = match suggestion.autofix_safety {
63            AutofixSafety::CargoTomlOnly => "[autofix: Cargo.toml-only]".green(),
64            AutofixSafety::ManualOnly => "[autofix: manual]".dimmed(),
65        };
66        let verb = match suggestion.confidence {
67            Confidence::High => "→",
68            Confidence::Medium | Confidence::Low => "→ consider",
69        };
70
71        // Base suggestion line
72        println!(
73            " {} {} {} {} {}",
74            icon,
75            impact_tag,
76            suggestion.current.yellow(),
77            verb,
78            suggestion.recommended.green(),
79        );
80        println!(
81            "   {} {} {} {}",
82            confidence_tag,
83            risk_tag,
84            autofix_tag,
85            format!("evidence: {}", evidence_label(&suggestion.evidence_source)).dimmed()
86        );
87        println!("   {}", suggestion.reason.dimmed());
88
89        // Enrich with live intel if available
90        // For combo rules like "reqwest+serde_json", check each crate name
91        let crate_names: Vec<&str> = suggestion.current.split('+').collect();
92        for crate_name in crate_names {
93            if let Some(info) = intel.get(crate_name) {
94                let mut enrichment = format!("   latest: v{}", info.latest_version);
95                if let Some(recent) = info.recent_downloads {
96                    enrichment
97                        .push_str(&format!(", {} recent downloads", format_downloads(recent)));
98                }
99                println!("   {}", enrichment.dimmed());
100            }
101        }
102    }
103
104    let high_count = suggestions
105        .iter()
106        .filter(|s| matches!(s.impact, Impact::High))
107        .count();
108
109    println!();
110    println!(
111        "{}",
112        format!(
113            "{} high-impact upgrade{} available. Run `cargo bless --fix --dry-run` to preview Cargo.toml-only fixes.",
114            high_count,
115            if high_count == 1 { "" } else { "s" }
116        )
117        .bold()
118    );
119}
120
121fn evidence_label(source: &EvidenceSource) -> &'static str {
122    match source {
123        EvidenceSource::BlessedRs => "blessed.rs",
124        EvidenceSource::RustSec => "RustSec",
125        EvidenceSource::StdDocs => "std docs",
126        EvidenceSource::CrateDocs => "crate docs",
127        EvidenceSource::CratesIo => "crates.io",
128        EvidenceSource::Heuristic => "heuristic",
129    }
130}
131
132pub fn render_code_audit_report(report: &CodeAuditReport, verbose: bool) {
133    println!();
134    println!("{}", "🧨 Bullshit detector code audit".bold());
135    println!(
136        "{}",
137        format!(
138            "Scanned {} Rust file{}.",
139            report.files_scanned,
140            if report.files_scanned == 1 { "" } else { "s" }
141        )
142        .dimmed()
143    );
144
145    if report.is_clean() {
146        println!("{}", "✅ No bullshit detected in Rust source.".green());
147        return;
148    }
149
150    println!(
151        "{}",
152        format!(
153            "🚨 Bullshit detected: {} finding{} · heat {:.1}",
154            report.alerts.len(),
155            if report.alerts.len() == 1 { "" } else { "s" },
156            report.alerts.iter().map(|a| a.severity).sum::<f32>() * 10.0
157        )
158        .red()
159        .bold()
160    );
161
162    let mut counts = HashMap::<&'static str, usize>::new();
163    for alert in &report.alerts {
164        *counts.entry(kind_label(alert.kind)).or_default() += 1;
165    }
166    let mut counts: Vec<_> = counts.into_iter().collect();
167    counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
168    let summary = counts
169        .iter()
170        .map(|(kind, count)| format!("{kind}: {count}"))
171        .collect::<Vec<_>>()
172        .join(", ");
173    println!("{}", summary.dimmed());
174    println!();
175
176    let shown = if verbose {
177        report.alerts.len()
178    } else {
179        report.alerts.len().min(5)
180    };
181
182    for alert in report.alerts.iter().take(shown) {
183        println!(
184            " {} {} {}:{}:{}",
185            "•".red(),
186            kind_label(alert.kind).yellow().bold(),
187            alert.file.display().to_string().dimmed(),
188            alert.line,
189            alert.column
190        );
191        println!("   {}", alert.why_bs);
192        println!("   {}", format!("Fix: {}", alert.suggestion).green());
193        if !alert.context_snippet.is_empty() {
194            println!("   {}", alert.context_snippet.dimmed());
195        }
196    }
197
198    if !verbose && report.alerts.len() > shown {
199        println!();
200        println!(
201            "{}",
202            format!(
203                "Showing top {shown}. Run with --verbose for all {} findings, or --json for machine output.",
204                report.alerts.len()
205            )
206            .dimmed()
207        );
208    }
209}
210
211/// Format download counts in a human-readable way (e.g., "1.2M", "456K").
212fn format_downloads(count: u64) -> String {
213    if count >= 1_000_000 {
214        format!("{:.1}M", count as f64 / 1_000_000.0)
215    } else if count >= 1_000 {
216        format!("{:.1}K", count as f64 / 1_000.0)
217    } else {
218        count.to_string()
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_format_downloads() {
228        assert_eq!(format_downloads(0), "0");
229        assert_eq!(format_downloads(500), "500");
230        assert_eq!(format_downloads(1_500), "1.5K");
231        assert_eq!(format_downloads(1_200_000), "1.2M");
232        assert_eq!(format_downloads(100_000_000), "100.0M");
233    }
234}
235
236#[derive(Serialize)]
237pub struct JsonReport<'a> {
238    pub dependency_suggestions: &'a [Suggestion],
239    pub code_audit: Option<&'a CodeAuditReport>,
240}
241
242/// Render a unified JSON report for machine consumption.
243pub fn render_json_report(suggestions: &[Suggestion], code_audit: Option<&CodeAuditReport>) {
244    let report = JsonReport {
245        dependency_suggestions: suggestions,
246        code_audit,
247    };
248    match serde_json::to_string_pretty(&report) {
249        Ok(json) => println!("{}", json),
250        Err(e) => eprintln!("cargo-bless: failed to serialize JSON output: {}", e),
251    }
252}
253
254/// Render suggestions as a JSON array to stdout.
255/// Kept for library callers that rely on the old narrow JSON shape.
256pub fn render_json(suggestions: &[Suggestion]) {
257    match serde_json::to_string_pretty(suggestions) {
258        Ok(json) => println!("{}", json),
259        Err(e) => eprintln!("cargo-bless: failed to serialize JSON output: {}", e),
260    }
261}