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    #[serde(skip_serializing_if = "Vec::is_empty")]
403    pub security_advisories: Vec<crate::advisories::CrateAdvisories>,
404}
405
406pub fn render_advisories(advisories: &[crate::advisories::CrateAdvisories]) {
407    if advisories.is_empty() {
408        return;
409    }
410    println!();
411    println!("{}", "🔒 Security advisories".bold());
412    for hit in advisories {
413        let count = hit.advisories.len();
414        println!(
415            "  {} {} {}",
416            "⚠".yellow(),
417            hit.crate_name.yellow().bold(),
418            format!("({count} advisory{})", if count == 1 { "" } else { "ies" }).dimmed()
419        );
420        for adv in &hit.advisories {
421            let cve_tag = adv
422                .cve()
423                .map(|c| format!(" · {c}"))
424                .unwrap_or_default();
425            println!(
426                "     {} {}{}",
427                adv.id.red().bold(),
428                adv.summary.dimmed(),
429                cve_tag.dimmed()
430            );
431        }
432    }
433    println!();
434    println!(
435        "{}",
436        "  Run `cargo audit` for patch guidance. Add affected crates to bless.toml ignore_packages to suppress.".dimmed()
437    );
438}
439
440pub fn render_unified_json(report: JsonReportUnified<'_>) {
441    match serde_json::to_string_pretty(&report) {
442        Ok(json) => println!("{}", json),
443        Err(e) => eprintln!("cargo-bless: failed to serialize JSON output: {}", e),
444    }
445}
446
447/// Legacy shape for compatibility (flat `dependency_suggestions` at top level).
448#[derive(Serialize)]
449pub struct JsonReport<'a> {
450    pub dependency_suggestions: &'a [Suggestion],
451    pub code_audit: Option<&'a CodeAuditReport>,
452}
453
454/// Legacy narrow JSON (**`dependency_suggestions`** at top level) for crates embedding v0.1 output.
455pub fn render_json_report(suggestions: &[Suggestion], code_audit: Option<&CodeAuditReport>) {
456    let report = JsonReport {
457        dependency_suggestions: suggestions,
458        code_audit,
459    };
460    match serde_json::to_string_pretty(&report) {
461        Ok(json) => println!("{}", json),
462        Err(e) => eprintln!("cargo-bless: failed to serialize JSON output: {}", e),
463    }
464}
465
466/// Render suggestions as a JSON array to stdout.
467/// Kept for library callers that rely on the old narrow JSON shape.
468pub fn render_json(suggestions: &[Suggestion]) {
469    match serde_json::to_string_pretty(suggestions) {
470        Ok(json) => println!("{}", json),
471        Err(e) => eprintln!("cargo-bless: failed to serialize JSON output: {}", e),
472    }
473}
474
475// ── SARIF 2.1.0 output ───────────────────────────────────────────────────────
476
477#[derive(Serialize)]
478struct SarifRoot {
479    version: &'static str,
480    #[serde(rename = "$schema")]
481    schema: &'static str,
482    runs: Vec<SarifRun>,
483}
484
485#[derive(Serialize)]
486struct SarifRun {
487    tool: SarifTool,
488    results: Vec<SarifResult>,
489}
490
491#[derive(Serialize)]
492struct SarifTool {
493    driver: SarifDriver,
494}
495
496#[derive(Serialize)]
497struct SarifDriver {
498    name: &'static str,
499    version: &'static str,
500    #[serde(rename = "informationUri")]
501    information_uri: &'static str,
502    rules: Vec<SarifRule>,
503}
504
505#[derive(Serialize)]
506struct SarifRule {
507    id: String,
508    name: String,
509    #[serde(rename = "shortDescription")]
510    short_description: SarifMessage,
511}
512
513#[derive(Serialize)]
514struct SarifResult {
515    #[serde(rename = "ruleId")]
516    rule_id: String,
517    level: &'static str,
518    message: SarifMessage,
519    locations: Vec<SarifLocation>,
520}
521
522#[derive(Serialize)]
523struct SarifLocation {
524    #[serde(rename = "physicalLocation")]
525    physical_location: SarifPhysicalLocation,
526}
527
528#[derive(Serialize)]
529struct SarifPhysicalLocation {
530    #[serde(rename = "artifactLocation")]
531    artifact_location: SarifArtifactLocation,
532    region: SarifRegion,
533}
534
535#[derive(Serialize)]
536struct SarifArtifactLocation {
537    uri: String,
538    #[serde(rename = "uriBaseId")]
539    uri_base_id: &'static str,
540}
541
542#[derive(Serialize)]
543struct SarifRegion {
544    #[serde(rename = "startLine")]
545    start_line: usize,
546}
547
548#[derive(Serialize)]
549struct SarifMessage {
550    text: String,
551}
552
553pub fn render_sarif(report: &CodeAuditReport) {
554    use std::collections::BTreeMap;
555
556    // Collect unique rule IDs for the driver rules table
557    let mut rule_descriptions: BTreeMap<String, String> = BTreeMap::new();
558    for alert in &report.alerts {
559        rule_descriptions
560            .entry(format!("{:?}", alert.kind))
561            .or_insert_with(|| alert.why_bs.clone());
562    }
563
564    let rules: Vec<SarifRule> = rule_descriptions
565        .iter()
566        .map(|(id, desc)| SarifRule {
567            id: id.clone(),
568            name: id.clone(),
569            short_description: SarifMessage { text: desc.clone() },
570        })
571        .collect();
572
573    let results: Vec<SarifResult> = report
574        .alerts
575        .iter()
576        .map(|a| {
577            let uri = a
578                .file
579                .to_string_lossy()
580                .strip_prefix("./")
581                .unwrap_or(a.file.to_string_lossy().as_ref())
582                .to_string();
583            SarifResult {
584                rule_id: format!("{:?}", a.kind),
585                level: "warning",
586                message: SarifMessage {
587                    text: format!("{} — {}", a.why_bs, a.suggestion),
588                },
589                locations: vec![SarifLocation {
590                    physical_location: SarifPhysicalLocation {
591                        artifact_location: SarifArtifactLocation {
592                            uri,
593                            uri_base_id: "%SRCROOT%",
594                        },
595                        region: SarifRegion {
596                            start_line: a.line,
597                        },
598                    },
599                }],
600            }
601        })
602        .collect();
603
604    let sarif = SarifRoot {
605        version: "2.1.0",
606        schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
607        runs: vec![SarifRun {
608            tool: SarifTool {
609                driver: SarifDriver {
610                    name: "cargo-bless",
611                    version: env!("CARGO_PKG_VERSION"),
612                    information_uri: "https://github.com/Ruffian-L/cargo-bless",
613                    rules,
614                },
615            },
616            results,
617        }],
618    };
619
620    match serde_json::to_string_pretty(&sarif) {
621        Ok(json) => println!("{json}"),
622        Err(e) => eprintln!("cargo-bless: failed to serialize SARIF output: {e}"),
623    }
624}