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;
5use std::path::Path;
6
7use colored::*;
8use serde::Serialize;
9
10use crate::code_audit::{kind_label, CodeAuditReport};
11use crate::intel::CrateIntel;
12use crate::suggestions::{AutofixSafety, Confidence, Impact, MigrationRisk, SuggestionKind};
13use crate::suggestions::{EvidenceSource, Suggestion};
14
15fn print_suggestion_detail(suggestion: &Suggestion, intel: &HashMap<String, CrateIntel>) {
16    let icon = match suggestion.kind {
17        SuggestionKind::ModernAlternative => "•",
18        SuggestionKind::FeatureOptimization => "•",
19        SuggestionKind::StdReplacement => "•",
20        SuggestionKind::ComboWin => "•",
21        SuggestionKind::Unmaintained => "⚠️",
22    };
23
24    let impact_tag = match suggestion.impact {
25        Impact::High => "[HIGH]".red().bold(),
26        Impact::Medium => "[MED]".yellow().bold(),
27        Impact::Low => "[LOW]".dimmed(),
28    };
29    let confidence_tag = match suggestion.confidence {
30        Confidence::High => "[HIGH confidence]".green().bold(),
31        Confidence::Medium => "[MED confidence]".yellow(),
32        Confidence::Low => "[LOW confidence]".red(),
33    };
34    let risk_tag = match suggestion.migration_risk {
35        MigrationRisk::High => "[HIGH risk]".red().bold(),
36        MigrationRisk::Medium => "[MED risk]".yellow(),
37        MigrationRisk::Low => "[LOW risk]".green(),
38    };
39    let autofix_tag = match suggestion.autofix_safety {
40        AutofixSafety::CargoTomlOnly => "[autofix: Cargo.toml-only]".green(),
41        AutofixSafety::ManualOnly => "[autofix: manual]".dimmed(),
42    };
43    let verb = match suggestion.confidence {
44        Confidence::High => "→",
45        Confidence::Medium | Confidence::Low => "→ consider",
46    };
47
48    println!(
49        " {} {} {} {} {}",
50        icon,
51        impact_tag,
52        suggestion.current.yellow(),
53        verb,
54        suggestion.recommended.green(),
55    );
56    println!(
57        "   {} {} {} {}",
58        confidence_tag,
59        risk_tag,
60        autofix_tag,
61        format!("evidence: {}", evidence_label(&suggestion.evidence_source)).dimmed()
62    );
63    println!("   {}", suggestion.reason.dimmed());
64
65    let crate_names: Vec<&str> = suggestion.current.split('+').collect();
66    for crate_name in crate_names {
67        if let Some(info) = intel.get(crate_name) {
68            let mut enrichment = format!("   latest: v{}", info.latest_version);
69            if let Some(recent) = info.recent_downloads {
70                enrichment.push_str(&format!(", {} recent downloads", format_downloads(recent)));
71            }
72            println!("   {}", enrichment.dimmed());
73        }
74    }
75}
76
77/// One workspace member’s dependency suggestions shown in plain text reports.
78#[derive(Clone, Copy, Debug)]
79pub struct PackageSuggestionView<'a> {
80    pub name: &'a str,
81    pub version: &'a str,
82    pub manifest_path: &'a Path,
83    pub suggestions: &'a [Suggestion],
84}
85
86/// Render modernization output for one or many packages (`--workspace` / `--package` use multi headers).
87fn use_multi_headers(packages: &[PackageSuggestionView<'_>]) -> bool {
88    if packages.len() > 1 {
89        return true;
90    }
91    packages
92        .first()
93        .is_some_and(|p| p.suggestions.iter().any(|s| s.package.is_some()))
94}
95
96/// Single-root modernization report (`manifest_path` is only useful in grouped layouts).
97pub fn render_report(
98    project_name: &str,
99    version: &str,
100    suggestions: &[Suggestion],
101    intel: &HashMap<String, CrateIntel>,
102) {
103    render_packages_modernization(
104        &[PackageSuggestionView {
105            name: project_name,
106            version,
107            manifest_path: Path::new("Cargo.toml"),
108            suggestions,
109        }],
110        intel,
111    );
112}
113
114pub fn render_packages_modernization(
115    packages: &[PackageSuggestionView<'_>],
116    intel: &HashMap<String, CrateIntel>,
117) {
118    let all_empty = packages.iter().all(|p| p.suggestions.is_empty());
119
120    if all_empty {
121        println!(
122            "{}",
123            "✅ Your dependencies are already blessed! Nothing to modernize.".green()
124        );
125        return;
126    }
127
128    let multi = use_multi_headers(packages);
129
130    if !multi {
131        let p = &packages[0];
132        println!(
133            "{}",
134            format!("🚀 Modernization report for {} v{}", p.name, p.version).bold()
135        );
136        println!();
137        for suggestion in p.suggestions {
138            print_suggestion_detail(suggestion, intel);
139        }
140    } else {
141        for p in packages {
142            if p.suggestions.is_empty() {
143                continue;
144            }
145            println!(
146                "{}",
147                format!(
148                    "📦 {} v{} ({})",
149                    p.name,
150                    p.version,
151                    p.manifest_path.display()
152                )
153                .bold()
154            );
155            println!();
156            for suggestion in p.suggestions {
157                print_suggestion_detail(suggestion, intel);
158            }
159            println!();
160        }
161    }
162
163    let high_count = packages
164        .iter()
165        .flat_map(|p| p.suggestions)
166        .filter(|s| matches!(s.impact, Impact::High))
167        .count();
168
169    println!();
170    println!(
171        "{}",
172        format!(
173            "{} high-impact upgrade{} available. `--fix` only edits Cargo.toml (never Rust source). Run `cargo bless --fix --dry-run` to preview.",
174            high_count,
175            if high_count == 1 { "" } else { "s" }
176        )
177        .bold()
178    );
179}
180
181fn suggestion_kind_slug(kind: &SuggestionKind) -> &'static str {
182    match kind {
183        SuggestionKind::ModernAlternative => "modern_alternative",
184        SuggestionKind::FeatureOptimization => "feature_opt",
185        SuggestionKind::StdReplacement => "std_replace",
186        SuggestionKind::ComboWin => "combo_win",
187        SuggestionKind::Unmaintained => "unmaintained",
188    }
189}
190
191/// Paste-friendly condensed output (`--summary`).
192pub fn render_summary(scan_stats: &[(&str, usize, usize)], suggestions: &[Suggestion]) {
193    let pkg_ct = scan_stats.len();
194    println!(
195        "{}",
196        format!(
197            "📊 Summary — scanned {} workspace member{}",
198            pkg_ct,
199            if pkg_ct == 1 { "" } else { "s" }
200        )
201        .bold()
202    );
203    for (name, direct_ct, total_ct) in scan_stats {
204        println!(
205            "   • {} — {} direct deps, {} total in resolve",
206            name.bold(),
207            direct_ct,
208            total_ct
209        );
210    }
211
212    let mut hi = 0usize;
213    let mut med = 0usize;
214    let mut low = 0usize;
215    for s in suggestions {
216        match s.impact {
217            Impact::High => hi += 1,
218            Impact::Medium => med += 1,
219            Impact::Low => low += 1,
220        }
221    }
222
223    println!();
224    println!("Suggestions after policy: {}", suggestions.len());
225    println!("By impact — high: {hi}, medium: {med}, low: {low}");
226
227    let mut kind_counts = HashMap::<&'static str, usize>::new();
228    for s in suggestions {
229        *kind_counts
230            .entry(suggestion_kind_slug(&s.kind))
231            .or_default() += 1;
232    }
233    let mut kind_pairs: Vec<_> = kind_counts.into_iter().collect();
234    kind_pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
235    if !kind_pairs.is_empty() {
236        println!(
237            "By kind — {}",
238            kind_pairs
239                .iter()
240                .map(|(k, c)| format!("{k}: {c}"))
241                .collect::<Vec<_>>()
242                .join(", ")
243        );
244    }
245
246    println!();
247    println!("{}", "Top patterns:".bold());
248    let mut patterns: Vec<String> = suggestions
249        .iter()
250        .map(|s| format!("{} → {}", s.current, s.recommended))
251        .collect();
252    patterns.sort();
253    patterns.dedup();
254    const MAX: usize = 14usize;
255    for line in patterns.iter().take(MAX) {
256        println!("   • {}", line);
257    }
258    if patterns.len() > MAX {
259        println!("   … and {} more", patterns.len() - MAX);
260    }
261
262    println!();
263    println!(
264        "{}",
265        "`--fix` changes Cargo.toml entries only — never Rust source. Check `autofix_safety` on each suggestion."
266            .dimmed()
267    );
268}
269
270fn evidence_label(source: &EvidenceSource) -> &'static str {
271    match source {
272        EvidenceSource::BlessedRs => "blessed.rs",
273        EvidenceSource::RustSec => "RustSec",
274        EvidenceSource::StdDocs => "std docs",
275        EvidenceSource::CrateDocs => "crate docs",
276        EvidenceSource::CratesIo => "crates.io",
277        EvidenceSource::Heuristic => "heuristic",
278    }
279}
280
281pub fn render_code_audit_report(report: &CodeAuditReport, verbose: bool) {
282    println!();
283    println!("{}", "🧨 Bullshit detector code audit".bold());
284    println!(
285        "{}",
286        format!(
287            "Scanned {} Rust file{}.",
288            report.files_scanned,
289            if report.files_scanned == 1 { "" } else { "s" }
290        )
291        .dimmed()
292    );
293
294    if report.is_clean() {
295        println!("{}", "✅ No bullshit detected in Rust source.".green());
296        return;
297    }
298
299    println!(
300        "{}",
301        format!(
302            "🚨 Bullshit detected: {} finding{} · heat {:.1}",
303            report.alerts.len(),
304            if report.alerts.len() == 1 { "" } else { "s" },
305            report.alerts.iter().map(|a| a.severity).sum::<f32>() * 10.0
306        )
307        .red()
308        .bold()
309    );
310
311    let mut counts = HashMap::<&'static str, usize>::new();
312    for alert in &report.alerts {
313        *counts.entry(kind_label(alert.kind)).or_default() += 1;
314    }
315    let mut counts: Vec<_> = counts.into_iter().collect();
316    counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
317    let summary = counts
318        .iter()
319        .map(|(kind, count)| format!("{kind}: {count}"))
320        .collect::<Vec<_>>()
321        .join(", ");
322    println!("{}", summary.dimmed());
323    println!();
324
325    let shown = if verbose {
326        report.alerts.len()
327    } else {
328        report.alerts.len().min(5)
329    };
330
331    for alert in report.alerts.iter().take(shown) {
332        println!(
333            " {} {} {}:{}:{}",
334            "•".red(),
335            kind_label(alert.kind).yellow().bold(),
336            alert.file.display().to_string().dimmed(),
337            alert.line,
338            alert.column
339        );
340        println!("   {}", alert.why_bs);
341        println!("   {}", format!("Fix: {}", alert.suggestion).green());
342        if !alert.context_snippet.is_empty() {
343            println!("   {}", alert.context_snippet.dimmed());
344        }
345    }
346
347    if !verbose && report.alerts.len() > shown {
348        println!();
349        println!(
350            "{}",
351            format!(
352                "Showing top {shown}. Run with --verbose for all {} findings, or --json for machine output.",
353                report.alerts.len()
354            )
355            .dimmed()
356        );
357    }
358}
359
360/// Format download counts in a human-readable way (e.g., "1.2M", "456K").
361fn format_downloads(count: u64) -> String {
362    if count >= 1_000_000 {
363        format!("{:.1}M", count as f64 / 1_000_000.0)
364    } else if count >= 1_000 {
365        format!("{:.1}K", count as f64 / 1_000.0)
366    } else {
367        count.to_string()
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_format_downloads() {
377        assert_eq!(format_downloads(0), "0");
378        assert_eq!(format_downloads(500), "500");
379        assert_eq!(format_downloads(1_500), "1.5K");
380        assert_eq!(format_downloads(1_200_000), "1.2M");
381        assert_eq!(format_downloads(100_000_000), "100.0M");
382    }
383}
384
385#[derive(Serialize)]
386pub struct JsonPackageOutput<'a> {
387    pub name: &'a str,
388    pub version: &'a str,
389    pub manifest_path: String,
390    pub dependency_suggestions: &'a [Suggestion],
391}
392
393/// Machine-readable report: `cargo_bless_version`, `workspace_scan`, `packages`, optional `code_audit`.
394#[derive(Serialize)]
395pub struct JsonReportUnified<'a> {
396    pub cargo_bless_version: &'a str,
397    pub workspace_scan: bool,
398    pub packages: Vec<JsonPackageOutput<'a>>,
399    pub code_audit: Option<&'a CodeAuditReport>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub hardcoded_values: Option<&'a [crate::bs_detector::BSHit]>,
402}
403
404pub fn render_unified_json(report: JsonReportUnified<'_>) {
405    match serde_json::to_string_pretty(&report) {
406        Ok(json) => println!("{}", json),
407        Err(e) => eprintln!("cargo-bless: failed to serialize JSON output: {}", e),
408    }
409}
410
411/// Legacy shape for compatibility (flat `dependency_suggestions` at top level).
412#[derive(Serialize)]
413pub struct JsonReport<'a> {
414    pub dependency_suggestions: &'a [Suggestion],
415    pub code_audit: Option<&'a CodeAuditReport>,
416}
417
418/// Legacy narrow JSON (**`dependency_suggestions`** at top level) for crates embedding v0.1 output.
419pub fn render_json_report(suggestions: &[Suggestion], code_audit: Option<&CodeAuditReport>) {
420    let report = JsonReport {
421        dependency_suggestions: suggestions,
422        code_audit,
423    };
424    match serde_json::to_string_pretty(&report) {
425        Ok(json) => println!("{}", json),
426        Err(e) => eprintln!("cargo-bless: failed to serialize JSON output: {}", e),
427    }
428}
429
430/// Render suggestions as a JSON array to stdout.
431/// Kept for library callers that rely on the old narrow JSON shape.
432pub fn render_json(suggestions: &[Suggestion]) {
433    match serde_json::to_string_pretty(suggestions) {
434        Ok(json) => println!("{}", json),
435        Err(e) => eprintln!("cargo-bless: failed to serialize JSON output: {}", e),
436    }
437}