Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/clientele/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ all = [
"tracing",
"unicode",
"wild",
"subcommands",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alphabetic ordering in these, always

]
argfile = ["dep:argfile"]
camino = ["dep:camino"]
Expand All @@ -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 }
Expand Down
5 changes: 5 additions & 0 deletions lib/clientele/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand Down
266 changes: 266 additions & 0 deletions lib/clientele/src/subcommands_provider.rs
Original file line number Diff line number Diff line change
@@ -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<Subcommand>,
}

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<Subcommand> {
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<Item = &Subcommand> {
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<PathBuf> {
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<PathBuf> {
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<Vec<String>> {
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::<Vec<_>>(),
);
}

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<PathBuf> {
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<PathBuf> {
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
}
}
Empty file removed lib/clientele/tests/.gitkeep
Empty file.
27 changes: 27 additions & 0 deletions lib/clientele/tests/subcommands_find.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
28 changes: 28 additions & 0 deletions lib/clientele/tests/subcommands_list.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
Loading