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}
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#[derive(Serialize)]
413pub struct JsonReport<'a> {
414 pub dependency_suggestions: &'a [Suggestion],
415 pub code_audit: Option<&'a CodeAuditReport>,
416}
417
418pub 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
430pub 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}
438
439#[derive(Serialize)]
442struct SarifRoot {
443 version: &'static str,
444 #[serde(rename = "$schema")]
445 schema: &'static str,
446 runs: Vec<SarifRun>,
447}
448
449#[derive(Serialize)]
450struct SarifRun {
451 tool: SarifTool,
452 results: Vec<SarifResult>,
453}
454
455#[derive(Serialize)]
456struct SarifTool {
457 driver: SarifDriver,
458}
459
460#[derive(Serialize)]
461struct SarifDriver {
462 name: &'static str,
463 version: &'static str,
464 #[serde(rename = "informationUri")]
465 information_uri: &'static str,
466 rules: Vec<SarifRule>,
467}
468
469#[derive(Serialize)]
470struct SarifRule {
471 id: String,
472 name: String,
473 #[serde(rename = "shortDescription")]
474 short_description: SarifMessage,
475}
476
477#[derive(Serialize)]
478struct SarifResult {
479 #[serde(rename = "ruleId")]
480 rule_id: String,
481 level: &'static str,
482 message: SarifMessage,
483 locations: Vec<SarifLocation>,
484}
485
486#[derive(Serialize)]
487struct SarifLocation {
488 #[serde(rename = "physicalLocation")]
489 physical_location: SarifPhysicalLocation,
490}
491
492#[derive(Serialize)]
493struct SarifPhysicalLocation {
494 #[serde(rename = "artifactLocation")]
495 artifact_location: SarifArtifactLocation,
496 region: SarifRegion,
497}
498
499#[derive(Serialize)]
500struct SarifArtifactLocation {
501 uri: String,
502 #[serde(rename = "uriBaseId")]
503 uri_base_id: &'static str,
504}
505
506#[derive(Serialize)]
507struct SarifRegion {
508 #[serde(rename = "startLine")]
509 start_line: usize,
510}
511
512#[derive(Serialize)]
513struct SarifMessage {
514 text: String,
515}
516
517pub fn render_sarif(report: &CodeAuditReport) {
518 use std::collections::BTreeMap;
519
520 let mut rule_descriptions: BTreeMap<String, String> = BTreeMap::new();
522 for alert in &report.alerts {
523 rule_descriptions
524 .entry(format!("{:?}", alert.kind))
525 .or_insert_with(|| alert.why_bs.clone());
526 }
527
528 let rules: Vec<SarifRule> = rule_descriptions
529 .iter()
530 .map(|(id, desc)| SarifRule {
531 id: id.clone(),
532 name: id.clone(),
533 short_description: SarifMessage { text: desc.clone() },
534 })
535 .collect();
536
537 let results: Vec<SarifResult> = report
538 .alerts
539 .iter()
540 .map(|a| {
541 let uri = a
542 .file
543 .to_string_lossy()
544 .strip_prefix("./")
545 .unwrap_or(a.file.to_string_lossy().as_ref())
546 .to_string();
547 SarifResult {
548 rule_id: format!("{:?}", a.kind),
549 level: "warning",
550 message: SarifMessage {
551 text: format!("{} — {}", a.why_bs, a.suggestion),
552 },
553 locations: vec![SarifLocation {
554 physical_location: SarifPhysicalLocation {
555 artifact_location: SarifArtifactLocation {
556 uri,
557 uri_base_id: "%SRCROOT%",
558 },
559 region: SarifRegion {
560 start_line: a.line,
561 },
562 },
563 }],
564 }
565 })
566 .collect();
567
568 let sarif = SarifRoot {
569 version: "2.1.0",
570 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
571 runs: vec![SarifRun {
572 tool: SarifTool {
573 driver: SarifDriver {
574 name: "cargo-bless",
575 version: env!("CARGO_PKG_VERSION"),
576 information_uri: "https://github.com/Ruffian-L/cargo-bless",
577 rules,
578 },
579 },
580 results,
581 }],
582 };
583
584 match serde_json::to_string_pretty(&sarif) {
585 Ok(json) => println!("{json}"),
586 Err(e) => eprintln!("cargo-bless: failed to serialize SARIF output: {e}"),
587 }
588}