use atty::Stream;
use clap::Parser;
use disk_cleaner::{analyze, DiskItem, FileInfo, ProjectAnalysis, ScanOptions};
use pretty_bytes::converter::convert as pretty_bytes;
use std::collections::BTreeSet;
use std::env;
use std::error::Error;
use std::io;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use std::time::SystemTime;
use termcolor::{Buffer, BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
const INDENT_COLOR: Option<Color> = Some(Color::Rgb(75, 75, 75));
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn print_logo(buffer: &mut Buffer) -> io::Result<()> {
buffer.reset()?;
write!(buffer, " ")?;
buffer.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
write!(buffer, "╺━━━━━━━━━━")?;
buffer.set_color(ColorSpec::new().set_fg(Some(Color::Rgb(0, 139, 139))))?;
writeln!(buffer, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸")?;
buffer.reset()?;
writeln!(buffer, " ┃")?;
buffer.reset()?;
write!(buffer, " ┃ ")?;
buffer.set_color(
ColorSpec::new()
.set_fg(Some(Color::Cyan))
.set_bold(true),
)?;
writeln!(buffer, "◈ D I S K C L E A N E R")?;
buffer.reset()?;
write!(buffer, " ┃ ")?;
buffer.set_color(ColorSpec::new().set_fg(Some(Color::Rgb(100, 100, 100))))?;
writeln!(buffer, "disk usage analyzer and cleaner v{}", VERSION)?;
buffer.reset()?;
writeln!(buffer, " ┃")?;
buffer.reset()?;
write!(buffer, " ")?;
buffer.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
write!(buffer, "╺━━━━━━━━━━")?;
buffer.set_color(ColorSpec::new().set_fg(Some(Color::Rgb(0, 139, 139))))?;
writeln!(buffer, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸")?;
Ok(())
}
struct Spinner {
stop: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
impl Spinner {
fn start(label: &str) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let stop_flag = stop.clone();
let label = label.to_string();
let handle = thread::spawn(move || {
let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let mut out = io::stdout();
let mut i = 0;
while !stop_flag.load(Ordering::Relaxed) {
let _ = write!(out, "\r\x1b[36m{}\x1b[0m {}", frames[i], label);
let _ = out.flush();
i = (i + 1) % frames.len();
thread::sleep(Duration::from_millis(80));
}
let _ = write!(out, "\r\x1b[2K");
let _ = out.flush();
});
Spinner {
stop,
handle: Some(handle),
}
}
fn stop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl Drop for Spinner {
fn drop(&mut self) {
self.stop();
}
}
mod shape {
pub const INDENT: &str = "│";
pub const _LAST_WITH_CHILDREN: &str = "└─┬";
pub const LAST: &str = "└──";
pub const ITEM: &str = "├──";
pub const _ITEM_WITH_CHILDREN: &str = "├─┬";
pub const SPACING: &str = "──";
}
fn main() -> Result<(), Box<dyn Error>> {
let config = Config::parse();
let current_dir = env::current_dir()?;
let target_dir = config.target_dir.as_ref().unwrap_or(¤t_dir);
if config.tui {
return disk_cleaner::tui::run_from_path(
target_dir.to_string_lossy().to_string(),
config.apparent,
);
}
let file_info = FileInfo::from_path(target_dir, config.apparent)?;
let is_tty = atty::is(Stream::Stdout);
let color_choice = if is_tty {
ColorChoice::Auto
} else {
ColorChoice::Never
};
let stdout = BufferWriter::stdout(color_choice);
{
let mut intro = stdout.buffer();
if !config.json {
if is_tty {
print_logo(&mut intro)?;
writeln!(&mut intro)?;
}
writeln!(&mut intro, "Analyzing: {}\n", target_dir.display())?;
stdout.print(&intro)?;
}
}
let mut spinner = if !config.json && is_tty {
Some(Spinner::start("Scanning directory..."))
} else {
None
};
let analysed = match file_info {
FileInfo::Directory { volume_id } => {
let result = DiskItem::from_analyze(target_dir, config.apparent, volume_id);
if let Some(s) = spinner.as_mut() {
s.stop();
}
result?
}
_ => return Err(format!("{} is not a directory!", target_dir.display()).into()),
};
let mut buffer = stdout.buffer();
if config.json {
let serialized = serde_json::to_string(&analysed)?;
writeln!(&mut buffer, "{}", serialized)?;
stdout.print(&buffer)?;
return Ok(());
}
show(&analysed, &config, &mut DisplayInfo::new(), &mut buffer)?;
run_projects_flow(target_dir, &stdout, buffer, config.apparent)?;
Ok(())
}
fn run_projects_flow(
target_dir: &PathBuf,
stdout: &BufferWriter,
buffer: Buffer,
apparent: bool,
) -> Result<(), Box<dyn Error>> {
let interactive = atty::is(Stream::Stdout) && atty::is(Stream::Stdin);
let is_tty = atty::is(Stream::Stdout);
stdout.print(&buffer)?;
let mut buffer = stdout.buffer();
let opts = ScanOptions {
follow_symlinks: false,
same_file_system: false,
apparent,
};
let mut spinner = if is_tty {
Some(Spinner::start("Scanning for build artifacts..."))
} else {
None
};
let mut projects: Vec<ProjectAnalysis> = analyze(target_dir, &opts).collect();
if let Some(s) = spinner.as_mut() {
s.stop();
}
projects.sort_by_key(|b| std::cmp::Reverse(b.artifact_size));
if projects.is_empty() {
writeln!(&mut buffer, "No reclaimable build artifacts found.")?;
stdout.print(&buffer)?;
return Ok(());
}
let total: u64 = projects.iter().map(|p| p.artifact_size).sum();
writeln!(&mut buffer)?;
writeln!(
&mut buffer,
"Reclaimable build artifacts ({} projects, {} total):",
projects.len(),
pretty_bytes(total as f64)
)?;
for (i, project) in projects.iter().enumerate() {
write_project_row(i, project, &mut buffer)?;
}
stdout.print(&buffer)?;
if !interactive {
return Ok(());
}
let selection = loop {
write_and_flush_prompt("Clean which? [1,3,5-7 / all / q to skip]: ")?;
let mut line = String::new();
let read = io::stdin().read_line(&mut line)?;
if read == 0 {
eprintln!("(input closed — skipping)");
return Ok(());
}
match parse_selection(&line, projects.len()) {
ParseResult::Skip => return Ok(()),
ParseResult::Invalid => {
eprintln!("Invalid selection, try again.");
continue;
}
ParseResult::Set(indices) => break indices,
}
};
let mut reclaimed: u64 = 0;
let mut failed: usize = 0;
for &index in &selection {
let project = &projects[index];
match project.project.clean() {
Ok(()) => {
reclaimed += project.artifact_size;
println!(
"cleaned {} ({})",
project.project.path.display(),
pretty_bytes(project.artifact_size as f64)
);
}
Err(e) => {
failed += 1;
eprintln!("failed {}: {}", project.project.path.display(), e);
}
}
}
println!(
"Done: reclaimed {}, {} failed.",
pretty_bytes(reclaimed as f64),
failed
);
Ok(())
}
fn write_project_row(i: usize, project: &ProjectAnalysis, buffer: &mut Buffer) -> io::Result<()> {
let size_color = if project.artifact_size > 1_000_000_000 {
Some(Color::Red)
} else if project.artifact_size > 100_000_000 {
Some(Color::Yellow)
} else {
None
};
write!(buffer, " {:>2}. ", i + 1)?;
buffer.set_color(ColorSpec::new().set_fg(size_color))?;
write!(buffer, "[{:>10}]", pretty_bytes(project.artifact_size as f64))?;
buffer.reset()?;
write!(buffer, " ")?;
buffer.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
write!(buffer, "{:<14}", project.project.type_name())?;
buffer.reset()?;
write!(buffer, " {}", project.project.path.display())?;
if let Some(mtime) = project.last_modified {
write!(buffer, " {}", relative_time(mtime))?;
}
writeln!(buffer)?;
Ok(())
}
fn relative_time(t: SystemTime) -> String {
match SystemTime::now().duration_since(t) {
Ok(d) => {
let s = d.as_secs();
if s < 60 {
format!("{}s ago", s)
} else if s < 3600 {
format!("{}m ago", s / 60)
} else if s < 86400 {
format!("{}h ago", s / 3600)
} else {
format!("{}d ago", s / 86400)
}
}
Err(_) => "just now".to_string(),
}
}
fn write_and_flush_prompt(msg: &str) -> io::Result<()> {
let mut out = io::stdout();
out.write_all(msg.as_bytes())?;
out.flush()
}
enum ParseResult {
Skip,
Invalid,
Set(Vec<usize>),
}
fn parse_selection(input: &str, total: usize) -> ParseResult {
let trimmed = input.trim();
if trimmed.is_empty() {
return ParseResult::Skip;
}
if trimmed.eq_ignore_ascii_case("q") || trimmed.eq_ignore_ascii_case("quit") {
return ParseResult::Skip;
}
if trimmed.eq_ignore_ascii_case("all") || trimmed.eq_ignore_ascii_case("a") {
return ParseResult::Set((0..total).collect());
}
let mut indices: BTreeSet<usize> = BTreeSet::new();
for token in trimmed.split(',') {
let token = token.trim();
match parse_token(token, total) {
Some(range) => indices.extend(range),
None => return ParseResult::Invalid,
}
}
if indices.is_empty() {
return ParseResult::Invalid;
}
ParseResult::Set(indices.into_iter().collect())
}
fn parse_token(token: &str, total: usize) -> Option<std::ops::RangeInclusive<usize>> {
if let Some((lhs, rhs)) = token.split_once('-') {
let start = lhs.trim().parse::<usize>().ok()?;
let end = rhs.trim().parse::<usize>().ok()?;
if start == 0 || end == 0 || start > end {
return None;
}
let (s, e) = (start - 1, end - 1);
if e >= total {
return None;
}
Some(s..=e)
} else {
let n = token.parse::<usize>().ok()?;
if n == 0 || n > total {
return None;
}
Some(n - 1..=n - 1)
}
}
fn show(item: &DiskItem, conf: &Config, info: &mut DisplayInfo, buffer: &mut Buffer) -> io::Result<()> {
show_item(item, info, buffer)?;
if info.level < conf.max_depth {
if let Some(children) = &item.children {
let children = children
.iter()
.map(|child| (child, size_fraction(child, item)))
.filter(|&(_, fraction)| fraction > conf.min_percent)
.collect::<Vec<_>>();
if let Some((last_child, rest)) = children.split_last() {
for &(child, fraction) in rest.iter() {
info.push(child, fraction, false);
show(child, conf, info, buffer)?;
info.pop();
}
let &(child, fraction) = last_child;
info.push(child, fraction, true);
show(child, conf, info, buffer)?;
info.pop();
}
}
}
Ok(())
}
fn show_item(item: &DiskItem, info: &DisplayInfo, buffer: &mut Buffer) -> io::Result<()> {
buffer.set_color(ColorSpec::new().set_fg(INDENT_COLOR))?;
write!(buffer, "{}{}", info.indents, info.prefix())?;
buffer.set_color(ColorSpec::new().set_fg(info.color()))?;
write!(buffer, " {:.2}% ", info.fraction)?;
buffer.reset()?;
write!(buffer, "[{}]", pretty_bytes(item.disk_size as f64),)?;
buffer.set_color(ColorSpec::new().set_fg(INDENT_COLOR))?;
write!(buffer, " {} ", shape::SPACING)?;
buffer.reset()?;
writeln!(buffer, "{}", item.name)?;
Ok(())
}
fn size_fraction(child: &DiskItem, parent: &DiskItem) -> f64 {
100.0 * (child.disk_size as f64 / parent.disk_size as f64)
}
#[derive(Debug)]
struct DisplayInfo {
fraction: f64,
level: usize,
last: bool,
indents: String,
}
impl DisplayInfo {
fn new() -> Self {
Self {
fraction: 100.0,
level: 0,
last: true,
indents: String::new(),
}
}
fn push(&mut self, _child: &DiskItem, fraction: f64, is_last: bool) {
let indent_char = if self.last { " " } else { shape::INDENT };
self.indents.push_str(indent_char);
self.indents.push_str(" ");
self.level += 1;
self.fraction = fraction;
self.last = is_last;
}
fn pop(&mut self) {
self.level -= 1;
let mut new_len = self.indents.len();
for _ in 0..3 {
new_len = self.indents[..new_len]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
self.indents.truncate(new_len);
}
fn prefix(&self) -> &'static str {
if self.last {
shape::LAST
} else {
shape::ITEM
}
}
fn color(&self) -> Option<Color> {
if self.level == 0 {
Some(Color::Green)
} else if self.fraction > 20.0 {
Some(Color::Red)
} else {
Some(Color::Cyan)
}
}
}
#[derive(Parser)]
struct Config {
#[arg(short = 'd', default_value = "1")]
max_depth: usize,
#[arg(
short = 'm',
default_value = "0.1",
value_parser = parse_percent
)]
min_percent: f64,
target_dir: Option<PathBuf>,
#[arg(short = 'a')]
apparent: bool,
#[arg(short = 'j')]
json: bool,
#[arg(short = 't', long = "tui")]
tui: bool,
}
fn parse_percent(src: &str) -> Result<f64, Box<dyn Error + Send + Sync>> {
let num = src.parse::<f64>()?;
if (0.0..=100.0).contains(&num) {
Ok(num)
} else {
Err("Percentage must be in range [0, 100].".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item() -> DiskItem {
DiskItem {
name: "x".into(),
disk_size: 1,
children: None,
}
}
#[test]
fn push_pop_roundtrip_with_multibyte_indent() {
let mut info = DisplayInfo::new();
info.push(&item(), 10.0, false);
info.push(&item(), 20.0, false);
info.push(&item(), 30.0, false);
assert_eq!(info.level, 3);
assert_eq!(info.indents, " │ │ ");
assert_eq!(info.indents.chars().count(), 9);
info.pop();
assert_eq!(info.indents, " │ ");
assert_eq!(info.level, 2);
info.pop();
info.pop();
assert_eq!(info.level, 0);
assert!(info.indents.is_empty(), "indents not fully popped: {:?}", info.indents);
}
fn as_set(result: ParseResult) -> Vec<usize> {
match result {
ParseResult::Set(v) => v,
other => panic!("expected Set, got {:?}", match other {
ParseResult::Skip => "Skip",
ParseResult::Invalid => "Invalid",
ParseResult::Set(_) => unreachable!(),
}),
}
}
#[test]
fn parse_selection_skip_on_empty_or_quit() {
assert!(matches!(parse_selection("", 5), ParseResult::Skip));
assert!(matches!(parse_selection(" ", 5), ParseResult::Skip));
assert!(matches!(parse_selection("q", 5), ParseResult::Skip));
assert!(matches!(parse_selection("QUIT", 5), ParseResult::Skip));
}
#[test]
fn parse_selection_all_selects_everything() {
assert_eq!(as_set(parse_selection("all", 5)), vec![0, 1, 2, 3, 4]);
assert_eq!(as_set(parse_selection("a", 5)), vec![0, 1, 2, 3, 4]);
}
#[test]
fn parse_selection_single_and_list() {
assert_eq!(as_set(parse_selection("1", 5)), vec![0]);
assert_eq!(as_set(parse_selection("1,3,5", 5)), vec![0, 2, 4]);
assert_eq!(as_set(parse_selection("1, 3", 5)), vec![0, 2]); }
#[test]
fn parse_selection_range_inclusive() {
assert_eq!(as_set(parse_selection("1-3", 5)), vec![0, 1, 2]);
assert_eq!(as_set(parse_selection("5-5", 5)), vec![4]); }
#[test]
fn parse_selection_dedups_and_sorts() {
assert_eq!(as_set(parse_selection("3,1,2", 5)), vec![0, 1, 2]);
assert_eq!(as_set(parse_selection("1,1,2", 5)), vec![0, 1]);
assert_eq!(as_set(parse_selection("1-3,2", 5)), vec![0, 1, 2]);
}
#[test]
fn parse_selection_rejects_invalid_tokens() {
assert!(matches!(parse_selection("0", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("6", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("1-6", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("3-1", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("1,2,", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("1,,2", 5), ParseResult::Invalid));
assert!(matches!(parse_selection(",", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("all,3", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("abc", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("1-", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("-3", 5), ParseResult::Invalid));
assert!(matches!(parse_selection("1.5", 5), ParseResult::Invalid));
assert!(matches!(
parse_selection("999999999999999999999", 5),
ParseResult::Invalid
));
}
}