返回 CodeWhale
dependencies.rs
根目录 / crates / tui / src / dependencies.rs
1 //! External-binary dependency resolution for tools that shell out to
2 //! locally-installed programs (Python for `code_execution` / RLM REPL,
3 //! `pdftotext` for PDF reading in `read_file`, future tools as added).
4 //!
5 //! Before v0.8.31, tools that called external binaries hardcoded the
6 //! command name and failed at execution time when the binary wasn't on
7 //! `PATH`. The most-cited example was `code_execution`, which spawned
8 //! `python3` directly — Windows users (where the launcher is `py` or
9 //! `python`, not `python3`) saw `Failed to execute tool: program not
10 //! found` with no upstream hint of what was wrong.
11 //!
12 //! This module centralises the probe-then-decide pattern. The supported
13 //! callers today are:
14 //!
15 //! - Tool catalog construction (`core::engine::tool_catalog`): for
16 //! tools that should be advertised to the model only when the
17 //! required runtime is present.
18 //! - Doctor command (`run_doctor` in `main.rs`): for surfacing the
19 //! resolved state to the user so missing dependencies aren't an
20 //! invisible failure.
21 //! - Long-lived REPL runtime (`repl::runtime`): for RLM and inline `repl`
22 //! blocks that need to spawn Python on every supported platform.
23 //!
24 //! Results are cached for the process lifetime via [`std::sync::OnceLock`]
25 //! — probing a binary involves a `Command::output` per candidate and
26 //! we'd rather not pay that on every model turn.
27
28 use std::path::{Path, PathBuf};
29 use std::process::Command;
30 use std::sync::OnceLock;
31
32 /// Candidate executable names for the Python interpreter, in the
33 /// order we try them. On Windows the launcher convention is `py -3`,
34 /// so we add it as a third option; the resolver splits on whitespace
35 /// at execution time so `py -3 /tmp/code.py` runs correctly.
36 ///
37 /// Order matters: `python3` first because it's the unambiguous v3
38 /// binary on Unix and rules out Python 2 leftovers. `python` second
39 /// covers Windows installations that drop the version suffix and
40 /// modern macOS where Homebrew installs both. `py -3` last as a
41 /// Windows-launcher fallback.
42 pub const PYTHON_CANDIDATES: &[&str] = &["python3", "python", "py -3"];
43
44 /// Probe a single executable. Returns `true` when the candidate
45 /// responds to `--version` with a successful exit. Splits on
46 /// whitespace so `"py -3"` works as a candidate.
47 ///
48 /// We deliberately use `--version` rather than `which` so the probe
49 /// is portable across Unix, Windows (no `which` by default), and
50 /// containers. The downside is that we spawn a subprocess per
51 /// candidate; the resolver caches the result so this only fires
52 /// once per process.
53 #[must_use]
54 pub fn probe_executable(spec: &str) -> bool {
55 probe_executable_with_flag(spec, "--version")
56 }
57
58 /// Probe a single executable using an explicit version/help flag.
59 ///
60 /// Most tools report their presence via `--version`, but some do not:
61 /// Poppler's `pdftotext` treats `--version` as an input *filename* and
62 /// exits non-zero ("I/O Error: Couldn't open file '--version'"), so the
63 /// default probe reports it missing even when it is installed (#1667).
64 /// Such tools pass their own flag (e.g. `-v`) here.
65 #[must_use]
66 pub fn probe_executable_with_flag(spec: &str, version_flag: &str) -> bool {
67 let mut parts = spec.split_whitespace();
68 let Some(program) = parts.next() else {
69 return false;
70 };
71 let mut cmd = Command::new(program);
72 crate::utils::suppress_console_window(&mut cmd);
73 for arg in parts {
74 cmd.arg(arg);
75 }
76 cmd.arg(version_flag);
77
78 // Silence the subprocess's stdout/stderr — the version banner would
79 // otherwise print to our terminal during startup, which is
80 // confusing on the TUI's first frame.
81 cmd.stdout(std::process::Stdio::null());
82 cmd.stderr(std::process::Stdio::null());
83
84 matches!(cmd.status(), Ok(status) if status.success())
85 }
86
87 /// Probe a single executable and capture its version banner in one spawn.
88 ///
89 /// Same contract as [`probe_executable`] (success = exit 0), but returns the
90 /// trimmed stdout so callers that want the banner don't need a second process
91 /// launch. Returns `None` when the probe fails or stdout is not valid UTF-8.
92 pub fn probe_executable_capturing(spec: &str, version_flag: &str) -> Option<String> {
93 let mut parts = spec.split_whitespace();
94 let program = parts.next()?;
95 let mut cmd = Command::new(program);
96 crate::utils::suppress_console_window(&mut cmd);
97 for arg in parts {
98 cmd.arg(arg);
99 }
100 cmd.arg(version_flag);
101 cmd.stderr(std::process::Stdio::null());
102
103 let output = cmd.output().ok()?;
104 if !output.status.success() {
105 return None;
106 }
107 String::from_utf8(output.stdout)
108 .ok()
109 .map(|s| s.trim().to_string())
110 .filter(|s| !s.is_empty())
111 }
112
113 fn executable_path_candidates(program: &str) -> Vec<PathBuf> {
114 let program_path = Path::new(program);
115 if program_path.components().count() > 1 {
116 return vec![program_path.to_path_buf()];
117 }
118
119 let Some(path) = std::env::var_os("PATH") else {
120 return vec![PathBuf::from(program)];
121 };
122
123 let mut candidates = Vec::new();
124 for dir in std::env::split_paths(&path) {
125 let bare = dir.join(program);
126 candidates.push(bare.clone());
127
128 #[cfg(windows)]
129 if Path::new(program).extension().is_none() {
130 let pathext =
131 std::env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
132 for ext in pathext.to_string_lossy().split(';') {
133 if ext.is_empty() {
134 continue;
135 }
136 candidates.push(bare.with_extension(ext.trim_start_matches('.')));
137 }
138 }
139 }
140
141 candidates
142 }
143
144 fn resolve_executable_path(spec: &str, version_flag: &str) -> Option<String> {
145 let mut parts = spec.split_whitespace();
146 let program = parts.next()?;
147 let args: Vec<&str> = parts.collect();
148
149 for candidate in executable_path_candidates(program) {
150 if !candidate.is_file() {
151 continue;
152 }
153
154 let mut cmd = Command::new(&candidate);
155 cmd.args(&args)
156 .arg(version_flag)
157 .stdout(std::process::Stdio::null())
158 .stderr(std::process::Stdio::null());
159
160 if matches!(cmd.status(), Ok(status) if status.success()) {
161 return Some(candidate.to_string_lossy().into_owned());
162 }
163 }
164
165 None
166 }
167
168 /// Resolve the Python interpreter once per process. Returns the
169 /// candidate spec (e.g. `"python3"` or `"py -3"`) that succeeded,
170 /// or `None` when every candidate failed.
171 ///
172 /// Callers that need to spawn the interpreter should split this
173 /// string on whitespace — see [`split_interpreter_spec`].
174 pub fn resolve_python_interpreter() -> Option<String> {
175 static CACHE: OnceLock<Option<String>> = OnceLock::new();
176 CACHE
177 .get_or_init(|| {
178 for candidate in PYTHON_CANDIDATES {
179 if probe_executable(candidate) {
180 tracing::info!(
181 target: "tool_dependencies",
182 candidate = candidate,
183 "Resolved Python interpreter",
184 );
185 return Some((*candidate).to_string());
186 }
187 }
188 tracing::warn!(
189 target: "tool_dependencies",
190 tried = ?PYTHON_CANDIDATES,
191 "No Python interpreter found",
192 );
193 None
194 })
195 .clone()
196 }
197
198 /// Resolve `pdftotext` (from Poppler) once per process. Used by
199 /// file and web PDF paths for truthful availability diagnostics. Unlike
200 /// the Python case, `read_file` itself still works for text files
201 /// when `pdftotext` is missing — this resolver exists so the doctor
202 /// command can surface the miss before a PDF read returns its typed
203 /// `binary_unavailable` result.
204 pub fn resolve_pdftotext() -> Option<String> {
205 static CACHE: OnceLock<Option<String>> = OnceLock::new();
206 CACHE
207 .get_or_init(|| {
208 // Poppler's `pdftotext` rejects `--version` (it is parsed as an
209 // input filename and exits non-zero), so probe with `-v`, which
210 // prints the version banner and exits 0 (#1667).
211 if probe_executable_with_flag("pdftotext", "-v") {
212 Some("pdftotext".to_string())
213 } else {
214 None
215 }
216 })
217 .clone()
218 }
219
220 /// Resolve `tesseract` (OCR engine) once per process. Used by the
221 /// `image_ocr` tool on platforms that do not have a native OCR backend.
222 /// Tesseract is the de-facto open-source OCR engine and ships as a single
223 /// binary on every platform we support, so the candidate list is just
224 /// `tesseract`.
225 pub fn resolve_tesseract() -> Option<String> {
226 static CACHE: OnceLock<Option<String>> = OnceLock::new();
227 CACHE
228 .get_or_init(|| {
229 if probe_executable("tesseract") {
230 tracing::info!(
231 target: "tool_dependencies",
232 "Resolved tesseract binary for image_ocr",
233 );
234 Some("tesseract".to_string())
235 } else {
236 tracing::warn!(
237 target: "tool_dependencies",
238 "tesseract binary not found; image_ocr will rely on native OCR if available",
239 );
240 None
241 }
242 })
243 .clone()
244 }
245
246 /// Resolve `pandoc` (universal document converter) once per
247 /// process. Used by the `pandoc_convert` tool to decide whether
248 /// to register itself with the model. Pandoc is a single-binary
249 /// install, so the candidate list is just `pandoc` — no platform
250 /// fallback path.
251 pub fn resolve_pandoc() -> Option<String> {
252 static CACHE: OnceLock<Option<String>> = OnceLock::new();
253 CACHE
254 .get_or_init(|| {
255 if let Some(path) = resolve_executable_path("pandoc", "--version") {
256 tracing::info!(
257 target: "tool_dependencies",
258 "Resolved pandoc binary for pandoc_convert",
259 );
260 Some(path)
261 } else {
262 tracing::warn!(
263 target: "tool_dependencies",
264 "pandoc binary not found; pandoc_convert tool will not be registered",
265 );
266 None
267 }
268 })
269 .clone()
270 }
271
272 /// Resolve the Node.js runtime once per process. Used by the
273 /// `js_execution` tool to decide whether to advertise itself in
274 /// the catalog. Unlike Python, the executable name `node` is the
275 /// same across every platform we ship to — there's no `node3` or
276 /// `node.exe` variant to fall through to — so this is a single
277 /// probe rather than a candidate ladder.
278 pub fn resolve_node() -> Option<String> {
279 static CACHE: OnceLock<Option<String>> = OnceLock::new();
280 CACHE
281 .get_or_init(|| {
282 if probe_executable("node") {
283 tracing::info!(
284 target: "tool_dependencies",
285 "Resolved Node.js runtime for js_execution",
286 );
287 Some("node".to_string())
288 } else {
289 tracing::warn!(
290 target: "tool_dependencies",
291 "Node.js runtime not found; js_execution tool will not be advertised",
292 );
293 None
294 }
295 })
296 .clone()
297 }
298
299 // ---------------------------------------------------------------------------
300 // ExternalTool trait — unified subprocess interface
301 // ---------------------------------------------------------------------------
302
303 /// A tool that DeepSeek-TUI shells out to. Instead of scattering
304 /// `Command::new("git")` / `Command::new("gh")` across the codebase,
305 /// each external dependency implements this trait once in this module.
306 /// Callers ask the tool for a pre-populated [`Command`] and chain their
307 /// own args, working directory, and spawn method.
308 ///
309 /// # Example
310 ///
311 /// ```ignore
312 /// let output = Git::command()
313 /// .expect("git not found")
314 /// .args(["diff", "--stat"])
315 /// .current_dir(&workspace)
316 /// .output()?;
317 /// ```
318 pub trait ExternalTool {
319 /// Candidate binary names, tried in order until one responds to
320 /// `--version`. For single-binary tools (git, gh, node) this is a
321 /// one-element slice.
322 fn candidates() -> &'static [&'static str];
323
324 /// Resolve the best candidate once per process (cached). Returns
325 /// the spec string (e.g. `"python3"` or `"py -3"`).
326 fn resolve() -> Option<String>;
327
328 /// Quick availability check — true when the tool was found on PATH.
329 fn available() -> bool {
330 Self::resolve().is_some()
331 }
332
333 /// Build a `std::process::Command` pre-populated with the resolved
334 /// binary (and any fixed arguments from a multi-word candidate like
335 /// `"py -3"`). Returns `None` when the tool isn't installed.
336 ///
337 /// Callers should chain `.args(...)`, `.current_dir(...)`, and then
338 /// call `.output()`, `.status()`, or `.spawn()`.
339 fn command() -> Option<Command> {
340 let spec = Self::resolve()?;
341 let (program, fixed_args) = split_interpreter_spec(&spec);
342 let mut cmd = Command::new(&program);
343 crate::utils::suppress_console_window(&mut cmd);
344 for arg in &fixed_args {
345 cmd.arg(arg);
346 }
347 Some(cmd)
348 }
349
350 /// Convenience: run the tool with arguments in a working directory
351 /// and return the captured output.
352 fn output(args: &[&str], cwd: &std::path::Path) -> std::io::Result<std::process::Output> {
353 let mut cmd = Self::command().ok_or_else(|| {
354 std::io::Error::new(
355 std::io::ErrorKind::NotFound,
356 format!("{} not found on PATH", std::any::type_name::<Self>()),
357 )
358 })?;
359 cmd.args(args).current_dir(cwd).output()
360 }
361
362 /// Convenience: run the tool with arguments and return only the
363 /// exit status (discards stdout/stderr).
364 #[cfg_attr(not(test), expect(dead_code))]
365 fn status(args: &[&str], cwd: &std::path::Path) -> std::io::Result<std::process::ExitStatus> {
366 let mut cmd = Self::command().ok_or_else(|| {
367 std::io::Error::new(
368 std::io::ErrorKind::NotFound,
369 format!("{} not found on PATH", std::any::type_name::<Self>()),
370 )
371 })?;
372 cmd.args(args).current_dir(cwd).status()
373 }
374
375 /// Build a `tokio::process::Command` pre-populated with the resolved
376 /// binary (and any fixed arguments from a multi-word candidate like
377 /// `"py -3"`). Returns `None` when the tool isn't installed.
378 ///
379 /// Async callers (`code_execution`, `js_execution`) use this instead
380 /// of [`ExternalTool::command`] so they can `.await` the child.
381 fn tokio_command() -> Option<tokio::process::Command> {
382 let spec = Self::resolve()?;
383 let (program, fixed_args) = split_interpreter_spec(&spec);
384 let mut cmd = tokio::process::Command::new(&program);
385 crate::utils::suppress_tokio_console_window(&mut cmd);
386 for arg in &fixed_args {
387 cmd.arg(arg);
388 }
389 Some(cmd)
390 }
391 }
392
393 // ---------------------------------------------------------------------------
394 // Concrete tool implementations
395 // ---------------------------------------------------------------------------
396
397 /// Git version control.
398 pub struct Git;
399
400 impl Git {
401 /// Construct a read-only review command with content conversion disabled.
402 /// Review callers also pass `--no-ext-diff` and `--no-textconv` for diffs.
403 /// Configured filters otherwise execute even when those flags are present.
404 pub(crate) fn review_command(workspace: &Path) -> anyhow::Result<Command> {
405 use anyhow::{Context, bail};
406
407 let base = || -> anyhow::Result<Command> {
408 let mut command = Self::command().context("git not found on PATH")?;
409 command
410 .current_dir(workspace)
411 .stdin(std::process::Stdio::null())
412 // GIT_CONFIG redirects only `git config`, not `git diff`.
413 // Both phases must observe the same effective repository config.
414 .env_remove("GIT_CONFIG")
415 .env_remove("GIT_CONFIG_PARAMETERS")
416 .env("GIT_CONFIG_COUNT", "2")
417 .env("GIT_CONFIG_KEY_0", "core.fsmonitor")
418 .env("GIT_CONFIG_VALUE_0", "false")
419 .env("GIT_CONFIG_KEY_1", "core.hooksPath")
420 .env(
421 "GIT_CONFIG_VALUE_1",
422 if cfg!(windows) { "NUL" } else { "/dev/null" },
423 )
424 .env("GIT_NO_LAZY_FETCH", "1")
425 .env("GIT_NO_REPLACE_OBJECTS", "1")
426 .env("GIT_TERMINAL_PROMPT", "0")
427 .env("GIT_PAGER", "");
428 Ok(command)
429 };
430 let output = base()?
431 .args([
432 "config",
433 "--null",
434 "--name-only",
435 "--get-regexp",
436 r"^filter\..*\.(clean|process|required)$",
437 ])
438 .output()
439 .context("Failed to inspect Git review filters")?;
440 let no_filters =
441 output.status.code() == Some(1) && output.stdout.is_empty() && output.stderr.is_empty();
442 if (!output.status.success() && !no_filters)
443 || (!output.stdout.is_empty() && !output.stdout.ends_with(&[0]))
444 {
445 bail!("Cannot safely inspect Git review configuration");
446 }
447 let mut filters = std::collections::BTreeSet::new();
448 for key in output
449 .stdout
450 .split(|byte| *byte == 0)
451 .filter(|key| !key.is_empty())
452 {
453 let key =
454 std::str::from_utf8(key).context("Git review filter name is not valid UTF-8")?;
455 let (driver, _) = key
456 .rsplit_once('.')
457 .context("Invalid Git review filter key")?;
458 filters.insert(driver);
459 }
460 let mut command = base()?;
461 let mut count = 2;
462 for driver in filters {
463 for (suffix, value) in [("clean", ""), ("process", ""), ("required", "false")] {
464 // A subsection may contain '='; `-c key=value` would then
465 // override a different key. Separate env fields preserve it.
466 command.env(
467 format!("GIT_CONFIG_KEY_{count}"),
468 format!("{driver}.{suffix}"),
469 );
470 command.env(format!("GIT_CONFIG_VALUE_{count}"), value);
471 count += 1;
472 }
473 }
474 command.env("GIT_CONFIG_COUNT", count.to_string());
475 Ok(command)
476 }
477 }
478
479 impl ExternalTool for Git {
480 fn candidates() -> &'static [&'static str] {
481 &["git"]
482 }
483
484 /// Every `Git` invocation in the product is issued against a repository
485 /// the user also works in by hand. `git status` and `git diff`
486 /// opportunistically refresh the index, and that refresh takes
487 /// `.git/index.lock` — which is why a user's own `git commit` could fail
488 /// with "Unable to create '.../.git/index.lock': File exists" while
489 /// codewhale was merely idling in the same repo (#5617, reported by
490 /// @LmeSzinc).
491 ///
492 /// `GIT_OPTIONAL_LOCKS=0` suppresses only *optional* lock-taking, so
493 /// reads stop touching the index while genuine writes (`add`, `commit`,
494 /// `stash`, `update-ref`) are unaffected — including the snapshot
495 /// side-repo runner, which writes to its own git dir. `git diff --quiet`
496 /// exit-code semantics are preserved, which `snapshot::repo` relies on
497 /// for `/undo` cursoring.
498 ///
499 /// Set here rather than on the `ExternalTool::command` default so it does
500 /// not leak onto `Gh`, `Cargo`, `Node`, `Python`, or `RustC`. Prefer the
501 /// environment variable over the `--no-optional-locks` flag: the flag is
502 /// top-level (it must precede the subcommand, awkward for the several
503 /// call sites that build argument vectors), it would change the
504 /// agent-visible command string rendered by `tools::git::format_command`,
505 /// and an unknown flag hard-fails on old git while an unknown environment
506 /// variable is silently ignored.
507 fn command() -> Option<Command> {
508 let spec = Self::resolve()?;
509 let (program, fixed_args) = split_interpreter_spec(&spec);
510 let mut cmd = Command::new(&program);
511 crate::utils::suppress_console_window(&mut cmd);
512 for arg in &fixed_args {
513 cmd.arg(arg);
514 }
515 cmd.env("GIT_OPTIONAL_LOCKS", "0");
516 Some(cmd)
517 }
518
519 fn resolve() -> Option<String> {
520 static CACHE: OnceLock<Option<String>> = OnceLock::new();
521 CACHE
522 .get_or_init(|| {
523 for candidate in Self::candidates() {
524 if probe_executable(candidate) {
525 tracing::info!(target: "tool_dependencies", "Resolved git binary");
526 return Some((*candidate).to_string());
527 }
528 }
529 None
530 })
531 .clone()
532 }
533 }
534
535 /// GitHub CLI.
536 pub struct Gh;
537
538 impl ExternalTool for Gh {
539 fn candidates() -> &'static [&'static str] {
540 &["gh"]
541 }
542
543 fn resolve() -> Option<String> {
544 static CACHE: OnceLock<Option<String>> = OnceLock::new();
545 CACHE
546 .get_or_init(|| {
547 for candidate in Self::candidates() {
548 if probe_executable(candidate) {
549 tracing::info!(target: "tool_dependencies", "Resolved gh binary");
550 return Some((*candidate).to_string());
551 }
552 }
553 None
554 })
555 .clone()
556 }
557 }
558
559 /// Rust compiler — used for version reporting in diagnostics.
560 pub struct RustC;
561
562 impl ExternalTool for RustC {
563 fn candidates() -> &'static [&'static str] {
564 &["rustc"]
565 }
566
567 fn resolve() -> Option<String> {
568 static CACHE: OnceLock<Option<String>> = OnceLock::new();
569 CACHE
570 .get_or_init(|| {
571 // Probe with capture so the `--version` banner observed during
572 // resolution is reused by [`rustc_version_banner`] instead of
573 // paying a second rustc process launch (each launch loads
574 // libLLVM, which dominated diagnostic-command init profiles).
575 for candidate in Self::candidates() {
576 if let Some(banner) = probe_executable_capturing(candidate, "--version") {
577 tracing::info!(target: "tool_dependencies", "Resolved rustc binary");
578 let _ = RUSTC_VERSION_BANNER.set(Some(banner));
579 return Some((*candidate).to_string());
580 }
581 }
582 None
583 })
584 .clone()
585 }
586 }
587
588 /// Captured `--version` banner from the [`RustC`] resolution probe.
589 ///
590 /// `None` until `RustC::resolve()`/`available()`/`command()` first runs, or
591 /// when rustc is absent/failing. Reading this after an `available()` check
592 /// yields the same string the tool would print, without a second process.
593 static RUSTC_VERSION_BANNER: OnceLock<Option<String>> = OnceLock::new();
594
595 /// The rustc `--version` banner, if rustc resolved successfully.
596 ///
597 /// Populated as a side effect of resolving [`RustC`]; this reads no fresh
598 /// process state. Callers wanting the value should touch `RustC::available()`
599 /// first (as the diagnostics path does).
600 #[must_use]
601 pub fn rustc_version_banner() -> Option<String> {
602 RUSTC_VERSION_BANNER.get().cloned().flatten()
603 }
604
605 /// Rust build tool — used by the `run_tests` tool.
606 pub struct Cargo;
607
608 impl ExternalTool for Cargo {
609 fn candidates() -> &'static [&'static str] {
610 &["cargo"]
611 }
612
613 fn resolve() -> Option<String> {
614 static CACHE: OnceLock<Option<String>> = OnceLock::new();
615 CACHE
616 .get_or_init(|| {
617 for candidate in Self::candidates() {
618 if probe_executable(candidate) {
619 tracing::info!(target: "tool_dependencies", "Resolved cargo binary");
620 return Some((*candidate).to_string());
621 }
622 }
623 None
624 })
625 .clone()
626 }
627 }
628
629 /// Python interpreter — used by `code_execution` tool and RLM REPL.
630 /// Delegates to the existing [`resolve_python_interpreter`] so the
631 /// multi-candidate ladder (`python3` → `python` → `py -3`) is
632 /// shared with legacy callers until they migrate to the trait.
633 pub struct Python;
634
635 impl ExternalTool for Python {
636 fn candidates() -> &'static [&'static str] {
637 PYTHON_CANDIDATES
638 }
639
640 fn resolve() -> Option<String> {
641 resolve_python_interpreter()
642 }
643 }
644
645 /// Node.js runtime — used by the `js_execution` tool.
646 /// The binary name `node` is the same on every platform we support,
647 /// so this is a single probe rather than a candidate ladder.
648 pub struct Node;
649
650 impl ExternalTool for Node {
651 fn candidates() -> &'static [&'static str] {
652 &["node"]
653 }
654
655 fn resolve() -> Option<String> {
656 resolve_node()
657 }
658 }
659
660 // ---------------------------------------------------------------------------
661 // Legacy interpreter helpers (kept for existing callers until migrated)
662 // ---------------------------------------------------------------------------
663
664 /// Split an interpreter spec like `"py -3"` into the program name
665 /// and any initial arguments. Returns `("py", vec!["-3"])` for the
666 /// example; returns `("python3", vec![])` for a bare name.
667 ///
668 /// Callers spawn `Command::new(program).args(args).arg(script_path)`.
669 #[must_use]
670 pub fn split_interpreter_spec(spec: &str) -> (String, Vec<String>) {
671 let mut parts = spec.split_whitespace();
672 let program = parts.next().unwrap_or("").to_string();
673 let args = parts.map(str::to_string).collect();
674 (program, args)
675 }
676
677 #[cfg(test)]
678 mod tests {
679 use super::*;
680
681 #[test]
682 fn probe_executable_returns_false_for_unknown_binary() {
683 // Pick a name we're confident isn't on any developer's PATH.
684 // If this ever starts failing locally, rename it.
685 assert!(!probe_executable("codewhale-tui-imaginary-binary-xyz123"));
686 }
687
688 #[test]
689 fn probe_executable_handles_multi_word_specs() {
690 // `py -3` should split correctly. The probe will fail on
691 // most non-Windows machines (no `py` launcher), which is
692 // fine — we're checking that the *split* doesn't crash.
693 let _ = probe_executable("py -3");
694 }
695
696 #[test]
697 fn probe_executable_with_flag_returns_false_for_unknown_binary() {
698 assert!(!probe_executable_with_flag(
699 "codewhale-tui-imaginary-binary-xyz123",
700 "-v"
701 ));
702 }
703
704 #[test]
705 fn probe_executable_delegates_to_double_dash_version() {
706 // `probe_executable` must remain exactly
707 // `probe_executable_with_flag(.., "--version")`.
708 let spec = "codewhale-tui-imaginary-binary-xyz123";
709 assert_eq!(
710 probe_executable(spec),
711 probe_executable_with_flag(spec, "--version")
712 );
713 }
714
715 #[test]
716 fn pdftotext_resolver_detects_installed_poppler_via_dash_v() {
717 // Regression for #1667: Poppler's `pdftotext` rejects `--version`
718 // (it is parsed as an input filename and exits non-zero), so the
719 // generic `--version` probe reports it missing even when installed.
720 // The resolver must probe with `-v`. Gated on pdftotext actually
721 // being installed so CI without Poppler stays green.
722 if probe_executable_with_flag("pdftotext", "-v") {
723 assert!(
724 resolve_pdftotext().is_some(),
725 "an installed pdftotext must be detected via -v (#1667)"
726 );
727 }
728 }
729
730 #[test]
731 fn split_interpreter_spec_strips_args() {
732 assert_eq!(
733 split_interpreter_spec("python3"),
734 ("python3".to_string(), Vec::<String>::new())
735 );
736 assert_eq!(
737 split_interpreter_spec("py -3"),
738 ("py".to_string(), vec!["-3".to_string()])
739 );
740 assert_eq!(
741 split_interpreter_spec(" python3 "),
742 ("python3".to_string(), Vec::<String>::new()),
743 "leading/trailing whitespace must be tolerated"
744 );
745 }
746
747 #[test]
748 fn split_interpreter_spec_handles_empty_string() {
749 assert_eq!(
750 split_interpreter_spec(""),
751 (String::new(), Vec::<String>::new())
752 );
753 }
754
755 #[test]
756 fn python_resolver_is_cached_across_calls() {
757 // Whatever the first call returns, subsequent calls return
758 // the same value (cached). If this test ever flakes, the
759 // OnceLock semantics changed and we need to rethink the
760 // resolver.
761 let first = resolve_python_interpreter();
762 let second = resolve_python_interpreter();
763 assert_eq!(first, second);
764 }
765
766 #[test]
767 fn python_resolver_returns_some_on_developer_machines() {
768 // CI hosts have Python; developer machines have Python.
769 // The one environment where this returns None is bare-bones
770 // Windows / minimal CI containers — fine, those just don't
771 // get code_execution registered, which is the whole point.
772 // We don't assert Some() because we don't want this test
773 // to fail in those environments. Instead we just confirm
774 // the resolver doesn't panic and returns a stable value.
775 let resolved = resolve_python_interpreter();
776 if let Some(name) = resolved {
777 assert!(
778 !name.is_empty(),
779 "resolved interpreter name must be non-empty"
780 );
781 // The resolved name must be one of our candidates.
782 assert!(
783 PYTHON_CANDIDATES.contains(&name.as_str()),
784 "resolved {name:?} is not in PYTHON_CANDIDATES {PYTHON_CANDIDATES:?}"
785 );
786 }
787 }
788
789 // ===================================================================
790 // ExternalTool trait tests
791 // ===================================================================
792
793 #[test]
794 fn python_candidates_matches_const() {
795 assert_eq!(Python::candidates(), PYTHON_CANDIDATES);
796 }
797
798 #[test]
799 fn node_candidates_is_node_only() {
800 assert_eq!(Node::candidates(), &["node"]);
801 }
802
803 #[test]
804 fn git_candidates_is_git_only() {
805 assert_eq!(Git::candidates(), &["git"]);
806 }
807
808 #[test]
809 fn gh_candidates_is_gh_only() {
810 assert_eq!(Gh::candidates(), &["gh"]);
811 }
812
813 #[test]
814 fn rustc_candidates_is_rustc_only() {
815 assert_eq!(RustC::candidates(), &["rustc"]);
816 }
817
818 #[test]
819 fn cargo_candidates_is_cargo_only() {
820 assert_eq!(Cargo::candidates(), &["cargo"]);
821 }
822
823 #[test]
824 fn concrete_resolvers_do_not_cross_contaminate_when_available() {
825 let values = [
826 Git::resolve().map(|v| ("git", v)),
827 Gh::resolve().map(|v| ("gh", v)),
828 RustC::resolve().map(|v| ("rustc", v)),
829 Cargo::resolve().map(|v| ("cargo", v)),
830 Node::resolve().map(|v| ("node", v)),
831 ];
832 let resolved: Vec<(&str, String)> = values.into_iter().flatten().collect();
833
834 for i in 0..resolved.len() {
835 for j in (i + 1)..resolved.len() {
836 assert_ne!(
837 resolved[i].1, resolved[j].1,
838 "{} and {} unexpectedly resolved to the same binary",
839 resolved[i].0, resolved[j].0
840 );
841 }
842 }
843 }
844
845 #[test]
846 fn git_resolve_is_cached() {
847 let first = Git::resolve();
848 let second = Git::resolve();
849 assert_eq!(first, second);
850 }
851
852 #[test]
853 fn gh_resolve_is_cached() {
854 let first = Gh::resolve();
855 let second = Gh::resolve();
856 assert_eq!(first, second);
857 }
858
859 #[test]
860 fn python_trait_resolve_is_cached() {
861 let first = Python::resolve();
862 let second = Python::resolve();
863 assert_eq!(first, second);
864 }
865
866 #[test]
867 fn node_resolve_is_cached() {
868 let first = Node::resolve();
869 let second = Node::resolve();
870 assert_eq!(first, second);
871 }
872
873 #[test]
874 fn rustc_resolve_is_cached() {
875 let first = RustC::resolve();
876 let second = RustC::resolve();
877 assert_eq!(first, second);
878 }
879
880 #[test]
881 fn cargo_resolve_is_cached() {
882 let first = Cargo::resolve();
883 let second = Cargo::resolve();
884 assert_eq!(first, second);
885 }
886
887 #[test]
888 fn git_available_matches_resolve() {
889 assert_eq!(Git::available(), Git::resolve().is_some());
890 }
891
892 #[test]
893 fn python_available_matches_resolve() {
894 assert_eq!(Python::available(), Python::resolve().is_some());
895 }
896
897 #[test]
898 fn node_available_matches_resolve() {
899 assert_eq!(Node::available(), Node::resolve().is_some());
900 }
901
902 #[test]
903 fn rustc_available_matches_resolve() {
904 assert_eq!(RustC::available(), RustC::resolve().is_some());
905 }
906
907 #[test]
908 fn cargo_available_matches_resolve() {
909 assert_eq!(Cargo::available(), Cargo::resolve().is_some());
910 }
911
912 #[test]
913 fn git_command_returns_some_when_available() {
914 if Git::available() {
915 assert!(Git::command().is_some());
916 }
917 }
918
919 /// Every git command we build must be lock-free (#5617). Without this,
920 /// a read-only probe can take `.git/index.lock` in the user's own
921 /// repository and break a `git commit` they run by hand.
922 #[test]
923 fn git_command_never_takes_optional_locks() {
924 if !Git::available() {
925 return;
926 }
927 let cmd = Git::command().expect("git resolves when available");
928 let value = cmd
929 .get_envs()
930 .find(|(key, _)| *key == std::ffi::OsStr::new("GIT_OPTIONAL_LOCKS"))
931 .and_then(|(_, value)| value)
932 .expect("GIT_OPTIONAL_LOCKS must be set on every git command");
933 assert_eq!(value, std::ffi::OsStr::new("0"));
934 }
935
936 /// The suppression is deliberately scoped to git. Other external tools
937 /// have no index to protect and must not inherit a git-specific variable.
938 #[test]
939 fn optional_lock_suppression_does_not_leak_to_other_tools() {
940 for cmd in [Gh::command(), Cargo::command(), Node::command()]
941 .into_iter()
942 .flatten()
943 {
944 assert!(
945 !cmd.get_envs()
946 .any(|(key, _)| key == std::ffi::OsStr::new("GIT_OPTIONAL_LOCKS")),
947 "only Git may set GIT_OPTIONAL_LOCKS"
948 );
949 }
950 }
951
952 #[test]
953 fn python_command_returns_some_when_available() {
954 if Python::available() {
955 assert!(Python::command().is_some());
956 }
957 }
958
959 #[test]
960 fn python_tokio_command_returns_some_when_available() {
961 if Python::available() {
962 assert!(Python::tokio_command().is_some());
963 }
964 }
965
966 #[test]
967 fn node_tokio_command_returns_some_when_available() {
968 if Node::available() {
969 assert!(Node::tokio_command().is_some());
970 }
971 }
972
973 #[test]
974 fn git_output_version_succeeds() {
975 // Only run when git is actually installed.
976 if !Git::available() {
977 return;
978 }
979 let tmp = std::env::temp_dir();
980 let out = Git::output(&["--version"], &tmp);
981 assert!(
982 out.is_ok(),
983 "git --version must succeed when git is available"
984 );
985 let out = out.unwrap();
986 assert!(out.status.success(), "git --version must exit 0");
987 let stdout = String::from_utf8_lossy(&out.stdout);
988 assert!(
989 stdout.contains("git version"),
990 "git --version stdout must contain 'git version', got: {}",
991 stdout.trim()
992 );
993 }
994
995 #[test]
996 fn python_output_version_succeeds() {
997 if !Python::available() {
998 return;
999 }
1000 let tmp = std::env::temp_dir();
1001 let out = Python::output(&["--version"], &tmp);
1002 assert!(out.is_ok(), "python --version must spawn");
1003 let out = out.unwrap();
1004 // Python --version writes to stdout on 3.x, so just check
1005 // that it succeeded (exit 0).
1006 assert!(out.status.success(), "python --version must exit 0");
1007 }
1008
1009 #[test]
1010 fn node_output_version_succeeds() {
1011 if !Node::available() {
1012 return;
1013 }
1014 let tmp = std::env::temp_dir();
1015 let out = Node::output(&["--version"], &tmp);
1016 assert!(out.is_ok(), "node --version must spawn");
1017 let out = out.unwrap();
1018 assert!(out.status.success(), "node --version must exit 0");
1019 }
1020
1021 #[test]
1022 fn cargo_output_version_succeeds() {
1023 if !Cargo::available() {
1024 return;
1025 }
1026 let tmp = std::env::temp_dir();
1027 let out = Cargo::output(&["--version"], &tmp);
1028 assert!(out.is_ok(), "cargo --version must spawn");
1029 let out = out.unwrap();
1030 assert!(out.status.success(), "cargo --version must exit 0");
1031 }
1032
1033 #[test]
1034 fn external_tool_output_respects_cwd() {
1035 // Verify that `output()` runs in the requested directory.
1036 if !Git::available() {
1037 return;
1038 }
1039 let tmp = std::env::temp_dir();
1040 let out = Git::output(&["rev-parse", "--show-toplevel"], &tmp);
1041 assert!(out.is_ok(), "git rev-parse must spawn");
1042 let out = out.unwrap();
1043 // rev-parse --show-toplevel in a non-git dir should fail
1044 // because temp_dir is not a git repo. That's expected.
1045 // The key assertion: the command executed without IO errors.
1046 // We don't assert success because temp_dir might or might not
1047 // be inside a git worktree.
1048 let _ = out; // just checking it didn't panic/IO-error
1049 }
1050 }
1051
1051 lines RUST