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
55fn record_history(results: &[ProjectActionResult]) -> Option<HistoryTrend> {
59 let path = history::history_file_path()?;
60 record_history_at(&path, results, history::now_unix())
61}
62
63fn 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; }
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 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 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}