Skip to main content

rumdl_lib/code_block_tools/
binary.rs

1//! Project-scoped executable discovery shared by execution and cache identity.
2
3use std::collections::BTreeMap;
4use std::ffi::OsStr;
5use std::path::{Path, PathBuf};
6
7use super::config::BinaryPreference;
8use super::lookup;
9
10/// Start project discovery at the document directory, preserving nearer installs.
11/// Ancestor lookup is bounded by the first repository, or a rumdl config if
12/// there is no repository. Without either marker, only this directory is used.
13pub 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    // Match discovery's canonical paths, including Windows verbatim prefixes.
25    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                // A home configuration is user-wide, not a project boundary.
44                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
63/// Resolve only already-installed executables; never install or download tools.
64pub fn resolve(
65    binary: &str,
66    preferences: &BTreeMap<String, BinaryPreference>,
67    root: &Path,
68    search_path: Option<&OsStr>,
69) -> Option<PathBuf> {
70    // Explicit paths retain the existing cwd-relative semantics.
71    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                // Windows PATH lookup does not select extensionless Unix shims.
86                // Apply the same rule in project directories rather than shadowing
87                // a usable system executable with node_modules' shell script.
88                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}