1use anyhow::{Context, Result};
2use runemark::{
3 ColorMode, Console, ErrorBlock, Finding, FindingGroup, Location, Metric, NextStep, Report,
4 Tone, Trend, Verdict,
5};
6use serde::{Deserialize, Serialize};
7use std::io::Write;
8use std::path::PathBuf;
9
10use crate::history::HistoryTrend;
11use crate::level_b::FingerprintSummary;
12
13pub fn format_bytes(bytes: u64) -> String {
14 const KIB: u64 = 1024;
15 const MIB: u64 = 1024 * KIB;
16 const GIB: u64 = 1024 * MIB;
17 const TIB: u64 = 1024 * GIB;
18
19 if bytes >= TIB {
20 format!("{:.2} TiB", bytes as f64 / TIB as f64)
21 } else if bytes >= GIB {
22 format!("{:.2} GiB", bytes as f64 / GIB as f64)
23 } else if bytes >= MIB {
24 format!("{:.2} MiB", bytes as f64 / MIB as f64)
25 } else if bytes >= KIB {
26 format!("{:.2} KiB", bytes as f64 / KIB as f64)
27 } else {
28 format!("{} B", bytes)
29 }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum CleaningLevel {
34 Coarse,
35 Fine,
36 Skipped,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ProjectActionResult {
41 pub project_name: String,
42 pub project_path: PathBuf,
43 pub target_path: PathBuf,
44 pub level: CleaningLevel,
45 pub original_size_bytes: u64,
46 pub reclaimed_bytes: u64,
47 pub details: String,
48 pub fingerprint_summary: Option<FingerprintSummary>,
49 pub error: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct BroomReportSummary {
54 pub root_path: PathBuf,
55 pub dry_run: bool,
56 pub total_projects_scanned: usize,
57 pub coarse_cleaned_count: usize,
58 pub fine_cleaned_count: usize,
59 pub skipped_count: usize,
60 pub error_count: usize,
61 pub total_reclaimed_bytes: u64,
62 pub history_trend: Option<HistoryTrend>,
63 pub results: Vec<ProjectActionResult>,
64}
65
66pub fn render_report(
67 summary: &BroomReportSummary,
68 output_format: OutputFormat,
69 color_mode: ColorMode,
70 out: &mut dyn Write,
71) -> Result<()> {
72 match output_format {
73 OutputFormat::Tty => render_tty_report(summary, color_mode, out),
74 OutputFormat::Json => render_json_report(summary, out),
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum OutputFormat {
80 Tty,
81 Json,
82}
83
84fn render_tty_report(
85 summary: &BroomReportSummary,
86 color_mode: ColorMode,
87 out: &mut dyn Write,
88) -> Result<()> {
89 let console = Console::new(color_mode, true);
90
91 let mode_str = if summary.dry_run { " (Dry Run)" } else { "" };
92 let title = console.paint(Tone::Title, format!("cargo-broom Summary{}", mode_str));
93 writeln!(out, "{}", title)?;
94
95 let verdict = if summary.error_count > 0 {
96 Verdict::Warning
97 } else if summary.total_reclaimed_bytes > 0 {
98 Verdict::Info
99 } else {
100 Verdict::Passed
101 };
102
103 let mut report = Report::new("cargo-broom", verdict);
104
105 if summary.total_reclaimed_bytes > 0 {
106 let metric_label = if summary.dry_run {
107 "Reclaimable"
108 } else {
109 "Reclaimed"
110 };
111 report = report.add_metric(Metric::new(
112 metric_label,
113 format_bytes(summary.total_reclaimed_bytes),
114 ));
115 }
116
117 if let Some(trend) = &summary.history_trend {
118 report = report.add_metric(history_metric(trend));
119 }
120
121 let coarse_results: Vec<_> = summary
123 .results
124 .iter()
125 .filter(|r| r.level == CleaningLevel::Coarse)
126 .collect();
127 if !coarse_results.is_empty() {
128 let mut group = FindingGroup::new("Level A — Full Target Clean (Coarse)");
129 for res in coarse_results {
130 let finding = Finding::new(
131 Tone::Info,
132 format!(
133 "{} ({})",
134 res.project_name,
135 format_bytes(res.reclaimed_bytes)
136 ),
137 )
138 .with_location(Location::Artifact(res.target_path.clone()));
139 group = group.add_finding(finding);
140 }
141 report = report.add_group(group);
142 }
143
144 let fine_results: Vec<_> = summary
146 .results
147 .iter()
148 .filter(|r| r.level == CleaningLevel::Fine)
149 .collect();
150 if !fine_results.is_empty() {
151 let mut group = FindingGroup::new("Level B — Selective / Experimental Fine Clean");
152 for res in fine_results {
153 let details = if let Some(ref fp) = res.fingerprint_summary {
154 format!(
155 "{} (pruned {} stale fingerprints, {} files)",
156 res.project_name, fp.stale_fingerprints, fp.removed_files_count
157 )
158 } else {
159 res.project_name.clone()
160 };
161 let finding = Finding::new(
162 Tone::Info,
163 format!("{} — {}", details, format_bytes(res.reclaimed_bytes)),
164 )
165 .with_location(Location::Artifact(res.target_path.clone()));
166 group = group.add_finding(finding);
167 }
168 report = report.add_group(group);
169 }
170
171 if summary.dry_run && summary.total_reclaimed_bytes > 0 {
173 let step = NextStep::new(format!(
174 "Run `cargo broom -y` to reclaim {}",
175 format_bytes(summary.total_reclaimed_bytes)
176 ))
177 .with_command("cargo broom -y");
178 report = report.add_next_step(step);
179 }
180
181 let rendered = report.render(console);
182 writeln!(out, "{}", rendered)?;
183
184 for res in &summary.results {
186 if let Some(ref err_msg) = res.error {
187 let block = ErrorBlock::new(format!("Error processing {}", res.project_name))
188 .with_explanation(err_msg);
189 writeln!(out, "{}", block.render(console))?;
190 }
191 }
192
193 Ok(())
194}
195
196fn history_metric(trend: &HistoryTrend) -> Metric {
200 let delta = trend.current_total_bytes as i64 - trend.oldest_total_bytes as i64;
201 let (direction, sign) = if delta <= 0 {
202 (Trend::Positive, "-")
203 } else {
204 (Trend::Negative, "+")
205 };
206 let delta_str = format!(
207 "{sign}{} over {}d ({} tracked)",
208 format_bytes(delta.unsigned_abs()),
209 crate::history::RETENTION_DAYS,
210 trend.tracked_targets
211 );
212
213 Metric::new("History", format_bytes(trend.current_total_bytes)).with_trend(direction, delta_str)
214}
215
216fn render_json_report(summary: &BroomReportSummary, out: &mut dyn Write) -> Result<()> {
217 let json = serde_json::to_string_pretty(summary)?;
218 writeln!(out, "{}", json)?;
219 Ok(())
220}
221
222pub fn parse_size_string(s: Option<&str>) -> Result<Option<u64>> {
225 let Some(s) = s else {
226 return Ok(None);
227 };
228 let s = s.trim();
229 if s.is_empty() {
230 anyhow::bail!("size value must not be empty");
231 }
232 let lower = s.to_lowercase();
233 let parsed = if let Some(num_str) = lower.strip_suffix("mb") {
234 parse_scaled_size(num_str, 1024 * 1024)
235 } else if let Some(num_str) = lower.strip_suffix("gb") {
236 parse_scaled_size(num_str, 1024 * 1024 * 1024)
237 } else if let Some(num_str) = lower.strip_suffix("kb") {
238 parse_scaled_size(num_str, 1024)
239 } else if let Some(num_str) = lower.strip_suffix('b') {
240 parse_scaled_size(num_str, 1)
241 } else {
242 parse_scaled_size(s, 1)
243 }
244 .with_context(|| format!("invalid size value `{s}`"))?;
245 Ok(Some(parsed))
246}
247
248fn parse_scaled_size(value: &str, multiplier: u64) -> Result<u64> {
249 value
250 .trim()
251 .parse::<u64>()?
252 .checked_mul(multiplier)
253 .context("size value is too large")
254}