diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 909c4753..c462fd70 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -1676,7 +1676,12 @@ fn run_cursor( match (project, output) { (Some(project_dir), None) => { let session = build_cursor_session(&path, Some(&project_dir), config)?; - write_into_cursor_db(&session, &project_dir, config)?; + write_into_cursor_db( + &session, + &project_dir, + config, + &crate::config::search_path(), + )?; } (None, Some(out_path)) => { let session = build_cursor_session(&path, None, config)?; @@ -1698,10 +1703,11 @@ pub(crate) fn project_cursor( path: &toolpath::v1::Path, project_dir: &std::path::Path, config: &Config, + search_path: &[PathBuf], ) -> Result { let session = build_cursor_session(path, Some(project_dir), config)?; let id = session.data.composer_id.clone(); - write_into_cursor_db(&session, project_dir, config)?; + write_into_cursor_db(&session, project_dir, config, search_path)?; Ok(id) } @@ -1754,6 +1760,7 @@ fn write_into_cursor_db( session: &toolpath_cursor::CursorSession, project_dir: &std::path::Path, config: &Config, + search_path: &[PathBuf], ) -> Result<()> { let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; @@ -1819,18 +1826,15 @@ fn write_into_cursor_db( ); eprintln!(); eprintln!("Open the workspace in Cursor.app:"); - for line in cursor_open_hints(&project_dir) { + for line in cursor_open_hints(&project_dir, search_path) { eprintln!(" {line}"); } Ok(()) } -fn cursor_open_hints(workspace: &std::path::Path) -> Vec { +fn cursor_open_hints(workspace: &std::path::Path, search_path: &[PathBuf]) -> Vec { let ws = workspace.display().to_string(); - let cursor_on_path = std::env::var_os("PATH") - .into_iter() - .flat_map(|p| std::env::split_paths(&p).collect::>()) - .any(|d| d.join("cursor").is_file()); + let cursor_on_path = search_path.iter().any(|d| d.join("cursor").is_file()); if cursor_on_path { return vec![format!("cursor {ws}")]; } diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 21eed44d..9d1218af 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -84,12 +84,18 @@ pub struct ResumeArgs { } pub fn run(args: ResumeArgs, config: &Config) -> Result<()> { - run_with_strategy(args, &RealExec, config) + let search_path = crate::config::search_path(); + run_with_strategy(args, &RealExec, config, &search_path) } /// Internal entry point that the integration tests call with a /// `RecordingExec` strategy. Production callers use [`run`]. -pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy, config: &Config) -> Result<()> { +pub fn run_with_strategy( + args: ResumeArgs, + exec: &dyn ExecStrategy, + config: &Config, + search_path: &[PathBuf], +) -> Result<()> { let (graph, source_harness) = resolve_input(&args, config)?; let path = ensure_path_with_agent(&graph)?; @@ -100,7 +106,7 @@ pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy, config: &Con None => std::env::current_dir()?, }; - let target = pick_harness(args.harness, source_harness, None)?; + let target = pick_harness(args.harness, source_harness, search_path)?; eprintln!( "Picked harness: {}{}", target.name(), @@ -111,8 +117,8 @@ pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy, config: &Con } ); - let session_id = project_into_harness(path, target, &cwd, config)?; - let (binary, argv) = invocation_for(target, &session_id, &cwd); + let session_id = project_into_harness(path, target, &cwd, config, search_path)?; + let (binary, argv) = invocation_for(target, &session_id, &cwd, search_path); exec_harness(&binary, &argv, &cwd, exec) } @@ -275,16 +281,10 @@ pub(crate) fn resolve_input( Ok((graph, harness)) } -/// Probe `$PATH` (or `path_override`, for tests) for a given binary name. -/// Cross-platform: on Windows, also tries `.exe`. -pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool { - let dirs: Vec = match path_override { - Some(p) => vec![p.to_path_buf()], - None => std::env::var_os("PATH") - .map(|p| std::env::split_paths(&p).collect()) - .unwrap_or_default(), - }; - for d in dirs { +/// Probe `search_path` for a given binary name. Cross-platform: on +/// Windows, also tries `.exe`. +pub(crate) fn binary_on_path(name: &str, search_path: &[PathBuf]) -> bool { + for d in search_path { let candidate = d.join(name); if candidate.is_file() { return true; @@ -304,18 +304,18 @@ pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path> /// explicitly from the IDE's command palette, but `open -a Cursor` /// (macOS) / `xdg-open` (Linux) always work. Treat cursor as available /// when either path is open. -pub(crate) fn harness_available(harness: Harness, path_override: Option<&std::path::Path>) -> bool { - if binary_on_path(harness.name(), path_override) { +pub(crate) fn harness_available(harness: Harness, search_path: &[PathBuf]) -> bool { + if binary_on_path(harness.name(), search_path) { return true; } if harness == Harness::Cursor { #[cfg(target_os = "macos")] { - return binary_on_path("open", path_override); + return binary_on_path("open", search_path); } #[cfg(all(unix, not(target_os = "macos")))] { - return binary_on_path("xdg-open", path_override); + return binary_on_path("xdg-open", search_path); } } false @@ -326,15 +326,13 @@ pub(crate) fn harness_available(harness: Harness, path_override: Option<&std::pa /// - If `arg` is `Some`, validate the named harness is on PATH and return it. /// - Otherwise, enumerate installed harnesses and launch the fzf picker. /// `source` is used to label the source row in the picker UI. -/// -/// `path_override` is `None` in production; tests pass `Some(dir)` to fake `$PATH`. pub(crate) fn pick_harness( arg: Option, source: Option, - path_override: Option<&std::path::Path>, + search_path: &[PathBuf], ) -> Result { if let Some(h) = arg { - if !harness_available(h, path_override) { + if !harness_available(h, search_path) { anyhow::bail!( "harness `{}` isn't on PATH; install it or pick another with `--harness`", h.name() @@ -346,7 +344,7 @@ pub(crate) fn pick_harness( let installed: Vec = Harness::ALL .iter() .copied() - .filter(|h| harness_available(*h, path_override)) + .filter(|h| harness_available(*h, search_path)) .collect(); if installed.is_empty() { @@ -426,16 +424,17 @@ pub(crate) fn invocation_for( harness: Harness, session_id: &str, cwd: &std::path::Path, + search_path: &[PathBuf], ) -> (String, Vec) { if harness == Harness::Cursor { - return cursor_invocation(cwd); + return cursor_invocation(cwd, search_path); } (harness.name().to_string(), argv_for(harness, session_id)) } -fn cursor_invocation(cwd: &std::path::Path) -> (String, Vec) { +fn cursor_invocation(cwd: &std::path::Path, search_path: &[PathBuf]) -> (String, Vec) { let workspace = cwd.to_string_lossy().into_owned(); - if binary_on_path("cursor", None) { + if binary_on_path("cursor", search_path) { ("cursor".to_string(), vec![workspace]) } else { #[cfg(target_os = "macos")] @@ -463,6 +462,7 @@ pub(crate) fn project_into_harness( harness: Harness, cwd: &std::path::Path, config: &Config, + search_path: &[PathBuf], ) -> Result { match harness { Harness::Claude => match crate::cmd_export::project_claude(path, cwd, config)? { @@ -478,7 +478,7 @@ pub(crate) fn project_into_harness( Harness::Codex => crate::cmd_export::project_codex(path, cwd, config), Harness::Copilot => crate::cmd_export::project_copilot(path, cwd, config), Harness::Opencode => crate::cmd_export::project_opencode(path, cwd, config), - Harness::Cursor => crate::cmd_export::project_cursor(path, cwd, config), + Harness::Cursor => crate::cmd_export::project_cursor(path, cwd, config, search_path), Harness::Pi => crate::cmd_export::project_pi(path, cwd, config), } } @@ -604,13 +604,9 @@ mod tests { #[test] fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { - // The `$PATH` guard mutates process-global state; the lock - // serializes it against the other env-mutating tests. - let _env = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); let home = tempfile::tempdir().unwrap(); - let _path_guard = ScopedPathForResume::with_binaries(&["claude"]); + let bin_dir = fake_path_with(&["claude"]); + let search_path = vec![bin_dir.path().to_path_buf()]; let cwd = tempfile::tempdir().unwrap(); let doc_file = cwd.path().join("doc.json"); @@ -634,7 +630,13 @@ mod tests { }; let recorder = RecordingExec::default(); - run_with_strategy(args, &recorder, &config_with_home(home.path())).unwrap(); + run_with_strategy( + args, + &recorder, + &config_with_home(home.path()), + &search_path, + ) + .unwrap(); let cap = recorder.captured(); assert_eq!(cap.binary, "claude"); @@ -901,17 +903,19 @@ mod tests { #[test] fn binary_on_path_finds_present_binary() { let td = fake_path_with(&["claude"]); - assert!(binary_on_path("claude", Some(td.path()))); - assert!(!binary_on_path("gemini", Some(td.path()))); + let search_path = vec![td.path().to_path_buf()]; + assert!(binary_on_path("claude", &search_path)); + assert!(!binary_on_path("gemini", &search_path)); } #[test] fn pick_harness_explicit_arg_validates_path() { let td = fake_path_with(&["claude"]); - let result = pick_harness(Some(Harness::Claude), None, Some(td.path())); + let search_path = vec![td.path().to_path_buf()]; + let result = pick_harness(Some(Harness::Claude), None, &search_path); assert_eq!(result.unwrap(), Harness::Claude); - let err = pick_harness(Some(Harness::Gemini), None, Some(td.path())).unwrap_err(); + let err = pick_harness(Some(Harness::Gemini), None, &search_path).unwrap_err(); assert!(err.to_string().contains("`gemini` isn't on PATH")); } @@ -919,21 +923,25 @@ mod tests { #[test] fn cursor_available_via_open_fallback_on_macos() { let td = fake_path_with(&["open"]); - assert!(harness_available(Harness::Cursor, Some(td.path()))); - let picked = pick_harness(Some(Harness::Cursor), None, Some(td.path())); + let search_path = vec![td.path().to_path_buf()]; + assert!(harness_available(Harness::Cursor, &search_path)); + let picked = pick_harness(Some(Harness::Cursor), None, &search_path); assert_eq!(picked.unwrap(), Harness::Cursor); } #[test] fn cursor_unavailable_when_no_launcher_at_all() { let td = fake_path_with(&["claude"]); - assert!(!harness_available(Harness::Cursor, Some(td.path()))); + assert!(!harness_available( + Harness::Cursor, + &[td.path().to_path_buf()] + )); } #[test] fn cursor_invocation_includes_workspace_path() { let cwd = std::path::PathBuf::from("/tmp/some-workspace"); - let (binary, argv) = invocation_for(Harness::Cursor, "ignored-session-id", &cwd); + let (binary, argv) = invocation_for(Harness::Cursor, "ignored-session-id", &cwd, &[]); assert!( argv.iter().any(|a| a == "/tmp/some-workspace"), "workspace path must appear in argv; got {argv:?}", @@ -947,7 +955,8 @@ mod tests { #[test] fn pick_harness_zero_installed_errors() { let td = fake_path_with(&[]); - let err = pick_harness(None, Some(Harness::Claude), Some(td.path())).unwrap_err(); + let err = + pick_harness(None, Some(Harness::Claude), &[td.path().to_path_buf()]).unwrap_err(); assert!( err.to_string().contains("no installed harnesses") || err.to_string().contains("no harnesses on PATH"), @@ -987,7 +996,8 @@ mod tests { let path = make_convo_path_for_resume("claude-code://resume-test-session"); let config = config_with_home(home.path()); - let session_id = project_into_harness(&path, Harness::Claude, cwd.path(), &config).unwrap(); + let session_id = + project_into_harness(&path, Harness::Claude, cwd.path(), &config, &[]).unwrap(); assert!(!session_id.is_empty()); } @@ -1034,43 +1044,6 @@ mod tests { } } - struct ScopedPathForResume { - _bin_dir: tempfile::TempDir, - prev: Option, - } - - impl ScopedPathForResume { - /// Prepends a tempdir containing the named binaries to `PATH` for - /// the guard's lifetime. - fn with_binaries(binaries: &[&str]) -> Self { - let bin_dir = fake_path_with(binaries); - let prev = std::env::var_os("PATH"); - let new_path = std::env::join_paths( - std::iter::once(bin_dir.path().to_path_buf()) - .chain(std::env::split_paths(&prev.clone().unwrap_or_default())), - ) - .unwrap(); - unsafe { - std::env::set_var("PATH", new_path); - } - Self { - _bin_dir: bin_dir, - prev, - } - } - } - - impl Drop for ScopedPathForResume { - fn drop(&mut self) { - unsafe { - match &self.prev { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } - } - } - #[test] fn exec_strategy_recording_captures_invocation() { let recorder = RecordingExec::default(); diff --git a/crates/path-cli/src/config.rs b/crates/path-cli/src/config.rs index 9e5f6456..4dccd4c6 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -170,6 +170,16 @@ impl Config { } } +/// The directories of `$PATH`, in order. Empty when `$PATH` is unset. +/// +/// The environment is read here so consumers take the search path as a +/// parameter. +pub(crate) fn search_path() -> Vec { + std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default() +} + /// Display `path` as `~/relative/part` when it's under `home`, otherwise /// return its absolute lossy form. Pure helper — does no filesystem I/O. pub(crate) fn home_relative(path: &std::path::Path, home: Option<&std::path::Path>) -> String { @@ -198,9 +208,9 @@ mod tests { use super::*; /// `figment::Jail` restores the variables it sets, but it serializes - /// only against other Jail tests. Hold `TEST_ENV_LOCK` too: the - /// `$PATH` guard in `cmd_resume` mutates the environment under that - /// lock. + /// only against other Jail tests. Hold `TEST_ENV_LOCK` too: it + /// serializes against every other test that mutates the + /// environment. // result_large_err: the Jail closure returns figment's own // 208-byte error type. #[test] diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index c348ca1c..6de318e3 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -2,9 +2,9 @@ //! //! Tests dispatch through `path_cli::cmd_resume::run_with_strategy` //! with a `RecordingExec` strategy so the would-be `execvp` becomes a -//! captured `(binary, args, cwd)` tuple. Each test isolates `$HOME`, -//! `$TOOLPATH_CONFIG_DIR`, and `$PATH` via RAII guards under a shared -//! lock. +//! captured `(binary, args, cwd)` tuple. Each test isolates `$HOME` and +//! `$TOOLPATH_CONFIG_DIR` via an RAII guard under a shared lock, and +//! passes a tempdir of fake binaries as the search path. #![cfg(not(target_os = "emscripten"))] @@ -20,7 +20,7 @@ use support::*; fn file_input_explicit_claude_projects_and_records_exec() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::with_binary("claude"); + let bin = fake_bin_dir(&["claude"]); let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path("agent:claude-code", "claude-code://resume-claude-int"); @@ -31,6 +31,7 @@ fn file_input_explicit_claude_projects_and_records_exec() { args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, &home.config(), + &[bin.path().to_path_buf()], ) .unwrap(); @@ -55,7 +56,7 @@ fn file_input_explicit_claude_projects_and_records_exec() { fn file_input_explicit_gemini_projects_and_records_exec() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::with_binary("gemini"); + let bin = fake_bin_dir(&["gemini"]); let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path("agent:gemini-cli", "gemini-cli://resume-gemini-int"); @@ -66,6 +67,7 @@ fn file_input_explicit_gemini_projects_and_records_exec() { args_explicit(doc_file, cwd.path(), Harness::Gemini), &recorder, &home.config(), + &[bin.path().to_path_buf()], ) .unwrap(); @@ -84,7 +86,7 @@ fn file_input_explicit_gemini_projects_and_records_exec() { fn file_input_explicit_codex_projects_and_records_exec() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::with_binary("codex"); + let bin = fake_bin_dir(&["codex"]); let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path("agent:codex", "codex://resume-codex-int"); @@ -95,6 +97,7 @@ fn file_input_explicit_codex_projects_and_records_exec() { args_explicit(doc_file, cwd.path(), Harness::Codex), &recorder, &home.config(), + &[bin.path().to_path_buf()], ) .unwrap(); @@ -113,7 +116,7 @@ fn file_input_explicit_codex_projects_and_records_exec() { fn file_input_explicit_copilot_projects_and_records_exec() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::with_binary("copilot"); + let bin = fake_bin_dir(&["copilot"]); let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path("agent:copilot", "copilot://resume-copilot-int"); @@ -124,6 +127,7 @@ fn file_input_explicit_copilot_projects_and_records_exec() { args_explicit(doc_file, cwd.path(), Harness::Copilot), &recorder, &home.config(), + &[bin.path().to_path_buf()], ) .unwrap(); @@ -149,7 +153,7 @@ fn file_input_explicit_copilot_projects_and_records_exec() { fn file_input_explicit_opencode_projects_and_records_exec() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::with_binary("opencode"); + let bin = fake_bin_dir(&["opencode"]); let cwd = tempfile::tempdir().unwrap(); // Pre-create the opencode db with the canonical schema. (Schema DDL @@ -199,6 +203,7 @@ fn file_input_explicit_opencode_projects_and_records_exec() { args_explicit(doc_file, cwd.path(), Harness::Opencode), &recorder, &home.config(), + &[bin.path().to_path_buf()], ) .unwrap(); @@ -218,7 +223,7 @@ fn file_input_explicit_opencode_projects_and_records_exec() { fn file_input_explicit_pi_projects_and_records_exec() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::with_binary("pi"); + let bin = fake_bin_dir(&["pi"]); let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path("agent:pi", "pi://resume-pi-int"); @@ -229,6 +234,7 @@ fn file_input_explicit_pi_projects_and_records_exec() { args_explicit(doc_file, cwd.path(), Harness::Pi), &recorder, &home.config(), + &[bin.path().to_path_buf()], ) .unwrap(); @@ -249,7 +255,7 @@ fn file_input_explicit_pi_projects_and_records_exec() { fn cache_id_input_loads_and_projects() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::with_binary("claude"); + let bin = fake_bin_dir(&["claude"]); let cwd = tempfile::tempdir().unwrap(); // Seed a cache entry by writing the graph to @@ -278,7 +284,13 @@ fn cache_id_input_loads_and_projects() { }; let recorder = RecordingExec::default(); - run_with_strategy(resume_args, &recorder, &home.config()).unwrap(); + run_with_strategy( + resume_args, + &recorder, + &home.config(), + &[bin.path().to_path_buf()], + ) + .unwrap(); let cap = recorder.captured(); assert_eq!(cap.binary, "claude"); @@ -291,7 +303,7 @@ fn cache_id_input_loads_and_projects() { fn multi_path_graph_returns_clear_error() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::with_binary("claude"); + let bin = fake_bin_dir(&["claude"]); let cwd = tempfile::tempdir().unwrap(); let p1 = make_convo_path("agent:claude-code", "claude-code://multi-1"); @@ -314,6 +326,7 @@ fn multi_path_graph_returns_clear_error() { args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, &home.config(), + &[bin.path().to_path_buf()], ) .unwrap_err(); let s = err.to_string(); @@ -325,7 +338,7 @@ fn multi_path_graph_returns_clear_error() { fn agentless_path_returns_clear_error() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::with_binary("claude"); + let bin = fake_bin_dir(&["claude"]); let cwd = tempfile::tempdir().unwrap(); // human:* actor — should be rejected by ensure_path_with_agent. @@ -337,6 +350,7 @@ fn agentless_path_returns_clear_error() { args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, &home.config(), + &[bin.path().to_path_buf()], ) .unwrap_err(); assert!(err.to_string().contains("no agent session")); @@ -346,7 +360,6 @@ fn agentless_path_returns_clear_error() { fn explicit_harness_not_on_path_errors() { let _env = env_lock(); let home = ScopedHome::new(); - let _path = ScopedPath::empty(); let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path("agent:claude-code", "claude-code://no-binary"); @@ -357,6 +370,7 @@ fn explicit_harness_not_on_path_errors() { args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, &home.config(), + &[], ) .unwrap_err(); let s = err.to_string(); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index c652a3cf..cf62ad45 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -15,7 +15,7 @@ use path_cli::cmd_resume::ResumeArgs; use path_cli::config::Config; use path_cli::harness::Harness; -/// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or +/// Process-wide lock for tests that mutate `$HOME` or /// `$TOOLPATH_CONFIG_DIR`. Integration tests under `tests/resume.rs` /// can't reach the library's internal `crate::config::TEST_ENV_LOCK`, /// so we use a separate lock here. Crucially, no library test holds @@ -78,61 +78,22 @@ impl Drop for ScopedHome { } } -/// RAII guard that prepends a tempdir of fake binaries to `$PATH`. -pub struct ScopedPath { - _td: tempfile::TempDir, - prev: Option, -} - -impl ScopedPath { - pub fn with_binary(name: &str) -> Self { - Self::with_binaries(&[name]) - } - - pub fn with_binaries(names: &[&str]) -> Self { - let td = tempfile::tempdir().unwrap(); - for n in names { - let p = td.path().join(n); - std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perm = std::fs::metadata(&p).unwrap().permissions(); - perm.set_mode(0o755); - std::fs::set_permissions(&p, perm).unwrap(); - } - } - let prev = std::env::var_os("PATH"); - let new_path = std::env::join_paths( - std::iter::once(td.path().to_path_buf()) - .chain(std::env::split_paths(&prev.clone().unwrap_or_default())), - ) - .unwrap(); - unsafe { - std::env::set_var("PATH", new_path); - } - Self { _td: td, prev } - } - - pub fn empty() -> Self { - let td = tempfile::tempdir().unwrap(); - let prev = std::env::var_os("PATH"); - unsafe { - std::env::set_var("PATH", td.path()); - } - Self { _td: td, prev } - } -} - -impl Drop for ScopedPath { - fn drop(&mut self) { - unsafe { - match &self.prev { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } +/// A tempdir holding an executable stub per name in `names`. Pass its +/// path as the search path the code under test probes. +pub fn fake_bin_dir(names: &[&str]) -> tempfile::TempDir { + let td = tempfile::tempdir().unwrap(); + for n in names { + let p = td.path().join(n); + std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perm = std::fs::metadata(&p).unwrap().permissions(); + perm.set_mode(0o755); + std::fs::set_permissions(&p, perm).unwrap(); } } + td } /// Build a minimal `Path` whose single step has the given `actor`