1use 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#[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
86fn 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
96pub 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
191pub 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
360fn 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#[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#[derive(Serialize)]
449pub struct JsonReport<'a> {
450 pub dependency_suggestions: &'a [Suggestion],
451 pub code_audit: Option<&'a CodeAuditReport>,
452}
453
454pub 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
466pub 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#[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 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}