bnto_shell/
search_path.rs1use std::path::{Path, PathBuf};
10
11use serde_json::Value;
12
13pub 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
32pub 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
50fn 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
58fn 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
71const 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 #[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 #[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}