diff --git a/Cargo.lock b/Cargo.lock index 732e6969..16fa06b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -704,6 +704,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" version = "1.0.26" @@ -831,6 +840,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -858,6 +877,15 @@ dependencies = [ "winsafe", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1004,5 +1032,6 @@ dependencies = [ "rstest_reuse", "serde", "tempfile", + "walkdir", "which", ] diff --git a/Cargo.toml b/Cargo.toml index 58b0d57a..f0bd48ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ fastrand = "2.0.0" glob = "0.3.0" ouroboros = "0.18.3" serde = { version = "1.0.116", features = ["derive"] } +walkdir = "2.5.0" [target.'cfg(unix)'.dependencies] nix = { version = "0.30.1", default-features = false, features = [ diff --git a/contrib/completions/_zoxide b/contrib/completions/_zoxide index 97e654f3..5a03d7c1 100644 --- a/contrib/completions/_zoxide +++ b/contrib/completions/_zoxide @@ -32,6 +32,8 @@ _zoxide() { _arguments "${_arguments_options[@]}" : \ '-s+[The rank to increment the entry if it exists or initialize it with if it doesn'\''t]:SCORE:_default' \ '--score=[The rank to increment the entry if it exists or initialize it with if it doesn'\''t]:SCORE:_default' \ +'-r[Recursively add directories]' \ +'--recursive[Recursively add directories]' \ '-h[Print help]' \ '--help[Print help]' \ '-V[Print version]' \ @@ -138,6 +140,8 @@ _arguments "${_arguments_options[@]}" : \ ;; (remove) _arguments "${_arguments_options[@]}" : \ +'-r[Recursively remove directories]' \ +'--recursive[Recursively remove directories]' \ '-h[Print help]' \ '--help[Print help]' \ '-V[Print version]' \ diff --git a/contrib/completions/_zoxide.ps1 b/contrib/completions/_zoxide.ps1 index bb47d3a7..25a14520 100644 --- a/contrib/completions/_zoxide.ps1 +++ b/contrib/completions/_zoxide.ps1 @@ -36,6 +36,8 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { 'zoxide;add' { [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'The rank to increment the entry if it exists or initialize it with if it doesn''t') [CompletionResult]::new('--score', '--score', [CompletionResultType]::ParameterName, 'The rank to increment the entry if it exists or initialize it with if it doesn''t') + [CompletionResult]::new('-r', '-r', [CompletionResultType]::ParameterName, 'Recursively add directories') + [CompletionResult]::new('--recursive', '--recursive', [CompletionResultType]::ParameterName, 'Recursively add directories') [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') [CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version') @@ -118,6 +120,8 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { break } 'zoxide;remove' { + [CompletionResult]::new('-r', '-r', [CompletionResultType]::ParameterName, 'Recursively remove directories') + [CompletionResult]::new('--recursive', '--recursive', [CompletionResultType]::ParameterName, 'Recursively remove directories') [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') [CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version') diff --git a/contrib/completions/zoxide.bash b/contrib/completions/zoxide.bash index 82b174e3..c2ed13df 100644 --- a/contrib/completions/zoxide.bash +++ b/contrib/completions/zoxide.bash @@ -67,7 +67,7 @@ _zoxide() { return 0 ;; zoxide__add) - opts="-s -h -V --score --help --version ..." + opts="-s -r -h -V --score --recursive --help --version ..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -227,7 +227,7 @@ _zoxide() { return 0 ;; zoxide__remove) - opts="-h -V --help --version [PATHS]..." + opts="-r -h -V --recursive --help --version [PATHS]..." if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 diff --git a/contrib/completions/zoxide.elv b/contrib/completions/zoxide.elv index 93c57af8..a9159019 100644 --- a/contrib/completions/zoxide.elv +++ b/contrib/completions/zoxide.elv @@ -32,6 +32,8 @@ set edit:completion:arg-completer[zoxide] = {|@words| &'zoxide;add'= { cand -s 'The rank to increment the entry if it exists or initialize it with if it doesn''t' cand --score 'The rank to increment the entry if it exists or initialize it with if it doesn''t' + cand -r 'Recursively add directories' + cand --recursive 'Recursively add directories' cand -h 'Print help' cand --help 'Print help' cand -V 'Print version' @@ -105,6 +107,8 @@ set edit:completion:arg-completer[zoxide] = {|@words| cand --version 'Print version' } &'zoxide;remove'= { + cand -r 'Recursively remove directories' + cand --recursive 'Recursively remove directories' cand -h 'Print help' cand --help 'Print help' cand -V 'Print version' diff --git a/contrib/completions/zoxide.fish b/contrib/completions/zoxide.fish index 3a0bfe7a..4e7ef1d2 100644 --- a/contrib/completions/zoxide.fish +++ b/contrib/completions/zoxide.fish @@ -33,6 +33,7 @@ complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "init" -d 'Generate sh complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "query" -d 'Search for a directory in the database' complete -c zoxide -n "__fish_zoxide_needs_command" -f -a "remove" -d 'Remove a directory from the database' complete -c zoxide -n "__fish_zoxide_using_subcommand add" -s s -l score -d 'The rank to increment the entry if it exists or initialize it with if it doesn\'t' -r +complete -c zoxide -n "__fish_zoxide_using_subcommand add" -s r -l recursive -d 'Recursively add directories' complete -c zoxide -n "__fish_zoxide_using_subcommand add" -s h -l help -d 'Print help' complete -c zoxide -n "__fish_zoxide_using_subcommand add" -s V -l version -d 'Print version' complete -c zoxide -n "__fish_zoxide_using_subcommand edit; and not __fish_seen_subcommand_from decrement delete increment reload" -s h -l help -d 'Print help' @@ -69,5 +70,6 @@ complete -c zoxide -n "__fish_zoxide_using_subcommand query" -s l -l list -d 'Li complete -c zoxide -n "__fish_zoxide_using_subcommand query" -s s -l score -d 'Print score with results' complete -c zoxide -n "__fish_zoxide_using_subcommand query" -s h -l help -d 'Print help' complete -c zoxide -n "__fish_zoxide_using_subcommand query" -s V -l version -d 'Print version' +complete -c zoxide -n "__fish_zoxide_using_subcommand remove" -s r -l recursive -d 'Recursively remove directories' complete -c zoxide -n "__fish_zoxide_using_subcommand remove" -s h -l help -d 'Print help' complete -c zoxide -n "__fish_zoxide_using_subcommand remove" -s V -l version -d 'Print version' diff --git a/contrib/completions/zoxide.nu b/contrib/completions/zoxide.nu index 642908e6..4a465ce3 100644 --- a/contrib/completions/zoxide.nu +++ b/contrib/completions/zoxide.nu @@ -10,6 +10,7 @@ module completions { export extern "zoxide add" [ ...paths: path --score(-s): string # The rank to increment the entry if it exists or initialize it with if it doesn't + --recursive(-r) # Recursively add directories --help(-h) # Print help --version(-V) # Print version ] @@ -90,6 +91,7 @@ module completions { # Remove a directory from the database export extern "zoxide remove" [ ...paths: path + --recursive(-r) # Recursively remove directories --help(-h) # Print help --version(-V) # Print version ] diff --git a/contrib/completions/zoxide.ts b/contrib/completions/zoxide.ts index 1e0d4045..850b355c 100644 --- a/contrib/completions/zoxide.ts +++ b/contrib/completions/zoxide.ts @@ -15,6 +15,10 @@ const completion: Fig.Spec = { isOptional: true, }, }, + { + name: ["-r", "--recursive"], + description: "Recursively add directories", + }, { name: ["-h", "--help"], description: "Print help", @@ -267,6 +271,10 @@ const completion: Fig.Spec = { name: "remove", description: "Remove a directory from the database", options: [ + { + name: ["-r", "--recursive"], + description: "Recursively remove directories", + }, { name: ["-h", "--help"], description: "Print help", diff --git a/src/cmd/add.rs b/src/cmd/add.rs index 302ae0a6..7322c599 100644 --- a/src/cmd/add.rs +++ b/src/cmd/add.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Result, bail}; @@ -18,10 +18,17 @@ impl Run for Add { let mut db = Database::open()?; - for path in &self.paths { + // Build a unified iterator of PathBufs, whether recursive or not. + let paths: Box> = if self.recursive { + Box::new(self.paths.iter().flat_map(|p| util::walk_dir(p))) + } else { + Box::new(self.paths.iter().cloned()) + }; + + for path in paths { let path = if config::resolve_symlinks() { util::canonicalize } else { util::resolve_path }( - path, + &path, )?; let path = util::path_to_str(&path)?; diff --git a/src/cmd/cmd.rs b/src/cmd/cmd.rs index 7359786c..89366849 100644 --- a/src/cmd/cmd.rs +++ b/src/cmd/cmd.rs @@ -63,6 +63,10 @@ pub struct Add { /// doesn't #[clap(short, long)] pub score: Option, + + /// Recursively add directories + #[clap(short, long, default_value_t = false)] + pub recursive: bool, } /// Edit the database @@ -201,4 +205,8 @@ pub struct Query { pub struct Remove { #[clap(value_hint = ValueHint::DirPath)] pub paths: Vec, + + /// Recursively remove directories + #[clap(short, long, default_value_t = false)] + pub recursive: bool, } diff --git a/src/cmd/edit.rs b/src/cmd/edit.rs index 0f37165c..89e0d125 100644 --- a/src/cmd/edit.rs +++ b/src/cmd/edit.rs @@ -17,7 +17,7 @@ impl Run for Edit { match cmd { EditCommand::Decrement { path } => db.add(path, -1.0, now), EditCommand::Delete { path } => { - db.remove(path); + db.remove(path, false); } EditCommand::Increment { path } => db.add(path, 1.0, now), EditCommand::Reload => {} diff --git a/src/cmd/remove.rs b/src/cmd/remove.rs index 85204eab..6b6e9227 100644 --- a/src/cmd/remove.rs +++ b/src/cmd/remove.rs @@ -9,10 +9,10 @@ impl Run for Remove { let mut db = Database::open()?; for path in &self.paths { - if !db.remove(path) { + if !db.remove(path, self.recursive) { let path_abs = util::resolve_path(path)?; let path_abs = util::path_to_str(&path_abs)?; - if path_abs == path || !db.remove(path_abs) { + if path_abs == path || !db.remove(path_abs, self.recursive) { bail!("path not found in database: {path}") } } diff --git a/src/db/mod.rs b/src/db/mod.rs index d459f39a..7c6cbd74 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -101,17 +101,35 @@ impl Database { } /// Removes the directory with `path` from the store. This does not preserve - /// ordering, but is O(1). - pub fn remove(&mut self, path: impl AsRef) -> bool { - match self.dirs().iter().position(|dir| dir.path == path.as_ref()) { - Some(idx) => { - self.swap_remove(idx); - true + /// ordering, but is O(1). If recursive, this will remove all directories + /// starting with `path`. This is O(n) + pub fn remove(&mut self, path: impl AsRef, recursive: bool) -> bool { + if recursive { + self.remove_recursive(path) + } else { + match self.dirs().iter().position(|dir| dir.path == path.as_ref()) { + Some(idx) => { + self.swap_remove(idx); + true + } + None => false, } - None => false, } } + pub fn remove_recursive(&mut self, path: impl AsRef) -> bool { + let mut removed = false; + self.with_dirs_mut(|dirs| { + dirs.retain(|dir| { + let keep = !dir.path.starts_with(path.as_ref()); + removed |= !keep; + keep + }); + }); + self.with_dirty_mut(|dirty| *dirty = removed); + removed + } + pub fn swap_remove(&mut self, idx: usize) { self.with_dirs_mut(|dirs| dirs.swap_remove(idx)); self.with_dirty_mut(|dirty| *dirty = true); @@ -273,14 +291,14 @@ mod tests { { let mut db = Database::open_dir(data_dir.path()).unwrap(); - assert!(db.remove(path)); + assert!(db.remove(path, false)); db.save().unwrap(); } { let mut db = Database::open_dir(data_dir.path()).unwrap(); assert!(db.dirs().is_empty()); - assert!(!db.remove(path)); + assert!(!db.remove(path, false)); db.save().unwrap(); } } diff --git a/src/util.rs b/src/util.rs index 996f61d3..46669245 100644 --- a/src/util.rs +++ b/src/util.rs @@ -9,6 +9,7 @@ use std::{env, mem}; #[cfg(windows)] use anyhow::anyhow; use anyhow::{Context, Result, bail}; +use walkdir::WalkDir; use crate::db::{Dir, Epoch}; use crate::error::SilentExit; @@ -382,3 +383,12 @@ pub fn to_lowercase(s: impl AsRef) -> String { let s = s.as_ref(); if s.is_ascii() { s.to_ascii_lowercase() } else { s.to_lowercase() } } + +/// Recursively walk a directory and yield all directories. +pub fn walk_dir(path: &Path) -> impl Iterator { + WalkDir::new(path) + .into_iter() + .filter_map(|p| p.ok()) + .filter(|p| p.file_type().is_dir()) + .map(|p| p.into_path()) +}