Skip to main content

osdk_core/activate/
mod.rs

1//! Shell activation.
2//!
3//! Two mechanisms (both offered, like mise):
4//! - Shims (default): the shims dir on PATH; robust in IDEs/CI. Set up by
5//!   `osdk` itself when tools are installed.
6//! - Shell activation (`osdk activate <shell>`): injects a hook that runs
7//!   `osdk hook-env` on each prompt / dir change, putting a project's installed
8//!   commands before global shims and active versions' real bin dirs, and
9//!   exporting their env (GOROOT/JAVA_HOME/...).
10//!
11//! This module renders the per-shell snippets and computes the env delta.
12
13use std::collections::BTreeMap;
14use std::path::PathBuf;
15
16use crate::backend::registry::Registry;
17use crate::backend::Ctx;
18use crate::version::resolver::resolve_active;
19use crate::version::{select_version, ToolVersion, VersionInfo, VersionSpec};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Shell {
23    Bash,
24    Zsh,
25    Fish,
26    Powershell,
27}
28
29impl std::str::FromStr for Shell {
30    type Err = crate::error::Error;
31    fn from_str(s: &str) -> crate::Result<Self> {
32        Ok(match s.to_ascii_lowercase().as_str() {
33            "bash" => Shell::Bash,
34            "zsh" => Shell::Zsh,
35            "fish" => Shell::Fish,
36            "powershell" | "pwsh" => Shell::Powershell,
37            other => {
38                return Err(crate::error::Error::other(format!(
39                    "unsupported shell `{other}` (expected bash|zsh|fish|powershell)"
40                )))
41            }
42        })
43    }
44}
45
46/// Render the shell-integration snippet for `osdk activate <shell>`. The snippet
47/// wires a prompt/dir-change hook that evals `osdk hook-env`.
48pub fn activation_script(shell: Shell, osdk_bin: &str) -> String {
49    match shell {
50        Shell::Bash => format!(
51            r#"# osdk shell integration (bash)
52_osdk_hook() {{
53  local out
54  out="$({bin} hook-env --shell bash 2>/dev/null)" && eval "$out"
55}}
56if [[ ";${{PROMPT_COMMAND:-}};" != *";_osdk_hook;"* ]]; then
57  PROMPT_COMMAND="_osdk_hook${{PROMPT_COMMAND:+;$PROMPT_COMMAND}}"
58fi
59_osdk_hook
60"#,
61            bin = shell_quote(Shell::Bash, osdk_bin)
62        ),
63        Shell::Zsh => format!(
64            r#"# osdk shell integration (zsh)
65_osdk_hook() {{
66  local out
67  out="$({bin} hook-env --shell zsh 2>/dev/null)" && eval "$out"
68}}
69typeset -ag precmd_functions
70if [[ -z ${{precmd_functions[(r)_osdk_hook]}} ]]; then
71  precmd_functions+=(_osdk_hook)
72fi
73_osdk_hook
74"#,
75            bin = shell_quote(Shell::Zsh, osdk_bin)
76        ),
77        Shell::Fish => format!(
78            r#"# osdk shell integration (fish)
79function _osdk_hook --on-variable PWD --on-event fish_prompt
80  {bin} hook-env --shell fish 2>/dev/null | source
81end
82_osdk_hook
83"#,
84            bin = shell_quote(Shell::Fish, osdk_bin)
85        ),
86        Shell::Powershell => format!(
87            r#"# osdk shell integration (powershell)
88$script:OsdkHookRunning = $false
89function Invoke-OsdkHook {{
90  $out = & {bin} hook-env --shell powershell 2>$null
91  if ($out) {{ Invoke-Expression ($out -join "`n") }}
92}}
93Invoke-OsdkHook
94$ExecutionContext.SessionState.InvokeCommand.PostCommandLookupAction = {{
95  if ($script:OsdkHookRunning) {{ return }}
96  $script:OsdkHookRunning = $true
97  try {{ Invoke-OsdkHook }} finally {{ $script:OsdkHookRunning = $false }}
98}}
99"#,
100            bin = powershell_quote(osdk_bin)
101        ),
102    }
103}
104
105/// Render shell code that removes osdk's hook and restores the environment
106/// captured by [`render_hook_env`].
107pub fn deactivation_script(shell: Shell) -> String {
108    match shell {
109        Shell::Bash => r#"# osdk shell deactivation (bash)
110if [ -n "${OSDK_MANAGED_ENV:-}" ]; then
111  _osdk_saved_ifs=$IFS
112  IFS=,
113  for _osdk_key in $OSDK_MANAGED_ENV; do
114    case "$_osdk_key" in
115      ''|*[!A-Za-z0-9_]*) continue ;;
116    esac
117    eval "_osdk_present=\${OSDK_ORIG_${_osdk_key}_PRESENT:-0}"
118    if [ "$_osdk_present" = 1 ]; then
119      eval "export ${_osdk_key}=\${OSDK_ORIG_${_osdk_key}}"
120    else
121      unset "$_osdk_key"
122    fi
123    unset "OSDK_ORIG_${_osdk_key}" "OSDK_ORIG_${_osdk_key}_PRESENT" "OSDK_ORIG_${_osdk_key}_SET"
124  done
125  IFS=$_osdk_saved_ifs
126  unset _osdk_saved_ifs _osdk_key _osdk_present
127fi
128if [ -n "${OSDK_ORIGINAL_PATH_SET+x}" ]; then export PATH="$OSDK_ORIGINAL_PATH"; fi
129PROMPT_COMMAND=";${PROMPT_COMMAND:-};"
130PROMPT_COMMAND="${PROMPT_COMMAND//;_osdk_hook;/;}"
131PROMPT_COMMAND="${PROMPT_COMMAND#;}"
132PROMPT_COMMAND="${PROMPT_COMMAND%;}"
133unset -f _osdk_hook 2>/dev/null || true
134unset OSDK_MANAGED_ENV OSDK_ORIGINAL_PATH OSDK_ORIGINAL_PATH_SET
135"#
136        .to_string(),
137        Shell::Zsh => r#"# osdk shell deactivation (zsh)
138if [[ -n ${OSDK_MANAGED_ENV:-} ]]; then
139  local _osdk_key
140  for _osdk_key in ${(s:,:)OSDK_MANAGED_ENV}; do
141    [[ $_osdk_key == [A-Za-z_][A-Za-z0-9_]# ]] || continue
142    local _osdk_present_var="OSDK_ORIG_${_osdk_key}_PRESENT"
143    local _osdk_original_var="OSDK_ORIG_${_osdk_key}"
144    if [[ ${(P)_osdk_present_var:-0} == 1 ]]; then
145      export "$_osdk_key=${(P)_osdk_original_var}"
146    else
147      unset "$_osdk_key"
148    fi
149    unset "OSDK_ORIG_${_osdk_key}" "OSDK_ORIG_${_osdk_key}_PRESENT" "OSDK_ORIG_${_osdk_key}_SET"
150  done
151fi
152if [[ -n ${OSDK_ORIGINAL_PATH_SET+x} ]]; then export PATH="$OSDK_ORIGINAL_PATH"; fi
153precmd_functions=(${precmd_functions:#_osdk_hook})
154unfunction _osdk_hook 2>/dev/null || true
155unset OSDK_MANAGED_ENV OSDK_ORIGINAL_PATH OSDK_ORIGINAL_PATH_SET
156"#
157        .to_string(),
158        Shell::Fish => r#"# osdk shell deactivation (fish)
159if set -q OSDK_MANAGED_ENV
160  for _osdk_key in (string split , -- $OSDK_MANAGED_ENV)
161    string match -rq '^[A-Za-z_][A-Za-z0-9_]*$' -- $_osdk_key; or continue
162    set _osdk_original OSDK_ORIG_$_osdk_key
163    set _osdk_present $_osdk_original"_PRESENT"
164    if test "$$_osdk_present" = 1
165      set -gx $_osdk_key "$$_osdk_original"
166    else
167      set -e $_osdk_key
168    end
169    set -e $_osdk_original $_osdk_present $_osdk_original"_SET"
170  end
171  set -e _osdk_key _osdk_original _osdk_present
172end
173if set -q OSDK_ORIGINAL_PATH_SET
174  set -gx PATH $OSDK_ORIGINAL_PATH
175end
176functions -e _osdk_hook
177set -e OSDK_MANAGED_ENV OSDK_ORIGINAL_PATH OSDK_ORIGINAL_PATH_SET
178"#
179        .to_string(),
180        Shell::Powershell => r#"# osdk shell deactivation (powershell)
181if (Test-Path Env:OSDK_MANAGED_ENV) {
182  foreach ($osdkKey in ($env:OSDK_MANAGED_ENV -split ',')) {
183    if ($osdkKey -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { continue }
184    $original = "OSDK_ORIG_${osdkKey}"
185    $present = "${original}_PRESENT"
186    if ([Environment]::GetEnvironmentVariable($present) -eq '1') {
187      [Environment]::SetEnvironmentVariable($osdkKey, [Environment]::GetEnvironmentVariable($original))
188    } else {
189      Remove-Item "Env:$osdkKey" -ErrorAction SilentlyContinue
190    }
191    Remove-Item "Env:$original","Env:$present","Env:${original}_SET" -ErrorAction SilentlyContinue
192  }
193}
194if (Test-Path Env:OSDK_ORIGINAL_PATH_SET) { $env:PATH = $env:OSDK_ORIGINAL_PATH }
195$ExecutionContext.SessionState.InvokeCommand.PostCommandLookupAction = $null
196Remove-Item Function:Invoke-OsdkHook -ErrorAction SilentlyContinue
197Remove-Variable OsdkHookRunning -Scope Script -ErrorAction SilentlyContinue
198Remove-Item Env:OSDK_MANAGED_ENV,Env:OSDK_ORIGINAL_PATH,Env:OSDK_ORIGINAL_PATH_SET -ErrorAction SilentlyContinue
199"#
200        .to_string(),
201    }
202}
203
204/// The env changes to apply for the active toolset in `cwd`.
205pub struct EnvDelta {
206    /// Directories to prepend to PATH (project bins, shims, and active tools'
207    /// bin dirs, with a managed Node guard when required).
208    pub path_prepend: Vec<PathBuf>,
209    /// Variables to set (GOROOT, JAVA_HOME, ...).
210    pub set_vars: BTreeMap<String, String>,
211    /// Variables managed by the previous hook invocation but no longer active.
212    pub unset_vars: Vec<String>,
213}
214
215/// Compute the env delta for the directory `cwd`: for each backend with an
216/// active + installed version, collect its bin dirs and exec env. Invalid
217/// dynamic-tool inventory aborts activation instead of producing a partial
218/// environment that could route around the configured tool.
219pub fn compute_env_delta(
220    ctx: &Ctx,
221    registry: &Registry,
222    cwd: &std::path::Path,
223) -> crate::Result<EnvDelta> {
224    let mut path_prepend = Vec::new();
225    let mut set_vars = BTreeMap::new();
226    let mut has_generated_shim = false;
227    let dynamic_report = crate::shim::scan_dynamic_installs(ctx)?;
228    let project_npm_bin = trusted_project_npm_bin(ctx, cwd)?;
229    let mut backend_ids = registry
230        .all()
231        .iter()
232        .map(|backend| backend.id().to_string())
233        .collect::<Vec<_>>();
234    backend_ids.extend(crate::shim::configured_and_installed_dynamic_ids(
235        ctx,
236        &dynamic_report,
237    ));
238    backend_ids.sort();
239    backend_ids.dedup();
240
241    for backend_id in backend_ids {
242        let Ok(backend) = registry.get(&backend_id) else {
243            continue;
244        };
245        let dynamic_request = crate::shim::dynamic_request_from_config(ctx, backend.id());
246        // Project-aware npm `use` is owned by the real project's package
247        // manager and its curated `.osdk/npm-bin` generation below. It has no
248        // osdk-owned install inventory to validate or expose here.
249        if dynamic_request.as_ref().is_some_and(|request| {
250            request.backend.starts_with("npm:")
251                && project_npm_bin.is_some()
252                && crate::shim::configured_npm_scope(ctx, request)
253                    .is_ok_and(|scope| scope == Some(crate::npm_tools::ToolScope::Project))
254        }) {
255            continue;
256        }
257        let active = match resolve_active(
258            backend.id(),
259            cwd,
260            &ctx.config.tools,
261            backend.idiomatic_files(),
262        ) {
263            Some(a) => Some((a.spec, a.is_range)),
264            None => dynamic_request
265                .as_ref()
266                .map(|request| (request.spec.to_string(), false)),
267        };
268        let Some((active_spec, active_is_range)) = active else {
269            continue;
270        };
271        // Resolve to an installed version.
272        let expanded = ctx
273            .config
274            .expand_alias(backend.id(), &active_spec)
275            .unwrap_or(active_spec);
276        let spec = strip_distribution_prefix(&expanded);
277        let parsed = if active_is_range {
278            VersionSpec::parse_range(spec).unwrap_or_else(|_| VersionSpec::parse(spec))
279        } else {
280            VersionSpec::parse(spec)
281        };
282        // Exact configured dynamic requests are validated at their requested
283        // root even when a backend omits legacy/missing inventories from its
284        // installed list. This turns stale complete roots into explicit errors.
285        let version = match (&dynamic_request, &parsed) {
286            (Some(_), VersionSpec::Exact(version)) => Some(version.clone()),
287            _ => {
288                let installed = if dynamic_request.is_some() {
289                    backend.list_installed(ctx)?
290                } else {
291                    backend.list_installed(ctx).unwrap_or_default()
292                };
293                if backend.id() == "python" {
294                    crate::backend::python::select_installed(spec, &installed)
295                } else {
296                    match &parsed {
297                        VersionSpec::Exact(v) if installed.iter().any(|i| i == v) => {
298                            Some(v.clone())
299                        }
300                        _ => {
301                            let infos: Vec<VersionInfo> =
302                                installed.iter().map(VersionInfo::stable).collect();
303                            select_version(&parsed, &infos).map(|vi| vi.version.clone())
304                        }
305                    }
306                }
307            }
308        };
309        let version = match version {
310            Some(v) => v,
311            None => continue,
312        };
313        let mut tv = ToolVersion::new(backend.id(), &version);
314        let dynamic_install = if let Some(request) = dynamic_request.as_ref() {
315            tv.options = request.options.clone();
316            Some(crate::shim::validated_dynamic_install(
317                ctx,
318                &dynamic_report,
319                request,
320                &version,
321            )?)
322        } else {
323            None
324        };
325        let routed_names = if let Some(install) = dynamic_install.as_ref() {
326            install.bin_names()
327        } else {
328            crate::shim::routed_bin_names(ctx, backend.as_ref(), &tv).unwrap_or_default()
329        };
330        if routed_names
331            .into_iter()
332            .any(|name| shim_exists(&ctx.dirs.shims(), &name))
333        {
334            has_generated_shim = true;
335        }
336        let bins = if let Some(install) = dynamic_install.as_ref() {
337            install.bin_paths()
338        } else {
339            backend.bin_paths(ctx, &tv).unwrap_or_default()
340        };
341        for b in bins {
342            if b.exists() {
343                path_prepend.push(b);
344            }
345        }
346        let env = if dynamic_request.is_some() {
347            Some(backend.exec_env(ctx, &tv)?)
348        } else {
349            backend.exec_env(ctx, &tv).ok()
350        };
351        if let Some(env) = env {
352            for (k, v) in env {
353                set_vars.insert(k, v);
354            }
355        }
356    }
357
358    prioritize_managed_paths(&mut path_prepend, &ctx.dirs.shims(), has_generated_shim);
359    if let Some(project_bin) = project_npm_bin {
360        prioritize_project_bin(&mut path_prepend, project_bin);
361    }
362
363    let previous = std::env::var("OSDK_MANAGED_ENV").unwrap_or_default();
364    let unset_vars = previous
365        .split(',')
366        .filter(|key| !key.is_empty() && valid_env_name(key) && !set_vars.contains_key(*key))
367        .map(str::to_string)
368        .collect();
369
370    Ok(EnvDelta {
371        path_prepend,
372        set_vars,
373        unset_vars,
374    })
375}
376
377fn trusted_project_npm_bin(ctx: &Ctx, cwd: &std::path::Path) -> crate::Result<Option<PathBuf>> {
378    let config_path = ctx.config.tool_origins.iter().find_map(|(key, origin)| {
379        let configured = key.starts_with("npm:")
380            || ctx
381                .config
382                .tools
383                .get(key)
384                .is_some_and(|value| value.starts_with("npm:"));
385        match (configured, origin) {
386            (true, crate::config::ToolConfigOrigin::ProjectConfig(path)) => Some(path),
387            _ => None,
388        }
389    });
390    let Some(config_path) = config_path else {
391        return Ok(None);
392    };
393    let Some(config_root) = config_path.parent() else {
394        return Ok(None);
395    };
396    let Ok(config_root) = dunce::canonicalize(config_root) else {
397        return Ok(None);
398    };
399    let Ok(cwd) = dunce::canonicalize(cwd) else {
400        return Ok(None);
401    };
402    let Some(project_root) = nearest_package_root(&cwd) else {
403        return Ok(None);
404    };
405    if !same_existing_path(&config_root, &project_root)
406        || !crate::trust::is_trusted(
407            &ctx.dirs.config,
408            config_path,
409            std::env::var_os("OSDK_TRUSTED_CONFIG_PATHS").as_ref(),
410        )
411        .unwrap_or(false)
412    {
413        return Ok(None);
414    }
415    let configured_specs = project_npm_configured_specs(ctx, config_path)?;
416    crate::backend::npm_package::validated_project_bin_dir(&project_root, &configured_specs)
417        .map_err(|error| crate::error::Error::other(error.to_string()))
418}
419
420fn project_npm_configured_specs(
421    ctx: &Ctx,
422    config_path: &std::path::Path,
423) -> crate::Result<BTreeMap<String, String>> {
424    let config_root = config_path.parent().ok_or_else(|| {
425        crate::error::Error::other(format!(
426            "project config has no parent: {}",
427            config_path.display()
428        ))
429    })?;
430    let config = crate::config::Config::load(&ctx.dirs.user_config_file(), config_root)?;
431    canonical_project_npm_specs(
432        config
433            .tools
434            .iter()
435            .filter(|(key, _)| {
436                matches!(
437                    config.tool_origins.get(*key),
438                    Some(crate::config::ToolConfigOrigin::ProjectConfig(path))
439                        if same_existing_path(path, config_path)
440                )
441            })
442            .map(|(key, value)| (key.clone(), value.clone())),
443    )
444}
445
446/// Reconstruct canonical npm backend specs from project tool entries without
447/// letting raw-key ordering decide which of two aliases wins.
448pub fn canonical_project_npm_specs(
449    entries: impl IntoIterator<Item = (String, String)>,
450) -> crate::Result<BTreeMap<String, String>> {
451    let mut entries = entries.into_iter().collect::<Vec<_>>();
452    entries.sort_by(|left, right| left.0.cmp(&right.0));
453    let mut configured = BTreeMap::<String, (String, String)>::new();
454    for (key, value) in entries {
455        let entry = if key.starts_with("npm:") {
456            Some((key.clone(), value))
457        } else {
458            crate::version::ToolRequest::parse(&value)
459                .ok()
460                .filter(|request| request.backend.starts_with("npm:"))
461                .map(|request| (request.backend, request.spec.to_string()))
462        };
463        let Some((backend, spec)) = entry else {
464            continue;
465        };
466        if let Some((existing_key, existing_spec)) = configured.get(&backend) {
467            if existing_spec != &spec {
468                return Err(crate::error::Error::other(format!(
469                    "conflicting project npm aliases for `{backend}`: `{existing_key}` configures `{existing_spec}`, but `{key}` configures `{spec}`"
470                )));
471            }
472            continue;
473        }
474        configured.insert(backend, (key, spec));
475    }
476    Ok(configured
477        .into_iter()
478        .map(|(backend, (_, spec))| (backend, spec))
479        .collect())
480}
481
482fn nearest_package_root(cwd: &std::path::Path) -> Option<PathBuf> {
483    let root = cwd.ancestors().find_map(|directory| {
484        let package_json = directory.join("package.json");
485        match std::fs::symlink_metadata(&package_json) {
486            Ok(metadata) => Some(
487                metadata
488                    .file_type()
489                    .is_file()
490                    .then(|| directory.to_path_buf()),
491            ),
492            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
493            // Fail closed when a possible nearer boundary cannot be inspected.
494            Err(_) => Some(None),
495        }
496    })??;
497    let canonical_root = dunce::canonicalize(&root).ok()?;
498    let canonical_package = dunce::canonicalize(root.join("package.json")).ok()?;
499    (canonical_package.is_file()
500        && canonical_package
501            .parent()
502            .is_some_and(|parent| same_existing_path(parent, &canonical_root)))
503    .then_some(canonical_root)
504}
505
506fn prioritize_project_bin(paths: &mut Vec<PathBuf>, project_bin: PathBuf) {
507    let node_collision = directory_provides_command(&project_bin, "node").unwrap_or(true);
508    // Never put a directory that can replace `node` on PATH. Elevating the
509    // complete managed Node bin would also elevate bundled npm/npx ahead of
510    // osdk's registry-aware shims, so fail closed for this unusual project.
511    if node_collision {
512        return;
513    }
514    paths.retain(|path| !same_existing_path(path, &project_bin));
515    paths.insert(0, project_bin);
516}
517
518fn same_existing_path(left: &std::path::Path, right: &std::path::Path) -> bool {
519    left == right || same_file::is_same_file(left, right).unwrap_or(false)
520}
521
522fn directory_provides_command(directory: &std::path::Path, command: &str) -> std::io::Result<bool> {
523    for entry in std::fs::read_dir(directory)? {
524        let file_name = entry?.file_name();
525        if command_name_matches(&file_name, command) {
526            return Ok(true);
527        }
528    }
529    Ok(false)
530}
531
532fn command_name_matches(file_name: &std::ffi::OsStr, command: &str) -> bool {
533    #[cfg(windows)]
534    {
535        let file_name = file_name.to_string_lossy();
536        file_name.eq_ignore_ascii_case(command)
537            || file_name
538                .get(..command.len() + 1)
539                .is_some_and(|prefix| prefix.eq_ignore_ascii_case(&format!("{command}.")))
540    }
541    #[cfg(not(windows))]
542    {
543        file_name == std::ffi::OsStr::new(command)
544    }
545}
546
547fn prioritize_managed_paths(
548    paths: &mut Vec<PathBuf>,
549    shims: &std::path::Path,
550    has_generated_shim: bool,
551) {
552    // Shell activation must not bypass the shim launchers: package-manager
553    // shims perform the registry preflight before entering the real binary.
554    // Keep the real backend dirs behind shims so executables without a
555    // generated shim remain available. Package managers still precede Node's
556    // bin dir there, preventing Node's bundled npm/corepack launchers from
557    // shadowing independently managed package managers. Once a shim starts,
558    // it removes the shim dir and constructs its own lifecycle-safe PATH.
559    paths.retain(|path| path != shims);
560    paths.sort_by_key(|path| managed_runtime_path_priority(path));
561    if has_generated_shim && !paths.is_empty() {
562        paths.insert(0, shims.to_path_buf());
563    }
564}
565
566fn shim_exists(shims: &std::path::Path, name: &str) -> bool {
567    shims.join(name).is_file() || (cfg!(windows) && shims.join(format!("{name}.cmd")).is_file())
568}
569
570fn managed_runtime_path_priority(path: &std::path::Path) -> u8 {
571    let components: std::collections::BTreeSet<_> = path
572        .components()
573        .filter_map(|component| component.as_os_str().to_str())
574        .collect();
575    if ["npm", "pnpm", "yarn"]
576        .iter()
577        .any(|backend| components.contains(backend))
578    {
579        0
580    } else if components.contains("node") {
581        1
582    } else {
583        2
584    }
585}
586
587/// Render `hook-env` output: shell commands that prepend PATH and set vars.
588pub fn render_hook_env(shell: Shell, delta: &EnvDelta) -> String {
589    let mut out = String::new();
590    render_path_reset(shell, &delta.path_prepend, &mut out);
591
592    for key in &delta.unset_vars {
593        if valid_env_name(key) {
594            render_restore_var(shell, key, &mut out);
595        }
596    }
597
598    for (k, v) in &delta.set_vars {
599        if valid_env_name(k) {
600            render_capture_var(shell, k, &mut out);
601            match shell {
602                Shell::Fish => out.push_str(&format!("set -gx {} {}\n", k, shell_quote(shell, v))),
603                Shell::Powershell => {
604                    out.push_str(&format!("$env:{} = {}\n", k, powershell_quote(v)))
605                }
606                _ => out.push_str(&format!("export {}={}\n", k, shell_quote(shell, v))),
607            }
608        }
609    }
610    let managed = delta
611        .set_vars
612        .keys()
613        .filter(|key| valid_env_name(key))
614        .cloned()
615        .collect::<Vec<_>>()
616        .join(",");
617    match shell {
618        Shell::Fish if managed.is_empty() => out.push_str("set -e OSDK_MANAGED_ENV\n"),
619        Shell::Fish => out.push_str(&format!(
620            "set -gx OSDK_MANAGED_ENV {}\n",
621            shell_quote(shell, &managed)
622        )),
623        Shell::Powershell if managed.is_empty() => {
624            out.push_str("Remove-Item Env:OSDK_MANAGED_ENV -ErrorAction SilentlyContinue\n")
625        }
626        Shell::Powershell => out.push_str(&format!(
627            "$env:OSDK_MANAGED_ENV = {}\n",
628            powershell_quote(&managed)
629        )),
630        _ if managed.is_empty() => out.push_str("unset OSDK_MANAGED_ENV\n"),
631        _ => out.push_str(&format!(
632            "export OSDK_MANAGED_ENV={}\n",
633            shell_quote(shell, &managed)
634        )),
635    }
636    out
637}
638
639fn render_path_reset(shell: Shell, paths: &[PathBuf], out: &mut String) {
640    match shell {
641        Shell::Fish => {
642            out.push_str(
643                "if not set -q OSDK_ORIGINAL_PATH_SET\n  set -gx OSDK_ORIGINAL_PATH $PATH\n  set -gx OSDK_ORIGINAL_PATH_SET 1\nend\n",
644            );
645            if paths.is_empty() {
646                out.push_str("set -gx PATH $OSDK_ORIGINAL_PATH\n");
647            } else {
648                out.push_str(&format!(
649                    "set -gx PATH {} $OSDK_ORIGINAL_PATH\n",
650                    paths
651                        .iter()
652                        .map(|path| shell_quote(shell, &path.display().to_string()))
653                        .collect::<Vec<_>>()
654                        .join(" ")
655                ));
656            }
657        }
658        Shell::Powershell => {
659            out.push_str(
660                "if (-not (Test-Path Env:OSDK_ORIGINAL_PATH_SET)) { $env:OSDK_ORIGINAL_PATH = $env:PATH; $env:OSDK_ORIGINAL_PATH_SET = '1' }\n",
661            );
662            if paths.is_empty() {
663                out.push_str("$env:PATH = $env:OSDK_ORIGINAL_PATH\n");
664            } else {
665                let joined = std::env::join_paths(paths)
666                    .unwrap_or_default()
667                    .to_string_lossy()
668                    .into_owned();
669                out.push_str(&format!(
670                    "$env:PATH = {} + [IO.Path]::PathSeparator + $env:OSDK_ORIGINAL_PATH\n",
671                    powershell_quote(&joined)
672                ));
673            }
674        }
675        _ => {
676            out.push_str(
677                "if [ -z \"${OSDK_ORIGINAL_PATH_SET+x}\" ]; then export OSDK_ORIGINAL_PATH=\"$PATH\"; export OSDK_ORIGINAL_PATH_SET=1; fi\n",
678            );
679            if paths.is_empty() {
680                out.push_str("export PATH=\"$OSDK_ORIGINAL_PATH\"\n");
681            } else {
682                let joined = paths
683                    .iter()
684                    .map(|path| path.display().to_string())
685                    .collect::<Vec<_>>()
686                    .join(":");
687                out.push_str(&format!(
688                    "export PATH={}:\"$OSDK_ORIGINAL_PATH\"\n",
689                    shell_quote(shell, &joined)
690                ));
691            }
692        }
693    }
694}
695
696fn render_capture_var(shell: Shell, key: &str, out: &mut String) {
697    let original = format!("OSDK_ORIG_{key}");
698    let present = format!("{original}_PRESENT");
699    let set = format!("{original}_SET");
700    match shell {
701        Shell::Fish => out.push_str(&format!(
702            "if not set -q {set}\n  if set -q {key}\n    set -gx {original} \"${key}\"\n    set -gx {present} 1\n  else\n    set -e {original}\n    set -gx {present} 0\n  end\n  set -gx {set} 1\nend\n"
703        )),
704        Shell::Powershell => out.push_str(&format!(
705            "if (-not (Test-Path Env:{set})) {{ if (Test-Path Env:{key}) {{ $env:{original} = $env:{key}; $env:{present} = '1' }} else {{ Remove-Item Env:{original} -ErrorAction SilentlyContinue; $env:{present} = '0' }}; $env:{set} = '1' }}\n"
706        )),
707        _ => out.push_str(&format!(
708            "if [ -z \"${{{set}+x}}\" ]; then if [ -n \"${{{key}+x}}\" ]; then export {original}=\"${key}\"; export {present}=1; else unset {original}; export {present}=0; fi; export {set}=1; fi\n"
709        )),
710    }
711}
712
713fn render_restore_var(shell: Shell, key: &str, out: &mut String) {
714    let original = format!("OSDK_ORIG_{key}");
715    let present = format!("{original}_PRESENT");
716    let set = format!("{original}_SET");
717    match shell {
718        Shell::Fish => out.push_str(&format!(
719            "if set -q {set}\n  if test \"${present}\" = 1\n    set -gx {key} \"${original}\"\n  else\n    set -e {key}\n  end\n  set -e {original} {present} {set}\nend\n"
720        )),
721        Shell::Powershell => out.push_str(&format!(
722            "if (Test-Path Env:{set}) {{ if ($env:{present} -eq '1') {{ $env:{key} = $env:{original} }} else {{ Remove-Item Env:{key} -ErrorAction SilentlyContinue }}; Remove-Item Env:{original},Env:{present},Env:{set} -ErrorAction SilentlyContinue }}\n"
723        )),
724        _ => out.push_str(&format!(
725            "if [ -n \"${{{set}+x}}\" ]; then if [ \"${{{present}:-0}}\" = 1 ]; then export {key}=\"${original}\"; else unset {key}; fi; unset {original} {present} {set}; fi\n"
726        )),
727    }
728}
729
730fn valid_env_name(key: &str) -> bool {
731    let mut chars = key.chars();
732    matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic())
733        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
734}
735
736fn shell_quote(shell: Shell, s: &str) -> String {
737    match shell {
738        Shell::Powershell => powershell_quote(s),
739        _ => {
740            // single-quote for POSIX/fish, escaping embedded quotes
741            let escaped = s.replace('\'', r"'\''");
742            format!("'{escaped}'")
743        }
744    }
745}
746
747fn powershell_quote(value: &str) -> String {
748    format!("'{}'", value.replace('\'', "''"))
749}
750
751/// Strip a leading `<word>-` distribution prefix (java's temurin-17).
752fn strip_distribution_prefix(spec: &str) -> &str {
753    if let Some((left, right)) = spec.split_once('-') {
754        if !left.is_empty() && left.chars().all(|c| c.is_ascii_alphabetic()) && !right.is_empty() {
755            return right;
756        }
757    }
758    spec
759}
760
761#[cfg(test)]
762mod tests {
763    use std::sync::Arc;
764
765    use super::*;
766    use crate::backend::Backend;
767    use crate::config::{Config, Settings, SourcesConfig};
768    use crate::dirs::Dirs;
769    use crate::platform::Platform;
770    use crate::store::Cas;
771
772    fn test_ctx(root: &std::path::Path, tools: &[(&str, &str)]) -> Ctx {
773        let dirs = Dirs::resolve_from(|key| match key {
774            "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
775            "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
776            "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
777            "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
778            "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
779            _ => None,
780        })
781        .unwrap();
782        dirs.ensure().unwrap();
783        Ctx {
784            cas: Arc::new(Cas::new(dirs.store.clone())),
785            dirs,
786            platform: Platform::current(),
787            config: Config {
788                settings: Settings::default(),
789                sources: SourcesConfig::default(),
790                tools: tools
791                    .iter()
792                    .map(|(tool, version)| (tool.to_string(), version.to_string()))
793                    .collect(),
794                tool_configs: BTreeMap::new(),
795                global_tools: BTreeMap::new(),
796                global_tool_configs: BTreeMap::new(),
797                tool_origins: BTreeMap::new(),
798                aliases: BTreeMap::new(),
799                project_config_path: None,
800            },
801            client: reqwest::Client::new(),
802            show_progress: false,
803        }
804    }
805
806    #[test]
807    fn parse_shell() {
808        assert_eq!("bash".parse::<Shell>().unwrap(), Shell::Bash);
809        assert_eq!("pwsh".parse::<Shell>().unwrap(), Shell::Powershell);
810        assert!("tcsh".parse::<Shell>().is_err());
811    }
812
813    #[test]
814    fn activation_snippet_mentions_hook_env() {
815        let s = activation_script(Shell::Bash, "osdk");
816        assert!(s.contains("hook-env"));
817        assert!(s.contains("PROMPT_COMMAND"));
818
819        let powershell =
820            activation_script(Shell::Powershell, r"C:\Program Files\中文 osdk\osdk.exe");
821        assert!(powershell.contains(r"& 'C:\Program Files\中文 osdk\osdk.exe' hook-env"));
822        assert!(powershell.contains("$script:OsdkHookRunning"));
823        assert!(powershell.contains("finally"));
824        assert!(
825            powershell.find("Invoke-OsdkHook\n").unwrap()
826                < powershell.find("PostCommandLookupAction").unwrap()
827        );
828    }
829
830    #[test]
831    fn deactivation_snippets_remove_hooks_and_restore_path() {
832        let bash = deactivation_script(Shell::Bash);
833        assert!(bash.contains("unset -f _osdk_hook"));
834        assert!(bash.contains("PATH=\"$OSDK_ORIGINAL_PATH\""));
835
836        let zsh = deactivation_script(Shell::Zsh);
837        assert!(zsh.contains("precmd_functions"));
838        assert!(zsh.contains("unfunction _osdk_hook"));
839
840        let fish = deactivation_script(Shell::Fish);
841        assert!(fish.contains("functions -e _osdk_hook"));
842
843        let powershell = deactivation_script(Shell::Powershell);
844        assert!(powershell.contains("PostCommandLookupAction = $null"));
845        assert!(powershell.contains("Remove-Variable OsdkHookRunning"));
846    }
847
848    #[test]
849    fn hook_env_renders_path_and_vars() {
850        let mut set_vars = BTreeMap::new();
851        set_vars.insert("GOROOT".to_string(), "/x/go".to_string());
852        let delta = EnvDelta {
853            path_prepend: vec![PathBuf::from("/x/go/bin")],
854            set_vars,
855            unset_vars: vec!["JAVA_HOME".into()],
856        };
857        let out = render_hook_env(Shell::Bash, &delta);
858        assert!(out.contains("export PATH='/x/go/bin':\"$OSDK_ORIGINAL_PATH\""));
859        assert!(out.contains("export GOROOT='/x/go'"));
860        assert!(out.contains("unset JAVA_HOME"));
861        assert!(out.contains("export OSDK_MANAGED_ENV='GOROOT'"));
862
863        let fish = render_hook_env(Shell::Fish, &delta);
864        assert!(fish.contains("set -gx PATH"));
865        assert!(fish.contains("set -gx GOROOT"));
866    }
867
868    #[test]
869    fn activation_path_prefers_shims_and_keeps_real_bin_fallbacks() {
870        let shims = PathBuf::from("/osdk/shims");
871        let npm = PathBuf::from("/osdk/installs/npm/10.9.0/bin");
872        let pnpm = PathBuf::from("/osdk/installs/pnpm/9.15.0");
873        let node = PathBuf::from("/osdk/installs/node/22.14.0/bin");
874        let go = PathBuf::from("/osdk/installs/go/1.24.0/bin");
875        let mut paths = vec![
876            node.clone(),
877            go.clone(),
878            npm.clone(),
879            shims.clone(),
880            pnpm.clone(),
881        ];
882
883        prioritize_managed_paths(&mut paths, &shims, true);
884
885        assert_eq!(paths, vec![shims, npm, pnpm, node, go]);
886    }
887
888    #[test]
889    fn command_collision_names_follow_target_rules() {
890        #[cfg(windows)]
891        for name in [
892            "node", "node.com", "NODE.EXE", "NoDe.BaT", "node.CMD", "NODE.ps1",
893        ] {
894            assert!(command_name_matches(std::ffi::OsStr::new(name), "node"));
895        }
896        #[cfg(windows)]
897        assert!(command_name_matches(
898            std::ffi::OsStr::new("node.js"),
899            "node"
900        ));
901
902        #[cfg(not(windows))]
903        {
904            assert!(command_name_matches(std::ffi::OsStr::new("node"), "node"));
905            assert!(!command_name_matches(std::ffi::OsStr::new("Node"), "node"));
906            assert!(!command_name_matches(
907                std::ffi::OsStr::new("node.cmd"),
908                "node"
909            ));
910        }
911    }
912
913    #[test]
914    fn project_path_is_deduplicated_against_existing_paths() {
915        let temporary = tempfile::tempdir().unwrap();
916        let project_bin = temporary.path().join(".osdk/npm-bin/generations/test/bin");
917        std::fs::create_dir_all(&project_bin).unwrap();
918        let canonical = dunce::canonicalize(&project_bin).unwrap();
919        let mut paths = vec![project_bin, PathBuf::from("/osdk/shims")];
920
921        prioritize_project_bin(&mut paths, canonical.clone());
922
923        assert_eq!(paths, vec![canonical, PathBuf::from("/osdk/shims")]);
924    }
925
926    #[cfg(unix)]
927    #[test]
928    fn project_path_is_deduplicated_against_a_symlink_alias() {
929        use std::os::unix::fs::symlink;
930
931        let temporary = tempfile::tempdir().unwrap();
932        let project_bin = temporary.path().join(".osdk/npm-bin/generations/test/bin");
933        let alias = temporary.path().join("project-bin");
934        std::fs::create_dir_all(&project_bin).unwrap();
935        symlink(&project_bin, &alias).unwrap();
936        let canonical = dunce::canonicalize(&project_bin).unwrap();
937        let mut paths = vec![alias, PathBuf::from("/osdk/shims")];
938
939        prioritize_project_bin(&mut paths, canonical.clone());
940
941        assert_eq!(paths, vec![canonical, PathBuf::from("/osdk/shims")]);
942    }
943
944    #[test]
945    fn raw_project_npm_bins_are_never_activated() {
946        let temporary = tempfile::tempdir().unwrap();
947        let mut ctx = test_ctx(temporary.path(), &[]);
948        let project = temporary.path().join("project");
949        let bin = project.join("node_modules/.bin");
950        std::fs::create_dir_all(&bin).unwrap();
951        std::fs::write(project.join("package.json"), b"{}").unwrap();
952        std::fs::write(
953            project.join("osdk.toml"),
954            "[tools.\"npm:prettier\"]\nversion = \"3\"\ninstaller = \"aube\"\n",
955        )
956        .unwrap();
957        ctx.config.tools.insert("npm:prettier".into(), "3".into());
958        ctx.config.tool_origins.insert(
959            "npm:prettier".into(),
960            crate::config::ToolConfigOrigin::ProjectConfig(project.join("osdk.toml")),
961        );
962
963        assert!(trusted_project_npm_bin(&ctx, &project).unwrap().is_none());
964        assert!(!compute_env_delta(&ctx, &Registry::new(), &project)
965            .unwrap()
966            .path_prepend
967            .contains(&dunce::canonicalize(bin).unwrap()));
968    }
969
970    #[test]
971    fn project_npm_specs_reject_conflicting_aliases_and_accept_identical_aliases() {
972        let temporary = tempfile::tempdir().unwrap();
973        let project = temporary.path().join("project");
974        std::fs::create_dir_all(&project).unwrap();
975        let config_path = project.join("osdk.toml");
976        std::fs::write(
977            &config_path,
978            "[tools]\n\"tool.alpha\" = \"npm:fixture-cli@1.2.3\"\n\"tool.beta\" = \"npm:fixture-cli@2.0.0\"\n",
979        )
980        .unwrap();
981        let mut ctx = test_ctx(temporary.path(), &[]);
982        ctx.config =
983            crate::config::Config::load(&temporary.path().join("config/config.toml"), &project)
984                .unwrap();
985
986        let error = project_npm_configured_specs(&ctx, &config_path).unwrap_err();
987        let message = error.to_string();
988        assert!(message.contains("npm:fixture-cli"), "{message}");
989        assert!(message.contains("tool.alpha"), "{message}");
990        assert!(message.contains("1.2.3"), "{message}");
991        assert!(message.contains("tool.beta"), "{message}");
992        assert!(message.contains("2.0.0"), "{message}");
993
994        std::fs::write(
995            &config_path,
996            "[tools]\n\"tool.alpha\" = \"npm:fixture-cli@1.2.3\"\n\"tool.beta\" = \"npm:fixture-cli@1.2.3\"\n",
997        )
998        .unwrap();
999        assert_eq!(
1000            project_npm_configured_specs(&ctx, &config_path).unwrap(),
1001            BTreeMap::from([("npm:fixture-cli".into(), "1.2.3".into())])
1002        );
1003    }
1004
1005    #[test]
1006    fn activation_fails_closed_on_corrupt_inventory_with_valid_configured_dynamic_tool() {
1007        let temporary = tempfile::tempdir().unwrap();
1008        let ctx = test_ctx(temporary.path(), &[("npm:fixture-cli", "1.2.3")]);
1009        let project = temporary.path().join("project");
1010        std::fs::create_dir_all(&project).unwrap();
1011
1012        let backend =
1013            crate::backend::npm_package::NpmPackageBackend::from_id("npm:fixture-cli").unwrap();
1014        let mut version = ToolVersion::new(backend.id(), "1.2.3");
1015        version.options.insert(
1016            crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
1017            "1.0.0".into(),
1018        );
1019        let identity = crate::tool::InstallIdentity::new(
1020            backend.id(),
1021            &version.version,
1022            ctx.platform.to_string(),
1023            crate::tool::InstallScope::Isolated,
1024            &version.options,
1025            vec![crate::tool::InstallDependency {
1026                kind: crate::tool::InstallDependencyKind::Runtime,
1027                id: "node".into(),
1028                version: "1.0.0".into(),
1029                identity: None,
1030            }],
1031            BTreeMap::new(),
1032        )
1033        .unwrap();
1034        let valid_root = crate::dirs::InstallLocator::new(&ctx.dirs, identity.clone())
1035            .unwrap()
1036            .install_root()
1037            .to_path_buf();
1038        let mut manifest = crate::inventory::DynamicToolManifest::from_identity(identity).unwrap();
1039        manifest.bins.push(crate::inventory::DynamicToolBin {
1040            name: "fixture-cli".into(),
1041            path: "bin/fixture-cli".into(),
1042        });
1043        manifest.write_atomic(&valid_root).unwrap();
1044        std::fs::create_dir_all(valid_root.join("bin")).unwrap();
1045        std::fs::write(valid_root.join("bin/fixture-cli"), b"fixture").unwrap();
1046        std::fs::write(valid_root.join(".osdk-complete"), b"").unwrap();
1047
1048        let corrupt_root = ctx.dirs.installs.join("github/corrupt/tool/1.0.0");
1049        std::fs::create_dir_all(&corrupt_root).unwrap();
1050        std::fs::write(
1051            crate::inventory::DynamicToolManifest::manifest_path(&corrupt_root),
1052            b"{not valid json",
1053        )
1054        .unwrap();
1055
1056        let error = match compute_env_delta(&ctx, &Registry::new(), &project) {
1057            Ok(_) => panic!("corrupt inventory unexpectedly produced an activation delta"),
1058            Err(error) => error,
1059        };
1060        let message = error.to_string();
1061        assert!(
1062            message.contains("refusing dynamic tool inventory scan"),
1063            "{message}"
1064        );
1065        assert!(message.contains("corrupt"), "{message}");
1066        assert!(
1067            message.contains(crate::inventory::INVENTORY_FILE),
1068            "{message}"
1069        );
1070    }
1071
1072    fn configured_dynamic_fixture(
1073        root: &std::path::Path,
1074        configured_options: BTreeMap<String, crate::config::ToolConfigValue>,
1075        manifest_options: BTreeMap<String, String>,
1076    ) -> (Ctx, std::path::PathBuf, std::path::PathBuf) {
1077        let mut ctx = test_ctx(root, &[("npm:fixture-cli", "1.2.3")]);
1078        let mut configured_options = configured_options;
1079        configured_options.insert(
1080            crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
1081            crate::config::ToolConfigValue::String("1.0.0".into()),
1082        );
1083        ctx.config.tool_configs.insert(
1084            "npm:fixture-cli".into(),
1085            crate::config::ToolConfigEntry::structured("1.2.3", configured_options),
1086        );
1087        let backend =
1088            crate::backend::npm_package::NpmPackageBackend::from_id("npm:fixture-cli").unwrap();
1089        let mut version = ToolVersion::new(backend.id(), "1.2.3");
1090        version.options = manifest_options;
1091        version.options.insert(
1092            crate::backend::npm_package::LOCKED_NPM_NODE_VERSION_OPTION.into(),
1093            "1.0.0".into(),
1094        );
1095        let identity = crate::tool::InstallIdentity::new(
1096            backend.id(),
1097            &version.version,
1098            ctx.platform.to_string(),
1099            crate::tool::InstallScope::Isolated,
1100            &version.options,
1101            vec![crate::tool::InstallDependency {
1102                kind: crate::tool::InstallDependencyKind::Runtime,
1103                id: "node".into(),
1104                version: "1.0.0".into(),
1105                identity: None,
1106            }],
1107            BTreeMap::new(),
1108        )
1109        .unwrap();
1110        let install_root = crate::dirs::InstallLocator::new(&ctx.dirs, identity.clone())
1111            .unwrap()
1112            .install_root()
1113            .to_path_buf();
1114        let bin = install_root.join("bin/fixture-cli");
1115        std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
1116        std::fs::write(&bin, b"fixture").unwrap();
1117        std::fs::write(install_root.join(".osdk-complete"), b"").unwrap();
1118        let mut manifest = crate::inventory::DynamicToolManifest::from_identity(identity).unwrap();
1119        manifest.bins.push(crate::inventory::DynamicToolBin {
1120            name: "fixture-cli".into(),
1121            path: "bin/fixture-cli".into(),
1122        });
1123        manifest.write_atomic(&install_root).unwrap();
1124        (ctx, install_root, bin)
1125    }
1126
1127    fn activation_error(ctx: &Ctx, project: &std::path::Path) -> String {
1128        match compute_env_delta(ctx, &Registry::new(), project) {
1129            Ok(_) => panic!("invalid dynamic install unexpectedly produced an activation delta"),
1130            Err(error) => error.to_string(),
1131        }
1132    }
1133
1134    #[test]
1135    fn activation_rejects_configured_dynamic_install_without_inventory() {
1136        let temporary = tempfile::tempdir().unwrap();
1137        let project = temporary.path().join("project");
1138        std::fs::create_dir_all(&project).unwrap();
1139        let (ctx, install_root, bin) =
1140            configured_dynamic_fixture(temporary.path(), BTreeMap::new(), BTreeMap::new());
1141        std::fs::remove_file(crate::inventory::DynamicToolManifest::manifest_path(
1142            &install_root,
1143        ))
1144        .unwrap();
1145
1146        let message = activation_error(&ctx, &project);
1147        assert!(
1148            message.contains("missing or invalid install identity"),
1149            "{message}"
1150        );
1151        assert!(message.contains("reinstall"), "{message}");
1152        assert!(
1153            bin.exists(),
1154            "fixture must retain the otherwise runnable bin"
1155        );
1156    }
1157
1158    #[test]
1159    fn activation_rejects_configured_dynamic_install_without_completion_marker() {
1160        let temporary = tempfile::tempdir().unwrap();
1161        let project = temporary.path().join("project");
1162        std::fs::create_dir_all(&project).unwrap();
1163        let (ctx, install_root, bin) =
1164            configured_dynamic_fixture(temporary.path(), BTreeMap::new(), BTreeMap::new());
1165        std::fs::remove_file(install_root.join(".osdk-complete")).unwrap();
1166
1167        let message = activation_error(&ctx, &project);
1168        assert!(
1169            message.contains("no complete selected install"),
1170            "{message}"
1171        );
1172        assert!(message.contains("reinstall"), "{message}");
1173        assert!(
1174            bin.exists(),
1175            "fixture must retain the otherwise runnable bin"
1176        );
1177    }
1178
1179    #[test]
1180    fn activation_rejects_legacy_dynamic_inventory() {
1181        let temporary = tempfile::tempdir().unwrap();
1182        let project = temporary.path().join("project");
1183        std::fs::create_dir_all(&project).unwrap();
1184        let (ctx, install_root, _) =
1185            configured_dynamic_fixture(temporary.path(), BTreeMap::new(), BTreeMap::new());
1186        std::fs::remove_file(crate::inventory::DynamicToolManifest::manifest_path(
1187            &install_root,
1188        ))
1189        .unwrap();
1190        std::fs::write(
1191            install_root.join(crate::inventory::LEGACY_INVENTORY_FILE),
1192            r#"{"schema":2,"id":"npm:fixture-cli","version":"1.2.3","bins":[{"name":"fixture-cli","path":"bin/fixture-cli"}]}"#,
1193        )
1194        .unwrap();
1195
1196        let message = activation_error(&ctx, &project);
1197        assert!(
1198            message.contains("missing or invalid install identity"),
1199            "{message}"
1200        );
1201        assert!(message.contains("reinstall"), "{message}");
1202    }
1203
1204    #[test]
1205    fn activation_rejects_configured_dynamic_option_mismatch() {
1206        let temporary = tempfile::tempdir().unwrap();
1207        let project = temporary.path().join("project");
1208        std::fs::create_dir_all(&project).unwrap();
1209        let (ctx, _, _) = configured_dynamic_fixture(
1210            temporary.path(),
1211            BTreeMap::from([(
1212                "installer".into(),
1213                crate::config::ToolConfigValue::String("aube".into()),
1214            )]),
1215            BTreeMap::from([("installer".into(), "npm".into())]),
1216        );
1217
1218        let message = activation_error(&ctx, &project);
1219        assert!(
1220            message.contains("no complete selected install"),
1221            "{message}"
1222        );
1223        assert!(message.contains("reinstall"), "{message}");
1224    }
1225
1226    #[test]
1227    fn activation_rejects_configured_dynamic_version_mismatch() {
1228        let temporary = tempfile::tempdir().unwrap();
1229        let project = temporary.path().join("project");
1230        std::fs::create_dir_all(&project).unwrap();
1231        let (ctx, install_root, _) =
1232            configured_dynamic_fixture(temporary.path(), BTreeMap::new(), BTreeMap::new());
1233        let old_manifest = crate::inventory::DynamicToolManifest::load(&install_root).unwrap();
1234        let identity = crate::tool::InstallIdentity::new(
1235            &old_manifest.identity.tool,
1236            "9.9.9",
1237            &old_manifest.identity.platform,
1238            old_manifest.identity.scope,
1239            &old_manifest.identity.material_options,
1240            old_manifest.identity.dependencies,
1241            old_manifest.identity.materials,
1242        )
1243        .unwrap();
1244        let mut manifest = crate::inventory::DynamicToolManifest::from_identity(identity).unwrap();
1245        manifest.bins = old_manifest.bins;
1246        let path = crate::inventory::DynamicToolManifest::manifest_path(&install_root);
1247        std::fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1248
1249        let message = activation_error(&ctx, &project);
1250        assert!(
1251            message.contains("refusing dynamic tool inventory scan"),
1252            "{message}"
1253        );
1254        assert!(message.contains("identity root"), "{message}");
1255    }
1256
1257    #[test]
1258    fn http_activation_uses_inventory_offline_and_rejects_receipt_tampering() {
1259        let temporary = tempfile::tempdir().unwrap();
1260        let project = temporary.path().join("project");
1261        std::fs::create_dir_all(&project).unwrap();
1262        let backend = "http:https://downloads.example.test/tool-{version}";
1263        let receipt_url = "https://downloads.example.test/tool-1.2.3";
1264        let digest = "a".repeat(64);
1265        let mut ctx = test_ctx(temporary.path(), &[(backend, "1.2.3")]);
1266        let options: BTreeMap<String, String> = BTreeMap::from([
1267            ("sha256".into(), digest.clone()),
1268            ("kind".into(), "file".into()),
1269            ("rename".into(), "fixture-http".into()),
1270        ]);
1271        ctx.config.tool_configs.insert(
1272            backend.into(),
1273            crate::config::ToolConfigEntry::structured(
1274                "1.2.3",
1275                options
1276                    .iter()
1277                    .map(|(key, value)| {
1278                        (
1279                            key.clone(),
1280                            crate::config::ToolConfigValue::String(value.clone()),
1281                        )
1282                    })
1283                    .collect(),
1284            ),
1285        );
1286        ctx.config.settings.offline = true;
1287        let mut version = ToolVersion::new(backend, "1.2.3");
1288        version.options = options;
1289        version.options.extend(BTreeMap::from([
1290            (
1291                crate::pipeline::LOCKED_ARTIFACT_URL_OPTION.into(),
1292                receipt_url.into(),
1293            ),
1294            (
1295                crate::pipeline::LOCKED_ARTIFACT_FILE_OPTION.into(),
1296                "tool-1.2.3".into(),
1297            ),
1298            (
1299                crate::pipeline::LOCKED_ARTIFACT_CHECKSUM_OPTION.into(),
1300                format!("sha256:{digest}"),
1301            ),
1302        ]));
1303        let locator = crate::backend::http::HttpBackend::install_locator_for(
1304            &ctx.dirs,
1305            ctx.platform,
1306            backend,
1307            &version,
1308        )
1309        .unwrap();
1310        let root = locator.install_root();
1311        std::fs::create_dir_all(root.join("bin")).unwrap();
1312        std::fs::write(root.join("bin/fixture-http"), b"fixture").unwrap();
1313        let mut manifest =
1314            crate::inventory::DynamicToolManifest::from_identity(locator.identity().clone())
1315                .unwrap();
1316        manifest.bins.push(crate::inventory::DynamicToolBin {
1317            name: "fixture-http".into(),
1318            path: "bin/fixture-http".into(),
1319        });
1320        manifest.write_atomic(root).unwrap();
1321        std::fs::write(
1322            root.join(".osdk-artifact.json"),
1323            format!(
1324                r#"{{"url":{receipt_url:?},"file_name":"tool-1.2.3","checksum":"sha256:{digest}"}}"#
1325            ),
1326        )
1327        .unwrap();
1328        std::fs::write(root.join(".osdk-complete"), b"").unwrap();
1329        std::fs::create_dir_all(ctx.dirs.shims()).unwrap();
1330        std::fs::write(ctx.dirs.shims().join("fixture-http"), b"shim").unwrap();
1331
1332        let delta = compute_env_delta(&ctx, &Registry::new(), &project).unwrap();
1333        let expected_bin = root.join("bin");
1334        assert!(delta
1335            .path_prepend
1336            .iter()
1337            .any(|path| same_existing_path(path, &expected_bin)));
1338
1339        let receipt_path = root.join(".osdk-artifact.json");
1340        let tampered = std::fs::read_to_string(&receipt_path).unwrap().replace(
1341            receipt_url,
1342            "https://downloads.example.test/substitute-1.2.3",
1343        );
1344        std::fs::write(receipt_path, tampered).unwrap();
1345        let message = activation_error(&ctx, &project);
1346        assert!(message.contains("receipt"), "{message}");
1347    }
1348
1349    #[test]
1350    fn activation_path_does_not_add_shims_without_an_active_runtime() {
1351        let mut no_active_bins = Vec::new();
1352        prioritize_managed_paths(
1353            &mut no_active_bins,
1354            std::path::Path::new("/osdk/shims"),
1355            false,
1356        );
1357        assert!(no_active_bins.is_empty());
1358    }
1359
1360    #[test]
1361    fn activation_path_does_not_add_missing_shims() {
1362        let node = PathBuf::from("/osdk/installs/node/22.14.0/bin");
1363        let mut paths = vec![node.clone()];
1364        prioritize_managed_paths(&mut paths, std::path::Path::new("/osdk/shims"), false);
1365        assert_eq!(paths, vec![node]);
1366    }
1367
1368    #[test]
1369    fn empty_delta_restores_original_path() {
1370        let delta = EnvDelta {
1371            path_prepend: Vec::new(),
1372            set_vars: BTreeMap::new(),
1373            unset_vars: vec!["GOROOT".into()],
1374        };
1375        let out = render_hook_env(Shell::Bash, &delta);
1376        assert!(out.contains("export PATH=\"$OSDK_ORIGINAL_PATH\""));
1377        assert!(out.contains("unset GOROOT"));
1378        assert!(out.contains("unset OSDK_MANAGED_ENV"));
1379    }
1380}