#![forbid(rust_2018_idioms, unsafe_code)]
use anyhow::{Context, Result, anyhow, bail};
use clap::{CommandFactory as _, Parser};
use dua::canonicalize_ignore_dirs;
use log::info;
use std::{
fs, io,
io::{IsTerminal, Write},
path::{Path, PathBuf},
process,
};
#[cfg(feature = "tui-crossplatform")]
use crate::interactive::input::{input_channel, input_channel_from_chars};
#[cfg(feature = "tui-crossplatform")]
use crate::interactive::terminal::TerminalApp;
#[cfg(feature = "tui-crossplatform")]
use crossterm::{
execute,
terminal::{EnterAlternateScreen, enable_raw_mode},
};
#[cfg(feature = "tui-crossplatform")]
use tui::{Terminal, backend::CrosstermBackend};
mod crossdev;
#[cfg(feature = "tui-crossplatform")]
mod interactive;
mod options;
fn stderr_if_tty() -> Option<io::Stderr> {
let stderr = io::stderr();
if stderr.is_terminal() {
Some(stderr)
} else {
None
}
}
#[cfg(feature = "tui-crossplatform")]
struct InteractiveTerminalGuard {
raw_mode: bool,
alternate_screen: bool,
focus_change: bool,
}
#[cfg(feature = "tui-crossplatform")]
impl Drop for InteractiveTerminalGuard {
fn drop(&mut self) {
if self.focus_change {
crossterm::execute!(io::stderr(), crossterm::event::DisableFocusChange).ok();
}
crossterm::execute!(io::stderr(), crossterm::cursor::Show).ok();
if self.raw_mode {
crossterm::terminal::disable_raw_mode().ok();
}
if self.alternate_screen {
crossterm::execute!(io::stderr(), crossterm::terminal::LeaveAlternateScreen).ok();
}
}
}
fn main() -> Result<()> {
#[cfg(feature = "tui-crossplatform")]
use options::Command::Interactive;
use options::Command::{Aggregate, Completions, Config};
let opt: options::Args = options::Args::parse_from(wild::args_os());
if let Some(log_file) = &opt.log_file {
log_panics::init();
let log_output = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_file)?;
fern::Dispatch::new()
.level(log::LevelFilter::Debug)
.format(|formatter_out, log_msg, log_rec| {
let when = jiff::Zoned::now();
formatter_out.finish(format_args!(
"[{} {} {}:{}] {}",
when.strftime("%Y-%m-%d %H:%M:%S%.3f %:z"),
log_rec.level(),
log_rec.file().unwrap_or("<unknown>"),
log_rec.line().unwrap_or(0),
log_msg
));
})
.chain(log_output)
.apply()?;
info!("dua options={opt:#?}");
}
let options::Args {
command,
traversal: global_traversal,
log_file: _used_above,
} = opt;
let res = match command {
#[cfg(feature = "tui-crossplatform")]
Some(Interactive {
traversal: subcommand_traversal,
no_entry_check,
once,
}) => {
let traversal = merge_traversal_args(&global_traversal, &subcommand_traversal);
let config = dua::Config::load()?;
let enable_focus_change = config.notifications.any_enabled();
let byte_format = traversal.byte_format(&config);
let walk_options = walk_options_from(&traversal);
let cross_filesystems = walk_options.cross_filesystems;
let no_tty_msg = "Interactive mode requires a connected terminal";
if !io::stderr().is_terminal() {
return Err(anyhow!(no_tty_msg));
}
let mut stderr = io::stderr();
let terminal_guard = if once.is_some() {
InteractiveTerminalGuard {
raw_mode: false,
alternate_screen: false,
focus_change: false,
}
} else {
enable_raw_mode().with_context(|| no_tty_msg)?;
execute!(stderr, EnterAlternateScreen,).with_context(|| no_tty_msg)?;
if enable_focus_change {
execute!(stderr, crossterm::event::EnableFocusChange)
.with_context(|| no_tty_msg)?;
}
InteractiveTerminalGuard {
raw_mode: true,
alternate_screen: true,
focus_change: enable_focus_change,
}
};
let mut terminal = Terminal::new(CrosstermBackend::new(stderr))
.with_context(|| "Could not instantiate terminal")?;
let mut app = TerminalApp::initialize(
&mut terminal,
walk_options,
byte_format,
!no_entry_check,
extract_paths_maybe_set_cwd(traversal.input, cross_filesystems)?,
config,
)?;
app.traverse()?;
let res = match once {
Some(input) => {
app.process_events_once(&mut terminal, input_channel_from_chars(input.as_str()))
}
None => app.process_events(
&mut terminal,
input_channel(app.state.terminal_focus.clone()),
),
};
let res = res.map(|r| (r, app.window.mark.take().map(|pane| pane.into_paths())));
std::mem::forget(app);
drop(terminal);
drop(terminal_guard);
io::stderr().flush().ok();
let exit_code = match res {
Ok((walk_result, paths)) => {
if let Some(paths) = paths {
for path in paths {
println!("{}", path.display());
}
}
walk_result.to_exit_code()
}
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(exit_code);
}
Some(Aggregate {
traversal: subcommand_traversal,
no_total,
no_sort,
statistics,
}) => {
let traversal = merge_traversal_args(&global_traversal, &subcommand_traversal);
let config = dua::Config::load()?;
let byte_format = traversal.byte_format(&config);
let walk_options = walk_options_from(&traversal);
let cross_filesystems = walk_options.cross_filesystems;
let stdout = io::stdout();
let stdout_locked = stdout.lock();
let (res, stats) = dua::aggregate(
stdout_locked,
stderr_if_tty(),
walk_options,
!no_total,
!no_sort,
byte_format,
extract_paths_maybe_set_cwd(traversal.input, cross_filesystems)?,
)?;
if statistics {
writeln!(io::stderr(), "{stats:?}").ok();
}
res
}
Some(Completions { shell }) => {
let mut cmd = options::Args::command();
let dua = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, dua, &mut io::stdout());
return Ok(());
}
Some(Config { command }) => match command {
options::ConfigCommand::Edit => {
edit_config()?;
return Ok(());
}
options::ConfigCommand::ShowDefault { reset_with_default } => {
show_default_config(reset_with_default)?;
return Ok(());
}
},
None => {
let config = dua::Config::load()?;
let byte_format = global_traversal.byte_format(&config);
let walk_options = walk_options_from(&global_traversal);
let cross_filesystems = walk_options.cross_filesystems;
let stdout = io::stdout();
let stdout_locked = stdout.lock();
dua::aggregate(
stdout_locked,
stderr_if_tty(),
walk_options,
true,
true,
byte_format,
extract_paths_maybe_set_cwd(global_traversal.input, cross_filesystems)?,
)?
.0
}
};
process::exit(res.to_exit_code());
}
fn is_default_ignore_dirs(list: &[PathBuf]) -> bool {
let defaults = options::DEFAULT_IGNORE_DIRS;
list.len() == defaults.len()
&& list
.iter()
.zip(defaults)
.all(|(input, default)| input == Path::new(default))
}
fn merge_traversal_args(
global: &options::TraversalArgs,
subcommand: &options::TraversalArgs,
) -> options::TraversalArgs {
options::TraversalArgs {
threads: if global.threads == options::DEFAULT_THREADS {
subcommand.threads
} else {
global.threads
},
format: global.format.or(subcommand.format),
apparent_size: global.apparent_size || subcommand.apparent_size,
count_hard_links: global.count_hard_links || subcommand.count_hard_links,
stay_on_filesystem: global.stay_on_filesystem || subcommand.stay_on_filesystem,
ignore_dirs: if is_default_ignore_dirs(&global.ignore_dirs) {
subcommand.ignore_dirs.clone()
} else {
global.ignore_dirs.clone()
},
input: subcommand.input.clone(),
}
}
fn walk_options_from(traversal: &options::TraversalArgs) -> dua::WalkOptions {
let mut walk_options = dua::WalkOptions {
threads: traversal.threads,
apparent_size: traversal.apparent_size,
count_hard_links: traversal.count_hard_links,
cross_filesystems: !traversal.stay_on_filesystem,
ignore_dirs: canonicalize_ignore_dirs(&traversal.ignore_dirs),
};
if walk_options.threads == 0 {
walk_options.threads = num_cpus::get();
}
walk_options
}
fn extract_paths_maybe_set_cwd(
mut paths: Vec<PathBuf>,
cross_filesystems: bool,
) -> Result<Vec<PathBuf>, io::Error> {
if paths.len() == 1 && paths[0].is_dir() {
std::env::set_current_dir(&paths[0])?;
paths.clear();
}
let device_id = std::env::current_dir()
.ok()
.and_then(|cwd| crossdev::init(&cwd).ok());
if paths.is_empty() {
cwd_dirlist().map(|paths| match device_id {
Some(device_id) if !cross_filesystems => paths
.into_iter()
.filter(|p| match p.metadata() {
Ok(meta) => crossdev::is_same_device(device_id, &meta),
Err(_) => true,
})
.collect(),
_ => paths,
})
} else {
Ok(paths)
}
}
fn cwd_dirlist() -> Result<Vec<PathBuf>, io::Error> {
let mut v: Vec<_> = fs::read_dir(".")?
.filter_map(|e| {
e.ok()
.and_then(|e| e.path().strip_prefix(".").ok().map(ToOwned::to_owned))
})
.filter(|p| {
if let Ok(meta) = p.symlink_metadata()
&& meta.file_type().is_symlink()
{
return false;
}
true
})
.collect();
v.sort();
Ok(v)
}
fn edit_config() -> Result<()> {
let path = dua::Config::path()?;
ensure_default_config_file(&path)?;
let editor = std::env::var("EDITOR").map_err(|_| {
anyhow!(
"$EDITOR is not set or is illformed UTF8. Created default configuration at {}",
path.display()
)
})?;
let Some(mut editor_parts) = shlex::split(&editor) else {
return Err(anyhow!(
"$EDITOR has invalid shell quoting. Created default configuration at {}",
path.display()
));
};
if editor_parts.is_empty() {
bail!(
"$EDITOR is empty. Created default configuration at {}",
path.display()
)
}
let editor_program = editor_parts.remove(0);
let status = std::process::Command::new(&editor_program)
.args(editor_parts)
.arg(&path)
.status()
.with_context(|| {
format!(
"Failed to launch editor {editor_program:?} (from $EDITOR={editor:?}) for {}",
path.display()
)
})?;
if status.success() {
println!("Successfully edited '{}'", path.display());
return Ok(());
}
Err(anyhow!(
"Editor {editor_program:?} (from $EDITOR={editor:?}) exited with status {status} while editing {}",
path.display()
))
}
fn show_default_config(overwrite_with_default: bool) -> Result<()> {
print!("{}", dua::Config::default_file_content());
if overwrite_with_default {
let path = dua::Config::path()?;
write_default_config_file(&path)?;
eprintln!("Reset configuration at '{}'", path.display());
}
Ok(())
}
fn ensure_default_config_file(path: &Path) -> Result<()> {
if path.exists() {
return Ok(());
}
write_default_config_file(path)
}
fn write_default_config_file(path: &Path) -> Result<()> {
if let Some(parent_dir) = path.parent() {
fs::create_dir_all(parent_dir).with_context(|| {
format!(
"Could not create configuration directory {}",
parent_dir.display()
)
})?;
}
fs::write(path, dua::Config::default_file_content()).with_context(|| {
format!(
"Could not write default configuration to {}",
path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{merge_traversal_args, write_default_config_file};
use std::fs;
use std::path::PathBuf;
#[test]
fn write_default_config_file_overwrites_existing_config() {
let dir = tempfile::tempdir().expect("temporary directory");
let path = dir.path().join("dua-cli").join("config.toml");
fs::create_dir_all(path.parent().expect("config parent")).expect("config directory");
fs::write(&path, "old config").expect("existing config");
write_default_config_file(&path).expect("default config written");
assert_eq!(
fs::read_to_string(&path).expect("default config"),
dua::Config::default_file_content()
);
}
#[test]
fn merge_traversal_args_prefers_global_threads() {
let custom_threads = super::options::DEFAULT_THREADS + 1;
let global = super::options::TraversalArgs {
threads: custom_threads,
format: None,
apparent_size: true,
count_hard_links: false,
stay_on_filesystem: false,
ignore_dirs: vec![],
input: vec![],
};
let subcommand = super::options::TraversalArgs {
threads: 2,
format: None,
apparent_size: false,
count_hard_links: true,
stay_on_filesystem: true,
ignore_dirs: vec![],
input: vec![PathBuf::from("subcommand-input")],
};
let merged = merge_traversal_args(&global, &subcommand);
assert_eq!(merged.threads, custom_threads);
assert_eq!(merged.input, subcommand.input);
}
#[test]
fn merge_traversal_args_uses_subcommand_threads_when_global_is_default() {
let global = super::options::TraversalArgs {
threads: super::options::DEFAULT_THREADS,
format: None,
apparent_size: false,
count_hard_links: false,
stay_on_filesystem: false,
ignore_dirs: vec![],
input: vec![],
};
let subcommand = super::options::TraversalArgs {
threads: 6,
format: None,
apparent_size: false,
count_hard_links: false,
stay_on_filesystem: false,
ignore_dirs: vec![],
input: vec![],
};
let merged = merge_traversal_args(&global, &subcommand);
assert_eq!(merged.threads, 6);
}
#[test]
fn merge_traversal_args_uses_global_format_and_or_booleans() {
let global = super::options::TraversalArgs {
threads: super::options::DEFAULT_THREADS,
format: Some(super::options::ByteFormat::MB),
apparent_size: true,
count_hard_links: false,
stay_on_filesystem: true,
ignore_dirs: vec![],
input: vec![],
};
let subcommand = super::options::TraversalArgs {
threads: 4,
format: Some(super::options::ByteFormat::GB),
apparent_size: false,
count_hard_links: true,
stay_on_filesystem: false,
ignore_dirs: vec![],
input: vec![],
};
let merged = merge_traversal_args(&global, &subcommand);
assert_eq!(merged.format, Some(super::options::ByteFormat::MB));
assert!(merged.apparent_size);
assert!(merged.count_hard_links);
assert!(merged.stay_on_filesystem);
}
#[test]
fn merge_traversal_args_prefers_global_ignore_dirs_when_custom() {
let global = super::options::TraversalArgs {
threads: super::options::DEFAULT_THREADS,
format: None,
apparent_size: false,
count_hard_links: false,
stay_on_filesystem: false,
ignore_dirs: vec![PathBuf::from("/custom-global-ignore")],
input: vec![],
};
let subcommand = super::options::TraversalArgs {
threads: super::options::DEFAULT_THREADS,
format: None,
apparent_size: false,
count_hard_links: false,
stay_on_filesystem: false,
ignore_dirs: vec![PathBuf::from("/custom-subcommand-ignore")],
input: vec![],
};
let merged = merge_traversal_args(&global, &subcommand);
assert_eq!(merged.ignore_dirs, global.ignore_dirs);
}
#[test]
fn merge_traversal_args_uses_subcommand_ignore_dirs_when_global_is_default() {
let global = super::options::TraversalArgs {
threads: super::options::DEFAULT_THREADS,
format: None,
apparent_size: false,
count_hard_links: false,
stay_on_filesystem: false,
ignore_dirs: super::options::DEFAULT_IGNORE_DIRS
.iter()
.map(PathBuf::from)
.collect(),
input: vec![],
};
let subcommand = super::options::TraversalArgs {
threads: super::options::DEFAULT_THREADS,
format: None,
apparent_size: false,
count_hard_links: false,
stay_on_filesystem: false,
ignore_dirs: vec![PathBuf::from("/custom-subcommand-ignore")],
input: vec![],
};
let merged = merge_traversal_args(&global, &subcommand);
assert_eq!(merged.ignore_dirs, subcommand.ignore_dirs);
}
}