返回 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 fn executable_path_candidates(program: &str) -> Vec<PathBuf> {
88 let program_path = Path::new(program);
89 if program_path.components().count() > 1 {
90 return vec![program_path.to_path_buf()];
91 }
92
93 let Some(path) = std::env::var_os("PATH") else {
94 return vec![PathBuf::from(program)];
95 };
96
97 let mut candidates = Vec::new();
98 for dir in std::env::split_paths(&path) {
99 let bare = dir.join(program);
100 candidates.push(bare.clone());
101
102 #[cfg(windows)]
103 if Path::new(program).extension().is_none() {
104 let pathext =
105 std::env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
106 for ext in pathext.to_string_lossy().split(';') {
107 if ext.is_empty() {
108 continue;
109 }
110 candidates.push(bare.with_extension(ext.trim_start_matches('.')));
111 }
112 }
113 }
114
115 candidates
116 }
117
118 fn resolve_executable_path(spec: &str, version_flag: &str) -> Option<String> {
119 let mut parts = spec.split_whitespace();
120 let program = parts.next()?;
121 let args: Vec<&str> = parts.collect();
122
123 for candidate in executable_path_candidates(program) {
124 if !candidate.is_file() {
125 continue;
126 }
127
128 let mut cmd = Command::new(&candidate);
129 cmd.args(&args)
130 .arg(version_flag)
131 .stdout(std::process::Stdio::null())
132 .stderr(std::process::Stdio::null());
133
134 if matches!(cmd.status(), Ok(status) if status.success()) {
135 return Some(candidate.to_string_lossy().into_owned());
136 }
137 }
138
139 None
140 }
141
142 /// Resolve the Python interpreter once per process. Returns the
143 /// candidate spec (e.g. `"python3"` or `"py -3"`) that succeeded,
144 /// or `None` when every candidate failed.
145 ///
146 /// Callers that need to spawn the interpreter should split this
147 /// string on whitespace — see [`split_interpreter_spec`].
148 pub fn resolve_python_interpreter() -> Option<String> {
149 static CACHE: OnceLock<Option<String>> = OnceLock::new();
150 CACHE
151 .get_or_init(|| {
152 for candidate in PYTHON_CANDIDATES {
153 if probe_executable(candidate) {
154 tracing::info!(
155 target: "tool_dependencies",
156 candidate = candidate,
157 "Resolved Python interpreter",
158 );
159 return Some((*candidate).to_string());
160 }
161 }
162 tracing::warn!(
163 target: "tool_dependencies",
164 tried = ?PYTHON_CANDIDATES,
165 "No Python interpreter found",
166 );
167 None
168 })
169 .clone()
170 }
171
172 /// Resolve `pdftotext` (from Poppler) once per process. Used by
173 /// file and web PDF paths for truthful availability diagnostics. Unlike
174 /// the Python case, `read_file` itself still works for text files
175 /// when `pdftotext` is missing — this resolver exists so the doctor
176 /// command can surface the miss before a PDF read returns its typed
177 /// `binary_unavailable` result.
178 pub fn resolve_pdftotext() -> Option<String> {
179 static CACHE: OnceLock<Option<String>> = OnceLock::new();
180 CACHE
181 .get_or_init(|| {
182 // Poppler's `pdftotext` rejects `--version` (it is parsed as an
183 // input filename and exits non-zero), so probe with `-v`, which
184 // prints the version banner and exits 0 (#1667).
185 if probe_executable_with_flag("pdftotext", "-v") {
186 Some("pdftotext".to_string())
187 } else {
188 None
189 }
190 })
191 .clone()
192 }
193
194 /// Resolve `tesseract` (OCR engine) once per process. Used by the
195 /// `image_ocr` tool on platforms that do not have a native OCR backend.
196 /// Tesseract is the de-facto open-source OCR engine and ships as a single
197 /// binary on every platform we support, so the candidate list is just
198 /// `tesseract`.
199 pub fn resolve_tesseract() -> Option<String> {
200 static CACHE: OnceLock<Option<String>> = OnceLock::new();
201 CACHE
202 .get_or_init(|| {
203 if probe_executable("tesseract") {
204 tracing::info!(
205 target: "tool_dependencies",
206 "Resolved tesseract binary for image_ocr",
207 );
208 Some("tesseract".to_string())
209 } else {
210 tracing::warn!(
211 target: "tool_dependencies",
212 "tesseract binary not found; image_ocr will rely on native OCR if available",
213 );
214 None
215 }
216 })
217 .clone()
218 }
219
220 /// Resolve `pandoc` (universal document converter) once per
221 /// process. Used by the `pandoc_convert` tool to decide whether
222 /// to register itself with the model. Pandoc is a single-binary
223 /// install, so the candidate list is just `pandoc` — no platform
224 /// fallback path.
225 pub fn resolve_pandoc() -> Option<String> {
226 static CACHE: OnceLock<Option<String>> = OnceLock::new();
227 CACHE
228 .get_or_init(|| {
229 if let Some(path) = resolve_executable_path("pandoc", "--version") {
230 tracing::info!(
231 target: "tool_dependencies",
232 "Resolved pandoc binary for pandoc_convert",
233 );
234 Some(path)
235 } else {
236 tracing::warn!(
237 target: "tool_dependencies",
238 "pandoc binary not found; pandoc_convert tool will not be registered",
239 );
240 None
241 }
242 })
243 .clone()
244 }
245
246 /// Resolve the Node.js runtime once per process. Used by the
247 /// `js_execution` tool to decide whether to advertise itself in
248 /// the catalog. Unlike Python, the executable name `node` is the
249 /// same across every platform we ship to — there's no `node3` or
250 /// `node.exe` variant to fall through to — so this is a single
251 /// probe rather than a candidate ladder.
252 pub fn resolve_node() -> Option<String> {
253 static CACHE: OnceLock<Option<String>> = OnceLock::new();
254 CACHE
255 .get_or_init(|| {
256 if probe_executable("node") {
257 tracing::info!(
258 target: "tool_dependencies",
259 "Resolved Node.js runtime for js_execution",
260 );
261 Some("node".to_string())
262 } else {
263 tracing::warn!(
264 target: "tool_dependencies",
265 "Node.js runtime not found; js_execution tool will not be advertised",
266 );
267 None
268 }
269 })
270 .clone()
271 }
272
273 // ---------------------------------------------------------------------------
274 // ExternalTool trait — unified subprocess interface
275 // ---------------------------------------------------------------------------
276
277 /// A tool that DeepSeek-TUI shells out to. Instead of scattering
278 /// `Command::new("git")` / `Command::new("gh")` across the codebase,
279 /// each external dependency implements this trait once in this module.
280 /// Callers ask the tool for a pre-populated [`Command`] and chain their
281 /// own args, working directory, and spawn method.
282 ///
283 /// # Example
284 ///
285 /// ```ignore
286 /// let output = Git::command()
287 /// .expect("git not found")
288 /// .args(["diff", "--stat"])
289 /// .current_dir(&workspace)
290 /// .output()?;
291 /// ```
292 pub trait ExternalTool {
293 /// Candidate binary names, tried in order until one responds to
294 /// `--version`. For single-binary tools (git, gh, node) this is a
295 /// one-element slice.
296 fn candidates() -> &'static [&'static str];
297
298 /// Resolve the best candidate once per process (cached). Returns
299 /// the spec string (e.g. `"python3"` or `"py -3"`).
300 fn resolve() -> Option<String>;
301
302 /// Quick availability check — true when the tool was found on PATH.
303 fn available() -> bool {
304 Self::resolve().is_some()
305 }
306
307 /// Build a `std::process::Command` pre-populated with the resolved
308 /// binary (and any fixed arguments from a multi-word candidate like
309 /// `"py -3"`). Returns `None` when the tool isn't installed.
310 ///
311 /// Callers should chain `.args(...)`, `.current_dir(...)`, and then
312 /// call `.output()`, `.status()`, or `.spawn()`.
313 fn command() -> Option<Command> {
314 let spec = Self::resolve()?;
315 let (program, fixed_args) = split_interpreter_spec(&spec);
316 let mut cmd = Command::new(&program);
317 crate::utils::suppress_console_window(&mut cmd);
318 for arg in &fixed_args {
319 cmd.arg(arg);
320 }
321 Some(cmd)
322 }
323
324 /// Convenience: run the tool with arguments in a working directory
325 /// and return the captured output.
326 fn output(args: &[&str], cwd: &std::path::Path) -> std::io::Result<std::process::Output> {
327 let mut cmd = Self::command().ok_or_else(|| {
328 std::io::Error::new(
329 std::io::ErrorKind::NotFound,
330 format!("{} not found on PATH", std::any::type_name::<Self>()),
331 )
332 })?;
333 cmd.args(args).current_dir(cwd).output()
334 }
335
336 /// Convenience: run the tool with arguments and return only the
337 /// exit status (discards stdout/stderr).
338 #[allow(dead_code)]
339 fn status(args: &[&str], cwd: &std::path::Path) -> std::io::Result<std::process::ExitStatus> {
340 let mut cmd = Self::command().ok_or_else(|| {
341 std::io::Error::new(
342 std::io::ErrorKind::NotFound,
343 format!("{} not found on PATH", std::any::type_name::<Self>()),
344 )
345 })?;
346 cmd.args(args).current_dir(cwd).status()
347 }
348
349 /// Build a `tokio::process::Command` pre-populated with the resolved
350 /// binary (and any fixed arguments from a multi-word candidate like
351 /// `"py -3"`). Returns `None` when the tool isn't installed.
352 ///
353 /// Async callers (`code_execution`, `js_execution`) use this instead
354 /// of [`ExternalTool::command`] so they can `.await` the child.
355 fn tokio_command() -> Option<tokio::process::Command> {
356 let spec = Self::resolve()?;
357 let (program, fixed_args) = split_interpreter_spec(&spec);
358 let mut cmd = tokio::process::Command::new(&program);
359 crate::utils::suppress_tokio_console_window(&mut cmd);
360 for arg in &fixed_args {
361 cmd.arg(arg);
362 }
363 Some(cmd)
364 }
365 }
366
367 // ---------------------------------------------------------------------------
368 // Concrete tool implementations
369 // ---------------------------------------------------------------------------
370
371 /// Git version control.
372 pub struct Git;
373
374 impl ExternalTool for Git {
375 fn candidates() -> &'static [&'static str] {
376 &["git"]
377 }
378
379 fn resolve() -> Option<String> {
380 static CACHE: OnceLock<Option<String>> = OnceLock::new();
381 CACHE
382 .get_or_init(|| {
383 for candidate in Self::candidates() {
384 if probe_executable(candidate) {
385 tracing::info!(target: "tool_dependencies", "Resolved git binary");
386 return Some((*candidate).to_string());
387 }
388 }
389 None
390 })
391 .clone()
392 }
393 }
394
395 /// GitHub CLI.
396 pub struct Gh;
397
398 impl ExternalTool for Gh {
399 fn candidates() -> &'static [&'static str] {
400 &["gh"]
401 }
402
403 fn resolve() -> Option<String> {
404 static CACHE: OnceLock<Option<String>> = OnceLock::new();
405 CACHE
406 .get_or_init(|| {
407 for candidate in Self::candidates() {
408 if probe_executable(candidate) {
409 tracing::info!(target: "tool_dependencies", "Resolved gh binary");
410 return Some((*candidate).to_string());
411 }
412 }
413 None
414 })
415 .clone()
416 }
417 }
418
419 /// Rust compiler — used for version reporting in diagnostics.
420 pub struct RustC;
421
422 impl ExternalTool for RustC {
423 fn candidates() -> &'static [&'static str] {
424 &["rustc"]
425 }
426
427 fn resolve() -> Option<String> {
428 static CACHE: OnceLock<Option<String>> = OnceLock::new();
429 CACHE
430 .get_or_init(|| {
431 for candidate in Self::candidates() {
432 if probe_executable(candidate) {
433 tracing::info!(target: "tool_dependencies", "Resolved rustc binary");
434 return Some((*candidate).to_string());
435 }
436 }
437 None
438 })
439 .clone()
440 }
441 }
442
443 /// Rust build tool — used by the `run_tests` tool.
444 pub struct Cargo;
445
446 impl ExternalTool for Cargo {
447 fn candidates() -> &'static [&'static str] {
448 &["cargo"]
449 }
450
451 fn resolve() -> Option<String> {
452 static CACHE: OnceLock<Option<String>> = OnceLock::new();
453 CACHE
454 .get_or_init(|| {
455 for candidate in Self::candidates() {
456 if probe_executable(candidate) {
457 tracing::info!(target: "tool_dependencies", "Resolved cargo binary");
458 return Some((*candidate).to_string());
459 }
460 }
461 None
462 })
463 .clone()
464 }
465 }
466
467 /// Python interpreter — used by `code_execution` tool and RLM REPL.
468 /// Delegates to the existing [`resolve_python_interpreter`] so the
469 /// multi-candidate ladder (`python3` → `python` → `py -3`) is
470 /// shared with legacy callers until they migrate to the trait.
471 pub struct Python;
472
473 impl ExternalTool for Python {
474 fn candidates() -> &'static [&'static str] {
475 PYTHON_CANDIDATES
476 }
477
478 fn resolve() -> Option<String> {
479 resolve_python_interpreter()
480 }
481 }
482
483 /// Node.js runtime — used by the `js_execution` tool.
484 /// The binary name `node` is the same on every platform we support,
485 /// so this is a single probe rather than a candidate ladder.
486 pub struct Node;
487
488 impl ExternalTool for Node {
489 fn candidates() -> &'static [&'static str] {
490 &["node"]
491 }
492
493 fn resolve() -> Option<String> {
494 resolve_node()
495 }
496 }
497
498 // ---------------------------------------------------------------------------
499 // Legacy interpreter helpers (kept for existing callers until migrated)
500 // ---------------------------------------------------------------------------
501
502 /// Split an interpreter spec like `"py -3"` into the program name
503 /// and any initial arguments. Returns `("py", vec!["-3"])` for the
504 /// example; returns `("python3", vec![])` for a bare name.
505 ///
506 /// Callers spawn `Command::new(program).args(args).arg(script_path)`.
507 #[must_use]
508 pub fn split_interpreter_spec(spec: &str) -> (String, Vec<String>) {
509 let mut parts = spec.split_whitespace();
510 let program = parts.next().unwrap_or("").to_string();
511 let args = parts.map(str::to_string).collect();
512 (program, args)
513 }
514
515 #[cfg(test)]
516 mod tests {
517 use super::*;
518
519 #[test]
520 fn probe_executable_returns_false_for_unknown_binary() {
521 // Pick a name we're confident isn't on any developer's PATH.
522 // If this ever starts failing locally, rename it.
523 assert!(!probe_executable("codewhale-tui-imaginary-binary-xyz123"));
524 }
525
526 #[test]
527 fn probe_executable_handles_multi_word_specs() {
528 // `py -3` should split correctly. The probe will fail on
529 // most non-Windows machines (no `py` launcher), which is
530 // fine — we're checking that the *split* doesn't crash.
531 let _ = probe_executable("py -3");
532 }
533
534 #[test]
535 fn probe_executable_with_flag_returns_false_for_unknown_binary() {
536 assert!(!probe_executable_with_flag(
537 "codewhale-tui-imaginary-binary-xyz123",
538 "-v"
539 ));
540 }
541
542 #[test]
543 fn probe_executable_delegates_to_double_dash_version() {
544 // `probe_executable` must remain exactly
545 // `probe_executable_with_flag(.., "--version")`.
546 let spec = "codewhale-tui-imaginary-binary-xyz123";
547 assert_eq!(
548 probe_executable(spec),
549 probe_executable_with_flag(spec, "--version")
550 );
551 }
552
553 #[test]
554 fn pdftotext_resolver_detects_installed_poppler_via_dash_v() {
555 // Regression for #1667: Poppler's `pdftotext` rejects `--version`
556 // (it is parsed as an input filename and exits non-zero), so the
557 // generic `--version` probe reports it missing even when installed.
558 // The resolver must probe with `-v`. Gated on pdftotext actually
559 // being installed so CI without Poppler stays green.
560 if probe_executable_with_flag("pdftotext", "-v") {
561 assert!(
562 resolve_pdftotext().is_some(),
563 "an installed pdftotext must be detected via -v (#1667)"
564 );
565 }
566 }
567
568 #[test]
569 fn split_interpreter_spec_strips_args() {
570 assert_eq!(
571 split_interpreter_spec("python3"),
572 ("python3".to_string(), Vec::<String>::new())
573 );
574 assert_eq!(
575 split_interpreter_spec("py -3"),
576 ("py".to_string(), vec!["-3".to_string()])
577 );
578 assert_eq!(
579 split_interpreter_spec(" python3 "),
580 ("python3".to_string(), Vec::<String>::new()),
581 "leading/trailing whitespace must be tolerated"
582 );
583 }
584
585 #[test]
586 fn split_interpreter_spec_handles_empty_string() {
587 assert_eq!(
588 split_interpreter_spec(""),
589 (String::new(), Vec::<String>::new())
590 );
591 }
592
593 #[test]
594 fn python_resolver_is_cached_across_calls() {
595 // Whatever the first call returns, subsequent calls return
596 // the same value (cached). If this test ever flakes, the
597 // OnceLock semantics changed and we need to rethink the
598 // resolver.
599 let first = resolve_python_interpreter();
600 let second = resolve_python_interpreter();
601 assert_eq!(first, second);
602 }
603
604 #[test]
605 fn python_resolver_returns_some_on_developer_machines() {
606 // CI hosts have Python; developer machines have Python.
607 // The one environment where this returns None is bare-bones
608 // Windows / minimal CI containers — fine, those just don't
609 // get code_execution registered, which is the whole point.
610 // We don't assert Some() because we don't want this test
611 // to fail in those environments. Instead we just confirm
612 // the resolver doesn't panic and returns a stable value.
613 let resolved = resolve_python_interpreter();
614 if let Some(name) = resolved {
615 assert!(
616 !name.is_empty(),
617 "resolved interpreter name must be non-empty"
618 );
619 // The resolved name must be one of our candidates.
620 assert!(
621 PYTHON_CANDIDATES.contains(&name.as_str()),
622 "resolved {name:?} is not in PYTHON_CANDIDATES {PYTHON_CANDIDATES:?}"
623 );
624 }
625 }
626
627 // ===================================================================
628 // ExternalTool trait tests
629 // ===================================================================
630
631 #[test]
632 fn python_candidates_matches_const() {
633 assert_eq!(Python::candidates(), PYTHON_CANDIDATES);
634 }
635
636 #[test]
637 fn node_candidates_is_node_only() {
638 assert_eq!(Node::candidates(), &["node"]);
639 }
640
641 #[test]
642 fn git_candidates_is_git_only() {
643 assert_eq!(Git::candidates(), &["git"]);
644 }
645
646 #[test]
647 fn gh_candidates_is_gh_only() {
648 assert_eq!(Gh::candidates(), &["gh"]);
649 }
650
651 #[test]
652 fn rustc_candidates_is_rustc_only() {
653 assert_eq!(RustC::candidates(), &["rustc"]);
654 }
655
656 #[test]
657 fn cargo_candidates_is_cargo_only() {
658 assert_eq!(Cargo::candidates(), &["cargo"]);
659 }
660
661 #[test]
662 fn concrete_resolvers_do_not_cross_contaminate_when_available() {
663 let values = [
664 Git::resolve().map(|v| ("git", v)),
665 Gh::resolve().map(|v| ("gh", v)),
666 RustC::resolve().map(|v| ("rustc", v)),
667 Cargo::resolve().map(|v| ("cargo", v)),
668 Node::resolve().map(|v| ("node", v)),
669 ];
670 let resolved: Vec<(&str, String)> = values.into_iter().flatten().collect();
671
672 for i in 0..resolved.len() {
673 for j in (i + 1)..resolved.len() {
674 assert_ne!(
675 resolved[i].1, resolved[j].1,
676 "{} and {} unexpectedly resolved to the same binary",
677 resolved[i].0, resolved[j].0
678 );
679 }
680 }
681 }
682
683 #[test]
684 fn git_resolve_is_cached() {
685 let first = Git::resolve();
686 let second = Git::resolve();
687 assert_eq!(first, second);
688 }
689
690 #[test]
691 fn gh_resolve_is_cached() {
692 let first = Gh::resolve();
693 let second = Gh::resolve();
694 assert_eq!(first, second);
695 }
696
697 #[test]
698 fn python_trait_resolve_is_cached() {
699 let first = Python::resolve();
700 let second = Python::resolve();
701 assert_eq!(first, second);
702 }
703
704 #[test]
705 fn node_resolve_is_cached() {
706 let first = Node::resolve();
707 let second = Node::resolve();
708 assert_eq!(first, second);
709 }
710
711 #[test]
712 fn rustc_resolve_is_cached() {
713 let first = RustC::resolve();
714 let second = RustC::resolve();
715 assert_eq!(first, second);
716 }
717
718 #[test]
719 fn cargo_resolve_is_cached() {
720 let first = Cargo::resolve();
721 let second = Cargo::resolve();
722 assert_eq!(first, second);
723 }
724
725 #[test]
726 fn git_available_matches_resolve() {
727 assert_eq!(Git::available(), Git::resolve().is_some());
728 }
729
730 #[test]
731 fn python_available_matches_resolve() {
732 assert_eq!(Python::available(), Python::resolve().is_some());
733 }
734
735 #[test]
736 fn node_available_matches_resolve() {
737 assert_eq!(Node::available(), Node::resolve().is_some());
738 }
739
740 #[test]
741 fn rustc_available_matches_resolve() {
742 assert_eq!(RustC::available(), RustC::resolve().is_some());
743 }
744
745 #[test]
746 fn cargo_available_matches_resolve() {
747 assert_eq!(Cargo::available(), Cargo::resolve().is_some());
748 }
749
750 #[test]
751 fn git_command_returns_some_when_available() {
752 if Git::available() {
753 assert!(Git::command().is_some());
754 }
755 }
756
757 #[test]
758 fn python_command_returns_some_when_available() {
759 if Python::available() {
760 assert!(Python::command().is_some());
761 }
762 }
763
764 #[test]
765 fn python_tokio_command_returns_some_when_available() {
766 if Python::available() {
767 assert!(Python::tokio_command().is_some());
768 }
769 }
770
771 #[test]
772 fn node_tokio_command_returns_some_when_available() {
773 if Node::available() {
774 assert!(Node::tokio_command().is_some());
775 }
776 }
777
778 #[test]
779 fn git_output_version_succeeds() {
780 // Only run when git is actually installed.
781 if !Git::available() {
782 return;
783 }
784 let tmp = std::env::temp_dir();
785 let out = Git::output(&["--version"], &tmp);
786 assert!(
787 out.is_ok(),
788 "git --version must succeed when git is available"
789 );
790 let out = out.unwrap();
791 assert!(out.status.success(), "git --version must exit 0");
792 let stdout = String::from_utf8_lossy(&out.stdout);
793 assert!(
794 stdout.contains("git version"),
795 "git --version stdout must contain 'git version', got: {}",
796 stdout.trim()
797 );
798 }
799
800 #[test]
801 fn python_output_version_succeeds() {
802 if !Python::available() {
803 return;
804 }
805 let tmp = std::env::temp_dir();
806 let out = Python::output(&["--version"], &tmp);
807 assert!(out.is_ok(), "python --version must spawn");
808 let out = out.unwrap();
809 // Python --version writes to stdout on 3.x, so just check
810 // that it succeeded (exit 0).
811 assert!(out.status.success(), "python --version must exit 0");
812 }
813
814 #[test]
815 fn node_output_version_succeeds() {
816 if !Node::available() {
817 return;
818 }
819 let tmp = std::env::temp_dir();
820 let out = Node::output(&["--version"], &tmp);
821 assert!(out.is_ok(), "node --version must spawn");
822 let out = out.unwrap();
823 assert!(out.status.success(), "node --version must exit 0");
824 }
825
826 #[test]
827 fn cargo_output_version_succeeds() {
828 if !Cargo::available() {
829 return;
830 }
831 let tmp = std::env::temp_dir();
832 let out = Cargo::output(&["--version"], &tmp);
833 assert!(out.is_ok(), "cargo --version must spawn");
834 let out = out.unwrap();
835 assert!(out.status.success(), "cargo --version must exit 0");
836 }
837
838 #[test]
839 fn external_tool_output_respects_cwd() {
840 // Verify that `output()` runs in the requested directory.
841 if !Git::available() {
842 return;
843 }
844 let tmp = std::env::temp_dir();
845 let out = Git::output(&["rev-parse", "--show-toplevel"], &tmp);
846 assert!(out.is_ok(), "git rev-parse must spawn");
847 let out = out.unwrap();
848 // rev-parse --show-toplevel in a non-git dir should fail
849 // because temp_dir is not a git repo. That's expected.
850 // The key assertion: the command executed without IO errors.
851 // We don't assert success because temp_dir might or might not
852 // be inside a git worktree.
853 let _ = out; // just checking it didn't panic/IO-error
854 }
855 }
856
856 lines RUST