Skip to main content

broom/
lib.rs

1pub mod budget_command;
2pub mod commands;
3pub mod config;
4pub mod discover;
5pub mod doctor_command;
6pub mod history;
7pub mod inspect_command;
8pub mod interactive;
9pub mod level_a;
10pub mod level_b;
11pub mod registry_command;
12pub mod report;
13pub mod toolchains_command;
14
15use anyhow::Result;
16use runemark::{ColorMode, Console, ProgressMode, ProgressSink, TerminalProgress};
17use std::io::Write;
18use std::path::PathBuf;
19
20use discover::{DiscoverOptions, discover_targets, is_build_running};
21use history::{HistoryEntry, HistoryTrend};
22use interactive::{CandidateTarget, prompt_interactive_selection};
23use level_a::{LevelAOptions, clean_coarse, should_clean_coarse};
24use level_b::{LevelBOptions, clean_fine};
25use report::{
26    BroomReportSummary, CleaningLevel, OutputFormat, ProjectActionResult, format_bytes,
27    render_report,
28};
29
30#[derive(Debug, Clone)]
31pub struct BroomRunnerOptions {
32    pub root_path: PathBuf,
33    pub dry_run: bool,
34    pub interactive: bool,
35    pub auto_confirm: bool,
36    pub keep_days: u64,
37    pub keep_size_bytes: Option<u64>,
38    pub experimental_fine: bool,
39    pub fine_only: bool,
40    pub coarse_only: bool,
41    pub toolchains: Vec<String>,
42    pub installed: bool,
43    pub tests_only: bool,
44    pub clean_incremental: bool,
45    pub clean_doc: bool,
46    pub trash: bool,
47    pub history: bool,
48    pub hidden: bool,
49    pub skip: Vec<String>,
50    pub ignore: Vec<String>,
51    pub output_format: OutputFormat,
52    pub color_mode: ColorMode,
53}
54
55/// Records this run's resulting target sizes to the `--history` log and, if any of
56/// them were already tracked, returns the net drift since each target's oldest
57/// still-retained entry (see `history::RETENTION_DAYS`).
58fn record_history(results: &[ProjectActionResult]) -> Option<HistoryTrend> {
59    let path = history::history_file_path()?;
60    record_history_at(&path, results, history::now_unix())
61}
62
63/// Core logic behind `--history`, with the log path and current time as explicit
64/// parameters so tests can exercise it without touching the real
65/// `~/.local/state/cargo-broom/history.jsonl` or depending on wall-clock time.
66fn record_history_at(
67    path: &std::path::Path,
68    results: &[ProjectActionResult],
69    now: u64,
70) -> Option<HistoryTrend> {
71    let existing = history::load_entries(path);
72
73    let new_entries: Vec<HistoryEntry> = results
74        .iter()
75        .map(|r| HistoryEntry {
76            timestamp_unix: now,
77            target_path: r.target_path.clone(),
78            project_name: r.project_name.clone(),
79            size_bytes: r.original_size_bytes.saturating_sub(r.reclaimed_bytes),
80        })
81        .collect();
82
83    let oldest = history::oldest_size_per_target(&existing);
84    let mut oldest_total_bytes = 0u64;
85    let mut current_total_bytes = 0u64;
86    let mut tracked_targets = 0usize;
87    for entry in &new_entries {
88        if let Some(&old_size) = oldest.get(&entry.target_path) {
89            oldest_total_bytes += old_size;
90            current_total_bytes += entry.size_bytes;
91            tracked_targets += 1;
92        }
93    }
94
95    if let Err(err) = history::append_and_prune(path, &existing, &new_entries, now) {
96        eprintln!(
97            "Warning: failed to update history log at {}: {err}",
98            path.display()
99        );
100    }
101
102    if tracked_targets == 0 {
103        return None; // nothing seen before, so there is no trend to report yet
104    }
105
106    Some(HistoryTrend {
107        tracked_targets,
108        oldest_total_bytes,
109        current_total_bytes,
110    })
111}
112
113pub fn run_broom(opts: BroomRunnerOptions, out: &mut dyn Write) -> Result<()> {
114    if !opts.dry_run && !opts.interactive && !opts.auto_confirm {
115        anyhow::bail!("refusing to delete without --dry-run, --interactive, or --yes");
116    }
117    if opts.fine_only && opts.coarse_only {
118        anyhow::bail!("--fine-only conflicts with --coarse-only");
119    }
120    if opts.coarse_only
121        && (opts.experimental_fine || opts.tests_only || opts.clean_incremental || opts.clean_doc)
122    {
123        anyhow::bail!("--coarse-only conflicts with fine-cleaning options");
124    }
125    if opts.fine_only && !opts.experimental_fine && !opts.clean_incremental && !opts.clean_doc {
126        anyhow::bail!(
127            "--fine-only requires --experimental-fine, --clean-incremental, or --clean-doc"
128        );
129    }
130    if opts.tests_only && !opts.experimental_fine {
131        anyhow::bail!("--tests-only requires --experimental-fine");
132    }
133
134    let discover_opts = DiscoverOptions {
135        hidden: opts.hidden,
136        skip_patterns: opts.skip.clone(),
137        ignore_patterns: opts.ignore.clone(),
138    };
139
140    let targets = discover_targets(&opts.root_path, &discover_opts)?;
141    let level_a_opts = LevelAOptions {
142        keep_days: opts.keep_days,
143        keep_size_bytes: opts.keep_size_bytes,
144    };
145
146    let level_b_opts = LevelBOptions {
147        keep_days: opts.keep_days,
148        toolchains: opts.toolchains.clone(),
149        installed: opts.installed,
150        experimental_fingerprints: opts.experimental_fine,
151        tests_only: opts.tests_only,
152        clean_incremental: opts.clean_incremental,
153        clean_doc: opts.clean_doc,
154    };
155
156    let total_scanned = targets.len();
157    let fine_requested =
158        opts.experimental_fine || opts.tests_only || opts.clean_incremental || opts.clean_doc;
159    let mut candidates = Vec::new();
160    let mut skipped_count = 0;
161    let mut results = Vec::new();
162    for target in targets {
163        if is_build_running(&target.target_path) {
164            // A running `cargo build` holds this target directory's lock; deleting or
165            // pruning files under it now could corrupt the in-progress build. Skip it
166            // outright rather than racing it, and say why instead of a silent no-op.
167            skipped_count += 1;
168            results.push(ProjectActionResult {
169                project_name: target.project_name.clone(),
170                project_path: target.project_path.clone(),
171                target_path: target.target_path.clone(),
172                level: CleaningLevel::Skipped,
173                original_size_bytes: target.size_bytes,
174                reclaimed_bytes: 0,
175                details: "Build in progress, skipped".to_string(),
176                fingerprint_summary: None,
177                error: None,
178            });
179            continue;
180        }
181
182        let proposed_level = if !opts.fine_only && should_clean_coarse(&target, &level_a_opts) {
183            CleaningLevel::Coarse
184        } else if !opts.coarse_only && fine_requested {
185            CleaningLevel::Fine
186        } else {
187            CleaningLevel::Skipped
188        };
189
190        let reclaimable_bytes = match proposed_level {
191            CleaningLevel::Coarse => target.size_bytes,
192            CleaningLevel::Fine => clean_fine(&target, &level_b_opts, true)
193                .map(|summary| summary.reclaimed_bytes)
194                .unwrap_or(0),
195            CleaningLevel::Skipped => 0,
196        };
197
198        if proposed_level == CleaningLevel::Skipped {
199            skipped_count += 1;
200        } else {
201            candidates.push(CandidateTarget {
202                target,
203                proposed_level,
204                reclaimable_bytes,
205            });
206        }
207    }
208
209    let candidate_count = candidates.len();
210    let selected_candidates = if opts.interactive {
211        prompt_interactive_selection(candidates)?
212    } else {
213        candidates
214    };
215
216    skipped_count += candidate_count.saturating_sub(selected_candidates.len());
217    let mut coarse_count = 0;
218    let mut fine_count = 0;
219    let mut error_count = 0;
220    let mut total_reclaimed = 0u64;
221
222    let is_tty = opts.output_format == OutputFormat::Tty;
223    let progress_mode = if is_tty {
224        ProgressMode::Auto
225    } else {
226        ProgressMode::Never
227    };
228    let console = Console::new(opts.color_mode, is_tty);
229    let progress = TerminalProgress::stderr(progress_mode, console, is_tty);
230    progress.start(
231        total_scanned as u64,
232        "Scanning & cleaning target directories",
233    );
234
235    for (idx, cand) in selected_candidates.into_iter().enumerate() {
236        let target = cand.target;
237        progress.advance(idx as u64 + 1, &format!("Cleaning {}", target.project_name));
238
239        match cand.proposed_level {
240            CleaningLevel::Coarse => match clean_coarse(&target, opts.dry_run, opts.trash) {
241                Ok(freed) => {
242                    coarse_count += 1;
243                    total_reclaimed += freed;
244                    let details = if opts.trash {
245                        format!("Coarse clean, moved to trash ({})", format_bytes(freed))
246                    } else {
247                        format!("Coarse clean ({})", format_bytes(freed))
248                    };
249                    results.push(ProjectActionResult {
250                        project_name: target.project_name.clone(),
251                        project_path: target.project_path.clone(),
252                        target_path: target.target_path.clone(),
253                        level: CleaningLevel::Coarse,
254                        original_size_bytes: target.size_bytes,
255                        reclaimed_bytes: freed,
256                        details,
257                        fingerprint_summary: None,
258                        error: None,
259                    });
260                }
261                Err(err) => {
262                    error_count += 1;
263                    results.push(ProjectActionResult {
264                        project_name: target.project_name.clone(),
265                        project_path: target.project_path.clone(),
266                        target_path: target.target_path.clone(),
267                        level: CleaningLevel::Coarse,
268                        original_size_bytes: target.size_bytes,
269                        reclaimed_bytes: 0,
270                        details: "Error during coarse clean".to_string(),
271                        fingerprint_summary: None,
272                        error: Some(err.to_string()),
273                    });
274                }
275            },
276            CleaningLevel::Fine => match clean_fine(&target, &level_b_opts, opts.dry_run) {
277                Ok(summary) => {
278                    if summary.unsupported_fingerprint_data {
279                        error_count += 1;
280                        results.push(ProjectActionResult {
281                            project_name: target.project_name.clone(),
282                            project_path: target.project_path.clone(),
283                            target_path: target.target_path.clone(),
284                            level: CleaningLevel::Fine,
285                            original_size_bytes: target.size_bytes,
286                            reclaimed_bytes: 0,
287                            details: "Unsupported or unreadable fingerprint data".to_string(),
288                            fingerprint_summary: None,
289                            error: Some(
290                                "fine cleanup skipped; coarse fallback is intentionally disabled"
291                                    .to_string(),
292                            ),
293                        });
294                    } else {
295                        fine_count += 1;
296                        total_reclaimed += summary.reclaimed_bytes;
297                        results.push(ProjectActionResult {
298                            project_name: target.project_name.clone(),
299                            project_path: target.project_path.clone(),
300                            target_path: target.target_path.clone(),
301                            level: CleaningLevel::Fine,
302                            original_size_bytes: target.size_bytes,
303                            reclaimed_bytes: summary.reclaimed_bytes,
304                            details: format!(
305                                "Fine clean (reclaimed {})",
306                                format_bytes(summary.reclaimed_bytes)
307                            ),
308                            fingerprint_summary: Some(summary),
309                            error: None,
310                        });
311                    }
312                }
313                Err(err) => {
314                    error_count += 1;
315                    results.push(ProjectActionResult {
316                        project_name: target.project_name.clone(),
317                        project_path: target.project_path.clone(),
318                        target_path: target.target_path.clone(),
319                        level: CleaningLevel::Fine,
320                        original_size_bytes: target.size_bytes,
321                        reclaimed_bytes: 0,
322                        details: "Error during fine clean".to_string(),
323                        fingerprint_summary: None,
324                        error: Some(err.to_string()),
325                    });
326                }
327            },
328            CleaningLevel::Skipped => {
329                skipped_count += 1;
330            }
331        }
332    }
333
334    // A dry run does not actually reach the sizes it reports, so recording it would
335    // pollute the trend log with states that never happened.
336    let history_trend = if opts.history && !opts.dry_run {
337        record_history(&results)
338    } else {
339        None
340    };
341
342    let report_summary = BroomReportSummary {
343        root_path: opts.root_path,
344        dry_run: opts.dry_run,
345        total_projects_scanned: total_scanned,
346        coarse_cleaned_count: coarse_count,
347        fine_cleaned_count: fine_count,
348        skipped_count,
349        error_count,
350        total_reclaimed_bytes: total_reclaimed,
351        history_trend,
352        results,
353    };
354
355    render_report(&report_summary, opts.output_format, opts.color_mode, out)?;
356    if error_count > 0 {
357        anyhow::bail!("{} project(s) could not be cleaned", error_count);
358    }
359    Ok(())
360}
361
362#[cfg(test)]
363mod history_wiring_tests {
364    use super::*;
365    use report::CleaningLevel;
366    use std::path::PathBuf;
367
368    fn unique_history_path(name: &str) -> PathBuf {
369        let dir = std::env::temp_dir().join(format!(
370            "broom_lib_history_test_{}_{}",
371            name,
372            std::process::id()
373        ));
374        let _ = std::fs::remove_dir_all(&dir);
375        std::fs::create_dir_all(&dir).unwrap();
376        dir.join("history.jsonl")
377    }
378
379    fn result_with_size(target_path: &str, size_bytes: u64) -> ProjectActionResult {
380        ProjectActionResult {
381            project_name: "dummy".to_string(),
382            project_path: PathBuf::from("/dummy"),
383            target_path: PathBuf::from(target_path),
384            level: CleaningLevel::Skipped,
385            original_size_bytes: size_bytes,
386            reclaimed_bytes: 0,
387            details: String::new(),
388            fingerprint_summary: None,
389            error: None,
390        }
391    }
392
393    #[test]
394    fn record_history_at_returns_none_on_first_ever_run() {
395        let path = unique_history_path("first_run");
396        let results = vec![result_with_size("/repo/target", 100)];
397
398        let trend = record_history_at(&path, &results, 1_000_000_000);
399        assert!(trend.is_none(), "nothing to compare against yet");
400        assert_eq!(history::load_entries(&path).len(), 1);
401    }
402
403    #[test]
404    fn record_history_at_reports_growth_across_runs() {
405        let path = unique_history_path("growth");
406
407        let first = vec![result_with_size("/repo/target", 100)];
408        record_history_at(&path, &first, 1_000_000_000);
409
410        let second = vec![result_with_size("/repo/target", 400)];
411        let trend = record_history_at(&path, &second, 1_000_000_100).unwrap();
412
413        assert_eq!(trend.tracked_targets, 1);
414        assert_eq!(trend.oldest_total_bytes, 100);
415        assert_eq!(trend.current_total_bytes, 400);
416    }
417}