From 635a97e812134827e4b0f32e6103fdb1a8f455ce Mon Sep 17 00:00:00 2001 From: James Falcon Date: Wed, 21 Sep 2022 23:26:18 -0500 Subject: [PATCH] Feature: zoxide edit subcommand Allows editing on the backing db by opening up $EDITOR with a human-editable format. Upon exit of the editor, changes are validated and written back to the DB. Fixes: #453 --- Cargo.lock | 54 ++++++++++ Cargo.toml | 3 +- contrib/completions/_zoxide | 14 +++ contrib/completions/_zoxide.ps1 | 8 ++ contrib/completions/zoxide.bash | 19 +++- contrib/completions/zoxide.elv | 7 ++ contrib/completions/zoxide.fish | 3 + contrib/completions/zoxide.ts | 14 +++ src/cmd/cmd.rs | 5 + src/cmd/edit.rs | 181 ++++++++++++++++++++++++++++++++ src/cmd/mod.rs | 2 + src/db/mod.rs | 12 ++- src/util.rs | 2 +- 13 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 src/cmd/edit.rs diff --git a/Cargo.lock b/Cargo.lock index 18ae546..4b9cac9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,20 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "console" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89eab4d20ce20cea182308bca13088fecea9c05f6776cf287205d41a0ed3c847" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "terminal_size", + "unicode-width", + "winapi", +] + [[package]] name = "crossbeam-utils" version = "0.8.11" @@ -191,6 +205,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "dialoguer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92e7e37ecef6857fdc0c0c5d42fd5b0938e46590c2183cc92dd310a6d078eb1" +dependencies = [ + "console", + "tempfile", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" @@ -235,6 +260,12 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "fastrand" version = "1.8.0" @@ -674,6 +705,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "termtree" version = "0.2.4" @@ -730,6 +771,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" +[[package]] +name = "unicode-width" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" + [[package]] name = "version_check" version = "0.9.4" @@ -814,6 +861,12 @@ dependencies = [ "shell-words", ] +[[package]] +name = "zeroize" +version = "1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c394b5bd0c6f669e7275d9c20aa90ae064cb22e75a1cad54e1b34088034b149f" + [[package]] name = "zoxide" version = "0.8.3" @@ -825,6 +878,7 @@ dependencies = [ "clap", "clap_complete", "clap_complete_fig", + "dialoguer", "dirs", "dunce", "fastrand", diff --git a/Cargo.toml b/Cargo.toml index 8c9c349..badce44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,11 +23,13 @@ anyhow = "1.0.32" askama = { version = "0.11.0", default-features = false } bincode = "1.3.1" clap = { version = "3.1.0", features = ["derive"] } +dialoguer = "0.10.2" dirs = "4.0.0" dunce = "1.0.1" fastrand = "1.7.0" glob = "0.3.0" serde = { version = "1.0.116", features = ["derive"] } +tempfile = "3.1.0" [target.'cfg(unix)'.dependencies] nix = { version = "0.24.1", default-features = false, features = [ @@ -47,7 +49,6 @@ clap_complete_fig = "3.1.0" assert_cmd = "2.0.0" rstest = { version = "0.15.0", default-features = false } rstest_reuse = "0.4.0" -tempfile = "3.1.0" [features] default = [] diff --git a/contrib/completions/_zoxide b/contrib/completions/_zoxide index 312adb6..08290ff 100644 --- a/contrib/completions/_zoxide +++ b/contrib/completions/_zoxide @@ -37,6 +37,14 @@ _arguments "${_arguments_options[@]}" \ '*::paths:_files -/' \ && ret=0 ;; +(edit) +_arguments "${_arguments_options[@]}" \ +'-h[Print help information]' \ +'--help[Print help information]' \ +'-V[Print version information]' \ +'--version[Print version information]' \ +&& ret=0 +;; (import) _arguments "${_arguments_options[@]}" \ '--from=[Application to import from]:FROM:(autojump z)' \ @@ -97,6 +105,7 @@ esac _zoxide_commands() { local commands; commands=( 'add:Add a new directory or increment its rank' \ +'edit:Modify list of paths and rankings in default editor' \ 'import:Import entries from another application' \ 'init:Generate shell configuration' \ 'query:Search for a directory in the database' \ @@ -109,6 +118,11 @@ _zoxide__add_commands() { local commands; commands=() _describe -t commands 'zoxide add commands' commands "$@" } +(( $+functions[_zoxide__edit_commands] )) || +_zoxide__edit_commands() { + local commands; commands=() + _describe -t commands 'zoxide edit commands' commands "$@" +} (( $+functions[_zoxide__import_commands] )) || _zoxide__import_commands() { local commands; commands=() diff --git a/contrib/completions/_zoxide.ps1 b/contrib/completions/_zoxide.ps1 index f1e0571..39a6647 100644 --- a/contrib/completions/_zoxide.ps1 +++ b/contrib/completions/_zoxide.ps1 @@ -26,6 +26,7 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { [CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version information') [CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version information') [CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add a new directory or increment its rank') + [CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Modify list of paths and rankings in default editor') [CompletionResult]::new('import', 'import', [CompletionResultType]::ParameterValue, 'Import entries from another application') [CompletionResult]::new('init', 'init', [CompletionResultType]::ParameterValue, 'Generate shell configuration') [CompletionResult]::new('query', 'query', [CompletionResultType]::ParameterValue, 'Search for a directory in the database') @@ -39,6 +40,13 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { [CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version information') break } + 'zoxide;edit' { + [CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help information') + [CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help information') + [CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version information') + [CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version information') + break + } 'zoxide;import' { [CompletionResult]::new('--from', 'from', [CompletionResultType]::ParameterName, 'Application to import from') [CompletionResult]::new('--merge', 'merge', [CompletionResultType]::ParameterName, 'Merge into existing database') diff --git a/contrib/completions/zoxide.bash b/contrib/completions/zoxide.bash index 03ad7a4..11751a4 100644 --- a/contrib/completions/zoxide.bash +++ b/contrib/completions/zoxide.bash @@ -15,6 +15,9 @@ _zoxide() { add) cmd+="__add" ;; + edit) + cmd+="__edit" + ;; import) cmd+="__import" ;; @@ -34,7 +37,7 @@ _zoxide() { case "${cmd}" in zoxide) - opts="-h -V --help --version add import init query remove" + opts="-h -V --help --version add edit import init query remove" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -61,6 +64,20 @@ _zoxide() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + zoxide__edit) + opts="-h -V --help --version" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; zoxide__import) opts="-h -V --from --merge --help --version " if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/contrib/completions/zoxide.elv b/contrib/completions/zoxide.elv index 2e98e78..8a2f82a 100644 --- a/contrib/completions/zoxide.elv +++ b/contrib/completions/zoxide.elv @@ -23,6 +23,7 @@ set edit:completion:arg-completer[zoxide] = {|@words| cand -V 'Print version information' cand --version 'Print version information' cand add 'Add a new directory or increment its rank' + cand edit 'Modify list of paths and rankings in default editor' cand import 'Import entries from another application' cand init 'Generate shell configuration' cand query 'Search for a directory in the database' @@ -34,6 +35,12 @@ set edit:completion:arg-completer[zoxide] = {|@words| cand -V 'Print version information' cand --version 'Print version information' } + &'zoxide;edit'= { + cand -h 'Print help information' + cand --help 'Print help information' + cand -V 'Print version information' + cand --version 'Print version information' + } &'zoxide;import'= { cand --from 'Application to import from' cand --merge 'Merge into existing database' diff --git a/contrib/completions/zoxide.fish b/contrib/completions/zoxide.fish index 16bf84a..8efc9bd 100644 --- a/contrib/completions/zoxide.fish +++ b/contrib/completions/zoxide.fish @@ -1,12 +1,15 @@ complete -c zoxide -n "__fish_use_subcommand" -s h -l help -d 'Print help information' complete -c zoxide -n "__fish_use_subcommand" -s V -l version -d 'Print version information' complete -c zoxide -n "__fish_use_subcommand" -f -a "add" -d 'Add a new directory or increment its rank' +complete -c zoxide -n "__fish_use_subcommand" -f -a "edit" -d 'Modify list of paths and rankings in default editor' complete -c zoxide -n "__fish_use_subcommand" -f -a "import" -d 'Import entries from another application' complete -c zoxide -n "__fish_use_subcommand" -f -a "init" -d 'Generate shell configuration' complete -c zoxide -n "__fish_use_subcommand" -f -a "query" -d 'Search for a directory in the database' complete -c zoxide -n "__fish_use_subcommand" -f -a "remove" -d 'Remove a directory from the database' complete -c zoxide -n "__fish_seen_subcommand_from add" -s h -l help -d 'Print help information' complete -c zoxide -n "__fish_seen_subcommand_from add" -s V -l version -d 'Print version information' +complete -c zoxide -n "__fish_seen_subcommand_from edit" -s h -l help -d 'Print help information' +complete -c zoxide -n "__fish_seen_subcommand_from edit" -s V -l version -d 'Print version information' complete -c zoxide -n "__fish_seen_subcommand_from import" -l from -d 'Application to import from' -r -f -a "{autojump ,z }" complete -c zoxide -n "__fish_seen_subcommand_from import" -l merge -d 'Merge into existing database' complete -c zoxide -n "__fish_seen_subcommand_from import" -s h -l help -d 'Print help information' diff --git a/contrib/completions/zoxide.ts b/contrib/completions/zoxide.ts index fe986b7..3237f30 100644 --- a/contrib/completions/zoxide.ts +++ b/contrib/completions/zoxide.ts @@ -21,6 +21,20 @@ const completion: Fig.Spec = { template: "folders", }, }, + { + name: "edit", + description: "Modify list of paths and rankings in default editor", + options: [ + { + name: ["-h", "--help"], + description: "Print help information", + }, + { + name: ["-V", "--version"], + description: "Print version information", + }, + ], + }, { name: "import", description: "Import entries from another application", diff --git a/src/cmd/cmd.rs b/src/cmd/cmd.rs index 59775c6..bc18cdd 100644 --- a/src/cmd/cmd.rs +++ b/src/cmd/cmd.rs @@ -24,6 +24,7 @@ const ENV_HELP: &str = "ENVIRONMENT VARIABLES: )] pub enum Cmd { Add(Add), + Edit(Edit), Import(Import), Init(Init), Query(Query), @@ -37,6 +38,10 @@ pub struct Add { pub paths: Vec, } +/// Modify list of paths and rankings in default editor +#[derive(Debug, Parser)] +pub struct Edit {} + /// Import entries from another application #[derive(Debug, Parser)] pub struct Import { diff --git a/src/cmd/edit.rs b/src/cmd/edit.rs new file mode 100644 index 0000000..eecdb22 --- /dev/null +++ b/src/cmd/edit.rs @@ -0,0 +1,181 @@ +use crate::cmd::{Edit, Run}; +use crate::db::{db_path, Database, DatabaseFile, Epoch, Rank}; +use crate::util::{rename, resolve_path}; +use crate::{config, util}; +use anyhow::Result; +use std::fmt::Write as FmtWrite; +use std::path::PathBuf; + +use core::mem; +use dialoguer::{Editor, Input}; +use tempfile::tempdir; + +const HEADER: &str = "\ +# Blank lines and lines prepended with '#' are ignored; Line order is insignificant +# last_accessed,rank,path +"; + +enum ValidationResult { + Success, + Retry, + Exit, +} + +impl Run for Edit { + fn run(&self) -> Result<()> { + let temp_dir = tempdir()?; + let temp_dir_path = temp_dir.path(); + while let Some(db_edits) = get_db_edits()? { + let mut db_file = DatabaseFile::new(temp_dir_path); + let mut db = db_file.open()?; + let result = validate_db(&mut db, db_edits); + match result { + ValidationResult::Success => { + db.save()?; + mem::drop(db); + mem::drop(db_file); + rename(db_path(temp_dir_path), db_path(config::data_dir()?))?; + return Ok(()); + } + ValidationResult::Exit => break, + ValidationResult::Retry => continue, + } + } + println!("Zoxide database not altered"); + Ok(()) + } +} + +fn get_db_edits() -> Result> { + let data_dir = config::data_dir()?; + let mut db = DatabaseFile::new(data_dir); + let mut db = db.open()?; + let mut stream = db.stream(util::current_time().unwrap()); + let mut to_edit = String::from(HEADER); + while let Some(dir) = stream.next() { + writeln!(&mut to_edit, "{},{},{}", dir.last_accessed, dir.rank, dir.path)?; + } + Ok(Editor::new().edit(&to_edit)?) +} + +fn validate_db(db: &mut Database, db_edits: String) -> ValidationResult { + let lines = db_edits.lines(); + + let mut errors: Vec<(usize, String)> = Vec::new(); + let mut warnings: Vec<(usize, String)> = Vec::new(); + + for (index, line) in lines.enumerate() { + let line_number = index + 1; + let first_char = line.trim().chars().next(); + if let Some(first_char) = first_char { + if first_char == '#' { + continue; + } + } else { + continue; + } + let mut split = line.split(','); + let (last_accessed_txt, rank_txt, path_txt) = (split.next(), split.next(), split.next()); + if split.next().is_some() { + errors.push((line_number, "Too many values on line".to_string())); + } + + let last_accessed: Option = match last_accessed_txt { + Some(value) => match value.trim().parse::() { + Ok(value) => Some(value), + Err(e) => { + errors.push((line_number, e.to_string())); + None + } + }, + None => { + errors.push((line_number, "Cannot parse 'last_accessed' field".to_string())); + None + } + }; + + let rank: Option = match rank_txt { + Some(value) => match value.trim().parse::() { + Ok(value) => Some(value), + Err(e) => { + errors.push((line_number, e.to_string())); + None + } + }, + None => { + errors.push((line_number, "Cannot parse 'rank' field".to_string())); + None + } + }; + + let path: Option = match path_txt { + Some(value) => { + if value.trim() != value { + warnings.push((line_number, "path contains trailing whitespace".to_string())); + } + match resolve_path(&PathBuf::from(value)) { + Ok(v) => { + if v.to_str().unwrap() != value { + errors.push((line_number, "path must be an absolute path".to_string())); + } + Some(value.to_string()) + } + Err(e) => { + errors.push((line_number, e.to_string())); + None + } + } + } + None => { + errors.push((line_number, "Cannot parse 'path' field".to_string())); + None + } + }; + + if let (Some(path), Some(last_accessed), Some(rank)) = (path, last_accessed, rank) { + db.add_raw(&path, last_accessed, rank); + } + } + let has_warnings = !warnings.is_empty(); + let has_errors = !errors.is_empty(); + if has_warnings { + println!("Warnings:"); + for (line_num, warning) in warnings { + println!("{line_num}: {warning}"); + } + println!(); + } + if has_errors { + println!("Errors:"); + for (line_num, error) in errors { + println!("line {line_num}: {error}"); + } + println!(); + } + if has_warnings || has_errors { + println!("You may:"); + println!("(e)dit the file again"); + println!("e(x)it without saving changes"); + if !has_errors { + println!("(s)ave changes and exit (DANGER!)"); + } + let selection = Input::new() + .with_prompt("Choice") + .validate_with(|input: &String| -> Result<(), &str> { + if input == "e" || input == "x" || (input == "s" && !has_errors) { + Ok(()) + } else { + Err("Invalid selection.") + } + }) + .interact() + .unwrap(); + return match selection.as_str() { + "e" => ValidationResult::Retry, + "s" => ValidationResult::Success, + "x" => ValidationResult::Exit, + i => panic!("Expected 'e', 's', or 'x'. Received {i}"), // We already validated input above + }; + } + ValidationResult::Success +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 876a6aa..5c17474 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,5 +1,6 @@ mod add; mod cmd; +mod edit; mod import; mod init; mod query; @@ -17,6 +18,7 @@ impl Run for Cmd { fn run(&self) -> Result<()> { match self { Cmd::Add(cmd) => cmd.run(), + Cmd::Edit(cmd) => cmd.run(), Cmd::Import(cmd) => cmd.run(), Cmd::Init(cmd) => cmd.run(), Cmd::Query(cmd) => cmd.run(), diff --git a/src/db/mod.rs b/src/db/mod.rs index cf9b54a..0d120bb 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -32,15 +32,19 @@ impl<'file> Database<'file> { /// Adds a new directory or increments its rank. Also updates its last accessed time. pub fn add>(&mut self, path: S, now: Epoch) { + self.add_raw(path, now, 1.0) + } + + pub fn add_raw>(&mut self, path: S, last_accessed: Epoch, rank: Rank) { let path = path.as_ref(); match self.dirs.iter_mut().find(|dir| dir.path == path) { None => { - self.dirs.push(Dir { path: path.to_string().into(), last_accessed: now, rank: 1.0 }); + self.dirs.push(Dir { path: path.to_string().into(), last_accessed, rank }); } Some(dir) => { - dir.last_accessed = now; - dir.rank += 1.0; + dir.last_accessed = last_accessed; + dir.rank += rank; } }; @@ -140,7 +144,7 @@ impl DatabaseFile { } } -fn db_path>(data_dir: P) -> PathBuf { +pub fn db_path>(data_dir: P) -> PathBuf { const DB_FILENAME: &str = "db.zo"; data_dir.as_ref().join(DB_FILENAME) } diff --git a/src/util.rs b/src/util.rs index d73e2cd..9bdd466 100644 --- a/src/util.rs +++ b/src/util.rs @@ -160,7 +160,7 @@ fn tmpfile>(dir: P) -> Result<(File, PathBuf)> { } /// Similar to [`fs::rename`], but retries on Windows. -fn rename, Q: AsRef>(from: P, to: Q) -> Result<()> { +pub fn rename, Q: AsRef>(from: P, to: Q) -> Result<()> { const MAX_ATTEMPTS: usize = 5; let from = from.as_ref(); let to = to.as_ref();