Skip to main content

broom/
report.rs

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    // Group A: Coarse Cleaned (Level A)
122    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    // Group B: Fine Cleaned (Level B)
145    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    // Next step recommendation
172    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    // Output individual project errors using ErrorBlock
185    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
196/// Turns a `HistoryTrend` (net drift since each tracked target's oldest still-retained
197/// `--history` entry) into a `runemark::Metric`: `Trend::Positive` when targets are net
198/// smaller than they were, `Trend::Negative` when they have grown back despite cleanup.
199fn 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
222/// Parses a human size string like `50MB`, `1GB`, or a plain byte count. Shared by
223/// `--keep-size` and `budget --limit`.
224pub 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}