rumdl_lib/code_block_tools/
binary.rs1use std::collections::BTreeMap;
4use std::ffi::OsStr;
5use std::path::{Path, PathBuf};
6
7use super::config::BinaryPreference;
8use super::lookup;
9
10pub fn project_root(path: &Path) -> PathBuf {
14 let absolute = if path.is_absolute() {
15 path.to_path_buf()
16 } else {
17 std::env::current_dir().unwrap_or_default().join(path)
18 };
19 let directory = if absolute.is_dir() {
20 absolute
21 } else {
22 absolute.parent().unwrap_or(&absolute).to_path_buf()
23 };
24 std::fs::canonicalize(&directory).unwrap_or(directory)
26}
27
28fn project_directories(start: &Path) -> Vec<&Path> {
29 use crate::config::file_source::{ConfigFileSource, FsConfigFiles};
30
31 let home = FsConfigFiles
32 .home_dir()
33 .map(|path| std::fs::canonicalize(&path).unwrap_or(path));
34 project_directories_with_home(start, home.as_deref())
35}
36
37fn project_directories_with_home<'a>(start: &'a Path, home: Option<&Path>) -> Vec<&'a Path> {
38 let boundary = start
39 .ancestors()
40 .find(|directory| directory.join(".git").exists())
41 .or_else(|| {
42 start.ancestors().find(|directory| {
43 if Some(*directory) == home {
45 return false;
46 }
47 [".rumdl.toml", "rumdl.toml", ".config/rumdl.toml"]
48 .iter()
49 .any(|name| directory.join(name).is_file())
50 || std::fs::read_to_string(directory.join("pyproject.toml"))
51 .ok()
52 .and_then(|text| toml::from_str::<toml::Value>(&text).ok())
53 .is_some_and(|value| value.get("tool").and_then(|tool| tool.get("rumdl")).is_some())
54 })
55 })
56 .unwrap_or(start);
57 start
58 .ancestors()
59 .take_while(|directory| directory.starts_with(boundary))
60 .collect()
61}
62
63pub fn resolve(
65 binary: &str,
66 preferences: &BTreeMap<String, BinaryPreference>,
67 root: &Path,
68 search_path: Option<&OsStr>,
69) -> Option<PathBuf> {
70 if binary.contains('/') || binary.contains('\\') {
72 return lookup::resolve_program(OsStr::new(binary), search_path);
73 }
74 let preference = preferences.get(binary).copied().unwrap_or_default();
75 let system = || lookup::resolve_program(OsStr::new(binary), search_path);
76 let project = || {
77 let locations = if cfg!(windows) {
78 [".venv/Scripts", "venv/Scripts", "node_modules/.bin"]
79 } else {
80 [".venv/bin", "venv/bin", "node_modules/.bin"]
81 };
82 project_directories(root).into_iter().find_map(|directory| {
83 locations.iter().find_map(|location| {
84 let mut candidate = directory.join(location).join(binary);
85 if cfg!(windows) && !binary.contains('.') {
89 candidate.set_extension("exe");
90 }
91 lookup::resolve_program(candidate.as_os_str(), None)
92 })
93 })
94 };
95 match preference {
96 BinaryPreference::Project => project().or_else(system),
97 BinaryPreference::System => system().or_else(project),
98 BinaryPreference::OnlyProject => project(),
99 BinaryPreference::OnlySystem => system(),
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 #[cfg(unix)]
107 use std::os::unix::fs::PermissionsExt;
108
109 fn executable(path: &Path) {
110 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
111 std::fs::write(path, "#!/bin/sh\ncat\n").unwrap();
112 #[cfg(unix)]
113 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
114 }
115
116 #[test]
117 fn preferences_select_the_same_binary_from_controlled_search_paths() {
118 let dir = tempfile::tempdir().unwrap();
119 let project = dir.path().join(if cfg!(windows) {
120 ".venv/Scripts/tool.exe"
121 } else {
122 ".venv/bin/tool"
123 });
124 let system = dir.path().join(if cfg!(windows) {
125 "system/tool.exe"
126 } else {
127 "system/tool"
128 });
129 executable(&project);
130 executable(&system);
131 let path = system.parent().unwrap().as_os_str();
132 for (preference, expected) in [
133 (BinaryPreference::Project, &project),
134 (BinaryPreference::OnlyProject, &project),
135 (BinaryPreference::System, &system),
136 (BinaryPreference::OnlySystem, &system),
137 ] {
138 let preferences = BTreeMap::from([("tool".into(), preference)]);
139 assert_eq!(
140 resolve("tool", &preferences, dir.path(), Some(path)).as_ref(),
141 Some(expected)
142 );
143 }
144 let preferences = BTreeMap::from([("tool".into(), BinaryPreference::OnlyProject)]);
145 std::fs::remove_file(&project).unwrap();
146 assert!(resolve("tool", &preferences, dir.path(), Some(path)).is_none());
147 assert_eq!(
148 resolve(system.to_str().unwrap(), &preferences, dir.path(), None),
149 Some(system)
150 );
151 }
152
153 #[test]
154 fn workspace_lookup_is_nearest_first_and_stops_at_git_files() {
155 let dir = tempfile::tempdir().unwrap();
156 let root = dir.path().join("workspace");
157 let nested = root.join("packages/app/docs");
158 std::fs::create_dir_all(&nested).unwrap();
159 std::fs::write(root.join(".git"), "gitdir: elsewhere").unwrap();
160 std::fs::write(root.join("packages/app/package.json"), "{}").unwrap();
161 let suffix = if cfg!(windows) {
162 "node_modules/.bin/tool.exe"
163 } else {
164 "node_modules/.bin/tool"
165 };
166 let outer = dir.path().join(suffix);
167 let hoisted = root.join(suffix);
168 let nearest = root.join("packages/app").join(suffix);
169 executable(&outer);
170 executable(&hoisted);
171 let preferences = BTreeMap::from([("tool".into(), BinaryPreference::OnlyProject)]);
172 assert_eq!(resolve("tool", &preferences, &nested, None), Some(hoisted.clone()));
173 executable(&nearest);
174 assert_eq!(resolve("tool", &preferences, &nested, None), Some(nearest.clone()));
175 std::fs::remove_file(nearest).unwrap();
176 std::fs::remove_file(hoisted).unwrap();
177 assert!(resolve("tool", &preferences, &nested, None).is_none());
178 assert!(resolve("tool", &BTreeMap::new(), &nested, None).is_none());
179 }
180 #[test]
181 fn pyproject_rumdl_bounds_non_git_workspace_lookup() {
182 let dir = tempfile::tempdir().unwrap();
183 let nested = dir.path().join("packages/app/docs");
184 std::fs::create_dir_all(&nested).unwrap();
185 std::fs::write(dir.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();
186 std::fs::write(
187 dir.path().join("packages/app/pyproject.toml"),
188 "[project]\nname='app'\n",
189 )
190 .unwrap();
191 let suffix = if cfg!(windows) {
192 ".venv/Scripts/tool.exe"
193 } else {
194 ".venv/bin/tool"
195 };
196 let tool = dir.path().join(suffix);
197 executable(&tool);
198 let preferences = BTreeMap::from([("tool".into(), BinaryPreference::OnlyProject)]);
199 assert_eq!(resolve("tool", &preferences, &nested, None), Some(tool));
200 }
201
202 #[cfg(windows)]
203 #[test]
204 fn project_lookup_ignores_cmd_wrappers_without_an_explicit_shell() {
205 let dir = tempfile::tempdir().unwrap();
206 std::fs::create_dir_all(dir.path().join("docs")).unwrap();
207 std::fs::write(dir.path().join(".git"), "").unwrap();
208 executable(&dir.path().join("node_modules/.bin/tool.cmd"));
209 let preferences = BTreeMap::from([("tool".into(), BinaryPreference::OnlyProject)]);
210 assert!(resolve("tool", &preferences, &dir.path().join("docs"), None).is_none());
211 executable(&dir.path().join(".venv/Scripts/tool.exe"));
212 assert!(resolve("tool", &preferences, &dir.path().join("docs"), None).is_some());
213 }
214 #[test]
215 fn user_home_config_is_not_a_project_but_an_explicit_git_root_is() {
216 let dir = tempfile::tempdir().unwrap();
217 let nested = dir.path().join("docs/nested");
218 std::fs::create_dir_all(&nested).unwrap();
219 std::fs::write(dir.path().join(".rumdl.toml"), "").unwrap();
220 assert_eq!(
221 project_directories_with_home(&nested, Some(dir.path())),
222 vec![nested.as_path()]
223 );
224 std::fs::write(dir.path().join(".git"), "").unwrap();
225 assert_eq!(
226 project_directories_with_home(&nested, Some(dir.path())).last().copied(),
227 Some(dir.path())
228 );
229 }
230}