use std::{
ffi::OsStr,
io,
path::{Path, PathBuf},
process::ExitStatus,
};
use anyhow::Result;
use thiserror::Error;
use tokio::process::Command;
use walkdir::WalkDir;
use which::which;
#[derive(Debug, Error)]
enum CommonError {
#[error("the provided path is incomplete; it's missing the final element")]
PathMissingLeafError,
#[error("the provided path contains characters that are not valid UTF-8 encoded")]
NonUtf8PathError,
}
pub fn same_path_with(
ori_path: impl AsRef<Path>,
suffix: &str,
c: &str,
) -> Result<impl AsRef<Path>> {
let ori_path = ori_path.as_ref();
let mut new_path = PathBuf::from(ori_path);
let new_name = ori_path
.file_stem()
.ok_or(CommonError::PathMissingLeafError)?
.to_str()
.ok_or(CommonError::NonUtf8PathError)?;
let new_name = format!("{}{}{}", new_name, c, suffix);
new_path.set_file_name(new_name);
new_path.set_extension(ori_path.extension().unwrap_or(OsStr::new("")));
Ok(new_path)
}
pub fn find_command_path<P: AsRef<Path>>(path: Option<P>, command: &str) -> Option<PathBuf> {
match path {
Some(path) => {
if path.as_ref().is_dir() {
for entry in WalkDir::new(path).max_depth(1) {
let entry = entry.ok()?;
if entry.file_name().to_string_lossy() == command {
return Some(entry.into_path());
}
}
}
None
}
None => which(command).ok(),
}
}
#[derive(Debug, Error)]
pub enum CommandError {
#[error("Command [{cmd}] failed with status code: {status}. Stderr: {stderr}")]
CommandFailed {
cmd: String,
status: ExitStatus,
stderr: String,
},
#[error(transparent)]
Io(#[from] io::Error),
}
pub async fn exec_command<S: AsRef<OsStr>>(
cmd: impl AsRef<Path>,
options: Option<Vec<S>>,
) -> Result<(String, String), CommandError> {
let mut command = Command::new(cmd.as_ref().as_os_str());
if let Some(options) = options {
command.args(options);
}
let output = command.output().await?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.success() {
Ok((stdout, stderr))
} else {
Err(CommandError::CommandFailed {
cmd: cmd.as_ref().to_string_lossy().to_string(),
status: output.status,
stderr,
})
}
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
#[test]
fn test_same_path_with() {
assert!(same_path_with("/", "f", "_").is_err());
assert_eq!(
same_path_with("/root", "f", "_").unwrap().as_ref(),
Path::new("/root_f")
);
assert_eq!(
same_path_with("/root/test", "f", "_").unwrap().as_ref(),
Path::new("/root/test_f")
);
assert_eq!(
same_path_with("/root/test.rs", "f", "_").unwrap().as_ref(),
Path::new("/root/test_f.rs")
);
}
#[test]
fn test_command_found_in_specified_path() {
let temp_dir = tempdir().expect("Failed to create temp dir");
let bin_dir = temp_dir.path().join("bin");
fs::create_dir(&bin_dir).expect("Failed to create bin dir");
let command_path = bin_dir.join("my-tool");
fs::write(&command_path, "I am a tool").expect("Failed to create command file");
let result = find_command_path(Some(&bin_dir), "my-tool");
assert_eq!(result, Some(command_path));
}
#[test]
fn test_command_not_found_in_specified_path() {
let temp_dir = tempdir().expect("Failed to create temp dir");
let bin_dir = temp_dir.path().join("bin");
fs::create_dir(&bin_dir).expect("Failed to create bin dir");
let another_file = bin_dir.join("another-tool");
fs::write(&another_file, "I am another tool").expect("Failed to create file");
let result = find_command_path(Some(&bin_dir), "non-existent-tool");
assert_eq!(result, None);
}
#[test]
fn test_specified_path_is_invalid() {
let invalid_path = PathBuf::from("this/path/does/not/exist/123");
let result = find_command_path(Some(&invalid_path), "some-command");
assert_eq!(result, None);
}
#[test]
fn test_no_path_specified_and_command_exists() {
let result = find_command_path::<&str>(None, "ls");
assert!(result.is_some());
}
#[test]
fn test_no_path_specified_and_command_does_not_exist() {
let non_existent_command = "a-very-unique-command-that-will-not-exist";
let result = find_command_path::<&str>(None, non_existent_command);
assert_eq!(result, None);
}
#[tokio::test]
async fn test_exec_command_success() {
let cmd = if cfg!(target_os = "windows") {
"cmd.exe"
} else {
"echo"
};
let options = if cfg!(target_os = "windows") {
vec!["/c", "echo hello world"]
} else {
vec!["hello world"]
};
let result = exec_command(cmd, Some(options)).await;
assert!(result.is_ok());
let (stdout, stderr) = result.unwrap();
assert_eq!(stdout.trim(), "hello world");
assert_eq!(stderr, "");
}
#[tokio::test]
async fn test_exec_command_success_without_options() {
let cmd = if cfg!(target_os = "windows") {
"dir"
} else {
"ls"
};
let result = exec_command(cmd, None::<Vec<&str>>).await;
assert!(result.is_ok());
let (stdout, stderr) = result.unwrap();
assert!(!stdout.is_empty());
assert!(stderr.is_empty());
}
#[tokio::test]
async fn test_exec_command_failure() {
let cmd = if cfg!(target_os = "windows") {
"cmd.exe"
} else {
"sh"
};
let options = if cfg!(target_os = "windows") {
vec!["/c", "exit 1"]
} else {
vec!["-c", "exit 1"]
};
let result = exec_command(cmd, Some(options)).await;
assert!(result.is_err());
if let Err(CommandError::CommandFailed {
cmd: _,
status,
stderr,
}) = result
{
assert_ne!(status.code(), Some(0));
assert_eq!(stderr, ""); } else {
panic!("Expected CommandFailed error, but got a different error.");
}
}
#[tokio::test]
async fn test_exec_command_not_found() {
let cmd = "a-non-existent-program-for-testing";
let result = exec_command(cmd, None::<Vec<&str>>).await;
assert!(result.is_err());
if let Err(CommandError::Io(e)) = result {
assert_eq!(e.kind(), std::io::ErrorKind::NotFound);
} else {
panic!("Expected an Io error, but got a different error.");
}
}
}