diff --git a/contrib/completions/_zoxide b/contrib/completions/_zoxide index 655998f..a02230a 100644 --- a/contrib/completions/_zoxide +++ b/contrib/completions/_zoxide @@ -98,8 +98,8 @@ esac _arguments "${_arguments_options[@]}" : \ '-f+[Output format (json or csv)]:FORMAT:(json csv)' \ '--format=[Output format (json or csv)]:FORMAT:(json csv)' \ -'-o+[Output file path (default\: stdout)]:OUT:_files' \ -'--out=[Output file path (default\: stdout)]:OUT:_files' \ +'-o+[Output file path]:OUT:_files' \ +'--out=[Output file path]:OUT:_files' \ '-h[Print help]' \ '--help[Print help]' \ '-V[Print version]' \ diff --git a/contrib/completions/_zoxide.ps1 b/contrib/completions/_zoxide.ps1 index 1f70e8d..f11b6c1 100644 --- a/contrib/completions/_zoxide.ps1 +++ b/contrib/completions/_zoxide.ps1 @@ -85,8 +85,8 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock { 'zoxide;export' { [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'Output format (json or csv)') [CompletionResult]::new('--format', '--format', [CompletionResultType]::ParameterName, 'Output format (json or csv)') - [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'Output file path (default: stdout)') - [CompletionResult]::new('--out', '--out', [CompletionResultType]::ParameterName, 'Output file path (default: stdout)') + [CompletionResult]::new('-o', '-o', [CompletionResultType]::ParameterName, 'Output file path') + [CompletionResult]::new('--out', '--out', [CompletionResultType]::ParameterName, 'Output file path') [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.elv b/contrib/completions/zoxide.elv index 6fac0cf..3b5737e 100644 --- a/contrib/completions/zoxide.elv +++ b/contrib/completions/zoxide.elv @@ -75,8 +75,8 @@ set edit:completion:arg-completer[zoxide] = {|@words| &'zoxide;export'= { cand -f 'Output format (json or csv)' cand --format 'Output format (json or csv)' - cand -o 'Output file path (default: stdout)' - cand --out 'Output file path (default: stdout)' + cand -o 'Output file path' + cand --out 'Output file path' 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 8c8e396..2efbc6b 100644 --- a/contrib/completions/zoxide.fish +++ b/contrib/completions/zoxide.fish @@ -52,7 +52,7 @@ complete -c zoxide -n "__fish_zoxide_using_subcommand edit; and __fish_seen_subc complete -c zoxide -n "__fish_zoxide_using_subcommand edit; and __fish_seen_subcommand_from reload" -s V -l version -d 'Print version' complete -c zoxide -n "__fish_zoxide_using_subcommand export" -s f -l format -d 'Output format (json or csv)' -r -f -a "json\t'' csv\t''" -complete -c zoxide -n "__fish_zoxide_using_subcommand export" -s o -l out -d 'Output file path (default: stdout)' -r -F +complete -c zoxide -n "__fish_zoxide_using_subcommand export" -s o -l out -d 'Output file path' -r -F complete -c zoxide -n "__fish_zoxide_using_subcommand export" -s h -l help -d 'Print help' complete -c zoxide -n "__fish_zoxide_using_subcommand export" -s V -l version -d 'Print version' complete -c zoxide -n "__fish_zoxide_using_subcommand import" -l from -d 'Application to import from' -r -f -a "autojump\t'' diff --git a/contrib/completions/zoxide.nu b/contrib/completions/zoxide.nu index a3916d1..9f2c254 100644 --- a/contrib/completions/zoxide.nu +++ b/contrib/completions/zoxide.nu @@ -50,7 +50,7 @@ module completions { # Export entries from the database export extern "zoxide export" [ --format(-f): string@"nu-complete zoxide export format" # Output format (json or csv) - --out(-o): path # Output file path (default: stdout) + --out(-o): path # Output file path --help(-h) # Print help --version(-V) # Print version ] diff --git a/contrib/completions/zoxide.ts b/contrib/completions/zoxide.ts index e8bae34..d0c7747 100644 --- a/contrib/completions/zoxide.ts +++ b/contrib/completions/zoxide.ts @@ -129,11 +129,10 @@ const completion: Fig.Spec = { }, { name: ["-o", "--out"], - description: "Output file path (default: stdout)", + description: "Output file path", isRepeatable: true, args: { name: "out", - isOptional: true, template: "filepaths", }, }, diff --git a/src/cmd/cmd.rs b/src/cmd/cmd.rs index f2d6a18..0a2e5c2 100644 --- a/src/cmd/cmd.rs +++ b/src/cmd/cmd.rs @@ -100,9 +100,9 @@ pub struct Export { #[clap(value_enum, long, short)] pub format: ExportFormat, - /// Output file path (default: stdout) + /// Output file path #[clap(long, short, value_hint = ValueHint::FilePath)] - pub out: Option, + pub out: PathBuf, } #[derive(ValueEnum, Clone, Debug)] diff --git a/src/cmd/export.rs b/src/cmd/export.rs index 430cae9..26851a3 100644 --- a/src/cmd/export.rs +++ b/src/cmd/export.rs @@ -1,5 +1,4 @@ use std::fs; -use std::io::{self, Write}; use std::path::Path; use anyhow::{Context, Result}; @@ -17,6 +16,8 @@ impl Run for Export { .context("could not serialize to JSON")?, ExportFormat::Csv => { let mut wtr = csv::Writer::from_writer(Vec::new()); + wtr.write_record(["path", "rank", "last_accessed"]) + .context("could not write CSV header")?; for dir in dirs { wtr.write_record([&*dir.path, &dir.rank.to_string(), &dir.last_accessed.to_string()]) .context("could not write CSV record")?; @@ -27,16 +28,8 @@ impl Run for Export { } }; - match &self.out { - Some(path) => { - write_to_file(path, &output) - .with_context(|| format!("could not write to file: {}", path.display()))?; - } - None => { - writeln!(io::stdout(), "{output}") - .context("could not write to stdout")?; - } - } + write_to_file(&self.out, &output) + .with_context(|| format!("could not write to file: {}", self.out.display()))?; Ok(()) } @@ -54,3 +47,116 @@ fn write_to_file(path: impl AsRef, content: &str) -> Result<()> { .with_context(|| format!("could not write to file: {}", path.display()))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Dir; + + fn create_test_db() -> tempfile::TempDir { + let data_dir = tempfile::tempdir().unwrap(); + let mut db = Database::open_dir(data_dir.path()).unwrap(); + for (path, rank, last_accessed) in [ + ("/home/alice/projects/zoxide", 42.5, 1714000000), + ("/home/alice/downloads", 7.0, 1713000000), + (r#"/tmp"quotes,commas""#, 1.0, 1712000000), + ] { + db.add_unchecked(path, rank, last_accessed); + } + db.save().unwrap(); + data_dir + } + + fn set_data_dir_env(data_dir: &tempfile::TempDir) { + unsafe { + std::env::set_var("_ZO_DATA_DIR", data_dir.path()); + } + } + + #[test] + fn export_json() { + let data_dir = create_test_db(); + set_data_dir_env(&data_dir); + + let out_file = data_dir.path().join("export.json"); + let export = Export { + format: ExportFormat::Json, + out: out_file.clone(), + }; + export.run().unwrap(); + + let content = fs::read_to_string(&out_file).unwrap(); + let result: Vec = serde_json::from_str(&content).unwrap(); + + assert_eq!(result.len(), 3); + assert!(result.iter().any(|d| d.path == "/home/alice/projects/zoxide")); + assert!(result.iter().any(|d| d.path == "/home/alice/downloads")); + assert!(result.iter().any(|d| d.path == r#"/tmp"quotes,commas""#)); + } + + #[test] + fn export_csv() { + let data_dir = create_test_db(); + set_data_dir_env(&data_dir); + + let out_file = data_dir.path().join("export.csv"); + let export = Export { + format: ExportFormat::Csv, + out: out_file.clone(), + }; + export.run().unwrap(); + + let content = fs::read_to_string(&out_file).unwrap(); + let mut rdr = csv::Reader::from_reader(content.as_bytes()); + + let headers = rdr.headers().unwrap(); + assert_eq!(headers, ["path", "rank", "last_accessed"].as_slice()); + + let records: Vec = rdr.records().map(|r| r.unwrap()).collect(); + assert_eq!(records.len(), 3); + + let paths: Vec<&str> = records.iter().map(|r| r.get(0).unwrap()).collect(); + assert!(paths.contains(&"/home/alice/projects/zoxide")); + assert!(paths.contains(&"/home/alice/downloads")); + assert!(paths.contains(&r#"/tmp"quotes,commas""#)); + } + + #[test] + fn export_csv_with_special_chars() { + let data_dir = create_test_db(); + set_data_dir_env(&data_dir); + + let out_file = data_dir.path().join("export.csv"); + let export = Export { + format: ExportFormat::Csv, + out: out_file.clone(), + }; + export.run().unwrap(); + + let content = fs::read_to_string(&out_file).unwrap(); + assert!(content.contains(r#""""#)); + assert!(content.contains(r#"/tmp""#)); + + let mut rdr = csv::Reader::from_reader(content.as_bytes()); + let records: Vec = rdr.records().map(|r| r.unwrap()).collect(); + + let special_record = records.iter().find(|r| r.get(0).unwrap().contains("quotes")); + assert!(special_record.is_some()); + assert_eq!(special_record.unwrap().get(0).unwrap(), r#"/tmp"quotes,commas""#); + } + + #[test] + fn export_creates_parent_directories() { + let data_dir = create_test_db(); + set_data_dir_env(&data_dir); + + let out_file = data_dir.path().join("nested").join("path").join("export.json"); + let export = Export { + format: ExportFormat::Json, + out: out_file.clone(), + }; + export.run().unwrap(); + + assert!(out_file.exists()); + } +}