use async_trait::async_trait;
use std::path::Path;
use super::{Builtin, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;
pub struct Basename;
#[async_trait]
impl Builtin for Basename {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: basename NAME [SUFFIX]\nPrint NAME with leading directory components removed.\nIf SUFFIX is specified, also remove a trailing SUFFIX.\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("basename (bashkit) 0.1"),
) {
return Ok(r);
}
if ctx.args.is_empty() {
return Ok(ExecResult::err(
"basename: missing operand\n".to_string(),
1,
));
}
let mut output = String::new();
let mut args_iter = ctx.args.iter();
let path_arg = args_iter
.next()
.expect("args_iter.next() valid: guarded by is_empty() check above");
let path = Path::new(path_arg);
let basename = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| {
if path_arg == "/" {
"/".to_string()
} else if path_arg.is_empty() {
String::new()
} else {
path_arg.clone()
}
});
let result = if let Some(suffix) = args_iter.next() {
if let Some(stripped) = basename.strip_suffix(suffix.as_str()) {
stripped.to_string()
} else {
basename
}
} else {
basename
};
output.push_str(&result);
output.push('\n');
Ok(ExecResult::ok(output))
}
}
pub struct Dirname;
#[async_trait]
impl Builtin for Dirname {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: dirname NAME...\nOutput each NAME with its last non-slash component and trailing slashes removed.\nIf NAME contains no slashes, output '.' (current directory).\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("dirname (bashkit) 0.1"),
) {
return Ok(r);
}
if ctx.args.is_empty() {
return Ok(ExecResult::err("dirname: missing operand\n".to_string(), 1));
}
let mut output = String::new();
for (i, arg) in ctx.args.iter().enumerate() {
if i > 0 {
output.push('\n');
}
let path = Path::new(arg);
let dirname = path
.parent()
.map(|p| {
let s = p.to_string_lossy();
if s.is_empty() {
".".to_string()
} else {
s.to_string()
}
})
.unwrap_or_else(|| {
if arg == "/" {
"/".to_string()
} else {
".".to_string()
}
});
output.push_str(&dirname);
}
output.push('\n');
Ok(ExecResult::ok(output))
}
}
pub struct Realpath;
#[async_trait]
impl Builtin for Realpath {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: realpath [PATH...]\nPrint the resolved absolute pathname.\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("realpath (bashkit) 0.1"),
) {
return Ok(r);
}
if ctx.args.is_empty() {
return Ok(ExecResult::err(
"realpath: missing operand\n".to_string(),
1,
));
}
let mut output = String::new();
for arg in ctx.args {
if arg.starts_with('-') {
continue; }
let resolved = super::resolve_path(ctx.cwd, arg);
output.push_str(&resolved.to_string_lossy());
output.push('\n');
}
Ok(ExecResult::ok(output))
}
}
pub struct Readlink;
#[async_trait]
impl Builtin for Readlink {
#[allow(clippy::collapsible_if)]
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let argv: Vec<std::ffi::OsString> = std::iter::once(std::ffi::OsString::from("readlink"))
.chain(ctx.args.iter().map(std::ffi::OsString::from))
.collect();
let cmd = super::generated::readlink_args::readlink_command()
.help_template("Usage: {usage}\n{about}\n\n{all-args}\n");
let matches = match cmd.try_get_matches_from(argv) {
Ok(m) => m,
Err(e) => {
let kind = e.kind();
let rendered = e.render().to_string();
if matches!(
kind,
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
) {
return Ok(ExecResult::ok(rendered));
}
return Ok(ExecResult::err(rendered, 2));
}
};
let mode = if matches.get_flag("canonicalize-existing") {
ReadlinkMode::CanonicalizeExisting
} else if matches.get_flag("canonicalize-missing") {
ReadlinkMode::CanonicalizeMissing
} else if matches.get_flag("canonicalize") {
ReadlinkMode::Canonicalize
} else {
ReadlinkMode::Raw
};
let suppress_terminator = matches.get_flag("no-newline");
let zero_terminated = matches.get_flag("zero");
let terminator: char = if zero_terminated { '\0' } else { '\n' };
let files: Vec<String> = matches
.get_many::<std::ffi::OsString>("files")
.map(|vs| vs.map(|v| v.to_string_lossy().into_owned()).collect())
.unwrap_or_default();
if files.is_empty() {
return Ok(ExecResult::err(
"readlink: missing operand\n".to_string(),
1,
));
}
let mut output = String::new();
let mut exit_code = 0;
let total_files = files.len();
for (idx, file) in files.iter().enumerate() {
let resolved = super::resolve_path(ctx.cwd, file);
let is_last = idx + 1 == total_files;
let needs_terminator = !(suppress_terminator && is_last);
match mode {
ReadlinkMode::Raw => {
match ctx.fs.read_link(&resolved).await {
Ok(target) => {
output.push_str(&target.to_string_lossy());
if needs_terminator {
output.push(terminator);
}
}
Err(_) => {
exit_code = 1;
}
}
}
ReadlinkMode::Canonicalize | ReadlinkMode::CanonicalizeMissing => {
let parent_missing = if mode == ReadlinkMode::Canonicalize {
resolved
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| ctx.fs.exists(p))
} else {
None
};
if let Some(fut) = parent_missing {
if !fut.await.unwrap_or(false) {
exit_code = 1;
continue;
}
}
output.push_str(&resolved.to_string_lossy());
if needs_terminator {
output.push(terminator);
}
}
ReadlinkMode::CanonicalizeExisting => {
if ctx.fs.exists(&resolved).await.unwrap_or(false) {
output.push_str(&resolved.to_string_lossy());
if needs_terminator {
output.push(terminator);
}
} else {
exit_code = 1;
}
}
}
}
if exit_code != 0 && output.is_empty() {
Ok(ExecResult::err(String::new(), exit_code))
} else if exit_code != 0 {
let mut result = ExecResult::with_code(output, exit_code);
result.exit_code = exit_code;
Ok(result)
} else {
Ok(ExecResult::ok(output))
}
}
}
#[derive(PartialEq)]
enum ReadlinkMode {
Raw,
Canonicalize,
CanonicalizeMissing,
CanonicalizeExisting,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::fs::InMemoryFs;
async fn run_basename(args: &[&str]) -> ExecResult {
let fs = Arc::new(InMemoryFs::new());
let mut variables = HashMap::new();
let env = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Basename.execute(ctx).await.unwrap()
}
async fn run_dirname(args: &[&str]) -> ExecResult {
let fs = Arc::new(InMemoryFs::new());
let mut variables = HashMap::new();
let env = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Dirname.execute(ctx).await.unwrap()
}
#[tokio::test]
async fn test_basename_simple() {
let result = run_basename(&["/usr/bin/sort"]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "sort\n");
}
#[tokio::test]
async fn test_basename_with_suffix() {
let result = run_basename(&["file.txt", ".txt"]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "file\n");
}
#[tokio::test]
async fn test_basename_no_suffix_match() {
let result = run_basename(&["file.txt", ".doc"]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "file.txt\n");
}
#[tokio::test]
async fn test_basename_no_dir() {
let result = run_basename(&["filename"]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "filename\n");
}
#[tokio::test]
async fn test_basename_trailing_slash() {
let result = run_basename(&["/usr/bin/"]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "bin\n");
}
#[tokio::test]
async fn test_basename_missing_operand() {
let result = run_basename(&[]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("missing operand"));
}
#[tokio::test]
async fn test_dirname_simple() {
let result = run_dirname(&["/usr/bin/sort"]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "/usr/bin\n");
}
#[tokio::test]
async fn test_dirname_no_dir() {
let result = run_dirname(&["filename"]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, ".\n");
}
#[tokio::test]
async fn test_dirname_root() {
let result = run_dirname(&["/"]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "/\n");
}
#[tokio::test]
async fn test_dirname_trailing_slash() {
let result = run_dirname(&["/usr/bin/"]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "/usr\n");
}
#[tokio::test]
async fn test_dirname_missing_operand() {
let result = run_dirname(&[]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("missing operand"));
}
use crate::fs::FileSystem;
async fn run_readlink_with_fs(args: &[&str], fs: Arc<dyn FileSystem>) -> ExecResult {
let mut variables = HashMap::new();
let env = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Readlink.execute(ctx).await.unwrap()
}
#[tokio::test]
async fn test_readlink_missing_operand() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
let result = run_readlink_with_fs(&[], fs).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("missing operand"));
}
#[tokio::test]
async fn test_readlink_raw_symlink() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
fs.symlink(Path::new("/target"), Path::new("/link"))
.await
.unwrap();
let result = run_readlink_with_fs(&["/link"], fs).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "/target\n");
}
#[tokio::test]
async fn test_readlink_raw_not_symlink() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
fs.write_file(Path::new("/file"), b"data").await.unwrap(); let result = run_readlink_with_fs(&["/file"], fs).await;
assert_eq!(result.exit_code, 1);
assert!(result.stdout.is_empty());
}
#[tokio::test]
async fn test_readlink_raw_nonexistent() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
let result = run_readlink_with_fs(&["/nonexistent"], fs).await;
assert_eq!(result.exit_code, 1);
}
#[tokio::test]
async fn test_readlink_f_canonicalize() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
fs.mkdir(Path::new("/home"), true).await.unwrap();
fs.mkdir(Path::new("/home/user"), true).await.unwrap();
let result = run_readlink_with_fs(&["-f", "/home/user/../user/./file"], fs).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "/home/user/file\n");
}
#[tokio::test]
async fn test_readlink_m_canonicalize_missing() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
let result = run_readlink_with_fs(&["-m", "/a/b/../c"], fs).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "/a/c\n");
}
#[tokio::test]
async fn test_readlink_e_existing() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
fs.mkdir(Path::new("/existing"), false).await.unwrap();
let result = run_readlink_with_fs(&["-e", "/existing"], fs).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "/existing\n");
}
#[tokio::test]
async fn test_readlink_e_nonexistent() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
let result = run_readlink_with_fs(&["-e", "/nonexistent"], fs).await;
assert_eq!(result.exit_code, 1);
assert!(result.stdout.is_empty());
}
#[tokio::test]
async fn test_readlink_invalid_option() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
let result = run_readlink_with_fs(&["--definitely-not-a-flag", "/file"], fs).await;
assert_eq!(result.exit_code, 2);
let stderr_lower = result.stderr.to_lowercase();
assert!(
stderr_lower.contains("unexpected argument")
|| stderr_lower.contains("unknown argument")
|| stderr_lower.contains("invalid option"),
"expected clap unknown-flag stderr, got {}",
result.stderr
);
}
}