Skip to main content

bnto_shell/
search_path.rs

1// `searchPath` — the folders a shell-run looks in for its command.
2//
3// `command` stays a bare name (validate.rs refuses paths and shells). When a
4// recipe sets `searchPath`, the command is found in those folders only, in
5// order, and run by its full path; PATH is not consulted, so a wrong folder
6// fails loudly instead of running some other copy. The child's environment
7// (PATH included) is untouched. Relative folders are under the work dir.
8
9use std::path::{Path, PathBuf};
10
11use serde_json::Value;
12
13/// `searchPath` as a list of folders: a list of text, or one string.
14/// Missing or empty means "use PATH as usual".
15pub fn read_search_path(value: Option<&Value>) -> Result<Vec<String>, String> {
16    let refused = || "'searchPath' must be a list of folder paths".to_string();
17    let folders = match value {
18        None | Some(Value::Null) => return Ok(Vec::new()),
19        Some(Value::String(s)) => vec![s.clone()],
20        Some(Value::Array(items)) => items
21            .iter()
22            .map(|v| v.as_str().map(str::to_string).ok_or_else(refused))
23            .collect::<Result<_, _>>()?,
24        Some(_) => return Err(refused()),
25    };
26    if folders.iter().any(|f| f.trim().is_empty()) {
27        return Err("'searchPath' has an empty folder".to_string());
28    }
29    Ok(folders)
30}
31
32/// The full path of `command` in the first of `folders` that holds it, or
33/// `None` when `folders` is empty (resolve through PATH as usual).
34pub fn resolve_command(
35    command: &str,
36    folders: &[String],
37    work_dir: Option<&Path>,
38) -> Result<Option<PathBuf>, String> {
39    if folders.is_empty() {
40        return Ok(None);
41    }
42    let dirs: Vec<PathBuf> = folders.iter().map(|f| under(work_dir, f)).collect();
43    dirs.iter()
44        .flat_map(|dir| candidates(command).map(move |name| dir.join(name)))
45        .find(|path| path.is_file())
46        .map(Some)
47        .ok_or_else(|| not_found(command, &dirs))
48}
49
50/// `folder`, or `folder` under the work dir when it's relative.
51fn under(work_dir: Option<&Path>, folder: &str) -> PathBuf {
52    match work_dir {
53        Some(base) if Path::new(folder).is_relative() => base.join(folder),
54        _ => PathBuf::from(folder),
55    }
56}
57
58/// The file names `command` may have: itself, and on Windows, when it has
59/// no extension, the program extensions Windows would try.
60fn candidates(command: &str) -> impl Iterator<Item = String> + '_ {
61    let bare = cfg!(windows) && Path::new(command).extension().is_none();
62    let extensions: &[&str] = if bare {
63        WINDOWS_PROGRAM_EXTENSIONS
64    } else {
65        &[]
66    };
67    std::iter::once(command.to_string())
68        .chain(extensions.iter().map(move |ext| format!("{command}{ext}")))
69}
70
71/// What Windows' default PATHEXT tries for a bare program name, in order.
72const WINDOWS_PROGRAM_EXTENSIONS: &[&str] = &[".com", ".exe", ".bat", ".cmd"];
73
74fn not_found(command: &str, dirs: &[PathBuf]) -> String {
75    let listed: Vec<String> = dirs.iter().map(|d| d.display().to_string()).collect();
76    format!(
77        "'{command}' was not found in searchPath: {}",
78        listed.join(", ")
79    )
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use serde_json::json;
86
87    fn touch(dir: &Path, name: &str) -> PathBuf {
88        let path = dir.join(name);
89        std::fs::write(&path, b"").unwrap();
90        path
91    }
92
93    fn folders(dirs: &[&Path]) -> Vec<String> {
94        dirs.iter()
95            .map(|d| d.to_string_lossy().into_owned())
96            .collect()
97    }
98
99    // --- Reading the param ---
100
101    #[test]
102    fn test_read_accepts_a_list_or_one_string() {
103        assert_eq!(read_search_path(None).unwrap(), Vec::<String>::new());
104        assert_eq!(read_search_path(Some(&json!("a"))).unwrap(), ["a"]);
105        assert_eq!(
106            read_search_path(Some(&json!(["a", "b"]))).unwrap(),
107            ["a", "b"]
108        );
109    }
110
111    #[test]
112    fn test_read_refuses_anything_but_text_folders() {
113        for bad in [json!(3), json!([1]), json!(["a", ""]), json!({ "a": 1 })] {
114            let err = read_search_path(Some(&bad)).unwrap_err();
115            assert!(err.contains("searchPath"), "{bad}: {err}");
116        }
117    }
118
119    // --- Resolving ---
120
121    #[test]
122    fn test_no_folders_means_path_as_usual() {
123        assert_eq!(resolve_command("git", &[], None).unwrap(), None);
124    }
125
126    #[test]
127    fn test_finds_the_command_in_the_first_folder_that_has_it() {
128        let (a, b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
129        let in_b = touch(b.path(), "tool.bat");
130        let found = resolve_command("tool.bat", &folders(&[a.path(), b.path()]), None);
131        assert_eq!(found.unwrap(), Some(in_b));
132        let in_a = touch(a.path(), "tool.bat");
133        let found = resolve_command("tool.bat", &folders(&[a.path(), b.path()]), None);
134        assert_eq!(found.unwrap(), Some(in_a));
135    }
136
137    #[test]
138    fn test_a_command_in_no_folder_fails_naming_it_and_the_folders() {
139        let a = tempfile::tempdir().unwrap();
140        let err = resolve_command("tool.bat", &folders(&[a.path()]), None).unwrap_err();
141        assert!(err.contains("tool.bat"), "{err}");
142        assert!(
143            err.contains(&a.path().to_string_lossy().into_owned()),
144            "{err}"
145        );
146    }
147
148    #[test]
149    fn test_a_folder_named_like_the_command_is_not_a_match() {
150        let a = tempfile::tempdir().unwrap();
151        std::fs::create_dir(a.path().join("tool.bat")).unwrap();
152        assert!(resolve_command("tool.bat", &folders(&[a.path()]), None).is_err());
153    }
154
155    #[test]
156    fn test_a_relative_folder_is_under_the_work_dir() {
157        let work = tempfile::tempdir().unwrap();
158        std::fs::create_dir(work.path().join("bin")).unwrap();
159        let tool = touch(&work.path().join("bin"), "tool.bat");
160        let found = resolve_command("tool.bat", &["bin".to_string()], Some(work.path()));
161        assert_eq!(found.unwrap(), Some(tool));
162    }
163
164    #[cfg(windows)]
165    #[test]
166    fn test_a_bare_name_finds_its_windows_program_file() {
167        let a = tempfile::tempdir().unwrap();
168        let bat = touch(a.path(), "tool.bat");
169        let found = resolve_command("tool", &folders(&[a.path()]), None);
170        assert_eq!(found.unwrap(), Some(bat));
171    }
172
173    #[cfg(unix)]
174    #[test]
175    fn test_a_bare_name_matches_only_that_file_on_unix() {
176        let a = tempfile::tempdir().unwrap();
177        touch(a.path(), "tool.sh");
178        assert!(resolve_command("tool", &folders(&[a.path()]), None).is_err());
179        let tool = touch(a.path(), "tool");
180        assert_eq!(
181            resolve_command("tool", &folders(&[a.path()]), None).unwrap(),
182            Some(tool)
183        );
184    }
185}