diff --git a/Cargo.lock b/Cargo.lock index 2647850..cce99c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,7 +231,7 @@ dependencies = [ "nom", "pathdiff", "serde", - "toml", + "toml 0.5.11", ] [[package]] @@ -295,6 +295,12 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "erdtree" version = "3.1.2" @@ -323,6 +329,7 @@ dependencies = [ "tempfile", "terminal_size", "thiserror", + "toml 0.8.8", "winapi", ] @@ -406,6 +413,12 @@ dependencies = [ "regex", ] +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + [[package]] name = "heck" version = "0.4.1" @@ -458,6 +471,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indextree" version = "4.6.0" @@ -765,9 +788,32 @@ checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" [[package]] name = "serde" -version = "1.0.156" +version = "1.0.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "314b5b092c0ade17c00142951e50ced110ec27cea304b1037c6969246c2469a4" +checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.193" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" +dependencies = [ + "serde", +] [[package]] name = "signal-hook" @@ -899,6 +945,40 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.8" @@ -1195,6 +1275,15 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +[[package]] +name = "winnow" +version = "0.5.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c830786f7720c2fd27a1a0e27a709dbd3c4d009b56d098fc742d4f4eab91fe2" +dependencies = [ + "memchr", +] + [[package]] name = "zerocopy" version = "0.7.25" diff --git a/Cargo.toml b/Cargo.toml index c1fd2f9..e344913 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ keywords = ["tree", "find", "ls", "du", "commandline"] exclude = ["assets/*", "scripts/*", "example/*"] readme = "README.md" license = "MIT" -rust-version = "1.74.0" +rust-version = "1.76.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -50,6 +50,7 @@ once_cell = "1.17.0" regex = "1.7.3" terminal_size = "0.2.6" thiserror = "1.0.40" +toml = "0.8.8" [target.'cfg(unix)'.dependencies] libc = "0.2.141" diff --git a/example/.erdtree.toml b/example/.erdtree.toml index 006cb2a..30e1a66 100644 --- a/example/.erdtree.toml +++ b/example/.erdtree.toml @@ -1,28 +1,26 @@ +# Please becareful modifying this file as some tests may assume +# this file to look a certain way. + icons = true -human = true # Compute file sizes like `du` [du] -disk_usage = "block" +metric = "block" icons = true layout = "flat" -no-ignore = true -no-git = true -hidden = true level = 1 # Do as `ls -l` [ls] icons = true -human = true level = 1 suppress-size = true long = true -no-ignore = true -hidden = true +gitignore = true +no_hidden = true # How many lines of Rust are in this code base? [rs] -disk-usage = "line" +metric = "line" level = 1 pattern = "\\.rs$" diff --git a/src/disk/mod.rs b/src/disk/mod.rs index 931d661..044407f 100644 --- a/src/disk/mod.rs +++ b/src/disk/mod.rs @@ -10,7 +10,7 @@ use std::{ /// Binary and SI prefixes. pub mod prefix; -/// https://doc.rust-lang.org/std/os/unix/fs/trait.MetadataExt.html#tymethod.blocks +/// #[cfg(unix)] const BLOCK_SIZE: u64 = 512; @@ -93,7 +93,7 @@ impl Usage { } /// Gets the apparent file size rather than disk usage. Refer to `--apparent-size` in the man - /// pages of `du`: https://man7.org/linux/man-pages/man1/du.1.html + /// pages of `du`: pub fn init_logical(metadata: &Metadata, presentation: BytePresentation) -> Self { let value = metadata.is_dir().then_some(0).unwrap_or(metadata.len()); diff --git a/src/disk/prefix.rs b/src/disk/prefix.rs index 448c420..ab13120 100644 --- a/src/disk/prefix.rs +++ b/src/disk/prefix.rs @@ -3,7 +3,7 @@ use std::{ fmt::{self, Display}, }; -/// https://en.wikipedia.org/wiki/Binary_prefix +/// #[derive(Debug, PartialEq)] pub enum Binary { Base, @@ -14,7 +14,7 @@ pub enum Binary { Pebi, } -/// https://en.wikipedia.org/wiki/International_System_of_Units +/// #[derive(Debug, PartialEq)] pub enum Si { Base, diff --git a/src/file/mod.rs b/src/file/mod.rs index 0bd1ec7..29a8297 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -107,7 +107,7 @@ impl File { Metric::Line => disk::Usage::init_line_count(&data, &metadata, *follow)?, #[cfg(unix)] - Metric::Blocks => disk::Usage::init_blocks(&metadata), + Metric::Block => disk::Usage::init_blocks(&metadata), }; #[cfg(unix)] diff --git a/src/main.rs b/src/main.rs index 7cb402e..915192c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ #![cfg_attr(windows, feature(windows_by_handle))] +use clap::CommandFactory; use log::Log; use std::{ io::{stdout, Write}, @@ -7,6 +8,7 @@ use std::{ /// Defines the command-line interface and the context used throughout Erdtree. mod user; +use user::Context; /// Concerned with disk usage calculation and presentation. mod disk; @@ -27,6 +29,8 @@ mod logging; /// Concerned with rendering the program output. mod render; +const BIN_NAME: &str = "erd"; + fn main() -> ExitCode { if let Err(e) = run() { eprintln!("{e}"); @@ -38,6 +42,11 @@ fn main() -> ExitCode { fn run() -> Result<()> { let mut ctx = user::Context::init()?; + if let Some(shell) = ctx.completions { + clap_complete::generate(shell, &mut Context::command(), BIN_NAME, &mut stdout()); + return Ok(()); + } + let logger = ctx .verbose .then_some(logging::LoggityLog::init()) diff --git a/src/user/args.rs b/src/user/args.rs index 7469fc3..e02850e 100644 --- a/src/user/args.rs +++ b/src/user/args.rs @@ -18,7 +18,7 @@ pub enum Metric { /// Total amount of blocks allocated to store a file on disk #[cfg(unix)] - Blocks, + Block, } /// Whether to report byte size using SI or binary prefixes or no prefix. diff --git a/src/user/config/mod.rs b/src/user/config/mod.rs new file mode 100644 index 0000000..3322310 --- /dev/null +++ b/src/user/config/mod.rs @@ -0,0 +1,25 @@ +const ERDTREE_CONFIG_TOML: &str = ".erdtree.toml"; +const ERDTREE_TOML_PATH: &str = "ERDTREE_TOML_PATH"; + +const ERDTREE_CONFIG_NAME: &str = ".erdtreerc"; +const ERDTREE_CONFIG_PATH: &str = "ERDTREE_CONFIG_PATH"; + +const ERDTREE_DIR: &str = "erdtree"; + +#[cfg(unix)] +const CONFIG_DIR: &str = ".config"; + +#[cfg(unix)] +const HOME: &str = "HOME"; + +#[cfg(unix)] +const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME"; + +/// Concerned with loading `.erdtree.toml`. +pub mod toml; + +/// Concerned with parsing the result of [`toml::load`] into args. +pub mod parse; + +#[cfg(test)] +mod test; diff --git a/src/user/config/parse.rs b/src/user/config/parse.rs new file mode 100644 index 0000000..4233048 --- /dev/null +++ b/src/user/config/parse.rs @@ -0,0 +1,163 @@ +use crate::user; +use ahash::HashMap; +use clap::{ + ArgMatches, + Command, + CommandFactory, + error::Error as ClapError, + FromArgMatches, + Parser +}; +use config::{Config, ConfigError}; +use toml::Value; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Deserialization(#[from] ConfigError), + + #[error("Table '#{0}' does not exist.")] + TableNotFound(String), + + #[error("Error while parsing config arguments: {0}")] + Parse(#[from] ClapError) +} + +type Result = std::result::Result; +type KeyTransformer = fn(String) -> String; +type TomlConfig = toml::map::Map; + +pub fn args(conf: Config, table_name: Option<&str>) -> Result> { + let maybe_config = conf.try_deserialize::().map(|raw_config| { + deep_transform_keys(raw_config, |mut key| { + key.make_ascii_lowercase(); + format!("--{}", key.replace("_", "-")) + }) + })?; + + let Some(mut config) = maybe_config else { + return Ok(None); + }; + + match table_name { + Some(name) => match config.remove(&format!("--{name}")) { + Some(Value::Table(sub_table)) => { + config = sub_table; + } + _ => return Err(Error::TableNotFound(name.to_string())), + } + None => remove_sub_tables(&mut config) + } + + let arg_matches = into_args(config)?; + + Ok(Some(arg_matches)) +} + +fn into_args(conf: TomlConfig) -> Result { + let mut args = vec![crate::BIN_NAME.to_string()]; + + for (arg_name, param) in conf { + match param { + Value::Array(array_args) => { + for arg in array_args { + if let Some(farg) = fmt_arg(arg) { + args.push(arg_name.clone()); + args.push(farg) + } + } + } + Value::Boolean(arg) if arg => { + args.push(arg_name.clone()); + } + _ => { + if let Some(farg) = fmt_arg(param) { + args.push(arg_name.clone()); + args.push(farg) + } + } + } + } + + let cmd = crate::user::Context::command() + .try_get_matches_from(args)?; + + Ok(cmd) +} + +/// Formats basic primitive types into OS args. Will ignore table and array types. +fn fmt_arg(val: Value) -> Option { + match val { + Value::Float(p) => Some(format!("{p}")), + Value::String(p) => Some(format!("{p}")), + Value::Datetime(p) => Some(format!("{p}")), + Value::Integer(p) => Some(format!("{p}")), + _ => None + } +} + +fn remove_sub_tables(conf: &mut TomlConfig) { + let mut sub_table_keys = Vec::new(); + + conf.iter().for_each(|(k, v)| { + if let Value::Table(_) = v { + sub_table_keys.push(k.clone()) + } + }); + + sub_table_keys.iter().for_each(|k| { + conf.remove(k); + }); +} + +/// Transforms all keys and nested keys to the format computed by `transformer`. +fn deep_transform_keys(toml: TomlConfig, transformer: KeyTransformer) -> Option { + let mut dfs_stack_src = vec![Value::Table(toml)]; + let mut dfs_stack_dst = vec![("".to_string(), toml::map::Map::default())]; + + let mut key_iters = HashMap::default(); + + 'outer: while !dfs_stack_src.is_empty() { + let Some(Value::Table(current_node)) = dfs_stack_src.last_mut() else { + continue; + }; + + let Some((dst_key, copy_dst)) = dfs_stack_dst.last_mut() else { + continue; + }; + + let keys = key_iters.entry(dst_key.clone()).or_insert_with(|| { + current_node.keys().cloned().collect::>().into_iter() + }); + + for key in keys { + match current_node.remove(&key) { + Some(value) => match value { + Value::Table(_) => { + let transformed_key = transformer(key); + dfs_stack_dst.push((transformed_key, toml::map::Map::default())); + dfs_stack_src.push(value); + continue 'outer; + } + _ => { + let transformed_key = transformer(key); + copy_dst.insert(transformed_key, value); + } + } + None => continue, + } + } + + dfs_stack_src.pop(); + + if let Some((transformed_key, current_map)) = dfs_stack_dst.pop() { + if let Some((_, parent_map)) = dfs_stack_dst.last_mut() { + parent_map.insert(transformed_key, Value::Table(current_map)); + } else { + return Some(current_map); + } + } + } + + None +} diff --git a/src/user/config/test.rs b/src/user/config/test.rs new file mode 100644 index 0000000..4253d0b --- /dev/null +++ b/src/user/config/test.rs @@ -0,0 +1,47 @@ +use crate::user::args::{Layout, Metric}; +use super::parse; +use std::env::current_dir; +use config::{Config, File}; + +#[test] +fn test_toml_parse_top_level_table() { + let config = load_example(); + + let arg_matches = parse::args(config, None) + .expect("Failed to parse example config.") + .expect("Expected top level table to be found."); + + let icons = arg_matches.get_one::("icons").unwrap(); + assert!(icons); +} + +#[test] +fn test_toml_parse_sub_table() { + let config = load_example(); + + let arg_matches = parse::args(config, Some("du")) + .expect("Failed to parse example config.") + .expect("Expected sub table to be found."); + + let metric = arg_matches.get_one::("metric").unwrap(); + assert_eq!(metric, &Metric::Block); + + let layout = arg_matches.get_one::("layout").unwrap(); + assert_eq!(layout, &Layout::Flat); + + let level = arg_matches.get_one::("level").unwrap(); + assert_eq!(*level, 1); +} + +fn load_example() -> Config { + let example_config = current_dir() + .ok() + .map(|p| p.join("example").join(super::ERDTREE_CONFIG_TOML)) + .and_then(|p| p.as_path().to_str().map(File::with_name)) + .unwrap(); + + Config::builder() + .add_source(example_config) + .build() + .expect("Failed to load example config.") +} diff --git a/src/user/config/toml.rs b/src/user/config/toml.rs new file mode 100644 index 0000000..b1ab279 --- /dev/null +++ b/src/user/config/toml.rs @@ -0,0 +1,144 @@ +use config::{Config, File}; +use std::{env, ffi::OsStr, path::PathBuf}; + +/// Reads in `.erdtree.toml` file. +#[cfg(windows)] +pub fn load() -> Option { + windows::load_toml() +} + +#[cfg(not(any(windows, unix)))] +pub fn load() -> Option { + None +} + +/// Reads in `.erdtree.toml` file. +#[cfg(unix)] +pub fn load() -> Option { + unix::load_toml() +} + +/// Attempts to load in `.erdtree.toml` from `$ERDTREE_TOML_PATH`. +fn toml_from_env() -> Option { + let path = match env::var_os(super::ERDTREE_TOML_PATH).map(PathBuf::from) { + Some(config_path) => config_path, + None => return None, + }; + + let file = path + .file_stem() + .and_then(OsStr::to_str) + .map(File::with_name)?; + + Config::builder().add_source(file).build().ok() +} + +/// Concerned with how to load `.erdtree.toml` on Unix systems. +#[cfg(unix)] +mod unix { + use super::super::{CONFIG_DIR, ERDTREE_CONFIG_TOML, ERDTREE_DIR, HOME, XDG_CONFIG_HOME}; + use config::{Config, File}; + use std::{env, ffi::OsStr, path::PathBuf}; + + /// Looks for `.erdtree.toml` in the following locations in order: + /// + /// - `$ERDTREE_TOML_PATH` + /// - `$XDG_CONFIG_HOME/erdtree/.erdtree.toml` + /// - `$XDG_CONFIG_HOME/.erdtree.toml` + /// - `$HOME/.config/erdtree/.erdtree.toml` + /// - `$HOME/.erdtree.toml` + pub fn load_toml() -> Option { + super::toml_from_env() + .or_else(toml_from_xdg_path) + .or_else(toml_from_home) + } + + /// Looks for `.erdtree.toml` in the following locations in order: + /// + /// - `$XDG_CONFIG_HOME/erdtree/.erdtree.toml` + /// - `$XDG_CONFIG_HOME/.erdtree.toml` + fn toml_from_xdg_path() -> Option { + let path = match env::var_os(XDG_CONFIG_HOME).map(PathBuf::from) { + Some(dir_path) => dir_path, + None => return None, + }; + + let file = path + .join(ERDTREE_DIR) + .join(ERDTREE_CONFIG_TOML) + .file_stem() + .and_then(OsStr::to_str) + .map(File::with_name)?; + + if let Ok(config) = Config::builder().add_source(file).build() { + return Some(config); + } + + let file = path + .join(ERDTREE_CONFIG_TOML) + .file_stem() + .and_then(OsStr::to_str) + .map(File::with_name)?; + + Config::builder().add_source(file).build().ok() + } + + /// Looks for `.erdtree.toml` in the following locations in order: + /// + /// - `$HOME/.config/erdtree/.erdtree.toml` + /// - `$HOME/.erdtree.toml` + fn toml_from_home() -> Option { + let home = match env::var_os(HOME).map(PathBuf::from) { + Some(path) => path, + None => return None, // Why don't you have `HOME` set? Weirdo. + }; + + let file = home + .join(CONFIG_DIR) + .join(ERDTREE_DIR) + .join(ERDTREE_CONFIG_TOML) + .file_stem() + .and_then(OsStr::to_str) + .map(File::with_name)?; + + if let Ok(config) = Config::builder().add_source(file).build() { + return Some(config); + } + + let file = home + .join(ERDTREE_CONFIG_TOML) + .file_stem() + .and_then(OsStr::to_str) + .map(File::with_name)?; + + Config::builder().add_source(file).build().ok() + } +} + +/// Concerned with how to load `.erdtree.toml` on Windows. +#[cfg(windows)] +mod windows { + use super::super::{ERDTREE_CONFIG_TOML, ERDTREE_DIR}; + use config::{Config, File}; + + /// Try to read in config from the following location: + /// - `%APPDATA%\erdtree\.erdtree.toml` + pub fn load_toml() -> Option { + super::toml_from_env().or_else(toml_from_appdata) + } + + /// Try to read in config from the following location: + /// - `%APPDATA%\erdtree\.erdtree.toml` + fn toml_from_appdata() -> Option { + let file = dirs::config_dir().and_then(|config_dir| { + config_dir + .join(ERDTREE_DIR) + .join(ERDTREE_CONFIG_TOML) + .to_str() + .and_then(|s| s.strip_suffix(".toml")) + .map(File::with_name) + })?; + + Config::builder().add_source(file).build().ok() + } +} diff --git a/src/user/mod.rs b/src/user/mod.rs index 522dac1..b84fd36 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -8,6 +8,9 @@ pub mod args; /// Concerned with properties of columns in the output which is essentially a 2D grid. pub mod column; +/// Concerned with loading and parsing the optional `erdtree.toml` config file. +mod config; + /// Defines the CLI whose purpose is to capture user arguments and reconcile them with arguments /// found with a config file if relevant. #[derive(Parser, Debug)] @@ -22,11 +25,11 @@ pub struct Context { /// Directory to traverse; defaults to current working directory dir: Option, - /// Ignore hidden files + /// Run the program ignoring hidden files #[arg(short = '.', long)] pub no_hidden: bool, - /// Ignore .git directory + /// Run the program skipping the .git directory #[arg(long)] pub no_git: bool, @@ -34,6 +37,10 @@ pub struct Context { #[arg(short, long, value_enum, default_value_t)] pub byte_units: args::BytePresentation, + /// Use configuration of a named table rather than the top-level table in .erdtree.toml + #[arg(short = 'c', long)] + pub config: Option, + /// Sort directories before or after all other file types #[arg(short, long, value_enum, default_value_t)] pub dir_order: args::DirOrder, @@ -46,7 +53,7 @@ pub struct Context { #[arg(short = 'f', long)] pub follow: bool, - /// Ignore files that match rules in all .gitignore files encountered during traversal + /// Run the program ignoring files that match rules in all .gitignore files encountered during traversal #[arg(short = 'i', long)] pub gitignore: bool, @@ -106,6 +113,10 @@ pub struct Context { #[arg(short, long, value_enum, default_value_t)] pub metric: args::Metric, + /// Run the program without reading .erdtree.toml + #[arg(short, long)] + pub no_config: bool, + /// Regular expression (or glob if '--glob' or '--iglob' is used) used to match files by their /// relative path #[arg(short, long, group = "searching")] @@ -146,6 +157,10 @@ pub struct Context { #[arg(short = 'v', long = "verbose")] pub verbose: bool, + #[arg(long)] + /// Print completions for a given shell to stdout + pub completions: Option, + ////////////////////////// /* INTERNAL USAGE BELOW */ //////////////////////////