use super::Builder;
use crate::cli::DisplayOptions;
use crate::color;
use crate::executor::{Executor, ExecutorOptions};
use anyhow::{Context, Result, bail};
use std::collections::HashSet;
use std::fmt::Write;
use std::fs;
use std::path::PathBuf;
impl Builder {
pub fn clean(
&self,
ctx: &crate::build_context::BuildContext,
verbose: bool,
processor_filter: Option<&[String]>,
sweep_empty_dirs: bool,
) -> Result<()> {
if let Some(names) = processor_filter {
crate::output::info(&color::bold(&format!(
"Cleaning outputs for: {}",
names.join(", ")
)));
} else {
crate::output::info(&color::bold("Cleaning build artifacts..."));
}
let processors = self.create_processors()?;
let mut graph = self.build_graph_for_clean_with_processors(ctx, &processors)?;
if let Some(names) = processor_filter {
let filter_set: HashSet<&str> = names.iter().map(std::string::String::as_str).collect();
graph.retain_products(|p| filter_set.contains(p.processor.as_str()));
}
let mut candidate_dirs: HashSet<PathBuf> = HashSet::new();
for product in graph.products() {
for output in &product.outputs {
if let Some(parent) = output.parent()
&& !parent.as_os_str().is_empty()
{
candidate_dirs.insert(parent.to_path_buf());
}
}
for dir in &product.output_dirs {
if let Some(parent) = dir.parent()
&& !parent.as_os_str().is_empty()
{
candidate_dirs.insert(parent.to_path_buf());
}
}
}
let policy = crate::executor::IncrementalPolicy;
let executor = Executor::new(
&processors,
ctx,
&policy,
ExecutorOptions {
parallel: 1,
verbose: false,
display_opts: DisplayOptions::minimal(),
batch_size: None,
explain: false,
retry: 0,
},
);
let stats = executor.clean(&graph, verbose)?;
let dirs_removed = if sweep_empty_dirs {
remove_empty_ancestors(&candidate_dirs, verbose)
} else {
0
};
let total_files: usize = stats.values().sum();
if total_files == 0 && dirs_removed == 0 {
crate::output::info(&color::dim("Clean summary: nothing to clean"));
} else {
crate::output::info(&color::bold("Clean summary:"));
let sorted_stats: std::collections::BTreeMap<_, _> = stats.iter().collect();
for (proc, count) in &sorted_stats {
crate::output::info(&format!(" {proc}: {count} file(s)"));
}
if dirs_removed > 0 {
crate::output::info(&format!(" {dirs_removed} empty dir(s) removed"));
}
crate::output::info(&color::green(&format!(
"Total: {total_files} file(s) removed",
)));
}
Ok(())
}
pub fn distclean(&self) -> Result<()> {
crate::output::info(&color::bold("Removing build directories..."));
let rsconstruct_dir = std::path::Path::new(".rsconstruct");
if rsconstruct_dir.exists() {
fs::remove_dir_all(rsconstruct_dir)
.context("Failed to remove .rsconstruct/ directory")?;
crate::output::info(&format!("Removed {}", rsconstruct_dir.display()));
}
let out_dir = std::path::Path::new(&self.config.build.output_dir);
if out_dir.exists() {
fs::remove_dir_all(out_dir)
.with_context(|| format!("Failed to remove {}/ directory", out_dir.display()))?;
crate::output::info(&format!("Removed {}", out_dir.display()));
}
crate::output::info(&color::green("Distclean completed!"));
Ok(())
}
pub fn hardclean(&self, ctx: &crate::build_context::BuildContext) -> Result<()> {
use std::process::Command;
if !std::path::Path::new(".git").exists() {
bail!("Not a git repository. Hardclean requires a git repository.");
}
crate::output::info(&color::bold("Running git clean -qffxd..."));
let mut cmd = Command::new("git");
cmd.args(["clean", "-qffxd"]);
let output =
crate::processors::run_command_capture(ctx, &cmd).context("Failed to run git clean")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git clean failed:\n{stderr}");
}
println!("{}", color::green("Hardclean completed!"));
Ok(())
}
pub fn clean_unknown(
&self,
ctx: &crate::build_context::BuildContext,
force: bool,
verbose: bool,
respect_gitignore: bool,
) -> Result<()> {
use ignore::WalkBuilder;
use std::process::Command;
if !std::path::Path::new(".git").exists() {
bail!("Not a git repository. clean unknown requires a git repository.");
}
let processors = self.create_processors()?;
let graph = self.build_graph_for_clean_with_processors(ctx, &processors)?;
let mut rsconstruct_outputs: HashSet<PathBuf> = HashSet::new();
let mut rsconstruct_output_dirs: Vec<PathBuf> = Vec::new();
for product in graph.products() {
for output in &product.outputs {
rsconstruct_outputs.insert(output.clone());
}
for dir in &product.output_dirs {
rsconstruct_output_dirs.push(dir.as_ref().clone());
}
}
let mut cmd = Command::new("git");
cmd.args(["ls-files", "--cached"]);
let output = crate::processors::run_command_capture(ctx, &cmd)
.context("Failed to run git ls-files")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git ls-files failed:\n{stderr}");
}
let git_tracked: HashSet<PathBuf> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(PathBuf::from)
.collect();
let mut unknown_files: Vec<PathBuf> = Vec::new();
let walker = WalkBuilder::new(".")
.hidden(false)
.git_ignore(respect_gitignore)
.git_global(respect_gitignore)
.git_exclude(respect_gitignore)
.build();
for entry in walker {
let Ok(entry) = entry else { continue };
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry
.path()
.strip_prefix("./")
.unwrap_or_else(|_| entry.path());
let path = PathBuf::from(path);
if path.starts_with(".git") || path.starts_with(".rsconstruct") {
continue;
}
if git_tracked.contains(&path) {
continue;
}
if rsconstruct_outputs.contains(&path) {
continue;
}
if rsconstruct_output_dirs
.iter()
.any(|dir| path.starts_with(dir))
{
continue;
}
unknown_files.push(path);
}
unknown_files.sort();
if unknown_files.is_empty() {
println!("{}", color::green("No unknown files found."));
return Ok(());
}
if force {
println!("{}", color::bold("Removing unknown files..."));
let mut removed = 0usize;
for path in &unknown_files {
if let Err(e) = fs::remove_file(path) {
eprintln!(" Failed to remove {}: {}", path.display(), e);
} else {
if verbose {
println!(" {}", path.display());
}
removed += 1;
}
}
let mut dirs_to_check: HashSet<PathBuf> = HashSet::new();
for path in &unknown_files {
let mut current = path.parent();
while let Some(dir) = current {
if dir == std::path::Path::new("") {
break;
}
dirs_to_check.insert(dir.to_path_buf());
current = dir.parent();
}
}
let mut sorted_dirs: Vec<PathBuf> = dirs_to_check.into_iter().collect();
sorted_dirs.sort_by_key(|b| std::cmp::Reverse(b.components().count()));
let mut dirs_removed = 0usize;
for dir in &sorted_dirs {
if fs::remove_dir(dir).is_ok() {
dirs_removed += 1;
}
}
let mut summary = format!("Removed {removed} unknown file(s)");
if dirs_removed > 0 {
let _ = write!(summary, ", {dirs_removed} empty dir(s)");
}
println!("{}", color::green(&summary));
} else {
println!(
"{}",
color::bold(&format!("Found {} unknown file(s):", unknown_files.len()))
);
for path in &unknown_files {
println!(" {}", path.display());
}
println!();
println!(
"{}",
color::dim("This is a dry run. Run without --dry-run to delete them.")
);
}
Ok(())
}
}
fn remove_empty_ancestors(candidates: &HashSet<PathBuf>, verbose: bool) -> usize {
let mut all: HashSet<PathBuf> = HashSet::new();
for dir in candidates {
let mut current: Option<&std::path::Path> = Some(dir.as_path());
while let Some(p) = current {
if p.as_os_str().is_empty() || p == std::path::Path::new(".") {
break;
}
all.insert(p.to_path_buf());
current = p.parent();
}
}
let mut sorted: Vec<PathBuf> = all.into_iter().collect();
sorted.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
let mut removed = 0usize;
for dir in &sorted {
if fs::remove_dir(dir).is_ok() {
removed += 1;
if verbose {
println!("Removed empty directory: {}", dir.display());
}
}
}
removed
}