use std::path::{Path, PathBuf};
use serde_json::Value;
pub fn read_search_path(value: Option<&Value>) -> Result<Vec<String>, String> {
let refused = || "'searchPath' must be a list of folder paths".to_string();
let folders = match value {
None | Some(Value::Null) => return Ok(Vec::new()),
Some(Value::String(s)) => vec![s.clone()],
Some(Value::Array(items)) => items
.iter()
.map(|v| v.as_str().map(str::to_string).ok_or_else(refused))
.collect::<Result<_, _>>()?,
Some(_) => return Err(refused()),
};
if folders.iter().any(|f| f.trim().is_empty()) {
return Err("'searchPath' has an empty folder".to_string());
}
Ok(folders)
}
pub fn resolve_command(
command: &str,
folders: &[String],
work_dir: Option<&Path>,
) -> Result<Option<PathBuf>, String> {
if folders.is_empty() {
return Ok(None);
}
let dirs: Vec<PathBuf> = folders.iter().map(|f| under(work_dir, f)).collect();
dirs.iter()
.flat_map(|dir| candidates(command).map(move |name| dir.join(name)))
.find(|path| path.is_file())
.map(Some)
.ok_or_else(|| not_found(command, &dirs))
}
fn under(work_dir: Option<&Path>, folder: &str) -> PathBuf {
match work_dir {
Some(base) if Path::new(folder).is_relative() => base.join(folder),
_ => PathBuf::from(folder),
}
}
fn candidates(command: &str) -> impl Iterator<Item = String> + '_ {
let bare = cfg!(windows) && Path::new(command).extension().is_none();
let extensions: &[&str] = if bare {
WINDOWS_PROGRAM_EXTENSIONS
} else {
&[]
};
std::iter::once(command.to_string())
.chain(extensions.iter().map(move |ext| format!("{command}{ext}")))
}
const WINDOWS_PROGRAM_EXTENSIONS: &[&str] = &[".com", ".exe", ".bat", ".cmd"];
fn not_found(command: &str, dirs: &[PathBuf]) -> String {
let listed: Vec<String> = dirs.iter().map(|d| d.display().to_string()).collect();
format!(
"'{command}' was not found in searchPath: {}",
listed.join(", ")
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn touch(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, b"").unwrap();
path
}
fn folders(dirs: &[&Path]) -> Vec<String> {
dirs.iter()
.map(|d| d.to_string_lossy().into_owned())
.collect()
}
#[test]
fn test_read_accepts_a_list_or_one_string() {
assert_eq!(read_search_path(None).unwrap(), Vec::<String>::new());
assert_eq!(read_search_path(Some(&json!("a"))).unwrap(), ["a"]);
assert_eq!(
read_search_path(Some(&json!(["a", "b"]))).unwrap(),
["a", "b"]
);
}
#[test]
fn test_read_refuses_anything_but_text_folders() {
for bad in [json!(3), json!([1]), json!(["a", ""]), json!({ "a": 1 })] {
let err = read_search_path(Some(&bad)).unwrap_err();
assert!(err.contains("searchPath"), "{bad}: {err}");
}
}
#[test]
fn test_no_folders_means_path_as_usual() {
assert_eq!(resolve_command("git", &[], None).unwrap(), None);
}
#[test]
fn test_finds_the_command_in_the_first_folder_that_has_it() {
let (a, b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let in_b = touch(b.path(), "tool.bat");
let found = resolve_command("tool.bat", &folders(&[a.path(), b.path()]), None);
assert_eq!(found.unwrap(), Some(in_b));
let in_a = touch(a.path(), "tool.bat");
let found = resolve_command("tool.bat", &folders(&[a.path(), b.path()]), None);
assert_eq!(found.unwrap(), Some(in_a));
}
#[test]
fn test_a_command_in_no_folder_fails_naming_it_and_the_folders() {
let a = tempfile::tempdir().unwrap();
let err = resolve_command("tool.bat", &folders(&[a.path()]), None).unwrap_err();
assert!(err.contains("tool.bat"), "{err}");
assert!(
err.contains(&a.path().to_string_lossy().into_owned()),
"{err}"
);
}
#[test]
fn test_a_folder_named_like_the_command_is_not_a_match() {
let a = tempfile::tempdir().unwrap();
std::fs::create_dir(a.path().join("tool.bat")).unwrap();
assert!(resolve_command("tool.bat", &folders(&[a.path()]), None).is_err());
}
#[test]
fn test_a_relative_folder_is_under_the_work_dir() {
let work = tempfile::tempdir().unwrap();
std::fs::create_dir(work.path().join("bin")).unwrap();
let tool = touch(&work.path().join("bin"), "tool.bat");
let found = resolve_command("tool.bat", &["bin".to_string()], Some(work.path()));
assert_eq!(found.unwrap(), Some(tool));
}
#[cfg(windows)]
#[test]
fn test_a_bare_name_finds_its_windows_program_file() {
let a = tempfile::tempdir().unwrap();
let bat = touch(a.path(), "tool.bat");
let found = resolve_command("tool", &folders(&[a.path()]), None);
assert_eq!(found.unwrap(), Some(bat));
}
#[cfg(unix)]
#[test]
fn test_a_bare_name_matches_only_that_file_on_unix() {
let a = tempfile::tempdir().unwrap();
touch(a.path(), "tool.sh");
assert!(resolve_command("tool", &folders(&[a.path()]), None).is_err());
let tool = touch(a.path(), "tool");
assert_eq!(
resolve_command("tool", &folders(&[a.path()]), None).unwrap(),
Some(tool)
);
}
}