diff --git a/.bin/update_version.sh b/.bin/update_version.sh index d6e9294..a4ba19f 100755 --- a/.bin/update_version.sh +++ b/.bin/update_version.sh @@ -17,10 +17,12 @@ fi echo "$PREV_VERSION -> $VERSION (${PREV_VERSION//-/--} -> ${VERSION//-/--})" -sed -e "s#Version-v${PREV_VERSION//-/--}-information#Version-v${VERSION//-/--}-information#g" \ - -e "s#tag/v${PREV_VERSION}#tag/v${VERSION}#g" \ - -e "s#crates.io-v${PREV_VERSION}#crates.io-v${VERSION}#g" \ - README.md > a; mv a README.md +for i in README.md docs/content/_index.md ; do + sed -e "s#Version-v${PREV_VERSION//-/--}-information#Version-v${VERSION//-/--}-information#g" \ + -e "s#tag/v${PREV_VERSION}#tag/v${VERSION}#g" \ + -e "s#crates.io-v${PREV_VERSION}#crates.io-v${VERSION}#g" \ + $i > a; mv a $i +done sed -e "s#version = \".*\"#version = \"${VERSION}\"#g" docs/config.toml > a ; mv a docs/config.toml sed "s/^version = \".*\"/version = \"${VERSION}\"/g" Cargo.toml > a && mv a Cargo.toml diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index f2c9e85..2bef074 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -6,6 +6,9 @@ on: - main types: [closed] +permissions: + contents: write + jobs: setup: runs-on: ubuntu-latest diff --git a/.gitmodules b/.gitmodules index a79acc7..a92e211 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "themes/cayman-hugo-theme"] path = themes/cayman-hugo-theme url = https://github.com/zwbetz-gh/cayman-hugo-theme.git -[submodule "docs/themes/cayman-hugo-theme"] - path = docs/themes/cayman-hugo-theme - url = https://github.com/zwbetz-gh/cayman-hugo-theme.git diff --git a/Cargo.toml b/Cargo.toml index 0142b2a..db57b60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = [ "lib", "cli" ] resolver = "3" [workspace.package] -version = "2.0.4" +version = "2.0.5" repository = "https://github.com/tamada/sibling" homepage = "https://tamada.github.io/sibling" readme = "README.md" @@ -13,31 +13,4 @@ authors = [ ] license-file = "LICENSE" categories = ["command-line-utilities"] -edition = "2021" - -# Config for 'cargo dist' -[workspace.metadata.dist] -# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.19.1" -# CI backends to support -ci = "github" -# The installers to generate for each app -installers = ["shell", "powershell", "npm", "homebrew", "msi"] -# A GitHub repo to push Homebrew formulas to -tap = "tamada/homebrew-tap" -# Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] -# The archive format to use for windows builds (defaults .zip) -windows-archive = ".tar.gz" -# The archive format to use for non-windows builds (defaults .tar.xz) -unix-archive = ".tar.gz" -# A namespace to use when publishing this package to the npm registry -npm-scope = "@htamada" -# Path that installers should place binaries in -install-path = "CARGO_HOME" -# Publish jobs to run in CI -publish-jobs = ["homebrew"] -# Publish jobs to run in CI -pr-run-mode = "plan" -# Whether to install an updater program -install-updater = false +edition = "2024" diff --git a/README.md b/README.md index 8c6c4b8..ec1e7da 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # sibling +[![crates.io](https://img.shields.io/badge/crates.io-v2.0.5-orange.svg?logo=rust)](https://crates.io/crates/sibling) +[![License](https://img.shields.io/badge/License-WTFPL-information.svg)](https://github.com/tamada/sibling/blob/master/LICENSE) +[![Version](https://img.shields.io/badge/Version-v2.0.5-information.svg)](https://github.com/tamada/sibling/releases/tag/v2.0.5) + [![build](https://github.com/tamada/sibling/actions/workflows/build.yaml/badge.svg)](https://github.com/tamada/sibling/actions/workflows/build.yaml) [![Coverage Status](https://coveralls.io/repos/github/tamada/sibling/badge.svg?branch=main)](https://coveralls.io/github/tamada/sibling?branch=main) - [![Rust Report Card](https://rust-reportcard.xuri.me/badge/github.com/tamada/sibling)](https://rust-reportcard.xuri.me/report/github.com/tamada/sibling) -[![crates.io](https://img.shields.io/badge/crates.io-v2.0.4-orange.svg?logo=rust)](https://crates.io/crates/sibling) -[![License](https://img.shields.io/badge/License-WTFPL-information.svg)](https://github.com/tamada/sibling/blob/master/LICENSE) -[![Version](https://img.shields.io/badge/Version-v2.0.4-information.svg)](https://github.com/tamada/sibling/releases/tag/v2.0.4) - get the next/previous sibling directory name. ## :speaking_head: Description diff --git a/assets/init/init.bash b/assets/init/init.bash index c345903..31f2a41 100644 --- a/assets/init/init.bash +++ b/assets/init/init.bash @@ -1,26 +1,26 @@ -__change_directory_to_sibling() { +__change_directory_with_sibling() { traversing_type="$1" if [[ "$traversing_type" == "" ]]; then traversing_type="next" + else + shift fi - step=1 - if [[ $# -eq 2 ]]; then - step=$2 - fi - result=$(sibling --type $traversing_type --csv --step $step) + eval "$(sibling minisib $traversing_type $@ | { + read -r next; echo "next='$next';" + read -r length; echo "length='$length';" + read -r current; echo "current='$current';" + read -r last_flag; echo "last_flag='$last_flag';" + })" sibling_status=$? - echo $result | while IFS=, read current next ci ni total - do - # echo "Current: $current, Next: $next, ci: $ci, ni: $ni, total: $total" - if [[ $sibling_status -eq 0 ]] ; then - # strip the first and last double quotes - cd "$(echo $next | sed -e 's/^"//' -e 's/"$//')" - echo "$PWD ($ci/$total)" - else - echo "Done ($ci/$total)" - cd .. - fi - done + # echo "Current: $current, Next: $next, ci: $ci, ni: $ni, total: $total" + if [[ $sibling_status -eq 0 ]] ; then + # strip the first and last double quotes + cd -- "$next" + echo "$PWD (${current}/${length})" + else + echo "Done (${current}/${length})" + cd .. + fi return $sibling_status } @@ -84,22 +84,21 @@ sibling_fzf() { } cdfirst() { - __change_directory_to_sibling first + __change_directory_with_sibling first $@ } cdlast() { - __change_directory_to_sibling last + __change_directory_with_sibling last $@ } cdnext() { - __change_directory_to_sibling next $@ + __change_directory_with_sibling next $@ } cdprev() { - __change_directory_to_sibling previous $@ + __change_directory_with_sibling previous $@ } cdrand() { - __change_directory_to_sibling random + __change_directory_with_sibling random $@ } - diff --git a/cli/src/cli.rs b/cli/src/cli.rs index bd2af7b..e161635 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use clap::{Parser, ValueEnum}; -use crate::LogLevel; +use crate::{LogLevel, minisib}; #[derive(Debug, Parser)] #[clap(version, author, about, arg_required_else_help = true)] @@ -10,15 +10,59 @@ pub struct CliOpts { #[clap(flatten)] pub(crate) p_opts: PrintingOpts, + #[clap(flatten)] + pub(crate) nexter_opts: NexterOpts, + + #[clap(flatten)] + pub(crate) init_script: InitOpts, + + #[clap(flatten)] + pub(crate) log_opts: LogOpts, + #[arg( - short, - long, - help = "specify the number of times to execute sibling", - value_name = "COUNT", - default_value_t = 1 + short = 'w', + long = "working-dir", + help = "set the current working directory.", + hide = true, + value_name = "DIR", + long_help = "This option is applied before any other processing. Therefore, other options that specify paths should use the relative path from this option value. If this option is not specified, the current directory is used." )] - pub step: usize, + pub(crate) cwd: Option, + #[arg(index = 1, help = "the target directory", value_name = "DIR")] + pub dirs: Vec, + + #[cfg(debug_assertions)] + #[clap(flatten)] + pub(crate) compopts: CompletionOpts, + + #[clap(subcommand)] + pub(crate) minisib: Option, +} + +impl CliOpts { + pub fn init(&mut self) { + self.log_opts.init(); + if let Some(cwd) = &self.cwd + && let Err(e) = std::env::set_current_dir(cwd) + { + log::error!("Failed to set current directory to {cwd:?}: {e}, use \".\""); + } + if self.dirs.is_empty() { + match std::env::current_dir() { + Ok(cwd) => self.dirs.push(cwd), + Err(e) => { + log::error!("Failed to get current directory: {e}"); + eprintln!("Error: failed to determine current working directory: {e}"); + std::process::exit(1); + } + } + } + } +} + +#[derive(Parser, Debug)] +pub(crate) struct LogOpts { #[arg( long, help = "set the log level", @@ -28,7 +72,29 @@ pub struct CliOpts { ignore_case = true )] pub log: LogLevel, +} + +impl LogOpts { + pub fn init(&self) { + use LogLevel::{Debug, Error, Info, Trace, Warn}; + if std::env::var_os("RUST_LOG").is_none() { + unsafe { + match self.log { + Error => std::env::set_var("RUST_LOG", "error"), + Warn => std::env::set_var("RUST_LOG", "warn"), + Info => std::env::set_var("RUST_LOG", "info"), + Debug => std::env::set_var("RUST_LOG", "debug"), + Trace => std::env::set_var("RUST_LOG", "trace"), + }; + } + } + env_logger::init(); + log::info!("Log level set to {:?}", self.log); + } +} +#[derive(Parser, Debug)] +pub(crate) struct InitOpts { #[arg( long, help = "generate the initialize script for the shell", @@ -37,6 +103,18 @@ pub struct CliOpts { default_missing_value = "bash" )] pub init: Option, +} + +#[derive(Parser, Debug)] +pub(crate) struct NexterOpts { + #[arg( + short, + long, + help = "specify the number of times to execute sibling", + value_name = "COUNT", + default_value_t = 1 + )] + pub step: i32, #[arg(short = 't', long = "type", help = "specify the nexter type", value_enum, default_value_t = sibling::NexterType::Next, value_name = "TYPE", ignore_case = true)] pub nexter: sibling::NexterType, @@ -49,12 +127,13 @@ pub struct CliOpts { )] pub input: Option, - #[arg(index = 1, help = "the target directory", value_name = "DIR")] - pub dirs: Vec, - - #[cfg(debug_assertions)] - #[clap(flatten)] - pub(crate) compopts: CompletionOpts, + #[arg( + short = 'a', + long, + help = "Set the targets to all directories from the given list. By default, the sibling skips non-existent directories.", + default_value_t = false + )] + pub all: bool, } #[cfg(debug_assertions)] @@ -96,7 +175,7 @@ pub(crate) struct PrintingOpts { pub format: Format, #[arg( - short, + short = 'A', long, help = "print the directory name in the absolute path", default_value_t = false diff --git a/cli/src/gencomp.rs b/cli/src/gencomp.rs index 8915f35..7288bd9 100644 --- a/cli/src/gencomp.rs +++ b/cli/src/gencomp.rs @@ -24,9 +24,21 @@ mod generator { app.set_bin_name(appname); generate_impl(Bash, &mut app, appname, outdir, format!("bash/{appname}")); - generate_impl(Elvish, &mut app, appname, outdir, format!("elvish/{appname}")); + generate_impl( + Elvish, + &mut app, + appname, + outdir, + format!("elvish/{appname}"), + ); generate_impl(Fish, &mut app, appname, outdir, format!("fish/{appname}")); - generate_impl(PowerShell, &mut app, appname, outdir, format!("powershell/{appname}")); + generate_impl( + PowerShell, + &mut app, + appname, + outdir, + format!("powershell/{appname}"), + ); generate_impl(Zsh, &mut app, appname, outdir, format!("zsh/_{appname}")); } } diff --git a/cli/src/init.rs b/cli/src/init.rs index 018ef4b..92154d2 100644 --- a/cli/src/init.rs +++ b/cli/src/init.rs @@ -19,3 +19,18 @@ pub(crate) fn generate_init_script(shell_name: &str) -> Result { None => Err(Error::NotFound(script_file.into())), } } + +#[cfg(test)] +mod tests { + #[test] + fn test_generate_init_script_with_bash() { + let script = super::generate_init_script("bash"); + assert!(script.is_ok()); + } + + #[test] + fn test_generate_init_script_with_unsupported_shell() { + let script = super::generate_init_script("fish"); + assert!(script.is_err()); + } +} \ No newline at end of file diff --git a/cli/src/main.rs b/cli/src/main.rs index fb791c7..2eb9de7 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -7,7 +7,8 @@ use sibling::{Dirs, Error, Nexter, Result}; mod cli; mod gencomp; mod init; -mod printer; +pub(crate) mod minisib; +pub(crate) mod printer; #[derive(clap::ValueEnum, Clone, Debug)] pub enum LogLevel { @@ -18,38 +19,23 @@ pub enum LogLevel { Trace, } -fn init_log(level: &LogLevel) { - use LogLevel::{Error, Warn, Info, Debug, Trace}; - if std::env::var_os("RUST_LOG").is_none() { - match level { - Error => std::env::set_var("RUST_LOG", "error"), - Warn => std::env::set_var("RUST_LOG", "warn"), - Info => std::env::set_var("RUST_LOG", "info"), - Debug => std::env::set_var("RUST_LOG", "debug"), - Trace => std::env::set_var("RUST_LOG", "trace"), - } - } - env_logger::init(); - log::info!("Log level set to {level:?}"); -} - -fn perform_impl( - dirs: &Dirs, - nexter: &dyn Nexter, - step: usize, - opts: &PrintingOpts, -) -> String { +fn perform_impl(dirs: &Dirs, nexter: &dyn Nexter, step: i32, opts: &PrintingOpts) -> String { let next = dirs.next_with(nexter, step); printer::result_string(dirs, next, opts) } fn perform_from_file(opts: CliOpts) -> Vec> { - let nexter = sibling::NexterFactory::create(opts.nexter); - let r = match opts.input { + let nexter = sibling::NexterFactory::create(opts.nexter_opts.nexter); + let r = match opts.nexter_opts.input { None => Err(Error::Fatal("input is not specified".into())), - Some(file) => match Dirs::new_from_file(file) { + Some(file) => match Dirs::new_from_file_with(file, opts.nexter_opts.all) { Err(e) => Err(e), - Ok(dirs) => Ok(perform_impl(&dirs, nexter.as_ref(), opts.step, &opts.p_opts)), + Ok(dirs) => Ok(perform_impl( + &dirs, + nexter.as_ref(), + opts.nexter_opts.step, + &opts.p_opts, + )), }, }; vec![r] @@ -58,7 +44,7 @@ fn perform_from_file(opts: CliOpts) -> Vec> { fn perform_each( dir: std::path::PathBuf, nexter: &dyn Nexter, - step: usize, + step: i32, opts: &PrintingOpts, ) -> Result { match Dirs::new(dir) { @@ -68,7 +54,7 @@ fn perform_each( } fn perform_sibling(opts: CliOpts) -> Vec> { - let nexter = sibling::NexterFactory::create(opts.nexter); + let nexter = sibling::NexterFactory::create(opts.nexter_opts.nexter); let target_dirs = if opts.dirs.is_empty() { vec![std::env::current_dir().unwrap()] } else { @@ -81,31 +67,27 @@ fn perform_sibling(opts: CliOpts) -> Vec> { } else { dir }; - let r = perform_each(dir, nexter.as_ref(), opts.step, &opts.p_opts); + let r = perform_each(dir, nexter.as_ref(), opts.nexter_opts.step, &opts.p_opts); result.push(r); } result } fn perform(opts: CliOpts) -> Vec> { - if let Some(shell) = opts.init { + if let Some(shell) = opts.init_script.init { vec![init::generate_init_script(&shell)] - } else if opts.input.is_some() { + } else if opts.nexter_opts.input.is_some() { perform_from_file(opts) + } else if let Some(minisib) = opts.minisib { + vec![minisib.perform()] } else { perform_sibling(opts) } } fn main() { - let mut args = std::env::args(); - let args = if args.len() == 1 { - vec![args.next().unwrap(), ".".into()] - } else { - args.collect() - }; - let opts = cli::CliOpts::parse_from(args); - init_log(&opts.log); + let mut opts = cli::CliOpts::parse(); + opts.init(); if cfg!(debug_assertions) { #[cfg(debug_assertions)] if opts.compopts.completion { diff --git a/cli/src/minisib.rs b/cli/src/minisib.rs new file mode 100644 index 0000000..49f2733 --- /dev/null +++ b/cli/src/minisib.rs @@ -0,0 +1,209 @@ +//! minisib: A minimal sibling command implementation +//! +//! This program is used for directory traversing in the shell scripts. +//! +//! ### Usage: +//! +//! ```text +//! minisib [NUM] [INPUT_FILE] +//! NEXTER_TYPE: next, previous, first, last, random, keep. +//! NUM: specify the number of times to execute sibling (default: 1) +//! -1 means minus one step, -2 means minus two steps, and +2 means plus two steps. +//! INPUT_FILE: file containing the list of directories (if not provided, uses current directory. +//! NUM and INPUT_FILE can be in any order. +//! ``` +//! +//! ### Output: +//! +//! The output of this subcommand consists of four lines: +//! +//! ```text +//! [NEXT_DIR_PATH or PARENT_DIR_PATH] +//! [TOTAL_NUMBER_OF_DIRECTORIES] +//! [INDEX_OF_NEXT_DIRECTORY (1-based, -1 if no next directory)] +//! [IS_LAST_ITEM (true/false)] +//! ``` +use clap::{Parser, Subcommand}; +use sibling::{Dirs, Error, NexterType, Result}; + +#[derive(Subcommand, Debug)] +pub(crate) enum MiniSibCommand { + #[command( + name = "minisib", + about = "Great Finding! A minimal sibling command implementation. This command is a helper utility for the shell. It assumes the user will not use it.", + hide = true + )] + MiniSib(RawOpts), +} + +impl MiniSibCommand { + pub(crate) fn perform(&self) -> Result { + match self { + MiniSibCommand::MiniSib(opts) => opts.parse().and_then(|item| item.perform()), + } + } +} + +#[derive(Parser, Debug)] +pub(crate) struct RawOpts { + #[arg(allow_hyphen_values = true)] + args: Vec, +} + +impl RawOpts { + pub(crate) fn parse(&self) -> Result { + if self.args.is_empty() { + Err(Error::Fatal("NEXTER_TYPE is required".into())) + } else if self.args.len() > 3 { + Err(Error::Fatal("Too many arguments".into())) + } else if self.args.len() == 1 { + let nexter = self.args.first().unwrap().parse::()?; + Ok(MiniSibOpts::new(nexter, 1, None)) + } else if self.args.len() == 2 { + let nexter = self.args.first().unwrap().parse::()?; + if let Ok(n) = self.args[1].parse::() { + let step = n; + Ok(MiniSibOpts::new(nexter, step, None)) + } else { + let file = self.args[1].as_str().to_string(); + Ok(MiniSibOpts::new(nexter, 1, Some(file))) + } + } else { + let nexter = self.args.first().unwrap().parse::()?; + if let Ok(n) = self.args[1].parse::() { + let step = n; + let file = Some(self.args[2].as_str().to_string()); + Ok(MiniSibOpts::new(nexter, step, file)) + } else if let Ok(n) = self.args[2].parse::() { + let step = n; + let file = Some(self.args[1].as_str().to_string()); + Ok(MiniSibOpts::new(nexter, step, file)) + } else { + Err(Error::Fatal("Invalid step argument".into())) + } + } + } +} + +pub(crate) struct MiniSibOpts { + pub(crate) nexter_type: NexterType, + pub(crate) step: i32, + pub(crate) file: Option, +} + +impl MiniSibOpts { + pub(crate) fn new(nexter_type: NexterType, step: i32, file: Option) -> Self { + MiniSibOpts { + nexter_type, + step, + file, + } + } + + pub(crate) fn perform(&self) -> Result { + let nexter = sibling::NexterFactory::create(self.nexter_type.clone()); + let target_dirs = if let Some(file) = &self.file { + Dirs::new_from_file(file)? + } else { + let cwd = std::env::current_dir().map_err(Error::Io)?; + Dirs::new(cwd)? + }; + if let Some(dir) = target_dirs.next_with(nexter.as_ref(), self.step) { + Ok(format!( + "{}\n{}\n{}\n{}", + crate::printer::path_to_string(Some(dir.path()), false, target_dirs.on_dirs()), + target_dirs.len(), + dir.index() + 1, + dir.is_last_item() + )) + } else { + Ok(format!( + "{}\n{}\n{}\n{}", + crate::printer::path_to_string( + Some(target_dirs.parent()), + false, + target_dirs.on_dirs() + ), + target_dirs.len(), + -1, + true + )) + } + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use crate::minisib::MiniSibCommand; + + #[test] + fn test_args_1() { + let args = vec!["sibling", "minisib", "previous", "-2", "dirs.txt"]; + let minisib = crate::cli::CliOpts::parse_from(&args).minisib; + assert!(minisib.is_some()); + let opts = if let Some(MiniSibCommand::MiniSib(opts)) = minisib { + opts.parse().unwrap() + } else { + panic!("Expected MiniSibCommand::MiniSib"); + }; + + assert_eq!(opts.step, -2); + assert_eq!(opts.nexter_type, super::NexterType::Previous); + assert_eq!(opts.file, Some("dirs.txt".into())); + } + + #[test] + fn test_args_2() { + let args = vec!["sibling", "minisib", "previous", "dirs.txt"]; + let minisib = crate::cli::CliOpts::parse_from(&args).minisib; + assert!(minisib.is_some()); + let opts = if let Some(MiniSibCommand::MiniSib(opts)) = minisib { + opts.parse().unwrap() + } else { + panic!("Expected MiniSibCommand::MiniSib"); + }; + assert_eq!(opts.step, 1); + assert_eq!(opts.nexter_type, super::NexterType::Previous); + assert_eq!(opts.file, Some("dirs.txt".into())); + } + + #[test] + fn test_args_3() { + let args = vec!["sibling", "minisib", "keep"]; + let minisib = crate::cli::CliOpts::parse_from(&args).minisib; + assert!(minisib.is_some()); + let opts = if let Some(MiniSibCommand::MiniSib(opts)) = minisib { + opts.parse().unwrap() + } else { + panic!("Expected MiniSibCommand::MiniSib"); + }; + assert_eq!(opts.step, 1); + assert_eq!(opts.nexter_type, super::NexterType::Keep); + assert!(opts.file.is_none()); + } + + #[test] + fn test_sibling() { + let args = vec![ + "sibling", + "--working-dir", + "../testdata/basic", + "minisib", + "first", + "1", + "dirlist.txt", + ]; + let mut cliopts = crate::cli::CliOpts::parse_from(&args); + cliopts.init(); + let result = cliopts.minisib.as_ref().unwrap().perform(); + assert!(result.is_ok()); + let output = result.unwrap(); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines[0], "a"); + assert_eq!(lines[1], "3"); + assert_eq!(lines[2], "1"); + assert_eq!(lines[3], "true"); + } +} diff --git a/cli/src/printer.rs b/cli/src/printer.rs index 4203350..99091e2 100644 --- a/cli/src/printer.rs +++ b/cli/src/printer.rs @@ -3,11 +3,7 @@ use std::path::Path; use crate::cli::{Format, PrintingOpts}; use sibling::{Dir, Dirs}; -pub(crate) fn result_string( - dirs: &Dirs, - next: Option>, - opts: &PrintingOpts, -) -> String { +pub(crate) fn result_string(dirs: &Dirs, next: Option>, opts: &PrintingOpts) -> String { match opts.format { Format::Json => json_string(dirs, next, opts.absolute), Format::Csv => csv_string(dirs, next, opts.absolute), @@ -18,16 +14,18 @@ pub(crate) fn result_string( } else { result_string_impl(dirs, next, opts) } - }, + } } } fn json_string(dirs: &Dirs, next: Option>, absolute: bool) -> String { let current = dirs.current(); - let next_path = next.as_ref().map(|n| pathbuf_to_string(Some(n.path()), absolute)); + let next_path = next + .as_ref() + .map(|n| path_to_string(Some(n.path()), absolute, dirs.on_dirs())); format!( r#"{{"current":{{"path":"{}","index":{}}},"next":{{"path":"{}","index":{}}},"total":{}}}"#, - pathbuf_to_string(Some(dirs.current().path()), absolute), + path_to_string(Some(dirs.current().path()), absolute, dirs.on_dirs()), current.index() + 1, next_path.unwrap_or_default(), next.map_or(-1, |n| i32::try_from(n.index()).unwrap() + 1), @@ -39,8 +37,8 @@ fn csv_string(dirs: &Dirs, next: Option>, absolute: bool) -> String { let current = dirs.current(); format!( r#""{}","{}",{},{},{}"#, - pathbuf_to_string(Some(dirs.current().path()), absolute), - pathbuf_to_string(next.as_ref().map(Dir::path), absolute), + path_to_string(Some(dirs.current().path()), absolute, dirs.on_dirs()), + path_to_string(next.as_ref().map(Dir::path), absolute, dirs.on_dirs()), current.index() + 1, next.map_or(-1, |n| i32::try_from(n.index()).unwrap() + 1), dirs.len() @@ -49,7 +47,7 @@ fn csv_string(dirs: &Dirs, next: Option>, absolute: bool) -> String { fn no_more_dir_string(dirs: &Dirs, opts: &PrintingOpts) -> String { if opts.parent { - pathbuf_to_string(Some(dirs.parent()), opts.absolute) + path_to_string(Some(dirs.parent()), opts.absolute, dirs.on_dirs()) } else { String::from("no more sibling directory") } @@ -71,34 +69,38 @@ fn list_string(dirs: &Dirs, next: Option<&Dir<'_>>, opts: &PrintingOpts) -> Stri "{:>4} {}{}", i + 1, prefix, - pathbuf_to_string(Some(dir), opts.absolute) + path_to_string(Some(dir), opts.absolute, dirs.on_dirs()) )); } result.join("\n") } fn result_string_impl(dirs: &Dirs, next: Option>, opts: &PrintingOpts) -> String { - let r = if opts.progress { + if opts.progress { format!( "{} ({}/{})", - pathbuf_to_string(next.as_ref().map(Dir::path), opts.absolute), + path_to_string(next.as_ref().map(Dir::path), opts.absolute, dirs.on_dirs()), next.map_or(-1, |n| i32::try_from(n.index()).unwrap()) + 1, dirs.len() ) } else { - pathbuf_to_string(next.as_ref().map(Dir::path), opts.absolute).to_string() - }; - r + path_to_string(next.as_ref().map(Dir::path), opts.absolute, dirs.on_dirs()).to_string() + } } -fn pathbuf_to_string(path: Option<&Path>, absolute: bool) -> String { +pub(crate) fn path_to_string(path: Option<&Path>, absolute: bool, on_dirs: bool) -> String { match path { Some(p) => { if absolute { - std::fs::canonicalize(p) - .unwrap() - .to_string_lossy() - .to_string() + match std::fs::canonicalize(p) { + Ok(path) => path.to_string_lossy().to_string(), + Err(e) => { + log::error!("Failed to get canonical path for {p:?}: {e}"); + p.to_string_lossy().to_string() + } + } + } else if on_dirs && !p.is_absolute() { + Path::new("..").join(p).to_string_lossy().to_string() } else { p.to_string_lossy().to_string() } @@ -106,3 +108,39 @@ fn pathbuf_to_string(path: Option<&Path>, absolute: bool) -> String { None => String::new(), } } + +#[cfg(test)] +mod tests { + use super::path_to_string; + use std::path::Path; + + #[test] + fn test_pathbuf_to_string_1() { + let p = Path::new("../testdata"); + let s = path_to_string(Some(p), false, true); + assert_eq!(s, "../../testdata"); + } + + #[test] + fn test_pathbuf_to_string_2() { + let p = Path::new("../testdata"); + let s = path_to_string(Some(p), false, false); + assert_eq!(s, "../testdata"); + } + + #[test] + fn test_pathbuf_to_string_3() { + let p = Path::new("../testdata"); + let s = path_to_string(Some(p), true, false); + let abs = std::fs::canonicalize(p).unwrap(); + assert_eq!(s, abs.to_string_lossy()); + } + + #[test] + fn test_pathbuf_to_string_4() { + let p = Path::new("../testdata"); + let s = path_to_string(Some(p), true, true); + let abs = std::fs::canonicalize(p).unwrap(); + assert_eq!(s, abs.to_string_lossy()); + } +} diff --git a/docs/config.toml b/docs/config.toml index 1f1f0fe..eb0abd1 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -2,36 +2,39 @@ baseURL = "https://tamada.github.io/sibling" languageCode = "en-us" defaultContentLanguage = "en" title = "Sibling" -enableEmoji = true -theme = "cayman-hugo-theme" googleAnalytics = "" - -pygmentsCodefences = true -pygmentsStyle = "pygments" +theme = ["github.com/tamada/hugo_theme_mod", "github.com/nunocoracao/blowfish/v2"] [params] - project_name = "sibling" - project_logo = "images/folders.svg" - project_tagline = "get next/previous sibling directory name" - dateFormat = "2006-01-02" - katex = true - version = "2.0.4" - footer = "[![GitHub](https://img.shields.io/badge/GitHub-tamada/sibling-blueviolet.svg?logo=github)](https://github.com/tamada/sibling) Made with [Hugo](https://gohugo.io/). Theme by [Cayman](https://github.com/zwbetz-gh/cayman-hugo-theme). Deployed to [GitHub Pages](https://pages.github.com/)." +logo = "images/logo.svg" +description = "get next/previous sibling directory name." +dateFormat = "2006-01-02" +copyright = "Copyright © 2024 by Haruaki Tamada. Made with [Hugo](https://gohugo.io/). Deployed to [GitHub Pages](https://pages.github.com/)." + +[menus] +[[menus.main]] + name = "🏃 Usage" + pageRef = "/usage" + weight = 30 +[[menus.main]] + name = "⚓️ Install" + pageRef = "/install" + weight = 50 +[[menus.main]] + identifier = "haruaki-tamada" + pre = "haruaki-tamada" + url = "https://tamada.github.io" + weight = 100 +[[menus.main]] + identifier = "github" + pre = "github" + url = "https://github.com/tamada/sibling" + weight = 200 +[[menus.footer]] + name = ":smile: About" + pageRef = "/about" + weight = 50 -[menu] - [[menu.nav]] - name = ":house: Home" - url = "/" - weight = 10 - [[menu.nav]] - name = ":anchor: Install" - url = "/install" - weight = 20 - [[menu.nav]] - name = ":runner: Usage" - url = "/usage" - weight = 30 - [[menu.nav]] - name = ":smile: About" - url = "/about" - weight = 50 \ No newline at end of file +[module] +[[module.imports]] +path = "github.com/tamada/hugo_theme_mod" diff --git a/docs/content/_index.md b/docs/content/_index.md index 924fad0..e3d6914 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -3,13 +3,15 @@ date: 2024-07-09 title: ":house: Home" --- +{{< keywordList markdownify="true" >}} +[![crates.io](https://img.shields.io/badge/crates.io-v2.0.5-orange.svg?logo=rust)](https://crates.io/crates/sibling) +[![License](https://img.shields.io/badge/License-WTFPL-information.svg)](https://github.com/tamada/sibling/blob/master/LICENSE) +[![Version](https://img.shields.io/badge/Version-v2.0.5-information.svg)](https://github.com/tamada/sibling/releases/tag/v2.0.5) + [![build](https://github.com/tamada/sibling/actions/workflows/build.yaml/badge.svg)](https://github.com/tamada/sibling/actions/workflows/build.yaml) [![Coverage Status](https://coveralls.io/repos/github/tamada/sibling/badge.svg?branch=main)](https://coveralls.io/github/tamada/sibling?branch=main) - [![Rust Report Card](https://rust-reportcard.xuri.me/badge/github.com/tamada/sibling)](https://rust-reportcard.xuri.me/report/github.com/tamada/sibling) - -[![License](https://img.shields.io/badge/License-WTFPL-information.svg)](https://github.com/tamada/sibling/blob/master/LICENSE) -[![Version](https://img.shields.io/badge/Version-{{< version double_dash="true" >}}-information.svg)](https://github.com/tamada/sibling/releases/tag/v{{< version >}}) +{{< /keywordList >}} ## :speaking_head: Description diff --git a/docs/content/about.md b/docs/content/about.md index ff14b8b..8303fbc 100644 --- a/docs/content/about.md +++ b/docs/content/about.md @@ -1,8 +1,9 @@ --- title: ":smile: About" +date: 2024-07-09 --- -### :scroll: License +## :scroll: License * [WTFPL](https://github.com/tamada/sibling/blob/master/LICNESE) * :+1: Commercial use @@ -10,12 +11,14 @@ title: ":smile: About" * :+1: Distribution * :+1: Private use -### :man_office_worker: Developers :woman_office_worker: +## :man_office_worker: Developers :woman_office_worker: * [Haruaki Tamada](https://tamada.github.io) -### :link: Useful links for directory traversing +## :link: Useful links for directory traversing +- [zoxide](https://github.com/ajeetdsouza/zoxide) + - A smarter cd command. Supports all major shells. - [cdargs](https://github.com/cbxbiker61/cdargs) - Directory bookmarking system - Enhanced cd utilities - [Is there a directory history for bash?](https://superuser.com/questions/299694/is-there-a-directory-history-for-bash) diff --git a/docs/content/install.md b/docs/content/install.md index 6441f4b..8c2cee0 100644 --- a/docs/content/install.md +++ b/docs/content/install.md @@ -1,8 +1,9 @@ --- title: ":anchor: Installation" +date: 2024-07-09 --- -### :beer: Homebrew +## :beer: Homebrew Install `sibling` via [Homebrew](https://brew.sh), simply run: @@ -18,7 +19,7 @@ Other shell scripts are not supported, yet. eval "$(sibling --init bash)" ``` -### :muscle: Compiling yourself +## :muscle: Compiling yourself Get source codes by `git clone` or download from [GitHub](https://github.com/tamada/sibling), then run `cargo` to build `sibling` @@ -29,9 +30,9 @@ $ cd sibling $ cargo build ``` -### :briefcase: Requirements +## :briefcase: Requirements -#### Development +### Development - Rust 1.78 or later - Dependencies (See `Cargo.toml`) diff --git a/docs/content/usage.md b/docs/content/usage.md index ca20dac..0c623cd 100644 --- a/docs/content/usage.md +++ b/docs/content/usage.md @@ -1,5 +1,6 @@ --- title: ":runner: Usage" +date: 2025-12-03 --- ## :cool: Utility commands diff --git a/docs/go.mod b/docs/go.mod new file mode 100644 index 0000000..bb6307f --- /dev/null +++ b/docs/go.mod @@ -0,0 +1,8 @@ +module github.com/tamada/sibling + +go 1.25.6 + +require ( + github.com/nunocoracao/blowfish/v2 v2.97.0 // indirect + github.com/tamada/hugo_theme_mod v0.0.0-20260208080146-1497c09c3593 // indirect +) diff --git a/docs/go.sum b/docs/go.sum new file mode 100644 index 0000000..ff07e7d --- /dev/null +++ b/docs/go.sum @@ -0,0 +1,12 @@ +github.com/nunocoracao/blowfish/v2 v2.97.0 h1:ofFBkTQKFDnM5/XVLReuJ6KPzLQFwscEg0dYEebWXvw= +github.com/nunocoracao/blowfish/v2 v2.97.0/go.mod h1:4SkMc+Ht8gpQCwArqiHMBDP3soxi2OWuAhVney+cuyk= +github.com/tamada/hugo_theme_mod v0.0.0-20260208061658-30eb80995452 h1:/7cT/INXFtsoRrN75SJhc9n6W6rEudQ+La1qGPgrqys= +github.com/tamada/hugo_theme_mod v0.0.0-20260208061658-30eb80995452/go.mod h1:9X6na3BQHPMbdY85ViJZuM6OxnPEjQ/073zT26mDZKc= +github.com/tamada/hugo_theme_mod v0.0.0-20260208070750-39a85c2d7cc1 h1:LT6HAq8FLDw8iGW1yFVpNEMW2gFGauDLWWgWcwv/xx0= +github.com/tamada/hugo_theme_mod v0.0.0-20260208070750-39a85c2d7cc1/go.mod h1:9X6na3BQHPMbdY85ViJZuM6OxnPEjQ/073zT26mDZKc= +github.com/tamada/hugo_theme_mod v0.0.0-20260208075559-9ad21fe5c7a6 h1:jxMxw5sgZscY+fui/MeTGLQJZ4zzxMOFmtfch8rR84g= +github.com/tamada/hugo_theme_mod v0.0.0-20260208075559-9ad21fe5c7a6/go.mod h1:9X6na3BQHPMbdY85ViJZuM6OxnPEjQ/073zT26mDZKc= +github.com/tamada/hugo_theme_mod v0.0.0-20260208075822-8b99fee04b44 h1:hZyXSMFkfdXkeVXxoJhAjZQHll7t0BoIZcMih9yhTgw= +github.com/tamada/hugo_theme_mod v0.0.0-20260208075822-8b99fee04b44/go.mod h1:9X6na3BQHPMbdY85ViJZuM6OxnPEjQ/073zT26mDZKc= +github.com/tamada/hugo_theme_mod v0.0.0-20260208080146-1497c09c3593 h1:PY+EasAh+2UdYzMs/uq49J+wIRDIsqDUvdCa2r+gVn8= +github.com/tamada/hugo_theme_mod v0.0.0-20260208080146-1497c09c3593/go.mod h1:9X6na3BQHPMbdY85ViJZuM6OxnPEjQ/073zT26mDZKc= diff --git a/docs/layouts/_default/single.html b/docs/layouts/_default/single.html deleted file mode 100644 index ace1256..0000000 --- a/docs/layouts/_default/single.html +++ /dev/null @@ -1,4 +0,0 @@ -{{ define "main" }} -

{{ .Title | emojify }}

- {{ .Content }} -{{ end }} diff --git a/docs/layouts/index.html b/docs/layouts/index.html deleted file mode 100644 index d7d8b4e..0000000 --- a/docs/layouts/index.html +++ /dev/null @@ -1,18 +0,0 @@ -{{ define "main" }} -

{{ .Title | emojify }}

- -{{ .Content }} - -
    -{{ $pages := where site.RegularPages "Type" "in" site.Params.mainSections }} -{{ range $pages.ByPublishDate.Reverse }} -
  • - {{ $dateFormat := $.Site.Params.dateFormat | default "Jan 2, 2006" }} - {{ .PublishDate.Format $dateFormat }} - - {{ .Title }} - -
  • -{{ end }} -
-{{ end }} diff --git a/docs/layouts/partials/head.html b/docs/layouts/partials/head.html deleted file mode 100644 index b1db87d..0000000 --- a/docs/layouts/partials/head.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - {{ hugo.Generator }} - - - {{ $caymanScss := "scss/cayman.scss" }} - {{ $options := (dict "targetPath" "css/cayman.css") }} - {{ $caymanCss := resources.Get $caymanScss | resources.ExecuteAsTemplate $caymanScss . | toCSS $options | fingerprint }} - - - - - - - - {{ partial "katex.html" . }} - {{ .Title | emojify }} | {{ $.Site.Title }} - diff --git a/docs/layouts/partials/header-nav.html b/docs/layouts/partials/header-nav.html deleted file mode 100644 index 5474ebc..0000000 --- a/docs/layouts/partials/header-nav.html +++ /dev/null @@ -1,21 +0,0 @@ - diff --git a/docs/layouts/shortcodes/logo.html b/docs/layouts/shortcodes/logo.html deleted file mode 100644 index 2373690..0000000 --- a/docs/layouts/shortcodes/logo.html +++ /dev/null @@ -1,9 +0,0 @@ -{{- $img := .Get "src" }} -{{- $width := .Get "width" }} -{{- $caption := .Get "caption" }} -
- -
- {{ $caption | markdownify }} -
-
diff --git a/docs/layouts/shortcodes/version.html b/docs/layouts/shortcodes/version.html deleted file mode 100644 index 9eb493b..0000000 --- a/docs/layouts/shortcodes/version.html +++ /dev/null @@ -1,5 +0,0 @@ -{{- $version := $.Site.Params.version }} -{{- if eq (.Get "double_dash") "true" -}} -{{- $version = strings.Replace $version "-" "--" -}} -{{- end -}} -{{ $version }} \ No newline at end of file diff --git a/docs/resources/logo.pptx b/docs/resources/logo.pptx new file mode 100644 index 0000000..7e5e450 Binary files /dev/null and b/docs/resources/logo.pptx differ diff --git a/docs/resources/rodentia-icons_folder-drag-accept.svg b/docs/resources/rodentia-icons_folder-drag-accept.svg new file mode 100644 index 0000000..5a2013e --- /dev/null +++ b/docs/resources/rodentia-icons_folder-drag-accept.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/resources/rodentia-icons_folder-visiting-green.svg b/docs/resources/rodentia-icons_folder-visiting-green.svg new file mode 100644 index 0000000..7855ca5 --- /dev/null +++ b/docs/resources/rodentia-icons_folder-visiting-green.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/resources/rodentia-icons_folder-visiting-red.svg b/docs/resources/rodentia-icons_folder-visiting-red.svg new file mode 100644 index 0000000..c46688d --- /dev/null +++ b/docs/resources/rodentia-icons_folder-visiting-red.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/themes/cayman-hugo-theme b/docs/themes/cayman-hugo-theme deleted file mode 160000 index 73140a8..0000000 --- a/docs/themes/cayman-hugo-theme +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 73140a8ea8beecefbbca393f0a34cf4bb1b5387c diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 165a4da..7faf013 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -12,7 +12,10 @@ homepage.workspace = true license-file.workspace = true [dependencies] -rand = "0.9.0" +rand = "0.10.0" rust-embed = "8.5.0" clap = { version = "4.5.23", features = ["derive"] } log = "0.4.29" + +[dev-dependencies] +env_logger = "0.11.8" diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 3f4ea4b..8b38346 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -16,6 +16,7 @@ use std::fmt::Display; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; +use std::str::FromStr; use clap::ValueEnum; @@ -39,9 +40,28 @@ pub enum NexterType { Keep, } +impl FromStr for NexterType { + type Err = Error; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "first" => Ok(NexterType::First), + "last" => Ok(NexterType::Last), + "previous" => Ok(NexterType::Previous), + "next" => Ok(NexterType::Next), + "random" => Ok(NexterType::Random), + "keep" => Ok(NexterType::Keep), + _ => Err(Error::UnknownNexterType(s.to_string())), + } + } +} + /// The error type for sibling. #[derive(Debug)] pub enum Error { + /// Multiple errors occurred (array of errors). + Array(Vec), + /// A fatal error with a custom message. + Fatal(String), /// I/O error occurred while accessing the file system. Io(std::io::Error), /// The specified path was not found. @@ -52,10 +72,8 @@ pub enum Error { NotDir(PathBuf), /// The specified path is not a file. NotFile(PathBuf), - /// A fatal error with a custom message. - Fatal(String), - /// Multiple errors occurred (array of errors). - Array(Vec), + /// The specified nexter type is unknown. + UnknownNexterType(String), } impl Display for Error { @@ -73,17 +91,21 @@ impl Display for Error { Error::NotFile(path) => write!(f, "{}: Not a file", path.display()), Error::NotFound(path) => write!(f, "{}: Not found", path.display()), Error::Fatal(message) => write!(f, "Fatal error: {message}"), + Error::UnknownNexterType(s) => write!(f, "Unknown nexter type: {s}"), } } } /// Stores a list of sibling directories, the parent directory path, and the index of the current directory. +/// This struct manages the entries of sibling directories, parent directory, and the current directory index. #[derive(Debug, Clone)] pub struct Dirs { /// The list of sibling directories, sorted alphabetically. entries: Vec, /// The parent directory that contains all the sibling directories. parent: PathBuf, + /// Flag indicating whether the current directory is among the sibling directories. + on_dirs: bool, /// The index of the current directory in the `dirs` vector. current: usize, } @@ -147,7 +169,9 @@ impl Dirs { /// /// # Returns /// - /// A [`Result`]<[`Dirs`]> instance. + /// A [`Result`]<[`Dirs`], [`Error`]> instance. + /// The resultant `Dirs` contains the list of sibling directories of the given path. + /// The parent directory is set to the parent of the given path. /// /// # Errors /// @@ -182,34 +206,49 @@ impl Dirs { /// Create a new [`Dirs`] instance from the given file. /// The file should contain a list of directories, one per line. /// The first line can optionally specify the parent directory in the format `parent:/path/to`. + /// The first character of the line is `#`, it is treated as a comment and ignored. + /// If the current directory is not found in the list, the current index is set to 0. + /// If `all_target` parameter is false, non-existent directories are skipped and + /// the resultant list does not include them. /// /// # Returns - /// A [`Result`]<[`Dirs`]> instance. + /// A [`Result`]<[`Dirs`], [`Error`]> instance. /// /// # Errors /// /// - Returns [`Error::Io`] if an I/O error occurs. /// - Returns [`Error::NotFile`] if the given path is not a file. /// - Returns [`Error::NotFound`] if the given path does not exist. - pub fn new_from_file>(file: S) -> Result { + pub fn new_from_file_with>(file: S, all_target: bool) -> Result { log::debug!("Dirs::new_from_file(file={})", file.as_ref()); let file = file.as_ref(); if file == "-" { log::info!("Reading directories from stdin"); - return Ok(build_from_reader(Box::new(std::io::stdin().lock()))); + return Ok(build_from_reader( + Box::new(std::io::stdin().lock()), + all_target, + )); } let path = PathBuf::from(file); if !path.exists() { - log::error!("Dirs::new_from_file: Not found: {}", path.display()); + log::error!( + "Dirs::new_from_file: Not found: {}, pwd: {}", + path.display(), + std::env::current_dir().unwrap().display() + ); Err(Error::NotFound(path)) } else if path.is_dir() { log::error!("Dirs::new_from_file: Not a file: {}", path.display()); Err(Error::NotFile(path)) } else { - build_from_list(&path) + build_from_list_file(&path, all_target) } } + pub fn new_from_file>(file: S) -> Result { + Dirs::new_from_file_with(file, false) + } + /// Get the parent directory path. #[must_use] pub fn parent(&self) -> &Path { @@ -228,6 +267,10 @@ impl Dirs { self.entries.is_empty() } + pub fn on_dirs(&self) -> bool { + self.on_dirs + } + /// Get the number of directories in the list. #[must_use] pub fn len(&self) -> usize { @@ -241,13 +284,8 @@ impl Dirs { } /// Get the next directory using the given [`Nexter`] and step. - /// - /// ## Panics - /// - /// This function will panic if the step exceeds `i32::MAX`. - #[must_use] - pub fn next_with(&self, nexter: &dyn Nexter, step: usize) -> Option> { - nexter.next_with(self, i32::try_from(step).unwrap()) + pub fn next_with(&self, nexter: &dyn Nexter, step: i32) -> Option> { + nexter.next_with(self, step) } /// Get an iterator over the directories. @@ -258,28 +296,23 @@ impl Dirs { /// Collect sibling directories under parent and compute the index of current. fn build_dirs(parent: Option<&Path>, current: PathBuf) -> Result { - log::trace!("build_dirs(parent={parent:?}, current={})", current.display()); - let Some(parent) = parent else { + log::trace!( + "build_dirs(parent={parent:?}, current={})", + current.display() + ); + let Some(parent) = parent else { log::error!("build_dirs: No parent for current={}", current.display()); return Err(Error::NoParent(current)); }; let mut errs = vec![]; let dirs = collect_dirs(parent, &mut errs); if errs.is_empty() { - let current_index = find_current(&dirs, ¤t); - let index = if current_index == -1 { - log::warn!( - "build_dirs: current directory not found in siblings: {}", - current.display() - ); - 0 - } else { - usize::try_from(current_index).unwrap() - }; + let (index, on_dirs) = find_current(&dirs, ¤t); log::info!("build_dirs: siblings={}, current_index={index}", dirs.len()); Ok(Dirs { entries: dirs, parent: parent.to_path_buf(), + on_dirs, current: index, }) } else { @@ -315,44 +348,74 @@ fn collect_dirs(parent: &Path, errs: &mut Vec) -> Vec { } /// Return the index of current in dirs, or 0 if not found. -fn find_current(dirs: &[PathBuf], current: &PathBuf) -> i32 { - let idx = dirs - .iter() - .position(|dir| dir == current) - .map_or(-1, |i| i32::try_from(i).unwrap()); - log::trace!("find_current: index={} for {}", idx, current.display()); - idx +fn find_current(dirs: &[PathBuf], current: &PathBuf) -> (usize, bool) { + let idx = dirs.iter().position(|dir| dir == current); + if let Some(index) = idx { + log::trace!("find_current: found current at index {index}"); + (index, true) + } else { + log::warn!("find_current: current directory not found in siblings"); + (0, false) + } } /// Parse lines from a reader; parent: sets base, remaining lines are directory entries. -fn build_from_reader(reader: Box) -> Dirs { - let lines = reader - .lines() - .filter_map(|line| line.map(|n| n.trim().to_string()).ok()) - .collect::>(); - let base = if let Some(base) = lines.iter().find(|l| l.starts_with("parent:")) { - base.chars().skip(7).collect::().trim().to_string() - } else { - ".".to_string() - }; - let dirs = lines - .iter() - .filter(|l| !l.starts_with("parent:")) - .map(PathBuf::from) - .collect::>(); - log::debug!("build_from_reader: base='{}', entries={}", base, dirs.len()); - let current = find_current_dir_index(&dirs); - if current == 0 { - log::warn!("build_from_reader: current directory not found in siblings"); +fn build_from_reader(reader: Box, all_target: bool) -> Dirs { + let mut parent = None; + let mut lines = vec![]; + for line in reader.lines().map_while(|r| r.ok()) { + if line.starts_with("parent:") { + parent = Some( + line.chars() + .skip("parent:".len()) + .collect::() + .trim() + .to_string(), + ); + } else if line.starts_with("#") { + continue; + } else { + let p = Path::new(line.trim()); + if !all_target && not_exists(p) { + continue; + } else { + lines.push(p.to_path_buf()); + } + } + } + log::debug!( + "build_from_reader: base='{}', entries={}", + parent.as_deref().unwrap_or(""), + lines.len() + ); + let (current, on_dirs) = find_current_dir_index(&lines); + if !on_dirs { + log::debug!("build_from_reader: current directory not found in siblings"); } Dirs { - entries: dirs, - parent: PathBuf::from(base), + entries: lines, + parent: PathBuf::from(parent.unwrap_or_else(|| { + if on_dirs { + "..".to_string() + } else { + ".".to_string() + } + })), + on_dirs, current, } } -fn find_current_dir_index(dirs: &[PathBuf]) -> usize { +fn not_exists(path: &Path) -> bool { + if path.exists() { + false + } else { + let sibling_p = Path::new("..").join(path); + !sibling_p.exists() + } +} + +fn find_current_dir_index(dirs: &[PathBuf]) -> (usize, bool) { log::trace!("find_current_dir_index(dirs.len={})", dirs.len()); if let Ok(pwd) = std::env::current_dir() { let cwd = PathBuf::from("."); @@ -360,18 +423,18 @@ fn find_current_dir_index(dirs: &[PathBuf]) -> usize { .iter() .position(|dir| dir == &cwd || pwd.ends_with(dir)) { - return pos; + return (pos, true); } } - 0 + (0, false) } -fn build_from_list(filename: &Path) -> Result { +fn build_from_list_file(filename: &Path, all_target: bool) -> Result { if let Ok(f) = std::fs::File::open(filename) { let reader = BufReader::new(f); - Ok(build_from_reader(Box::new(reader))) + Ok(build_from_reader(Box::new(reader), all_target)) } else { - log::error!("build_from_list: I/O error: {}", filename.display()); + log::error!("build_from_list_file: I/O error: {}", filename.display()); Err(Error::Io(std::io::Error::last_os_error())) } } @@ -450,9 +513,7 @@ impl Nexter for Next { impl Nexter for Random { fn next_with<'a>(&self, dirs: &'a Dirs, _step: i32) -> Option> { - use rand::Rng; - let mut rng = rand::rng(); - let next = rng.random_range(0..dirs.len()); + let next = rand::random_range(0..dirs.len()); log::trace!("Random::next_with -> index {next}"); Some(Dir::new(dirs, next)) } @@ -472,14 +533,15 @@ fn next_impl(dirs: &Dirs, step: i32) -> Option> { dirs.current ); if next < 0 || next >= length { - log::warn!( - "next_impl: out of range (next={next}, len={length})", - ); + log::warn!("next_impl: out of range (next={next}, len={length})",); None } else if next == 0 { Some(Dir::new_of_last_item(dirs, 0)) } else if next == length - 1 { - Some(Dir::new_of_last_item(dirs, usize::try_from(length - 1).unwrap())) + Some(Dir::new_of_last_item( + dirs, + usize::try_from(length - 1).unwrap(), + )) } else { Some(Dir::new(dirs, usize::try_from(next).unwrap())) } @@ -489,6 +551,51 @@ fn next_impl(dirs: &Dirs, step: i32) -> Option> { mod tests { use super::*; + #[test] + fn test_error_display_not_dir() { + let err = Error::NotDir(PathBuf::from("/path/to/file")); + assert_eq!( + format!("{}", err), + "/path/to/file: Not a directory".to_string() + ); + } + + #[test] + fn test_error_display_not_file() { + let err = Error::NotFile(PathBuf::from("/path/to/file")); + assert_eq!( + format!("{}", err), + "/path/to/file: Not a file".to_string() + ); + } + + #[test] + fn test_error_display_no_parent() { + let err = Error::NoParent(PathBuf::from("/path/to/file")); + assert_eq!( + format!("{}", err), + "/path/to/file: No parent directory".to_string() + ); + } + + #[test] + fn test_error_display_fatal() { + let err = Error::Fatal("Some fatal error".into()); + assert_eq!( + format!("{}", err), + "Fatal error: Some fatal error".to_string() + ); + } + + #[test] + fn test_error_display_unknown_nexter_type() { + let err = Error::UnknownNexterType("unknown".into()); + assert_eq!( + format!("{}", err), + "Unknown nexter type: unknown".to_string() + ); + } + #[test] fn test_dirs_new() { let dirs = Dirs::new(PathBuf::from("../testdata/basic/d")); @@ -530,12 +637,13 @@ mod tests { #[test] fn test_dir_from_file() { - let dirs = Dirs::new_from_file("../testdata/basic/dirlist.txt"); + let dirs = Dirs::new_from_file_with("../testdata/basic/dirlist.txt", true); assert!(dirs.is_ok()); let dirs = dirs.unwrap(); - assert_eq!(dirs.len(), 4); - assert_eq!(dirs.current, 1); - assert_eq!(dirs.parent, PathBuf::from("testdata/basic")); + assert_eq!(dirs.len(), 3); + assert_eq!(dirs.current, 0); + assert!(!dirs.on_dirs()); + assert_eq!(dirs.parent, PathBuf::from(".")); } #[test] diff --git a/lib/tests/common/mod.rs b/lib/tests/common/mod.rs new file mode 100644 index 0000000..013be36 --- /dev/null +++ b/lib/tests/common/mod.rs @@ -0,0 +1,8 @@ +pub(super) fn init() { + if std::env::var_os("RUST_LOG").is_none() { + unsafe { + std::env::set_var("RUST_LOG", "debug"); + } + } + let _ = env_logger::builder().is_test(true).try_init(); +} diff --git a/lib/tests/skip_non_existent_dir.rs b/lib/tests/skip_non_existent_dir.rs new file mode 100644 index 0000000..f831a92 --- /dev/null +++ b/lib/tests/skip_non_existent_dir.rs @@ -0,0 +1,33 @@ +use std::path::Path; + +mod common; + +#[test] +pub fn test_skip_non_existent_dir() { + common::init(); + std::env::set_current_dir(Path::new("../testdata/basic")) + .expect("Failed to change current directory"); + let dirs = + sibling::Dirs::new_from_file("../skip.txt").expect("Failed to create Dirs from skip.txt"); + assert_eq!(dirs.len(), 2); + + let dir = dirs + .next(sibling::NexterFactory::create(sibling::NexterType::Next).as_ref()) + .expect("Failed to get next directory"); + assert_eq!(dir.path().to_string_lossy(), "worried"); +} + +#[test] +pub fn test_skip_non_existent_dir2() { + common::init(); + std::env::set_current_dir(Path::new("../testdata/basic")) + .expect("Failed to change current directory"); + let dirs = sibling::Dirs::new_from_file_with("../skip.txt", true) + .expect("Failed to create Dirs from skip.txt"); + assert_eq!(dirs.len(), 4); + + let dir = dirs + .next(sibling::NexterFactory::create(sibling::NexterType::Previous).as_ref()) + .expect("Failed to get next directory"); + assert_eq!(dir.path().to_string_lossy(), "unknown_dir"); +} diff --git a/testdata/basic/dirlist.txt b/testdata/basic/dirlist.txt index c03f276..de98044 100644 --- a/testdata/basic/dirlist.txt +++ b/testdata/basic/dirlist.txt @@ -1,5 +1,3 @@ -testdata/basic/a -. -testdata/basic/b -testdata/basic/c -parent:testdata/basic +a +b +c diff --git a/testdata/demo/.init_demo.sh b/testdata/demo/.init_demo.sh new file mode 100644 index 0000000..923c2d5 --- /dev/null +++ b/testdata/demo/.init_demo.sh @@ -0,0 +1,2 @@ +LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | fold -w 8 | head -n 100 | xargs mkdir +\ls | shuf | head -5 > target.txt diff --git a/testdata/demo/target.txt b/testdata/demo/target.txt new file mode 100644 index 0000000..cd542bb --- /dev/null +++ b/testdata/demo/target.txt @@ -0,0 +1,5 @@ +ZY5ByfDz +g2md2Rj2 +D6Wwt9gT +YZHpDTsi +rsIXWSba diff --git a/testdata/skip.txt b/testdata/skip.txt new file mode 100644 index 0000000..fbcf9fa --- /dev/null +++ b/testdata/skip.txt @@ -0,0 +1,4 @@ +unknown_dir +basic +worried +non_exists