Skip to main content

cuenv_core/tools/
activation.rs

1//! Tool activation planning and environment mutation helpers.
2//!
3//! Tool activation is inferred from lockfile tool metadata so every execution
4//! path (`tools activate`, `exec`, `task`, CI) applies the same environment
5//! mutations.
6
7use super::{Platform, default_cache_dir};
8use crate::lockfile::Lockfile;
9use crate::{Error, Result};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::collections::{BTreeMap, HashSet};
13use std::path::{Path, PathBuf};
14
15/// A configured activation step from runtime/lockfile configuration.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "camelCase")]
18pub struct ToolActivationStep {
19    /// Environment variable to mutate (for example `PATH`).
20    pub var: String,
21    /// Mutation operation.
22    pub op: ToolActivationOperation,
23    /// Separator for joining values (defaults to `:`).
24    #[serde(default = "default_separator")]
25    pub separator: String,
26    /// Source reference that resolves to one or more paths.
27    pub from: ToolActivationSource,
28}
29
30fn default_separator() -> String {
31    ":".to_string()
32}
33
34/// Mutation operation for tool activation.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "lowercase")]
37pub enum ToolActivationOperation {
38    /// Replace the variable with the resolved value.
39    Set,
40    /// Prepend the resolved value before the current value.
41    Prepend,
42    /// Append the resolved value after the current value.
43    Append,
44}
45
46/// Activation source selector.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(tag = "type", rename_all = "camelCase")]
49pub enum ToolActivationSource {
50    /// All bin directories for tools available on the current platform.
51    AllBinDirs,
52    /// All lib directories for tools available on the current platform.
53    AllLibDirs,
54    /// Bin directory for a specific tool.
55    ToolBinDir { tool: String },
56    /// Lib directory for a specific tool.
57    ToolLibDir { tool: String },
58}
59
60/// A resolved activation step with a concrete value to apply.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ResolvedToolActivationStep {
63    /// Environment variable to mutate.
64    pub var: String,
65    /// Mutation operation.
66    pub op: ToolActivationOperation,
67    /// Separator for joining values.
68    pub separator: String,
69    /// Resolved value (already joined using `separator`).
70    pub value: String,
71}
72
73/// Options for resolving tool activation.
74#[derive(Debug, Clone)]
75pub struct ToolActivationResolveOptions<'a> {
76    /// Lockfile containing tools and activation config.
77    pub lockfile: &'a Lockfile,
78    /// Absolute path to the loaded lockfile.
79    pub lockfile_path: &'a Path,
80    /// Target platform to resolve against.
81    pub platform: Platform,
82    /// Cache directory for provider-backed tools.
83    pub cache_dir: PathBuf,
84}
85
86impl<'a> ToolActivationResolveOptions<'a> {
87    /// Create options using current platform and default cache directory.
88    #[must_use]
89    pub fn new(lockfile: &'a Lockfile, lockfile_path: &'a Path) -> Self {
90        Self {
91            lockfile,
92            lockfile_path,
93            platform: Platform::current(),
94            cache_dir: default_cache_dir(),
95        }
96    }
97
98    /// Override platform.
99    #[must_use]
100    pub fn with_platform(mut self, platform: Platform) -> Self {
101        self.platform = platform;
102        self
103    }
104
105    /// Override cache directory.
106    #[must_use]
107    pub fn with_cache_dir(mut self, cache_dir: PathBuf) -> Self {
108        self.cache_dir = cache_dir;
109        self
110    }
111}
112
113/// Validate lockfile activation configuration for the selected platform.
114///
115/// # Errors
116///
117/// Returns an error when:
118/// - an explicit activation step has an empty variable name
119/// - a per-tool reference targets an unknown tool
120/// - a per-tool reference targets a Nix tool on this platform (unsupported in v1)
121pub fn validate_tool_activation(options: &ToolActivationResolveOptions<'_>) -> Result<()> {
122    if options.lockfile.tools.is_empty() {
123        return Ok(());
124    }
125
126    // Auto-inferred mode: no explicit activation config required.
127    if options.lockfile.tools_activation.is_empty() {
128        return Ok(());
129    }
130
131    let platform_key = options.platform.to_string();
132
133    for step in &options.lockfile.tools_activation {
134        if step.var.trim().is_empty() {
135            return Err(Error::configuration(
136                "Tool activation entry has an empty `var` value.",
137            ));
138        }
139
140        match &step.from {
141            ToolActivationSource::ToolBinDir { tool }
142            | ToolActivationSource::ToolLibDir { tool } => {
143                let Some(locked_tool) = options.lockfile.tools.get(tool) else {
144                    return Err(Error::configuration(format!(
145                        "Tool activation references unknown tool '{}'.",
146                        tool
147                    )));
148                };
149
150                if let Some(platform_data) = locked_tool.platforms.get(&platform_key)
151                    && platform_data.provider == "nix"
152                {
153                    return Err(Error::configuration(format!(
154                        "Tool activation per-tool references do not support Nix tools yet ('{}'). \
155                         Use allBinDirs/allLibDirs instead.",
156                        tool
157                    )));
158                }
159            }
160            ToolActivationSource::AllBinDirs | ToolActivationSource::AllLibDirs => {}
161        }
162    }
163
164    Ok(())
165}
166
167/// Resolve configured tool activation steps to concrete values for the selected platform.
168///
169/// # Errors
170///
171/// Returns validation errors from [`validate_tool_activation`].
172pub fn resolve_tool_activation(
173    options: &ToolActivationResolveOptions<'_>,
174) -> Result<Vec<ResolvedToolActivationStep>> {
175    let path_index = ToolPathIndex::collect(options)?;
176    if options.lockfile.tools_activation.is_empty() {
177        let mut resolved = vec![
178            ResolvedToolActivationStep {
179                var: "PATH".to_string(),
180                op: ToolActivationOperation::Prepend,
181                separator: ":".to_string(),
182                value: join_paths(&path_index.all_bin_dirs, ":"),
183            },
184            ResolvedToolActivationStep {
185                var: "DYLD_LIBRARY_PATH".to_string(),
186                op: ToolActivationOperation::Prepend,
187                separator: ":".to_string(),
188                value: join_paths(&path_index.all_lib_dirs, ":"),
189            },
190            ResolvedToolActivationStep {
191                var: "LD_LIBRARY_PATH".to_string(),
192                op: ToolActivationOperation::Prepend,
193                separator: ":".to_string(),
194                value: join_paths(&path_index.all_lib_dirs, ":"),
195            },
196            ResolvedToolActivationStep {
197                var: "CPATH".to_string(),
198                op: ToolActivationOperation::Prepend,
199                separator: ":".to_string(),
200                value: join_paths(&path_index.all_include_dirs, ":"),
201            },
202            ResolvedToolActivationStep {
203                var: "PKG_CONFIG_PATH".to_string(),
204                op: ToolActivationOperation::Prepend,
205                separator: ":".to_string(),
206                value: join_paths(&path_index.all_pkgconfig_dirs, ":"),
207            },
208        ];
209        for (var, value) in &path_index.file_env_exports {
210            resolved.push(ResolvedToolActivationStep {
211                var: var.clone(),
212                op: ToolActivationOperation::Set,
213                separator: ":".to_string(),
214                value: value.to_string_lossy().to_string(),
215            });
216        }
217        return Ok(resolved);
218    }
219
220    validate_tool_activation(options)?;
221    let mut resolved = Vec::with_capacity(options.lockfile.tools_activation.len());
222
223    for step in &options.lockfile.tools_activation {
224        let paths = match &step.from {
225            ToolActivationSource::AllBinDirs => path_index.all_bin_dirs.clone(),
226            ToolActivationSource::AllLibDirs => path_index.all_lib_dirs.clone(),
227            ToolActivationSource::ToolBinDir { tool } => path_index
228                .tool_bin_dirs
229                .get(tool)
230                .cloned()
231                .unwrap_or_default(),
232            ToolActivationSource::ToolLibDir { tool } => path_index
233                .tool_lib_dirs
234                .get(tool)
235                .cloned()
236                .unwrap_or_default(),
237        };
238
239        resolved.push(ResolvedToolActivationStep {
240            var: step.var.clone(),
241            op: step.op.clone(),
242            separator: step.separator.clone(),
243            value: join_paths(&paths, &step.separator),
244        });
245    }
246
247    Ok(resolved)
248}
249
250/// Apply a resolved activation step against the current value.
251///
252/// Returns `None` for no-op mutations (prepend/append with empty resolved value).
253#[must_use]
254pub fn apply_resolved_tool_activation(
255    current: Option<&str>,
256    step: &ResolvedToolActivationStep,
257) -> Option<String> {
258    match step.op {
259        ToolActivationOperation::Set => Some(step.value.clone()),
260        ToolActivationOperation::Prepend => {
261            if step.value.is_empty() {
262                return None;
263            }
264            match current {
265                Some(existing) if !existing.is_empty() => {
266                    Some(format!("{}{}{}", step.value, step.separator, existing))
267                }
268                _ => Some(step.value.clone()),
269            }
270        }
271        ToolActivationOperation::Append => {
272            if step.value.is_empty() {
273                return None;
274            }
275            match current {
276                Some(existing) if !existing.is_empty() => {
277                    Some(format!("{}{}{}", existing, step.separator, step.value))
278                }
279                _ => Some(step.value.clone()),
280            }
281        }
282    }
283}
284
285#[derive(Debug, Default)]
286struct ToolPathIndex {
287    all_bin_dirs: Vec<PathBuf>,
288    all_lib_dirs: Vec<PathBuf>,
289    all_include_dirs: Vec<PathBuf>,
290    all_pkgconfig_dirs: Vec<PathBuf>,
291    tool_bin_dirs: BTreeMap<String, Vec<PathBuf>>,
292    tool_lib_dirs: BTreeMap<String, Vec<PathBuf>>,
293    file_env_exports: BTreeMap<String, PathBuf>,
294}
295
296impl ToolPathIndex {
297    fn collect(options: &ToolActivationResolveOptions<'_>) -> Result<Self> {
298        Self::collect_with(options, nix_profile_path_for_project)
299    }
300
301    fn collect_with<F>(
302        options: &ToolActivationResolveOptions<'_>,
303        nix_profile_path_for_project: F,
304    ) -> Result<Self>
305    where
306        F: Fn(&Path) -> Result<PathBuf>,
307    {
308        let mut index = Self::default();
309        let mut all_bin_seen = HashSet::new();
310        let mut all_lib_seen = HashSet::new();
311        let mut all_include_seen = HashSet::new();
312        let mut all_pkgconfig_seen = HashSet::new();
313        let platform_key = options.platform.to_string();
314        let lockfile_dir = options.lockfile_path.parent().unwrap_or(Path::new("."));
315        let mut nix_profile_path: Option<Option<PathBuf>> = None;
316
317        for (name, tool) in &options.lockfile.tools {
318            let Some(platform_data) = tool.platforms.get(&platform_key) else {
319                continue;
320            };
321
322            match platform_data.provider.as_str() {
323                "nix" => {
324                    // Non-Nix tools should still activate even when we cannot derive
325                    // the project-local Nix profile path in constrained environments.
326                    let profile_path = nix_profile_path
327                        .get_or_insert_with(|| nix_profile_path_for_project(lockfile_dir).ok());
328                    let Some(profile_path) = profile_path.as_ref() else {
329                        continue;
330                    };
331                    add_existing_dir(
332                        &mut index.all_bin_dirs,
333                        &mut all_bin_seen,
334                        profile_path.join("bin"),
335                    );
336                    add_existing_dir(
337                        &mut index.all_lib_dirs,
338                        &mut all_lib_seen,
339                        profile_path.join("lib"),
340                    );
341                    add_existing_dir(
342                        &mut index.all_include_dirs,
343                        &mut all_include_seen,
344                        profile_path.join("include"),
345                    );
346                    add_existing_dir(
347                        &mut index.all_pkgconfig_dirs,
348                        &mut all_pkgconfig_seen,
349                        profile_path.join("lib").join("pkgconfig"),
350                    );
351                    add_tool_existing_dir(&mut index.tool_bin_dirs, name, profile_path.join("bin"));
352                    add_tool_existing_dir(&mut index.tool_lib_dirs, name, profile_path.join("lib"));
353                }
354                "rustup" => {
355                    let toolchain = platform_data
356                        .source
357                        .get("toolchain")
358                        .and_then(|v| v.as_str())
359                        .unwrap_or("stable");
360                    let rustup_dir = rustup_toolchain_dir(toolchain, &options.platform);
361                    add_existing_dir(
362                        &mut index.all_bin_dirs,
363                        &mut all_bin_seen,
364                        rustup_dir.join("bin"),
365                    );
366                    add_existing_dir(
367                        &mut index.all_lib_dirs,
368                        &mut all_lib_seen,
369                        rustup_dir.join("lib"),
370                    );
371                    add_existing_dir(
372                        &mut index.all_include_dirs,
373                        &mut all_include_seen,
374                        rustup_dir.join("include"),
375                    );
376                    add_existing_dir(
377                        &mut index.all_pkgconfig_dirs,
378                        &mut all_pkgconfig_seen,
379                        rustup_dir.join("lib").join("pkgconfig"),
380                    );
381                    add_tool_existing_dir(&mut index.tool_bin_dirs, name, rustup_dir.join("bin"));
382                    add_tool_existing_dir(&mut index.tool_lib_dirs, name, rustup_dir.join("lib"));
383                }
384                "github" => {
385                    let tool_dir = options
386                        .cache_dir
387                        .join("github")
388                        .join(name)
389                        .join(&tool.version);
390                    let extract: Vec<super::ToolExtract> = platform_data
391                        .source
392                        .get("extract")
393                        .cloned()
394                        .and_then(|value| serde_json::from_value(value).ok())
395                        .unwrap_or_default();
396
397                    if extract.is_empty() {
398                        // Legacy fallback: assume binary unless source path hints library.
399                        let path_hint_is_lib = platform_data
400                            .source
401                            .get("path")
402                            .and_then(|v| v.as_str())
403                            .is_some_and(path_looks_like_library);
404                        if path_hint_is_lib {
405                            let lib_dir = tool_dir.join("lib");
406                            add_existing_dir(
407                                &mut index.all_lib_dirs,
408                                &mut all_lib_seen,
409                                lib_dir.clone(),
410                            );
411                            add_tool_existing_dir(&mut index.tool_lib_dirs, name, lib_dir);
412                        } else {
413                            let bin_dir = tool_dir.join("bin");
414                            add_existing_dir(
415                                &mut index.all_bin_dirs,
416                                &mut all_bin_seen,
417                                bin_dir.clone(),
418                            );
419                            add_tool_existing_dir(&mut index.tool_bin_dirs, name, bin_dir);
420                        }
421                        continue;
422                    }
423
424                    for item in extract {
425                        match item {
426                            super::ToolExtract::Bin { .. } => {
427                                let bin_dir = tool_dir.join("bin");
428                                add_existing_dir(
429                                    &mut index.all_bin_dirs,
430                                    &mut all_bin_seen,
431                                    bin_dir.clone(),
432                                );
433                                add_tool_existing_dir(&mut index.tool_bin_dirs, name, bin_dir);
434                            }
435                            super::ToolExtract::Lib { path, env } => {
436                                let lib_dir = tool_dir.join("lib");
437                                add_existing_dir(
438                                    &mut index.all_lib_dirs,
439                                    &mut all_lib_seen,
440                                    lib_dir.clone(),
441                                );
442                                add_tool_existing_dir(
443                                    &mut index.tool_lib_dirs,
444                                    name,
445                                    lib_dir.clone(),
446                                );
447                                if let Some(var) = env {
448                                    let file_name = Path::new(&path)
449                                        .file_name()
450                                        .and_then(|n| n.to_str())
451                                        .unwrap_or(path.as_str());
452                                    let file_path = lib_dir.join(file_name);
453                                    upsert_file_env_export(
454                                        &mut index.file_env_exports,
455                                        &var,
456                                        file_path,
457                                    )?;
458                                }
459                            }
460                            super::ToolExtract::Include { .. } => {
461                                add_existing_dir(
462                                    &mut index.all_include_dirs,
463                                    &mut all_include_seen,
464                                    tool_dir.join("include"),
465                                );
466                            }
467                            super::ToolExtract::PkgConfig { .. } => {
468                                add_existing_dir(
469                                    &mut index.all_pkgconfig_dirs,
470                                    &mut all_pkgconfig_seen,
471                                    tool_dir.join("lib").join("pkgconfig"),
472                                );
473                            }
474                            super::ToolExtract::File { path, env } => {
475                                if let Some(var) = env {
476                                    let file_name = Path::new(&path)
477                                        .file_name()
478                                        .and_then(|n| n.to_str())
479                                        .unwrap_or(path.as_str());
480                                    let file_path = tool_dir.join("files").join(file_name);
481                                    upsert_file_env_export(
482                                        &mut index.file_env_exports,
483                                        &var,
484                                        file_path,
485                                    )?;
486                                }
487                            }
488                        }
489                    }
490                }
491                provider_name => {
492                    let tool_dir = options
493                        .cache_dir
494                        .join(provider_name)
495                        .join(name)
496                        .join(&tool.version);
497                    let bin_dir = tool_dir.join("bin");
498                    let lib_dir = tool_dir.join("lib");
499                    let include_dir = tool_dir.join("include");
500                    let pkgconfig_dir = tool_dir.join("lib").join("pkgconfig");
501                    add_existing_dir(&mut index.all_bin_dirs, &mut all_bin_seen, bin_dir.clone());
502                    add_existing_dir(&mut index.all_lib_dirs, &mut all_lib_seen, lib_dir.clone());
503                    add_existing_dir(
504                        &mut index.all_include_dirs,
505                        &mut all_include_seen,
506                        include_dir.clone(),
507                    );
508                    add_existing_dir(
509                        &mut index.all_pkgconfig_dirs,
510                        &mut all_pkgconfig_seen,
511                        pkgconfig_dir,
512                    );
513                    add_tool_existing_dir(&mut index.tool_bin_dirs, name, bin_dir);
514                    add_tool_existing_dir(&mut index.tool_lib_dirs, name, lib_dir);
515
516                    // Some providers store binaries directly in the tool root.
517                    if tool_dir.join(name).exists() || tool_dir.join(format!("{name}.exe")).exists()
518                    {
519                        add_existing_dir(
520                            &mut index.all_bin_dirs,
521                            &mut all_bin_seen,
522                            tool_dir.clone(),
523                        );
524                        add_tool_existing_dir(&mut index.tool_bin_dirs, name, tool_dir);
525                    }
526                }
527            }
528        }
529
530        Ok(index)
531    }
532}
533
534fn add_existing_dir(paths: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, dir: PathBuf) {
535    if !dir.exists() {
536        return;
537    }
538    if seen.insert(dir.clone()) {
539        paths.push(dir);
540    }
541}
542
543fn add_tool_existing_dir(map: &mut BTreeMap<String, Vec<PathBuf>>, tool: &str, dir: PathBuf) {
544    if !dir.exists() {
545        return;
546    }
547    let dirs = map.entry(tool.to_string()).or_default();
548    if !dirs.contains(&dir) {
549        dirs.push(dir);
550    }
551}
552
553fn path_looks_like_library(path: &str) -> bool {
554    let ext_is = |target: &str| {
555        Path::new(path)
556            .extension()
557            .and_then(|ext| ext.to_str())
558            .is_some_and(|ext| ext.eq_ignore_ascii_case(target))
559    };
560    ext_is("dylib") || ext_is("so") || path.to_ascii_lowercase().contains(".so.") || ext_is("dll")
561}
562
563fn upsert_file_env_export(
564    exports: &mut BTreeMap<String, PathBuf>,
565    var: &str,
566    path: PathBuf,
567) -> Result<()> {
568    match exports.get(var) {
569        Some(existing) if existing != &path => Err(Error::configuration(format!(
570            "Conflicting file env export for '{}': '{}' vs '{}'",
571            var,
572            existing.display(),
573            path.display()
574        ))),
575        Some(_) => Ok(()),
576        None => {
577            exports.insert(var.to_string(), path);
578            Ok(())
579        }
580    }
581}
582
583fn join_paths(paths: &[PathBuf], separator: &str) -> String {
584    paths
585        .iter()
586        .map(|p| p.display().to_string())
587        .collect::<Vec<_>>()
588        .join(separator)
589}
590
591fn rustup_toolchain_dir(toolchain: &str, platform: &Platform) -> PathBuf {
592    let rustup_home = std::env::var("RUSTUP_HOME").map_or_else(
593        |_| {
594            dirs::home_dir()
595                .unwrap_or_else(|| PathBuf::from("."))
596                .join(".rustup")
597        },
598        PathBuf::from,
599    );
600    rustup_home
601        .join("toolchains")
602        .join(format!("{toolchain}-{}", rustup_host_triple(platform)))
603}
604
605fn rustup_host_triple(platform: &Platform) -> String {
606    let arch = match platform.arch {
607        super::Arch::Arm64 => "aarch64",
608        super::Arch::X86_64 => "x86_64",
609    };
610
611    let os = match platform.os {
612        super::Os::Darwin => "apple-darwin",
613        super::Os::Linux => "unknown-linux-gnu",
614    };
615
616    format!("{arch}-{os}")
617}
618
619fn nix_profile_path_for_project(project_root: &Path) -> Result<PathBuf> {
620    let cache = crate::paths::cache_dir()?;
621    let project_id = project_profile_id(project_root);
622    Ok(cache.join("nix-profiles").join(project_id))
623}
624
625fn project_profile_id(project_root: &Path) -> String {
626    let canonical = project_root
627        .canonicalize()
628        .unwrap_or_else(|_| project_root.to_path_buf());
629    let mut hasher = Sha256::new();
630    hasher.update(canonical.to_string_lossy().as_bytes());
631    format!("{:x}", hasher.finalize())[..16].to_string()
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::lockfile::{LockedTool, LockedToolPlatform, Lockfile};
638    use std::collections::BTreeMap;
639    use std::fs;
640
641    fn current_platform_key() -> String {
642        Platform::current().to_string()
643    }
644
645    #[test]
646    fn test_validate_missing_activation_is_allowed() {
647        let platform_key = current_platform_key();
648        let mut lockfile = Lockfile::new();
649        lockfile.tools.insert(
650            "jq".to_string(),
651            LockedTool {
652                version: "1.7.1".to_string(),
653                platforms: BTreeMap::from([(
654                    platform_key,
655                    LockedToolPlatform {
656                        provider: "github".to_string(),
657                        digest: "sha256:abc".to_string(),
658                        source: serde_json::json!({
659                            "type": "github",
660                            "repo": "jqlang/jq",
661                            "tag": "jq-1.7.1",
662                            "asset": "jq-macos-arm64",
663                        }),
664                        size: None,
665                        dependencies: vec![],
666                    },
667                )]),
668            },
669        );
670
671        let temp = tempfile::tempdir().unwrap();
672        let lockfile_path = temp.path().join("cuenv.lock");
673        let options = ToolActivationResolveOptions::new(&lockfile, &lockfile_path);
674        let result = validate_tool_activation(&options);
675
676        assert!(result.is_ok());
677    }
678
679    #[test]
680    fn test_validate_rejects_per_tool_nix_reference() {
681        let platform_key = current_platform_key();
682        let mut lockfile = Lockfile::new();
683        lockfile.tools.insert(
684            "rust".to_string(),
685            LockedTool {
686                version: "1.0.0".to_string(),
687                platforms: BTreeMap::from([(
688                    platform_key,
689                    LockedToolPlatform {
690                        provider: "nix".to_string(),
691                        digest: "sha256:def".to_string(),
692                        source: serde_json::json!({
693                            "type": "nix",
694                            "flake": "nixpkgs",
695                            "package": "rustc",
696                        }),
697                        size: None,
698                        dependencies: vec![],
699                    },
700                )]),
701            },
702        );
703        lockfile.tools_activation = vec![ToolActivationStep {
704            var: "PATH".to_string(),
705            op: ToolActivationOperation::Prepend,
706            separator: ":".to_string(),
707            from: ToolActivationSource::ToolBinDir {
708                tool: "rust".to_string(),
709            },
710        }];
711
712        let temp = tempfile::tempdir().unwrap();
713        let lockfile_path = temp.path().join("cuenv.lock");
714        let options = ToolActivationResolveOptions::new(&lockfile, &lockfile_path);
715        let result = validate_tool_activation(&options);
716
717        assert!(result.is_err());
718        assert!(
719            result
720                .unwrap_err()
721                .to_string()
722                .contains("do not support Nix tools")
723        );
724    }
725
726    #[test]
727    fn test_collect_with_failing_nix_profile_lookup_still_collects_non_nix_tools() {
728        let platform_key = current_platform_key();
729        let mut lockfile = Lockfile::new();
730        lockfile.tools.insert(
731            "jq".to_string(),
732            LockedTool {
733                version: "1.7.1".to_string(),
734                platforms: BTreeMap::from([(
735                    platform_key,
736                    LockedToolPlatform {
737                        provider: "github".to_string(),
738                        digest: "sha256:abc".to_string(),
739                        source: serde_json::json!({
740                            "type": "github",
741                            "repo": "jqlang/jq",
742                            "tag": "jq-1.7.1",
743                            "asset": "jq",
744                        }),
745                        size: None,
746                        dependencies: vec![],
747                    },
748                )]),
749            },
750        );
751
752        let temp = tempfile::tempdir().unwrap();
753        let lockfile_path = temp.path().join("cuenv.lock");
754        let cache_dir = temp.path().join("cache");
755        let bin_dir = cache_dir
756            .join("github")
757            .join("jq")
758            .join("1.7.1")
759            .join("bin");
760        fs::create_dir_all(&bin_dir).unwrap();
761
762        let options =
763            ToolActivationResolveOptions::new(&lockfile, &lockfile_path).with_cache_dir(cache_dir);
764        let index = ToolPathIndex::collect_with(&options, |_| {
765            Err(Error::configuration("Could not determine cache directory"))
766        })
767        .unwrap();
768
769        assert_eq!(index.all_bin_dirs, vec![bin_dir.clone()]);
770        assert_eq!(index.tool_bin_dirs.get("jq"), Some(&vec![bin_dir]));
771    }
772
773    #[test]
774    fn test_collect_with_failing_nix_profile_lookup_skips_nix_tools() {
775        let platform_key = current_platform_key();
776        let mut lockfile = Lockfile::new();
777        lockfile.tools.insert(
778            "jq".to_string(),
779            LockedTool {
780                version: "1.7.1".to_string(),
781                platforms: BTreeMap::from([(
782                    platform_key.clone(),
783                    LockedToolPlatform {
784                        provider: "github".to_string(),
785                        digest: "sha256:abc".to_string(),
786                        source: serde_json::json!({
787                            "type": "github",
788                            "repo": "jqlang/jq",
789                            "tag": "jq-1.7.1",
790                            "asset": "jq",
791                        }),
792                        size: None,
793                        dependencies: vec![],
794                    },
795                )]),
796            },
797        );
798        lockfile.tools.insert(
799            "rust".to_string(),
800            LockedTool {
801                version: "1.85.0".to_string(),
802                platforms: BTreeMap::from([(
803                    platform_key,
804                    LockedToolPlatform {
805                        provider: "nix".to_string(),
806                        digest: "sha256:def".to_string(),
807                        source: serde_json::json!({
808                            "type": "nix",
809                            "flake": "nixpkgs",
810                            "package": "rustc",
811                        }),
812                        size: None,
813                        dependencies: vec![],
814                    },
815                )]),
816            },
817        );
818
819        let temp = tempfile::tempdir().unwrap();
820        let lockfile_path = temp.path().join("cuenv.lock");
821        let cache_dir = temp.path().join("cache");
822        let bin_dir = cache_dir
823            .join("github")
824            .join("jq")
825            .join("1.7.1")
826            .join("bin");
827        fs::create_dir_all(&bin_dir).unwrap();
828
829        let options =
830            ToolActivationResolveOptions::new(&lockfile, &lockfile_path).with_cache_dir(cache_dir);
831        let index = ToolPathIndex::collect_with(&options, |_| {
832            Err(Error::configuration("Could not determine cache directory"))
833        })
834        .unwrap();
835
836        assert_eq!(index.all_bin_dirs, vec![bin_dir.clone()]);
837        assert_eq!(index.tool_bin_dirs.get("jq"), Some(&vec![bin_dir]));
838        assert!(!index.tool_bin_dirs.contains_key("rust"));
839        assert!(index.all_lib_dirs.is_empty());
840    }
841
842    #[test]
843    fn test_resolve_tool_activation_includes_file_env_exports() {
844        let platform = Platform::current();
845        let platform_key = platform.to_string();
846        let mut lockfile = Lockfile::new();
847        lockfile.tools.insert(
848            "foundationdb".to_string(),
849            LockedTool {
850                version: "7.3.63".to_string(),
851                platforms: BTreeMap::from([(
852                    platform_key,
853                    LockedToolPlatform {
854                        provider: "github".to_string(),
855                        digest: "sha256:abc".to_string(),
856                        source: serde_json::json!({
857                            "type": "github",
858                            "repo": "apple/foundationdb",
859                            "tag": "7.3.63",
860                            "asset": "FoundationDB.pkg",
861                            "extract": [
862                                {"kind": "bin", "path": "bin/fdbcli"},
863                                {"kind": "lib", "path": "lib/libfdb_c.dylib", "env": "FDB_CLIENT_LIB"},
864                                {"kind": "file", "path": "etc/fdb.cluster", "env": "FDB_CLUSTER_FILE"},
865                                {"kind": "include", "path": "include/foundationdb/fdb_c.h"},
866                                {"kind": "pkgconfig", "path": "lib/pkgconfig/foundationdb.pc"}
867                            ],
868                        }),
869                        size: None,
870                        dependencies: vec![],
871                    },
872                )]),
873            },
874        );
875
876        let temp = tempfile::tempdir().unwrap();
877        let lockfile_path = temp.path().join("cuenv.lock");
878        let cache_dir = temp.path().join("cache");
879        let tool_dir = cache_dir.join("github").join("foundationdb").join("7.3.63");
880        let bin_dir = tool_dir.join("bin");
881        let lib_dir = tool_dir.join("lib");
882        let files_dir = tool_dir.join("files");
883        let include_dir = tool_dir.join("include");
884        let pkgconfig_dir = lib_dir.join("pkgconfig");
885        fs::create_dir_all(&bin_dir).unwrap();
886        fs::create_dir_all(&lib_dir).unwrap();
887        fs::create_dir_all(&files_dir).unwrap();
888        fs::create_dir_all(&include_dir).unwrap();
889        fs::create_dir_all(&pkgconfig_dir).unwrap();
890        fs::write(bin_dir.join("fdbcli"), "").unwrap();
891        fs::write(lib_dir.join("libfdb_c.dylib"), "").unwrap();
892        fs::write(files_dir.join("fdb.cluster"), "").unwrap();
893        fs::write(include_dir.join("fdb_c.h"), "").unwrap();
894        fs::write(pkgconfig_dir.join("foundationdb.pc"), "").unwrap();
895
896        let options = ToolActivationResolveOptions::new(&lockfile, &lockfile_path)
897            .with_platform(platform)
898            .with_cache_dir(cache_dir);
899        let steps = resolve_tool_activation(&options).unwrap();
900
901        assert!(
902            steps
903                .iter()
904                .any(|step| step.var == "PATH" && step.value == bin_dir.to_string_lossy())
905        );
906        assert!(steps.iter().any(|step| {
907            step.var == "FDB_CLIENT_LIB"
908                && step.op == ToolActivationOperation::Set
909                && step.value == lib_dir.join("libfdb_c.dylib").to_string_lossy()
910        }));
911        assert!(steps.iter().any(|step| {
912            step.var == "FDB_CLUSTER_FILE"
913                && step.op == ToolActivationOperation::Set
914                && step.value == files_dir.join("fdb.cluster").to_string_lossy()
915        }));
916        assert!(
917            steps
918                .iter()
919                .any(|step| step.var == "CPATH" && step.value == include_dir.to_string_lossy())
920        );
921        assert!(steps.iter().any(|step| {
922            step.var == "PKG_CONFIG_PATH" && step.value == pkgconfig_dir.to_string_lossy()
923        }));
924    }
925
926    #[test]
927    fn test_apply_activation_operations() {
928        let set_step = ResolvedToolActivationStep {
929            var: "PATH".to_string(),
930            op: ToolActivationOperation::Set,
931            separator: ":".to_string(),
932            value: "/a:/b".to_string(),
933        };
934        let prepend_step = ResolvedToolActivationStep {
935            var: "PATH".to_string(),
936            op: ToolActivationOperation::Prepend,
937            separator: ":".to_string(),
938            value: "/tools".to_string(),
939        };
940        let append_step = ResolvedToolActivationStep {
941            var: "PATH".to_string(),
942            op: ToolActivationOperation::Append,
943            separator: ":".to_string(),
944            value: "/tail".to_string(),
945        };
946
947        let set_value = apply_resolved_tool_activation(None, &set_step).unwrap();
948        assert_eq!(set_value, "/a:/b");
949
950        let prepend_value =
951            apply_resolved_tool_activation(Some("/usr/bin"), &prepend_step).unwrap();
952        assert_eq!(prepend_value, "/tools:/usr/bin");
953
954        let append_value = apply_resolved_tool_activation(Some("/usr/bin"), &append_step).unwrap();
955        assert_eq!(append_value, "/usr/bin:/tail");
956    }
957}