use std::collections::HashMap;
use std::io::{BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use pristine::delete::confirm;
use pristine::repo::{Class, Repo, Reset, Selected, Selection};
use pristine::size::human;
use pristine::tui;
use pristine::{
DEFAULT_MIN_SIZE, Deleter, Enumeration, FallbackReport, Found, Hit, Plan, Planner, Removal,
Ruleset, SizeMode, Target, WalkOutcome, Walker,
};
#[derive(Debug, Parser)]
#[command(name = "pristine", version)]
#[command(args_conflicts_with_subcommands = true)]
struct Cli {
#[command(subcommand)]
mode: Option<Mode>,
#[command(flatten)]
sweep: Sweep,
}
#[derive(Debug, Subcommand)]
enum Mode {
Sweep(Sweep),
Repo(RepoArgs),
}
#[derive(Debug, Args)]
#[expect(
clippy::struct_excessive_bools,
reason = "these are command line flags, and the lint's advice — fold them into a state \
machine — would take the flags off the command line"
)]
struct Sweep {
#[arg(default_value = ".", value_name = "PATH")]
root: PathBuf,
#[arg(
long,
value_name = "SIZE",
default_value_t = DEFAULT_MIN_SIZE,
value_parser = parse_size,
)]
min_size: u64,
#[arg(long)]
ignored_files: bool,
#[arg(long, short = 'v')]
verbose: bool,
#[arg(long, value_name = "GLOB")]
exclude: Vec<String>,
#[arg(long, conflicts_with = "breakdown_under")]
breakdown: bool,
#[arg(long, value_name = "PATH")]
breakdown_under: Option<PathBuf>,
#[arg(long)]
delete: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long, value_name = "DURATION", value_parser = parse_duration)]
older_than: Option<Duration>,
#[arg(long, value_name = "BOOL", default_value_t = true, action = ArgAction::Set)]
one_file_system: bool,
#[arg(long)]
no_tui: bool,
}
impl Sweep {
fn interactive(&self) -> bool {
!self.no_tui
&& !self.delete
&& !self.dry_run
&& !self.yes
&& std::io::stdout().is_terminal()
}
fn size_mode(&self) -> Result<SizeMode, String> {
match &self.breakdown_under {
Some(scope) => Ok(SizeMode::BreakdownUnder(anchor(&self.root, scope)?)),
None if self.breakdown => Ok(SizeMode::Breakdown),
None => Ok(SizeMode::Skip),
}
}
}
fn anchor(root: &Path, scope: &Path) -> Result<PathBuf, String> {
let resolved_root = std::fs::canonicalize(root)
.map_err(|err| format!("the scan root {}: {err}", root.display()))?;
let resolved_scope = std::fs::canonicalize(scope)
.map_err(|err| format!("--breakdown-under {}: {err}", scope.display()))?;
let relative = resolved_scope.strip_prefix(&resolved_root).map_err(|_| {
format!(
"--breakdown-under {} is not inside the scan root {}; it prices a subtree of the \
scan, not a second tree",
scope.display(),
root.display()
)
})?;
Ok(root.join(relative))
}
#[derive(Debug, Args)]
#[expect(
clippy::struct_excessive_bools,
reason = "these are command line flags, and the lint's advice — fold them into a state \
machine — would take the flags off the command line"
)]
struct RepoArgs {
#[arg(default_value = ".", value_name = "PATH")]
path: PathBuf,
#[arg(
long,
value_name = "SCOPE",
num_args = 0..=1,
require_equals = true,
default_missing_value = "hard",
)]
reset: Option<ResetScope>,
#[arg(long)]
untracked: bool,
#[arg(long)]
ignored: bool,
#[arg(
long = "node-modules",
value_name = "BOOL",
num_args = 0..=1,
require_equals = true,
default_missing_value = "true",
action = ArgAction::Set,
)]
node_modules: Option<bool>,
#[arg(
long,
value_name = "BOOL",
num_args = 0..=1,
require_equals = true,
default_missing_value = "true",
action = ArgAction::Set,
)]
env: Option<bool>,
#[arg(long)]
dry_run: bool,
#[arg(long, short = 'y')]
yes: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ResetScope {
Worktree,
Hard,
}
impl From<ResetScope> for Reset {
fn from(scope: ResetScope) -> Self {
match scope {
ResetScope::Worktree => Self::WorkTree,
ResetScope::Hard => Self::Hard,
}
}
}
impl RepoArgs {
fn chosen(&self) -> bool {
self.reset.is_some()
|| self.untracked
|| self.ignored
|| self.node_modules.is_some()
|| self.env.is_some()
|| self.yes
}
fn selection(&self) -> Selection {
Selection {
reset: self.reset.map(Reset::from),
untracked: self.untracked,
ignored: self.ignored,
vendor: self.node_modules.unwrap_or(false),
env: self.env.unwrap_or(false),
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let stdout = std::io::stdout();
let mut out = stdout.lock();
let done = match &cli.mode {
Some(Mode::Repo(args)) => clean(args, &mut out),
Some(Mode::Sweep(sweep)) => sweep_with(sweep, &mut out),
None => sweep_with(&cli.sweep, &mut out),
};
match done {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(err) => {
eprintln!("pristine: {err}");
ExitCode::FAILURE
}
}
}
fn excludes(
root: &Path,
ruleset: &Ruleset,
extra: &[String],
) -> Result<Arc<Gitignore>, Box<dyn std::error::Error>> {
let mut builder = GitignoreBuilder::new(root);
for pattern in ruleset.excludes().iter().chain(extra) {
builder.add_line(None, pattern)?;
}
Ok(Arc::new(builder.build()?))
}
fn sweep_with(cli: &Sweep, out: &mut impl Write) -> Result<bool, Box<dyn std::error::Error>> {
if !cli.interactive() {
return run(cli, out);
}
let ruleset = Arc::new(Ruleset::load(None)?);
let outcome = tui::run(
&tui::Options {
root: cli.root.clone(),
min_size: cli.min_size,
size_mode: tui::size_mode(cli.size_mode()?),
one_file_system: cli.one_file_system,
older_than: cli.older_than,
ignored_files: cli.ignored_files,
excludes: excludes(&cli.root, &ruleset, &cli.exclude)?,
},
ruleset,
)?;
if !outcome.errors.is_empty() {
if cli.verbose {
for failure in &outcome.errors {
match &failure.path {
Some(path) => eprintln!("pristine: {}: {}", path.display(), failure.message),
None => eprintln!("pristine: {}", failure.message),
}
}
} else {
eprintln!(
"pristine: {} could not be read, so what was shown is a lower bound{}",
plural(outcome.errors.len(), PATH),
hint(false),
);
}
}
Ok(outcome.whole())
}
fn run(cli: &Sweep, out: &mut impl Write) -> Result<bool, Box<dyn std::error::Error>> {
let ruleset = Arc::new(Ruleset::load(None)?);
let hits = Mutex::new(Vec::new());
let sizes = Mutex::new(HashMap::new());
let outcome = Walker::new(&cli.root, Arc::clone(&ruleset))
.excludes(excludes(&cli.root, &ruleset, &cli.exclude)?)
.same_file_system(cli.one_file_system)
.ignored_files(cli.ignored_files)
.min_size(cli.min_size)
.size_mode(cli.size_mode()?)
.run(|found| match found {
Found::Claim(hit) => lock(&hits).push(hit),
Found::Pricing(_) => {}
Found::Priced(priced) => {
lock(&sizes).insert(priced.path, priced.size);
}
});
let mut hits = hits.into_inner().unwrap_or_else(PoisonError::into_inner);
let sizes = sizes.into_inner().unwrap_or_else(PoisonError::into_inner);
for hit in &mut hits {
if let Some(size) = sizes.get(&hit.path) {
hit.size = *size;
}
}
hits.sort_by(|a, b| {
b.size
.bytes()
.cmp(&a.size.bytes())
.then_with(|| a.path.cmp(&b.path))
});
let whole = outcome.errors.is_empty();
if !cli.delete && !cli.dry_run {
for hit in &hits {
writeln!(out, "{}", row(hit, &cli.root))?;
}
writeln!(out, "{}", summary(&hits))?;
report_unpriced(out, unpriced(&hits))?;
report_fallback(out, &outcome.fallback)?;
report_scan(out, &outcome, &cli.root, cli.verbose)?;
return Ok(whole);
}
let plan = Planner::new(&cli.root)
.one_file_system(cli.one_file_system)
.older_than(cli.older_than)
.plan(hits.iter().map(Target::from));
let unit = noun(&hits);
write_plan(out, &plan, unit)?;
report_unpriced(out, plan.unpriced())?;
report_fallback(out, &outcome.fallback)?;
report_scan(out, &outcome, &cli.root, cli.verbose)?;
if cli.dry_run {
writeln!(out, "\ndry run: nothing was removed")?;
return Ok(whole);
}
if plan.is_empty() {
writeln!(out, "\nnothing was removed")?;
return Ok(whole);
}
if !cli.yes {
let question = format!("\nRemove {}?", plural(plan.targets().len(), unit));
let stdin = std::io::stdin();
if !confirm(&question, &mut stdin.lock(), out)? {
writeln!(out, "nothing was removed")?;
return Ok(whole);
}
}
let removal = Deleter::new().remove(&plan);
write_removal(out, &removal, plan.root(), unit)?;
for failure in &removal.failures {
eprintln!("pristine: {}: {}", failure.path.display(), failure.message);
}
Ok(whole && removal.is_clean())
}
fn clean(args: &RepoArgs, out: &mut impl Write) -> Result<bool, Box<dyn std::error::Error>> {
let repo = Repo::discover(&args.path)?;
let enumeration = repo.enumerate()?;
let selection = if args.chosen() {
args.selection()
} else {
let stdin = std::io::stdin();
ask(&mut stdin.lock(), out)?
};
if selection.is_empty() {
writeln!(out, "nothing selected, so nothing was removed")?;
return Ok(true);
}
let selected = pristine::repo::select(&enumeration, &selection);
let mut plan = Planner::new(repo.root()).plan(selected.targets.iter().cloned());
write_repo_plan(out, &plan, selection, &selected, &enumeration)?;
if args.dry_run {
if selection.reset.is_some() {
writeln!(
out,
"note: a real run re-asks git after the reset, so the list above is an upper \
bound"
)?;
}
writeln!(out, "\ndry run: nothing was reset and nothing was removed")?;
return Ok(true);
}
if selection.reset.is_none() && plan.is_empty() {
writeln!(out, "\nnothing was removed")?;
return Ok(true);
}
if !args.yes
&& !confirm(
&question(selection, &plan),
&mut std::io::stdin().lock(),
out,
)?
{
writeln!(out, "nothing was reset and nothing was removed")?;
return Ok(true);
}
if let Some(reset) = selection.reset {
repo.reset(reset)?;
writeln!(out, "reset: done")?;
let (refreshed, withdrawn) = reconsider(&repo, selection, &plan)?;
write_withdrawn(out, &withdrawn, plan.root())?;
plan = refreshed;
}
if plan.is_empty() {
return Ok(true);
}
let removal = Deleter::new().remove(&plan);
write_removal(out, &removal, plan.root(), PATH)?;
for failure in &removal.failures {
eprintln!("pristine: {}: {}", failure.path.display(), failure.message);
}
Ok(removal.is_clean())
}
fn reconsider(
repo: &Repo,
selection: Selection,
confirmed: &Plan,
) -> Result<(Plan, Vec<PathBuf>), pristine::RepoError> {
let enumeration = repo.enumerate()?;
let approved: Vec<&Path> = confirmed
.targets()
.iter()
.map(|target| target.requested.as_path())
.collect();
let mut targets = Vec::new();
let mut withdrawn = Vec::new();
for target in pristine::repo::select(&enumeration, &selection).targets {
if approved.iter().any(|ok| target.path.starts_with(ok)) {
targets.push(target);
} else {
withdrawn.push(target.path);
}
}
withdrawn.sort_unstable();
Ok((Planner::new(repo.root()).plan(targets), withdrawn))
}
fn write_withdrawn(
out: &mut impl Write,
withdrawn: &[PathBuf],
root: &Path,
) -> std::io::Result<()> {
if withdrawn.is_empty() {
return Ok(());
}
writeln!(
out,
"withdrawn after the reset: {}, because the reset made them reach past the plan you \
confirmed. Run again to see them.",
plural(withdrawn.len(), PATH)
)?;
for path in withdrawn {
let path = path.strip_prefix(root).unwrap_or(path);
writeln!(out, " {}", path.display())?;
}
Ok(())
}
fn ask(input: &mut impl BufRead, output: &mut impl Write) -> std::io::Result<Selection> {
let reset = ask_reset(input, output)?;
let untracked = confirm("\nRemove untracked files?", input, output)?;
let ignored = confirm("\nRemove ignored files?", input, output)?;
let (vendor, env) = if untracked || ignored {
(
confirm(
"\n Include vendored dependencies (node_modules)?",
input,
output,
)?,
confirm("\n Include env files (*.env*)?", input, output)?,
)
} else {
(false, false)
};
writeln!(output)?;
Ok(Selection {
reset,
untracked,
ignored,
vendor,
env,
})
}
fn ask_reset(input: &mut impl BufRead, output: &mut impl Write) -> std::io::Result<Option<Reset>> {
writeln!(output, "Reset changed (tracked) files?")?;
writeln!(output, " 1) No, leave my changes")?;
writeln!(
output,
" 2) Discard working-tree changes only ({})",
Reset::WorkTree.command()
)?;
writeln!(
output,
" 3) Discard everything (hard reset) ({})",
Reset::Hard.command()
)?;
write!(output, "> [1] ")?;
output.flush()?;
let mut answer = String::new();
if input.read_line(&mut answer)? == 0 {
return Ok(None);
}
Ok(match answer.trim().to_ascii_lowercase().as_str() {
"2" | "worktree" => Some(Reset::WorkTree),
"3" | "hard" => Some(Reset::Hard),
_ => None,
})
}
fn write_repo_plan(
out: &mut impl Write,
plan: &Plan,
selection: Selection,
selected: &Selected,
enumeration: &Enumeration,
) -> std::io::Result<()> {
if let Some(reset) = selection.reset {
writeln!(out, "reset: {reset} ({})", reset.command())?;
}
write_plan(out, plan, PATH)?;
for (count, what, flag) in [
(selected.vendor, VENDORED, "--node-modules"),
(selected.env, ENV_FILE, "--env"),
] {
if count > 0 {
writeln!(
out,
"excluded: {} ({flag} includes them)",
plural(count, what)
)?;
}
}
if !selected.concealed.is_empty() {
writeln!(
out,
"held back: {}, because git offered them whole and they hold something you did not \
ask to remove",
plural(selected.concealed.len(), PATH)
)?;
for concealed in &selected.concealed {
let hint = match concealed.reason.class() {
Some(Class::Vendor) => " (--node-modules includes it)",
Some(Class::Env) => " (--env includes it)",
_ => "",
};
writeln!(
out,
" {} — {}{hint}",
concealed.path.display(),
concealed.reason
)?;
}
}
if !enumeration.skipped.is_empty() {
writeln!(
out,
"skipped: {} git will not clean",
plural(enumeration.skipped.len(), NESTED_REPOSITORY)
)?;
for path in &enumeration.skipped {
let path = path.strip_prefix(plan.root()).unwrap_or(path);
writeln!(out, " {}", path.display())?;
}
}
Ok(())
}
fn question(selection: Selection, plan: &Plan) -> String {
let removing =
(!plan.is_empty()).then(|| format!("remove {}", plural(plan.targets().len(), PATH)));
let resetting = selection.reset.map(|reset| reset.to_string());
let mut halves: Vec<String> = resetting.into_iter().chain(removing).collect();
if halves.is_empty() {
halves.push("do nothing".to_owned());
}
let mut question = halves.join(" and ");
question[..1].make_ascii_uppercase();
format!("\n{question}?")
}
fn row(hit: &Hit, root: &Path) -> String {
let path = hit.path.strip_prefix(root).unwrap_or(&hit.path);
format!(
"{:>10} {:<60} {}",
hit.size.label(),
path.display(),
hit.label()
)
}
fn summary(hits: &[Hit]) -> String {
let priced: u64 = hits.iter().filter_map(|hit| hit.size.bytes()).sum();
format!(
"\n{} reclaimable, {} priced, {} not priced",
plural(hits.len(), noun(hits)),
human(priced),
unpriced(hits),
)
}
fn noun(hits: &[Hit]) -> Noun {
if hits.iter().any(Hit::is_ignored_file) {
PATH
} else {
DIRECTORY
}
}
fn unpriced(hits: &[Hit]) -> usize {
hits.iter().filter(|hit| hit.size.bytes().is_none()).count()
}
fn report_unpriced(out: &mut impl Write, unpriced: usize) -> std::io::Result<()> {
if unpriced == 0 {
return Ok(());
}
writeln!(
out,
"not priced: nothing looked inside. --breakdown prices every claim, --breakdown-under \
<PATH> just one subtree; both walk what they price."
)
}
fn report_fallback(out: &mut impl Write, fallback: &FallbackReport) -> std::io::Result<()> {
if !fallback.enabled {
return Ok(());
}
if fallback.is_inert() {
return writeln!(
out,
"fallback tier: inert — nothing scanned is in a git work tree, so nothing could be \
judged reclaimable by inference (floor was {})",
human(fallback.min_size)
);
}
let held_back = if fallback.holding_a_checkout == 0 {
String::new()
} else {
format!(
"; {} left alone because they hold a checkout",
plural(fallback.holding_a_checkout, DIRECTORY)
)
};
let (unit, files) = if fallback.files_enabled {
(
PATH,
format!(
", {} of them {} the floor does not apply to",
fallback.files,
if fallback.files == 1 {
"a file"
} else {
"files"
}
),
)
} else {
(
DIRECTORY,
" (directories only; --ignored-files claims gitignored files too)".to_owned(),
)
};
writeln!(
out,
"fallback tier: {} found in {} above a {} floor{held_back}{files}",
plural(fallback.hits, unit),
plural(fallback.work_trees, WORK_TREE),
human(fallback.min_size),
)
}
fn write_plan(out: &mut impl Write, plan: &Plan, unit: Noun) -> std::io::Result<()> {
let root = plan.root();
for target in plan.targets() {
let path = target.path.strip_prefix(root).unwrap_or(&target.path);
writeln!(out, "{:>10} {}", target.size.label(), path.display())?;
}
writeln!(
out,
"\nplan: {}, {} priced, {} not priced",
plural(plan.targets().len(), unit),
human(plan.measured_bytes()),
plan.unpriced(),
)?;
if plan.kept().is_empty() {
return Ok(());
}
writeln!(out, "kept: {}", plural(plan.kept().len(), unit))?;
for refused in plan.kept() {
let path = refused.path.strip_prefix(root).unwrap_or(&refused.path);
writeln!(out, " {} — {}", path.display(), refused.reason)?;
}
Ok(())
}
fn write_removal(
out: &mut impl Write,
removal: &Removal,
root: &Path,
unit: Noun,
) -> std::io::Result<()> {
let complete = removal
.removed
.iter()
.filter(|removed| removed.complete)
.count();
writeln!(
out,
"\nremoved {}, {} freed",
plural(complete, unit),
human(removal.bytes_freed()),
)?;
if !removal.kept.is_empty() {
writeln!(out, "kept {}:", plural(removal.kept.len(), unit))?;
for refused in &removal.kept {
let path = refused.path.strip_prefix(root).unwrap_or(&refused.path);
writeln!(out, " {} — {}", path.display(), refused.reason)?;
}
}
if !removal.failures.is_empty() {
writeln!(
out,
"failed on {}, listed on standard error",
plural(removal.failures.len(), PATH)
)?;
}
Ok(())
}
fn report_scan(
out: &mut impl Write,
outcome: &WalkOutcome,
root: &Path,
verbose: bool,
) -> std::io::Result<()> {
if outcome.excluded > 0 {
writeln!(
out,
"excluded: {} not walked, because you asked for that",
plural(outcome.excluded, PATH),
)?;
}
if outcome.errors.is_empty() {
return Ok(());
}
let (forbidden, failed): (Vec<_>, Vec<_>) = outcome
.errors
.iter()
.partition(|error| error.is_forbidden());
writeln!(
out,
"scan incomplete: {} could not be read, so everything above is a lower bound",
plural(outcome.errors.len(), PATH),
)?;
if !forbidden.is_empty() && !failed.is_empty() {
writeln!(
out,
" {} refused by the system, {} for other reasons{}",
forbidden.len(),
failed.len(),
hint(verbose),
)?;
} else if !forbidden.is_empty() {
writeln!(
out,
" all of them refused by the system rather than failing{}",
hint(verbose),
)?;
}
if !verbose {
return Ok(());
}
if !forbidden.is_empty() {
writeln!(
out,
" to stop seeing these, add to `exclude` in {}:",
Ruleset::user_config_path()
.as_deref()
.unwrap_or_else(|| Path::new("the rules file"))
.display(),
)?;
let mut shown: Vec<&Path> = forbidden
.iter()
.filter_map(|error| error.path.as_deref())
.map(|path| path.strip_prefix(root).unwrap_or(path))
.collect();
shown.sort_unstable();
for path in shown {
writeln!(out, " \"{}\",", path.display())?;
}
}
for error in failed {
match &error.path {
Some(path) => eprintln!("pristine: {}: {}", path.display(), error.message),
None => eprintln!("pristine: {}", error.message),
}
}
Ok(())
}
fn hint(verbose: bool) -> &'static str {
if verbose {
""
} else {
" · --verbose names them"
}
}
type Noun = (&'static str, &'static str);
const DIRECTORY: Noun = ("directory", "directories");
const PATH: Noun = ("path", "paths");
const NESTED_REPOSITORY: Noun = ("nested repository", "nested repositories");
const VENDORED: Noun = ("vendored path", "vendored paths");
const ENV_FILE: Noun = ("env file", "env files");
const WORK_TREE: Noun = ("work tree", "work trees");
fn plural(count: usize, (one, many): Noun) -> String {
format!("{count} {}", if count == 1 { one } else { many })
}
fn parse_size(text: &str) -> Result<u64, String> {
let text = text.trim();
let digits = text
.trim_end_matches(|c: char| c.is_ascii_alphabetic())
.trim();
let suffix = text[digits.len()..].trim();
let value: u64 = digits
.parse()
.map_err(|_| format!("`{text}` is not a whole number of bytes"))?;
let multiplier: u64 = match suffix.to_ascii_uppercase().as_str() {
"" | "B" => 1,
"K" | "KIB" => 1 << 10,
"M" | "MIB" => 1 << 20,
"G" | "GIB" => 1 << 30,
"T" | "TIB" => 1 << 40,
"KB" => 1_000,
"MB" => 1_000_000,
"GB" => 1_000_000_000,
"TB" => 1_000_000_000_000,
other => {
return Err(format!(
"`{other}` is not a size suffix; use K, M, G or T (1024-based) or KB, MB, GB \
or TB (1000-based)"
));
}
};
value
.checked_mul(multiplier)
.ok_or_else(|| format!("`{text}` does not fit in a size"))
}
fn parse_duration(text: &str) -> Result<Duration, String> {
const HOUR: u64 = 60 * 60;
const DAY: u64 = 24 * HOUR;
let text = text.trim();
let digits = text
.trim_end_matches(|c: char| c.is_ascii_alphabetic())
.trim();
let suffix = text[digits.len()..].trim();
let value: u64 = digits
.parse()
.map_err(|_| format!("`{text}` is not a whole number of time units"))?;
let unit = match suffix.to_ascii_lowercase().as_str() {
"h" => HOUR,
"d" => DAY,
"w" => 7 * DAY,
"m" => 30 * DAY,
"y" => 365 * DAY,
"" => return Err(format!("`{text}` needs a unit: h, d, w, m or y")),
other => {
return Err(format!(
"`{other}` is not a unit of time; use h, d, w, m or y"
));
}
};
value
.checked_mul(unit)
.map(Duration::from_secs)
.ok_or_else(|| format!("`{text}` does not fit in a duration"))
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
use super::{
Cli, Mode, RepoArgs, Reset, anchor, ask, ask_reset, human, parse_duration, parse_size,
};
use clap::{CommandFactory, Parser};
use pristine::{DEFAULT_MIN_SIZE, SizeMode};
use std::fs;
use std::time::Duration;
use tempfile::TempDir;
fn sweep(args: &[&str]) -> super::Sweep {
let mut line = vec!["pristine"];
line.extend_from_slice(args);
Cli::parse_from(line).sweep
}
fn repo(args: &[&str]) -> RepoArgs {
let mut line = vec!["pristine", "repo"];
line.extend_from_slice(args);
match Cli::parse_from(line).mode {
Some(Mode::Repo(args)) => args,
other => panic!("`repo` did not parse as repo mode: {other:?}"),
}
}
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn the_safe_defaults_are_the_defaults() {
let cli = sweep(&[]);
assert!(!cli.delete, "a bare run must not delete");
assert!(!cli.yes, "consent is never assumed");
assert!(cli.older_than.is_none(), "the age floor is opt-in");
assert!(cli.one_file_system, "a mount is not crossed by default");
assert!(!cli.breakdown, "a scan does not enumerate what it pruned");
assert!(cli.breakdown_under.is_none());
}
#[test]
fn a_mount_is_only_crossed_when_the_flag_says_so() {
assert!(!sweep(&["--one-file-system=false"]).one_file_system);
assert!(sweep(&["--one-file-system", "true"]).one_file_system);
}
#[test]
fn the_floor_defaults_to_ten_mebibytes_and_the_flag_overrides_it() {
assert_eq!(
sweep(&[]).min_size,
DEFAULT_MIN_SIZE,
"10 MiB is the documented default"
);
assert_eq!(sweep(&["--min-size", "512K"]).min_size, 512 * 1024);
}
#[test]
fn a_scan_prices_nothing_unless_it_is_asked_to() {
assert_eq!(sweep(&[]).size_mode().unwrap(), SizeMode::Skip);
assert_eq!(
sweep(&["--breakdown"]).size_mode().unwrap(),
SizeMode::Breakdown
);
}
#[test]
fn the_two_breakdown_flags_do_not_both_apply() {
assert!(
Cli::try_parse_from(["pristine", "--breakdown", "--breakdown-under", "."]).is_err()
);
}
#[test]
fn a_scope_is_anchored_the_way_the_walk_spells_its_hits() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("app")).unwrap();
let resolved = fs::canonicalize(tmp.path()).unwrap();
assert_eq!(
anchor(tmp.path(), &resolved.join("app")).unwrap(),
tmp.path().join("app")
);
let awkward = tmp.path().join("app/..");
assert_eq!(
anchor(&awkward, &tmp.path().join("app")).unwrap(),
awkward.join("app")
);
}
#[test]
fn a_scope_that_is_not_in_the_scan_is_refused_rather_than_pricing_nothing() {
let tmp = TempDir::new().unwrap();
let elsewhere = TempDir::new().unwrap();
assert!(
anchor(tmp.path(), elsewhere.path()).is_err(),
"another tree"
);
assert!(
anchor(tmp.path(), &tmp.path().join("typo")).is_err(),
"a path that is not there"
);
}
#[test]
fn a_bare_run_asks_and_a_run_with_any_action_flag_does_not() {
assert!(!repo(&[]).chosen(), "a bare run has nothing to go on");
for flag in [
"--reset",
"--untracked",
"--ignored",
"--node-modules",
"--env",
"--yes",
] {
assert!(repo(&[flag]).chosen(), "`{flag}` left the run interactive");
}
}
#[test]
fn yes_gates_the_confirmation_and_selects_nothing() {
let consented = repo(&["--yes"]);
assert!(consented.yes);
assert!(
consented.selection().is_empty(),
"consent was read as a selection"
);
assert!(consented.chosen(), "consent left the run interactive");
}
#[test]
fn vendor_and_env_are_off_unless_the_flag_turns_them_on() {
let asked_for_everything = repo(&["--untracked", "--ignored"]).selection();
assert!(asked_for_everything.untracked && asked_for_everything.ignored);
assert!(
!asked_for_everything.vendor && !asked_for_everything.env,
"asking for the lists was read as asking for what they hold back"
);
let opted_in = repo(&["--ignored", "--node-modules", "--env"]).selection();
assert!(opted_in.vendor && opted_in.env);
let opted_out = repo(&["--ignored", "--node-modules=false"]).selection();
assert!(!opted_out.vendor);
}
#[test]
fn a_bare_reset_is_a_hard_one_and_the_scope_is_spelled_out_otherwise() {
assert_eq!(repo(&["--reset"]).selection().reset, Some(Reset::Hard));
assert_eq!(repo(&["--reset=hard"]).selection().reset, Some(Reset::Hard));
assert_eq!(
repo(&["--reset=worktree"]).selection().reset,
Some(Reset::WorkTree)
);
assert_eq!(repo(&[]).selection().reset, None);
assert_eq!(
repo(&["--reset", "--untracked"]).selection().reset,
Some(Reset::Hard)
);
assert!(repo(&["--reset", "--untracked"]).untracked);
}
fn asked(answers: &str) -> (pristine::Selection, String) {
let mut shown = Vec::new();
let selection = ask(&mut answers.as_bytes(), &mut shown).unwrap();
(selection, String::from_utf8(shown).unwrap())
}
#[test]
fn a_cascade_nobody_answers_selects_nothing() {
for silence in ["", "\n\n\n\n\n"] {
assert!(
asked(silence).0.is_empty(),
"`{silence:?}` was read as a selection"
);
}
}
#[test]
fn the_cascade_asks_about_vendor_and_env_only_once_a_list_was_taken() {
let (_, untouched) = asked("\n\n\n");
assert!(
!untouched.contains("node_modules"),
"it asked about what to hold back from a list nobody took:\n{untouched}"
);
let (selection, asked_about) = asked("1\ny\nn\ny\nn\n");
assert!(selection.untracked && !selection.ignored);
assert!(selection.vendor && !selection.env, "{selection:?}");
assert!(asked_about.contains("node_modules"), "{asked_about}");
}
#[test]
fn only_a_number_that_names_a_reset_resets_anything() {
for answer in ["2\n", "worktree\n"] {
assert_eq!(
ask_reset(&mut answer.as_bytes(), &mut Vec::new()).unwrap(),
Some(Reset::WorkTree),
"{answer:?}"
);
}
for answer in ["3\n", "hard\n", "HARD\n"] {
assert_eq!(
ask_reset(&mut answer.as_bytes(), &mut Vec::new()).unwrap(),
Some(Reset::Hard),
"{answer:?}"
);
}
for answer in ["", "\n", "1\n", "y\n", "yes\n", "4\n", "everything\n"] {
assert_eq!(
ask_reset(&mut answer.as_bytes(), &mut Vec::new()).unwrap(),
None,
"{answer:?}"
);
}
}
#[test]
fn the_reset_menu_says_which_git_command_each_answer_runs() {
let mut shown = Vec::new();
ask_reset(&mut "\n".as_bytes(), &mut shown).unwrap();
let shown = String::from_utf8(shown).unwrap();
assert!(shown.contains("git restore -- ."), "{shown}");
assert!(shown.contains("git reset --hard HEAD"), "{shown}");
assert!(
shown.contains("[1]"),
"the default was not stated:\n{shown}"
);
}
#[test]
fn sizes_are_read_in_the_units_they_were_written_in() {
assert_eq!(parse_size("0"), Ok(0));
assert_eq!(parse_size("4096"), Ok(4096));
assert_eq!(parse_size("4096B"), Ok(4096));
assert_eq!(parse_size("1K"), Ok(1024));
assert_eq!(parse_size("1KiB"), Ok(1024));
assert_eq!(parse_size("1MiB"), Ok(1024 * 1024));
assert_eq!(parse_size("10 MiB"), Ok(DEFAULT_MIN_SIZE));
assert_eq!(parse_size("2GiB"), Ok(2 * 1024 * 1024 * 1024));
assert_eq!(parse_size("1MB"), Ok(1_000_000));
assert_eq!(parse_size("1kb"), Ok(1_000));
}
#[test]
fn a_size_that_cannot_be_read_is_refused_rather_than_guessed_at() {
for bad in ["", "MiB", "1.5G", "-1", "1 potato", "18446744073709551615K"] {
assert!(parse_size(bad).is_err(), "`{bad}` was accepted");
}
}
#[test]
fn an_age_is_read_in_the_units_it_was_written_in() {
const DAY: u64 = 24 * 60 * 60;
assert_eq!(parse_duration("12h"), Ok(Duration::from_secs(12 * 60 * 60)));
assert_eq!(parse_duration("7d"), Ok(Duration::from_secs(7 * DAY)));
assert_eq!(parse_duration("2w"), Ok(Duration::from_secs(14 * DAY)));
assert_eq!(parse_duration("3m"), Ok(Duration::from_secs(90 * DAY)));
assert_eq!(parse_duration("3M"), Ok(Duration::from_secs(90 * DAY)));
assert_eq!(parse_duration("1y"), Ok(Duration::from_secs(365 * DAY)));
assert_eq!(parse_duration(" 30 d "), Ok(Duration::from_secs(30 * DAY)));
}
#[test]
fn an_age_that_cannot_be_read_is_refused_rather_than_guessed_at() {
for bad in ["", "7", "d", "1.5d", "-7d", "7 potatoes", "7s", "7min"] {
assert!(parse_duration(bad).is_err(), "`{bad}` was accepted");
}
}
#[test]
fn sizes_are_printed_in_the_units_a_person_reads() {
assert_eq!(human(0), "0 B");
assert_eq!(human(512), "512 B");
assert_eq!(human(1024), "1.0 KiB");
assert_eq!(human(DEFAULT_MIN_SIZE), "10.0 MiB");
}
}