diff --git a/lib/clientele/Cargo.toml b/lib/clientele/Cargo.toml index 3e547b0..9cf5503 100644 --- a/lib/clientele/Cargo.toml +++ b/lib/clientele/Cargo.toml @@ -29,6 +29,7 @@ all = [ "tracing", "unicode", "wild", + "subcommands", ] argfile = ["dep:argfile"] camino = ["dep:camino"] @@ -44,11 +45,16 @@ parse-duration = ["dep:duration-str"] serde = ["camino?/serde1"] std = ["clap?/std", "dogma/std", "error-stack?/std", "tracing?/std"] tokio = ["dep:tokio"] +subcommands = [] tracing = ["dep:tracing"] unicode = ["clap?/unicode"] unstable = ["dogma/unstable"] wild = ["dep:wild"] + +[dev-dependencies] +temp-dir = "0.1.14" + [dependencies] argfile = { version = "0.2", default-features = false, optional = true } camino = { version = "1.1", default-features = false, optional = true } diff --git a/lib/clientele/src/lib.rs b/lib/clientele/src/lib.rs index 0bd3285..6886edc 100644 --- a/lib/clientele/src/lib.rs +++ b/lib/clientele/src/lib.rs @@ -38,6 +38,11 @@ pub use options::*; #[cfg(all(feature = "std", feature = "camino"))] pub mod paths; +#[cfg(all(feature = "std", feature = "subcommands"))] +mod subcommands_provider; +#[cfg(all(feature = "std", feature = "subcommands"))] +pub use subcommands_provider::*; + mod sysexits; pub use sysexits::*; diff --git a/lib/clientele/src/subcommands_provider.rs b/lib/clientele/src/subcommands_provider.rs new file mode 100644 index 0000000..812082e --- /dev/null +++ b/lib/clientele/src/subcommands_provider.rs @@ -0,0 +1,266 @@ +// This is free and unencumbered software released into the public domain. + +use std::path::{Path, PathBuf}; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Subcommand { + pub name: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct SubcommandsProvider { + commands: Vec, +} + +impl SubcommandsProvider { + pub fn collect(prefix: &str, level: usize) -> SubcommandsProvider { + let commands = Self::collect_commands(prefix) + .into_iter() + // Construct ExternalCommand. + .flat_map(|path| { + let name = path + .file_stem()? + .to_string_lossy() + .trim_start_matches(prefix) + .to_string(); + + Some(Subcommand { name, path }) + }) + // Respect level. + .filter(|cmd| { + let count = cmd.name.chars().filter(|&c| c == '-').count(); + count < level + }) + .collect(); + + SubcommandsProvider { commands } + } + + pub fn find(prefix: &str, name: &str) -> Option { + let name = format!("{}{}", prefix, name); + let path = Self::resolve_command(prefix, &name); + path.map(|path| Subcommand { name, path }) + } +} + +impl SubcommandsProvider { + pub fn iter(&self) -> impl Iterator { + self.commands.iter() + } +} + +#[cfg(unix)] +impl SubcommandsProvider { + fn filter_file(prefix: &str, path: &Path) -> bool { + use std::os::unix::prelude::*; + + let file_name = path.file_name(); + let Some(entry_name) = file_name.and_then(|name| name.to_str()) else { + // skip files with invalid names. + return false; + }; + + if entry_name.starts_with(".") || entry_name.ends_with("~") { + // skip hidden and backup files. + return false; + } + + if !entry_name.starts_with(prefix) { + // skip non-matching files. + return false; + } + + let Ok(metadata) = std::fs::metadata(path) else { + // couldn't get metadata. + return false; + }; + + if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 { + // skip non-executable files. + return false; + } + + true + } + + fn collect_commands(prefix: &str) -> Vec { + let Some(paths) = std::env::var_os("PATH") else { + // PATH variable is not set. + return vec![]; + }; + + let mut result = vec![]; + for path in std::env::split_paths(&paths) { + let Ok(dir) = std::fs::read_dir(path) else { + continue; + }; + + for entry in dir { + let Ok(entry) = entry else { + // invalid entry. + continue; + }; + + let path = entry.path(); + if Self::filter_file(prefix, &path) { + result.push(path); + } + } + } + + result + } + + fn resolve_command(prefix: &str, command: &str) -> Option { + let Some(paths) = std::env::var_os("PATH") else { + // PATH variable is not set. + return None; + }; + + for path in std::env::split_paths(&paths) { + let path = path.join(command); + + if !path.exists() { + continue; + } + + if !Self::filter_file(prefix, &path) { + continue; + } + + return Some(path); + } + + None + } +} + +#[cfg(windows)] +impl SubcommandsProvider { + fn get_path_exts() -> Option> { + let Ok(exts) = std::env::var("PATHEXT") else { + // PATHEXT variable is not set. + return None; + }; + + // NOTE: I am not sure if std::env::split_paths should be applied here, + // since it also deals with '"' which seems to not be used in PATHEXT? + return Some( + exts.split(';') + .map(|ext| ext[1..].to_lowercase()) + .collect::>(), + ); + } + + fn filter_file(prefix: &str, path: &Path, exts: Option<&[String]>) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x00000002; + + let file_name = path.file_name(); + let Some(entry_name) = file_name.and_then(|name| name.to_str()) else { + // skip files with invalid names. + return false; + }; + + if !entry_name.starts_with(prefix) { + // skip non-matching files. + return false; + } + + if let Some(exts) = exts { + let Some(entry_ext) = path.extension().and_then(|ext| ext.to_str()) else { + // skip files without extensions. + return false; + }; + + let entry_ext = entry_ext.to_lowercase(); + if !exts.contains(&entry_ext) { + // skip non-executable files + return false; + } + } + + let Ok(metadata) = std::fs::metadata(path) else { + // couldn't get metadata. + return false; + }; + + let is_hidden = metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0; + if is_hidden { + // skip hidden files + return false; + } + + true + } + + fn collect_commands(prefix: &str) -> Vec { + let Some(paths) = std::env::var_os("PATH") else { + // PATH variable is not set. + return vec![]; + }; + + let Some(exts) = Self::get_path_exts() else { + // PATHEXT variable is not set or invalid. + return vec![]; + }; + + let mut result = vec![]; + for path in std::env::split_paths(&paths) { + let Ok(dir) = std::fs::read_dir(path) else { + continue; + }; + + for entry in dir { + let Ok(entry) = entry else { + // invalid entry. + continue; + }; + + let path = entry.path(); + if Self::filter_file(prefix, &path, Some(&exts)) { + result.push(path); + } + } + } + + result + } + + fn resolve_command(prefix: &str, command: &str) -> Option { + let Some(paths) = std::env::var_os("PATH") else { + // PATH variable is not set. + return None; + }; + + let Some(exts) = Self::get_path_exts() else { + // PATHEXT variable is not set or invalid. + return None; + }; + + for path in std::env::split_paths(&paths) { + let mut path = path.join(command); + + // Extension is provided. Just check if file exists. + if path.extension().is_some() { + match path.exists() { + true if Self::filter_file(prefix, &path, None) => return Some(path), + _ => continue, + } + } + + // Iterate extensions and check if file exists. + for ext in &exts { + path.set_extension(ext); + + match path.exists() { + true if Self::filter_file(prefix, &path, None) => return Some(path), + _ => continue, + } + } + } + + None + } +} diff --git a/lib/clientele/tests/.gitkeep b/lib/clientele/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/clientele/tests/subcommands_find.rs b/lib/clientele/tests/subcommands_find.rs new file mode 100644 index 0000000..d5ee7c5 --- /dev/null +++ b/lib/clientele/tests/subcommands_find.rs @@ -0,0 +1,27 @@ +// This is free and unencumbered software released into the public domain. + +use clientele::SubcommandsProvider; + +mod subcommands_shared; +use subcommands_shared::{Result, TEST_FILES, TEST_PREFIX}; + +#[test] +pub fn test_find() -> Result<()> { + let dir = subcommands_shared::init()?; + + for file in TEST_FILES { + println!("{}: ", file.name); + + let cd_name = file.name.trim_start_matches(TEST_PREFIX); + let cmd = SubcommandsProvider::find(TEST_PREFIX, cd_name); + let path = dir.child(file.full_name()); + + // assert_eq!(cmd.is_some(), file.should_be_listed); + + if let Some(cmd) = cmd { + assert_eq!(cmd.path, path); + } + } + + Ok(()) +} diff --git a/lib/clientele/tests/subcommands_list.rs b/lib/clientele/tests/subcommands_list.rs new file mode 100644 index 0000000..c1657c5 --- /dev/null +++ b/lib/clientele/tests/subcommands_list.rs @@ -0,0 +1,28 @@ +// This is free and unencumbered software released into the public domain. + +use clientele::SubcommandsProvider; + +mod subcommands_shared; +use subcommands_shared::{Result, TEST_FILES, TEST_LEVEL, TEST_PREFIX}; + +#[test] +pub fn test_list() -> Result<()> { + let dir = subcommands_shared::init()?; + let cmds = SubcommandsProvider::collect(TEST_PREFIX, TEST_LEVEL); + + for file in TEST_FILES { + println!("{}: ", file.name); + + let cd_name = file.name.trim_start_matches(TEST_PREFIX); + let cmd = cmds.iter().find(|cmd| cmd.name == cd_name); + let path = dir.child(file.full_name()); + + assert_eq!(cmd.is_some(), file.should_be_listed); + + if let Some(cmd) = cmd { + assert_eq!(cmd.path, path); + } + } + + Ok(()) +} diff --git a/lib/clientele/tests/subcommands_shared.rs b/lib/clientele/tests/subcommands_shared.rs new file mode 100644 index 0000000..fb52ac9 --- /dev/null +++ b/lib/clientele/tests/subcommands_shared.rs @@ -0,0 +1,86 @@ +// This is free and unencumbered software released into the public domain. + +use temp_dir::TempDir; + +pub type Result = std::result::Result>; + +pub struct TestFile { + pub name: &'static str, + pub content: &'static str, + #[allow(dead_code)] + pub should_be_listed: bool, + #[allow(dead_code)] + pub win_ext: &'static str, +} + +impl TestFile { + pub fn full_name(&self) -> String { + #[cfg(windows)] + return format!("{}.{}", self.name, self.win_ext); + #[cfg(unix)] + return self.name.to_string(); + } +} + +pub static TEST_PREFIX: &str = "clientele-"; +pub static TEST_LEVEL: usize = 1; + +pub static TEST_FILES: &[TestFile] = &[ + TestFile { + name: "clientele-hello", + content: "Hello, world!", + should_be_listed: true, + win_ext: "bat", + }, + TestFile { + name: "clientele-two-levels", + content: "Should be filtered out!", + should_be_listed: false, + win_ext: "bat", + }, + TestFile { + name: "abcdefg-test", + content: "Shouldn't appear!", + should_be_listed: false, + win_ext: "bat", + }, + #[cfg(windows)] + TestFile { + name: "clientele-hola", + content: "Hola mundo!", + should_be_listed: true, + win_ext: "cmd", + }, +]; + +pub fn init() -> Result { + let dir = TempDir::new()?; + + std::env::set_var("PATH", dir.path()); + + #[cfg(unix)] + for file in TEST_FILES { + use std::fs::OpenOptions; + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + let content = format!("#!/bin/sh\necho {}", file.content); + let path = dir.child(file.name); + let mut file = OpenOptions::new() + .write(true) + .mode(0o755) + .truncate(true) + .create(true) + .open(&path)?; + file.write_all(content.as_bytes())?; + } + + #[cfg(windows)] + for file in TEST_FILES { + let name = file.full_name(); + let content = format!("@echo off\necho {}", file.content); + std::fs::write(dir.child(name), content)?; + } + + Ok(dir) +}