Skip to main content

rattler_shell/
activation.rs

1#![deny(missing_docs)]
2
3//! This crate provides helper functions to activate and deactivate virtual
4//! environments.
5
6#[cfg(target_family = "unix")]
7use std::io::Write;
8use std::{
9    collections::HashMap,
10    ffi::OsStr,
11    path::{Path, PathBuf},
12    process::ExitStatus,
13};
14
15#[cfg(target_family = "unix")]
16use anyhow::{Context, Result};
17use fs_err as fs;
18use indexmap::IndexMap;
19use itertools::Itertools;
20use rattler_conda_types::Platform;
21#[cfg(target_family = "unix")]
22use rattler_pty::unix::PtySession;
23
24use crate::shell::{Shell, ShellError, ShellScript};
25
26const ENV_START_SEPARATOR: &str = "____RATTLER_ENV_START____";
27
28/// Type of modification done to the `PATH` variable
29#[derive(Default, Clone)]
30pub enum PathModificationBehavior {
31    /// Replaces the complete path variable with specified paths.
32    #[default]
33    Replace,
34    /// Appends the new path variables to the path. E.g. <PATH:/new/path>
35    Append,
36    /// Prepends the new path variables to the path. E.g. "/new/path:$PATH"
37    Prepend,
38}
39
40/// A struct that contains the values of the environment variables that are
41/// relevant for the activation process. The values are stored as strings.
42/// Currently, only the `PATH` and `CONDA_PREFIX` environment variables are
43/// used.
44#[derive(Default, Clone)]
45pub struct ActivationVariables {
46    /// The value of the `CONDA_PREFIX` environment variable that contains the
47    /// activated conda prefix path
48    pub conda_prefix: Option<PathBuf>,
49
50    /// The value of the `PATH` environment variable that contains the paths to
51    /// the executables
52    pub path: Option<Vec<PathBuf>>,
53
54    /// The type of behavior of what should happen with the defined paths.
55    pub path_modification_behavior: PathModificationBehavior,
56
57    /// Current environment variables
58    pub current_env: HashMap<String, String>,
59}
60
61impl ActivationVariables {
62    /// Create a new `ActivationVariables` struct from the environment
63    /// variables.
64    pub fn from_env() -> Result<Self, std::env::VarError> {
65        // Read all environment variables here
66        let current_env: HashMap<String, String> = std::env::vars().collect();
67
68        Ok(Self {
69            conda_prefix: current_env.get("CONDA_PREFIX").map(PathBuf::from),
70            path: None,
71            path_modification_behavior: PathModificationBehavior::Prepend,
72            current_env,
73        })
74    }
75}
76
77/// A struct that holds values for the activation and deactivation
78/// process of an environment, e.g. activation scripts to execute or environment
79/// variables to set.
80#[derive(Debug)]
81pub struct Activator<T: Shell + 'static> {
82    /// The path to the root of the conda environment
83    pub target_prefix: PathBuf,
84
85    /// The type of shell that is being activated
86    pub shell_type: T,
87
88    /// Paths that need to be added to the PATH environment variable
89    pub paths: Vec<PathBuf>,
90
91    /// A list of scripts to run when activating the environment
92    pub activation_scripts: Vec<PathBuf>,
93
94    /// A list of scripts to run when deactivating the environment
95    pub deactivation_scripts: Vec<PathBuf>,
96
97    /// A list of environment variables to set before running the activation
98    /// scripts. These are evaluated before `activation_scripts` have run.
99    pub env_vars: IndexMap<String, String>,
100
101    /// A list of environment variables to set after running the activation
102    /// scripts. These are evaluated after `activation_scripts` have run.
103    pub post_activation_env_vars: IndexMap<String, String>,
104
105    /// The platform for which to generate the Activator
106    pub platform: Platform,
107}
108
109/// Collect all script files that match a certain shell type from a given path.
110/// The files are sorted by their filename.
111/// If the path does not exist, an empty vector is returned.
112/// If the path is not a directory, an error is returned.
113///
114/// # Arguments
115///
116/// * `path` - The path to the directory that contains the scripts
117/// * `shell_type` - The type of shell that the scripts are for
118///
119/// # Returns
120///
121/// A vector of paths to the scripts
122///
123/// # Errors
124///
125/// If the path is not a directory, an error is returned.
126fn collect_scripts<T: Shell>(path: &Path, shell_type: &T) -> Result<Vec<PathBuf>, std::io::Error> {
127    // Check if path exists
128    if !path.exists() {
129        return Ok(vec![]);
130    }
131
132    let paths = fs::read_dir(path)?;
133
134    let mut scripts = paths
135        .into_iter()
136        .filter_map(std::result::Result::ok)
137        .map(|r| r.path())
138        .filter(|path| shell_type.can_run_script(path))
139        .collect::<Vec<_>>();
140
141    scripts.sort();
142
143    Ok(scripts)
144}
145
146/// Error that can occur when activating a conda environment
147#[derive(thiserror::Error, Debug)]
148pub enum ActivationError {
149    /// An error that can occur when reading or writing files
150    #[error(transparent)]
151    IoError(#[from] std::io::Error),
152
153    /// An error that can occur when running a command
154    #[error(transparent)]
155    ShellError(#[from] ShellError),
156
157    /// An error that can occur when parsing JSON
158    #[error("Invalid json for environment vars: {0} in file {1:?}")]
159    InvalidEnvVarFileJson(serde_json::Error, PathBuf),
160
161    /// An error that can occur with malformed JSON when parsing files in the
162    /// `env_vars.d` directory
163    #[error("Malformed JSON: not a plain JSON object in file {file:?}")]
164    InvalidEnvVarFileJsonNoObject {
165        /// The path to the file that contains the malformed JSON
166        file: PathBuf,
167    },
168
169    /// An error that can occur when `state` file is malformed
170    #[error("Malformed JSON: file does not contain JSON object at key env_vars in file {file:?}")]
171    InvalidEnvVarFileStateFile {
172        /// The path to the file that contains the malformed JSON
173        file: PathBuf,
174    },
175
176    /// An error that occurs when writing the activation script to a file fails
177    #[error("Failed to write activation script to file {0}")]
178    FailedToWriteActivationScript(#[from] std::fmt::Error),
179
180    /// Failed to run the activation script
181    #[error("Failed to run activation script (status: {status})")]
182    FailedToRunActivationScript {
183        /// The contents of the activation script that was run
184        script: String,
185
186        /// The stdout output of executing the script
187        stdout: String,
188
189        /// The stderr output of executing the script
190        stderr: String,
191
192        /// The error code of running the script
193        status: ExitStatus,
194    },
195}
196
197/// Collect all environment variables that are set in a conda environment.
198/// The environment variables are collected from the `state` file and the
199/// `env_vars.d` directory in the given prefix and are returned as a ordered
200/// map.
201///
202/// # Arguments
203///
204/// * `prefix` - The path to the root of the conda environment
205///
206/// # Returns
207///
208/// A map of environment variables
209///
210/// # Errors
211///
212/// If the `state` file or the `env_vars.d` directory cannot be read, an error
213/// is returned.
214fn collect_env_vars(prefix: &Path) -> Result<IndexMap<String, String>, ActivationError> {
215    let state_file = prefix.join("conda-meta/state");
216    let pkg_env_var_dir = prefix.join("etc/conda/env_vars.d");
217    let mut env_vars = IndexMap::new();
218
219    if pkg_env_var_dir.exists() {
220        let env_var_files = pkg_env_var_dir.read_dir()?;
221
222        let mut env_var_files = env_var_files
223            .into_iter()
224            .filter_map(std::result::Result::ok)
225            .map(|e| e.path())
226            .filter(|path| path.is_file())
227            .collect::<Vec<_>>();
228
229        // sort env var files to get a deterministic order
230        env_var_files.sort();
231
232        let env_var_json_files = env_var_files
233            .iter()
234            .map(|path| {
235                fs::read_to_string(path)?
236                    .parse::<serde_json::Value>()
237                    .map_err(|e| ActivationError::InvalidEnvVarFileJson(e, path.clone()))
238            })
239            .collect::<Result<Vec<serde_json::Value>, ActivationError>>()?;
240
241        for (env_var_json, env_var_file) in env_var_json_files.iter().zip(env_var_files.iter()) {
242            let env_var_json = env_var_json.as_object().ok_or_else(|| {
243                ActivationError::InvalidEnvVarFileJsonNoObject {
244                    file: pkg_env_var_dir.clone(),
245                }
246            })?;
247
248            for (key, value) in env_var_json {
249                if let Some(value) = value.as_str() {
250                    env_vars.insert(key.clone(), value.to_string());
251                } else {
252                    tracing::warn!(
253                        "WARNING: environment variable {key} has no string value (path: {env_var_file:?})"
254                    );
255                }
256            }
257        }
258    }
259
260    if state_file.exists() {
261        let state_json = fs::read_to_string(&state_file)?;
262
263        // load json but preserve the order of dicts - for this we use the serde
264        // preserve_order feature
265        let state_json: serde_json::Value = serde_json::from_str(&state_json)
266            .map_err(|e| ActivationError::InvalidEnvVarFileJson(e, state_file.clone()))?;
267
268        let state_env_vars = state_json["env_vars"].as_object().ok_or_else(|| {
269            ActivationError::InvalidEnvVarFileStateFile {
270                file: state_file.clone(),
271            }
272        })?;
273
274        for (key, value) in state_env_vars {
275            if env_vars.contains_key(key) {
276                tracing::warn!(
277                    "WARNING: environment variable {key} already defined in packages (path: {state_file:?})"
278                );
279            }
280
281            if let Some(value) = value.as_str() {
282                env_vars.insert(key.to_uppercase(), value.to_string());
283            } else {
284                tracing::warn!(
285                    "WARNING: environment variable {key} has no string value (path: {state_file:?})"
286                );
287            }
288        }
289    }
290    Ok(env_vars)
291}
292
293/// Return a vector of path entries that are prefixed with the given path.
294///
295/// # Arguments
296///
297/// * `prefix` - The path to prefix the path entries with
298/// * `operating_system` - The operating system that the path entries are for
299///
300/// # Returns
301///
302/// A vector of path entries
303pub fn prefix_path_entries(prefix: &Path, platform: &Platform) -> Vec<PathBuf> {
304    if platform.is_windows() {
305        vec![
306            prefix.to_path_buf(),
307            prefix.join("Library/mingw-w64/bin"),
308            prefix.join("Library/usr/bin"),
309            prefix.join("Library/bin"),
310            prefix.join("Scripts"),
311            prefix.join("bin"),
312        ]
313    } else {
314        vec![prefix.join("bin")]
315    }
316}
317
318/// The result of a activation. It contains the activation script and the new
319/// path entries. The activation script already sets the PATH environment
320/// variable, but for "environment stacking" purposes it's useful to have the
321/// new path entries separately.
322pub struct ActivationResult<T: Shell + 'static> {
323    /// The activation script that sets the environment variables, runs
324    /// activation/deactivation scripts and sets the new PATH environment
325    /// variable
326    pub script: ShellScript<T>,
327    /// The new path entries that are added to the PATH environment variable
328    pub path: Vec<PathBuf>,
329}
330
331impl<T: Shell + Clone> Activator<T> {
332    /// Return unique env var keys from both `env_vars` and `post_activation_env_vars` in insertion order.
333    fn unique_env_keys(&self) -> impl Iterator<Item = &str> {
334        self.env_vars
335            .keys()
336            .chain(self.post_activation_env_vars.keys())
337            .map(String::as_str)
338            .unique()
339    }
340
341    // moved: apply_env_vars_with_backup now lives on `ShellScript`
342
343    /// Create a new activator for the given conda environment.
344    ///
345    /// # Arguments
346    ///
347    /// * `path` - The path to the root of the conda environment
348    /// * `shell_type` - The shell type that the activator is for
349    /// * `operating_system` - The operating system that the activator is for
350    ///
351    /// # Returns
352    ///
353    /// A new activator
354    ///
355    /// # Examples
356    ///
357    /// ```
358    /// use rattler_shell::activation::Activator;
359    /// use rattler_shell::shell;
360    /// use rattler_conda_types::Platform;
361    /// use std::path::PathBuf;
362    ///
363    /// let activator = Activator::from_path(&PathBuf::from("tests/fixtures/env_vars"), shell::Bash, Platform::Osx64).unwrap();
364    /// assert_eq!(activator.paths.len(), 1);
365    /// assert_eq!(activator.paths[0], PathBuf::from("tests/fixtures/env_vars/bin"));
366    /// ```
367    pub fn from_path(
368        path: &Path,
369        shell_type: T,
370        platform: Platform,
371    ) -> Result<Activator<T>, ActivationError> {
372        let activation_scripts = collect_scripts(&path.join("etc/conda/activate.d"), &shell_type)?;
373
374        let deactivation_scripts =
375            collect_scripts(&path.join("etc/conda/deactivate.d"), &shell_type)?;
376
377        let env_vars = collect_env_vars(path)?;
378
379        let paths = prefix_path_entries(path, &platform);
380
381        Ok(Activator {
382            target_prefix: path.to_path_buf(),
383            shell_type,
384            paths,
385            activation_scripts,
386            deactivation_scripts,
387            env_vars,
388            post_activation_env_vars: IndexMap::new(),
389            platform,
390        })
391    }
392
393    /// Starts a UNIX shell.
394    /// # Arguments
395    /// - `shell`: The type of shell to start. Must implement the `Shell` and
396    ///   `Copy` traits.
397    /// - `args`: A vector of arguments to pass to the shell.
398    /// - `env`: A `HashMap` containing environment variables to set in the
399    ///   shell.
400    /// - `prompt`: Prompt to the shell
401    #[cfg(target_family = "unix")]
402    #[allow(dead_code)]
403    async fn start_unix_shell<T_: Shell + Copy + 'static>(
404        shell: T_,
405        args: Vec<&str>,
406        env: &HashMap<String, String>,
407        prompt: String,
408    ) -> Result<Option<i32>> {
409        const DONE_STR: &str = "RATTLER_SHELL_ACTIVATION_DONE";
410        // create a tempfile for activation
411        let mut temp_file = tempfile::Builder::new()
412            .prefix("rattler_env_")
413            .suffix(&format!(".{}", shell.extension()))
414            .rand_bytes(3)
415            .tempfile()
416            .context("Failed to create tmp file")?;
417
418        let mut shell_script = ShellScript::new(shell, Platform::current());
419        for (key, value) in env {
420            shell_script
421                .set_env_var(key, value)
422                .context("Failed to set env var")?;
423        }
424
425        shell_script.echo(DONE_STR)?;
426
427        temp_file
428            .write_all(shell_script.contents()?.as_bytes())
429            .context("Failed to write shell script content")?;
430
431        // Write custom prompt to the env file
432        temp_file.write_all(prompt.as_bytes())?;
433
434        let mut command = std::process::Command::new(shell.executable());
435        command.args(args);
436
437        // Space added before `source` to automatically ignore it in history.
438        let mut source_command = " ".to_string();
439        shell
440            .run_script(&mut source_command, temp_file.path())
441            .context("Failed to run the script")?;
442
443        // Remove automatically added `\n`, if for some reason this fails, just ignore.
444        let source_command = source_command
445            .strip_suffix('\n')
446            .unwrap_or(source_command.as_str());
447
448        // Start process and send env activation to the shell.
449        let mut process = PtySession::new(command)?;
450        process
451            .send_line(source_command)
452            .context("Failed to send command to shell")?;
453
454        process
455            .interact(Some(DONE_STR))
456            .context("Failed to interact with shell process")
457    }
458
459    /// Create an activation script for a given shell and platform. This
460    /// returns a tuple of the newly computed PATH variable and the activation
461    /// script.
462    pub fn activation(
463        &self,
464        variables: ActivationVariables,
465    ) -> Result<ActivationResult<T>, ActivationError> {
466        let mut script = ShellScript::new(self.shell_type.clone(), self.platform);
467
468        let mut path = variables.path.clone().unwrap_or_default();
469        if let Some(conda_prefix) = variables.conda_prefix {
470            let deactivate = Activator::from_path(
471                Path::new(&conda_prefix),
472                self.shell_type.clone(),
473                self.platform,
474            )?;
475
476            for (key, _) in &deactivate.env_vars {
477                script.unset_env_var(key)?;
478            }
479
480            for deactivation_script in &deactivate.deactivation_scripts {
481                script.run_script(deactivation_script)?;
482            }
483
484            path.retain(|x| !deactivate.paths.contains(x));
485        }
486
487        // prepend new paths
488        let path = [self.paths.clone(), path].concat();
489
490        script.set_path(path.as_slice(), variables.path_modification_behavior)?;
491
492        // Get the current shell level
493        // For us, zero is the starting point, so we will increment it
494        // meaning that we will set CONDA_SHLVL to 1 on the first activation.
495        let shlvl = variables
496            .current_env
497            .get("CONDA_SHLVL")
498            .and_then(|s| s.parse::<i32>().ok())
499            .unwrap_or(0);
500
501        // Set the new CONDA_SHLVL first
502        let new_shlvl = shlvl + 1;
503        script.set_env_var("CONDA_SHLVL", &new_shlvl.to_string())?;
504
505        // Save original CONDA_PREFIX value if it exists
506        if let Some(existing_prefix) = variables.current_env.get("CONDA_PREFIX") {
507            script.set_env_var(
508                &format!("CONDA_ENV_SHLVL_{new_shlvl}_CONDA_PREFIX"),
509                existing_prefix,
510            )?;
511        }
512
513        // Set new CONDA_PREFIX
514        script.set_env_var("CONDA_PREFIX", &self.target_prefix.to_string_lossy())?;
515
516        // For each environment variable that was set during activation
517        script.apply_env_vars_with_backup(&variables.current_env, new_shlvl, &self.env_vars)?;
518
519        for activation_script in &self.activation_scripts {
520            script.run_script(activation_script)?;
521        }
522
523        // Set environment variables that should be applied after activation scripts
524        script.apply_env_vars_with_backup(
525            &variables.current_env,
526            new_shlvl,
527            &self.post_activation_env_vars,
528        )?;
529
530        Ok(ActivationResult { script, path })
531    }
532
533    /// Create a deactivation script for the environment.
534    /// This returns the deactivation script that unsets environment variables
535    /// and runs deactivation scripts.
536    pub fn deactivation(
537        &self,
538        variables: ActivationVariables,
539    ) -> Result<ActivationResult<T>, ActivationError> {
540        let mut script = ShellScript::new(self.shell_type.clone(), self.platform);
541
542        // Get the current CONDA shell level from passed environment variables
543        let current_conda_shlvl = variables
544            .current_env
545            .get("CONDA_SHLVL")
546            .and_then(|s| s.parse::<i32>().ok());
547
548        match current_conda_shlvl {
549            None => {
550                // Handle edge case: CONDA_SHLVL not set
551                script
552                    .echo("Warning: CONDA_SHLVL not set. This may indicate a broken workflow.")?;
553                script.echo(
554                    "Proceeding to unset conda variables without restoring previous values.",
555                )?;
556
557                // Just unset without restoring (each key once)
558                for key in self.unique_env_keys() {
559                    script.unset_env_var(key)?;
560                }
561                script.unset_env_var("CONDA_PREFIX")?;
562                script.unset_env_var("CONDA_SHLVL")?;
563            }
564            Some(current_level) if current_level <= 0 => {
565                // Handle edge case: CONDA_SHLVL zero or negative
566                script.echo("Warning: CONDA_SHLVL is zero or negative. This may indicate a broken workflow.")?;
567                script.echo(
568                    "Proceeding to unset conda variables without restoring previous values.",
569                )?;
570
571                // Just unset without restoring (each key once)
572                for key in self.unique_env_keys() {
573                    script.unset_env_var(key)?;
574                }
575                script.unset_env_var("CONDA_PREFIX")?;
576                script.unset_env_var("CONDA_SHLVL")?;
577            }
578            Some(current_level) => {
579                // Unset the current level
580                // For each environment variable that was set during activation
581                for key in self.unique_env_keys() {
582                    let backup_key = format!("CONDA_ENV_SHLVL_{current_level}_{key}");
583                    script.restore_env_var(key, &backup_key)?;
584                }
585
586                // Handle CONDA_PREFIX restoration
587                let backup_prefix = format!("CONDA_ENV_SHLVL_{current_level}_CONDA_PREFIX");
588                script.restore_env_var("CONDA_PREFIX", &backup_prefix)?;
589
590                let prev_shlvl = current_level - 1;
591
592                // Update CONDA_SHLVL
593                if prev_shlvl == 0 {
594                    script.unset_env_var("CONDA_SHLVL")?;
595                } else {
596                    script.set_env_var("CONDA_SHLVL", &prev_shlvl.to_string())?;
597                }
598            }
599        }
600
601        // Run all deactivation scripts
602        for deactivation_script in &self.deactivation_scripts {
603            script.run_script(deactivation_script)?;
604        }
605
606        Ok(ActivationResult {
607            script,
608            path: Vec::new(),
609        })
610    }
611
612    /// Runs the activation script and returns the environment variables changed
613    /// in the environment after running the script.
614    ///
615    /// If the `environment` parameter is not `None`, then it will overwrite the
616    /// parent environment variables when running the activation script.
617    pub fn run_activation(
618        &self,
619        variables: ActivationVariables,
620        environment: Option<HashMap<&OsStr, &OsStr>>,
621    ) -> Result<HashMap<String, String>, ActivationError> {
622        let activation_script = self.activation(variables)?.script;
623
624        // Create a script that starts by emitting all environment variables, then runs
625        // the activation script followed by again emitting all environment
626        // variables. Any changes should then become visible.
627        let mut activation_detection_script =
628            ShellScript::new(self.shell_type.clone(), self.platform);
629        activation_detection_script
630            .print_env()?
631            .echo(ENV_START_SEPARATOR)?
632            .append_script(&activation_script)
633            .echo(ENV_START_SEPARATOR)?
634            .print_env()?;
635
636        // Create a temporary file that we can execute with our shell.
637        let activation_script_dir = tempfile::TempDir::new()?;
638        let activation_script_path = activation_script_dir
639            .path()
640            .join(format!("activation.{}", self.shell_type.extension()));
641
642        // Write the activation script to the temporary file, closing the file
643        // afterwards
644        fs::write(
645            &activation_script_path,
646            activation_detection_script.contents()?,
647        )?;
648        // Get only the path to the temporary file
649        let mut activation_command = self
650            .shell_type
651            .create_run_script_command(&activation_script_path);
652
653        // Overwrite the environment variables with the ones provided
654        if let Some(environment) = environment.clone() {
655            activation_command.env_clear().envs(environment);
656        }
657
658        let activation_result = activation_command.output()?;
659
660        if !activation_result.status.success() {
661            return Err(ActivationError::FailedToRunActivationScript {
662                script: activation_detection_script.contents()?,
663                stdout: String::from_utf8_lossy(&activation_result.stdout).into_owned(),
664                stderr: String::from_utf8_lossy(&activation_result.stderr).into_owned(),
665                status: activation_result.status,
666            });
667        }
668
669        let stdout = String::from_utf8_lossy(&activation_result.stdout);
670        let (before_env, rest) = stdout
671            .split_once(ENV_START_SEPARATOR)
672            .unwrap_or(("", stdout.as_ref()));
673        let (_, after_env) = rest.rsplit_once(ENV_START_SEPARATOR).unwrap_or(("", ""));
674
675        // Parse both environments and find the difference
676        let before_env = self.shell_type.parse_env(before_env);
677        let after_env = self.shell_type.parse_env(after_env);
678
679        // Find and return the differences
680        Ok(after_env
681            .into_iter()
682            .filter(|(key, value)| before_env.get(key) != Some(value))
683            // this happens on Windows for some reason
684            // @SET "=C:=C:\Users\robostack\Programs\pixi"
685            // @SET "=ExitCode=00000000"
686            .filter(|(key, _)| !key.is_empty())
687            .map(|(key, value)| (key.to_owned(), value.to_owned()))
688            .collect())
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use std::{collections::BTreeMap, str::FromStr};
695
696    use tempfile::TempDir;
697
698    use super::*;
699    #[cfg(unix)]
700    use crate::activation::PathModificationBehavior;
701    use crate::shell::{self, native_path_to_unix, ShellEnum};
702
703    #[test]
704    #[cfg(unix)]
705    fn test_post_activation_env_vars_applied_after_scripts_bash() {
706        let temp_dir = TempDir::with_prefix("test_post_activation_env_vars").unwrap();
707
708        // Create a dummy activation script so the activator will run it
709        let activate_dir = temp_dir.path().join("etc/conda/activate.d");
710        fs::create_dir_all(&activate_dir).unwrap();
711        let script_path = activate_dir.join("script1.sh");
712        fs::write(&script_path, "# noop\n").unwrap();
713
714        // Build an activator with both pre and post env vars
715        let pre_env = IndexMap::from_iter([(String::from("A"), String::from("x"))]);
716
717        // Ensure we also override a pre var in post
718        let post_env = IndexMap::from_iter([
719            (String::from("B"), String::from("y")),
720            (String::from("A"), String::from("z")),
721        ]);
722
723        let activator = Activator {
724            target_prefix: temp_dir.path().to_path_buf(),
725            shell_type: shell::Bash,
726            paths: vec![temp_dir.path().join("bin")],
727            activation_scripts: vec![script_path.clone()],
728            deactivation_scripts: vec![],
729            env_vars: pre_env,
730            post_activation_env_vars: post_env,
731            platform: Platform::current(),
732        };
733
734        let result = activator
735            .activation(ActivationVariables {
736                conda_prefix: None,
737                path: None,
738                path_modification_behavior: PathModificationBehavior::Prepend,
739                current_env: HashMap::new(),
740            })
741            .unwrap();
742
743        let mut contents = result.script.contents().unwrap();
744
745        // Normalize prefix path for consistent assertions
746        let prefix = temp_dir.path().to_str().unwrap();
747        contents = contents.replace(prefix, "__PREFIX__");
748
749        // Check ordering: pre env vars before script run, post env vars after script run
750        let idx_pre_a = contents.find("export A=x").expect("missing pre env A=x");
751        let idx_run = contents
752            .find(". __PREFIX__/etc/conda/activate.d/script1.sh")
753            .expect("missing activation script run");
754        let idx_post_b = contents.find("export B=y").expect("missing post env B=y");
755        let idx_post_a = contents
756            .find("export A=z")
757            .expect("missing post override A=z");
758
759        assert!(
760            idx_pre_a < idx_run,
761            "pre env var should be before activation script"
762        );
763        assert!(
764            idx_run < idx_post_b,
765            "post env var should be after activation script"
766        );
767        assert!(
768            idx_run < idx_post_a,
769            "post override should be after activation script"
770        );
771    }
772
773    #[test]
774    fn test_collect_scripts() {
775        let tdir = TempDir::with_prefix("test").unwrap();
776
777        let path = tdir.path().join("etc/conda/activate.d/");
778        fs::create_dir_all(&path).unwrap();
779
780        let script1 = path.join("script1.sh");
781        let script2 = path.join("aaa.sh");
782        let script3 = path.join("xxx.sh");
783
784        fs::write(&script1, "").unwrap();
785        fs::write(&script2, "").unwrap();
786        fs::write(&script3, "").unwrap();
787
788        let shell_type = shell::Bash;
789
790        let scripts = collect_scripts(&path, &shell_type).unwrap();
791        assert_eq!(scripts.len(), 3);
792        assert_eq!(scripts[0], script2);
793        assert_eq!(scripts[1], script1);
794        assert_eq!(scripts[2], script3);
795
796        let activator = Activator::from_path(tdir.path(), shell_type, Platform::Osx64).unwrap();
797        assert_eq!(activator.activation_scripts.len(), 3);
798        assert_eq!(activator.activation_scripts[0], script2);
799        assert_eq!(activator.activation_scripts[1], script1);
800        assert_eq!(activator.activation_scripts[2], script3);
801    }
802
803    #[test]
804    fn test_collect_env_vars() {
805        let tdir = TempDir::with_prefix("test").unwrap();
806        let path = tdir.path().join("conda-meta/state");
807        fs::create_dir_all(path.parent().unwrap()).unwrap();
808
809        let quotes = r#"{"env_vars": {"Hallo": "myval", "TEST": "itsatest", "AAA": "abcdef"}}"#;
810        fs::write(&path, quotes).unwrap();
811
812        let env_vars = collect_env_vars(tdir.path()).unwrap();
813        assert_eq!(env_vars.len(), 3);
814
815        assert_eq!(env_vars["HALLO"], "myval");
816        assert_eq!(env_vars["TEST"], "itsatest");
817        assert_eq!(env_vars["AAA"], "abcdef");
818    }
819
820    #[test]
821    fn test_collect_env_vars_with_directory() {
822        let tdir = TempDir::with_prefix("test").unwrap();
823        let state_path = tdir.path().join("conda-meta/state");
824        fs::create_dir_all(state_path.parent().unwrap()).unwrap();
825
826        let content_pkg_1 = r#"{"VAR1": "someval", "TEST": "pkg1-test", "III": "super"}"#;
827        let content_pkg_2 = r#"{"VAR1": "overwrite1", "TEST2": "pkg2-test"}"#;
828
829        let env_var_d = tdir.path().join("etc/conda/env_vars.d");
830        fs::create_dir_all(&env_var_d).expect("Could not create env vars directory");
831
832        let pkg1 = env_var_d.join("pkg1.json");
833        let pkg2 = env_var_d.join("pkg2.json");
834
835        fs::write(pkg1, content_pkg_1).expect("could not write file");
836        fs::write(pkg2, content_pkg_2).expect("could not write file");
837
838        let quotes = r#"{"env_vars": {"Hallo": "myval", "TEST": "itsatest", "AAA": "abcdef"}}"#;
839        fs::write(&state_path, quotes).unwrap();
840
841        let env_vars = collect_env_vars(tdir.path()).expect("Could not load env vars");
842        assert_eq!(env_vars.len(), 6);
843
844        assert_eq!(env_vars["VAR1"], "overwrite1");
845        assert_eq!(env_vars["TEST"], "itsatest");
846        assert_eq!(env_vars["III"], "super");
847        assert_eq!(env_vars["TEST2"], "pkg2-test");
848        assert_eq!(env_vars["HALLO"], "myval");
849        assert_eq!(env_vars["AAA"], "abcdef");
850
851        // assert order of keys
852        let mut keys = env_vars.keys();
853        let key_vec = vec![
854            "VAR1", // overwritten - should this be sorted down?
855            "TEST", "III", "TEST2", "HALLO", "AAA",
856        ];
857
858        for key in key_vec {
859            assert_eq!(keys.next().unwrap(), key);
860        }
861    }
862
863    /// Regression test for: <https://github.com/conda/rattler/issues/2253>
864    ///
865    /// `collect_env_vars` used to check `state_env_vars.contains_key(key)` while
866    /// iterating `state_env_vars` — always true, so a spurious "already defined" warning
867    /// was emitted for **every** env var in the state file.
868    ///
869    /// After the fix, the warning should only fire when a key from `conda-meta/state`
870    /// actually conflicts with one already collected from `etc/conda/env_vars.d`.
871    #[test]
872    fn test_collect_env_vars_no_spurious_conflict_warnings() {
873        let tdir = TempDir::with_prefix("test_no_spurious_warnings").unwrap();
874        let state_path = tdir.path().join("conda-meta/state");
875        fs::create_dir_all(state_path.parent().unwrap()).unwrap();
876
877        let env_var_d = tdir.path().join("etc/conda/env_vars.d");
878        fs::create_dir_all(&env_var_d).unwrap();
879
880        // Pkg defines ONLY "PKG_VAR". "STATE_ONLY_VAR" is NOT in any package json.
881        let pkg_content = r#"{"PKG_VAR": "from_pkg"}"#;
882        fs::write(env_var_d.join("pkg.json"), pkg_content).unwrap();
883
884        // State file defines "STATE_ONLY_VAR" (no conflict) and "PKG_VAR" (real conflict).
885        let state_content =
886            r#"{"env_vars": {"STATE_ONLY_VAR": "state_val", "PKG_VAR": "state_override"}}"#;
887        fs::write(&state_path, state_content).unwrap();
888
889        let env_vars = collect_env_vars(tdir.path()).expect("collect_env_vars must succeed");
890
891        // Both keys must be present — collection itself must work correctly.
892        assert!(
893            env_vars.contains_key("STATE_ONLY_VAR"),
894            "STATE_ONLY_VAR (state-only key) must be collected"
895        );
896        assert!(
897            env_vars.contains_key("PKG_VAR"),
898            "PKG_VAR (conflict key) must be collected"
899        );
900
901        // Before the fix, `state_env_vars.contains_key(key)` was always true, so
902        // the warning fired for STATE_ONLY_VAR even though it had no conflict.
903        // The correct behaviour (after the fix) is:
904        //   - STATE_ONLY_VAR: no conflict → no warning (we can't assert on tracing
905        //     output easily, but we verify the logic by ensuring the change compiles
906        //     and the values are correctly collected).
907        //   - PKG_VAR: real conflict → warning is emitted (state value wins per
908        //     current semantics).
909        assert_eq!(env_vars["STATE_ONLY_VAR"], "state_val");
910        assert_eq!(env_vars["PKG_VAR"], "state_override");
911    }
912
913    #[test]
914    fn test_add_to_path() {
915        let prefix = PathBuf::from_str("/opt/conda").unwrap();
916        let new_paths = prefix_path_entries(&prefix, &Platform::Osx64);
917        assert_eq!(new_paths.len(), 1);
918    }
919
920    #[cfg(unix)]
921    fn create_temp_dir() -> TempDir {
922        let tempdir = TempDir::with_prefix("test").unwrap();
923        let path = tempdir.path().join("etc/conda/activate.d/");
924        fs::create_dir_all(&path).unwrap();
925
926        let script1 = path.join("script1.sh");
927
928        fs::write(script1, "").unwrap();
929
930        tempdir
931    }
932
933    #[cfg(unix)]
934    fn get_script<T: Clone + Shell + 'static>(
935        shell_type: T,
936        path_modification_behavior: PathModificationBehavior,
937    ) -> String {
938        let tdir = create_temp_dir();
939
940        let activator = Activator::from_path(tdir.path(), shell_type, Platform::Osx64).unwrap();
941
942        // Create a test environment
943        let test_env = HashMap::from([
944            ("FOO".to_string(), "bar".to_string()),
945            ("BAZ".to_string(), "qux".to_string()),
946        ]);
947
948        let result = activator
949            .activation(ActivationVariables {
950                conda_prefix: None,
951                path: Some(vec![
952                    PathBuf::from("/usr/bin"),
953                    PathBuf::from("/bin"),
954                    PathBuf::from("/usr/sbin"),
955                    PathBuf::from("/sbin"),
956                    PathBuf::from("/usr/local/bin"),
957                ]),
958                path_modification_behavior,
959                current_env: test_env,
960            })
961            .unwrap();
962        let prefix = tdir.path().to_str().unwrap();
963        let script = result.script.contents().unwrap();
964        script.replace(prefix, "__PREFIX__")
965    }
966
967    #[test]
968    #[cfg(unix)]
969    fn test_activation_script_bash() {
970        let script = get_script(shell::Bash, PathModificationBehavior::Append);
971        insta::assert_snapshot!("test_activation_script_bash_append", script);
972        let script = get_script(shell::Bash, PathModificationBehavior::Replace);
973        insta::assert_snapshot!("test_activation_script_bash_replace", script);
974        let script = get_script(shell::Bash, PathModificationBehavior::Prepend);
975        insta::assert_snapshot!("test_activation_script_bash_prepend", script);
976    }
977
978    #[test]
979    #[cfg(unix)]
980    fn test_activation_script_zsh() {
981        let script = get_script(shell::Zsh, PathModificationBehavior::Append);
982        insta::assert_snapshot!(script);
983    }
984
985    #[test]
986    #[cfg(unix)]
987    fn test_activation_script_fish() {
988        let script = get_script(shell::Fish, PathModificationBehavior::Append);
989        insta::assert_snapshot!(script);
990    }
991
992    #[test]
993    #[cfg(unix)]
994    fn test_activation_script_powershell() {
995        let script = get_script(
996            shell::PowerShell::default(),
997            PathModificationBehavior::Append,
998        );
999        insta::assert_snapshot!("test_activation_script_powershell_append", script);
1000        let script = get_script(
1001            shell::PowerShell::default(),
1002            PathModificationBehavior::Prepend,
1003        );
1004        insta::assert_snapshot!("test_activation_script_powershell_prepend", script);
1005        let script = get_script(
1006            shell::PowerShell::default(),
1007            PathModificationBehavior::Replace,
1008        );
1009        insta::assert_snapshot!("test_activation_script_powershell_replace", script);
1010    }
1011
1012    #[test]
1013    #[cfg(unix)]
1014    fn test_activation_script_cmd() {
1015        let script = get_script(shell::CmdExe, PathModificationBehavior::Append);
1016        assert!(script.contains("\r\n"));
1017        let script = script.replace("\r\n", "\n");
1018        // Filter out the \r\n line endings for the snapshot so that insta + git works
1019        // smoothly
1020        insta::assert_snapshot!("test_activation_script_cmd_append", script);
1021        let script =
1022            get_script(shell::CmdExe, PathModificationBehavior::Replace).replace("\r\n", "\n");
1023        insta::assert_snapshot!("test_activation_script_cmd_replace", script,);
1024        let script =
1025            get_script(shell::CmdExe, PathModificationBehavior::Prepend).replace("\r\n", "\n");
1026        insta::assert_snapshot!("test_activation_script_cmd_prepend", script);
1027    }
1028
1029    #[test]
1030    #[cfg(unix)]
1031    fn test_activation_script_xonsh() {
1032        let script = get_script(shell::Xonsh, PathModificationBehavior::Append);
1033        insta::assert_snapshot!(script);
1034    }
1035
1036    fn test_run_activation(shell: ShellEnum, with_unicode: bool) {
1037        let environment_dir = tempfile::TempDir::new().unwrap();
1038
1039        let env = if with_unicode {
1040            environment_dir.path().join("🦀")
1041        } else {
1042            environment_dir.path().to_path_buf()
1043        };
1044
1045        // Write some environment variables to the `conda-meta/state` folder.
1046        let state_path = env.join("conda-meta/state");
1047        fs::create_dir_all(state_path.parent().unwrap()).unwrap();
1048        let quotes = r#"{"env_vars": {"STATE": "Hello, world!"}}"#;
1049        fs::write(&state_path, quotes).unwrap();
1050
1051        // Write package specific environment variables
1052        let content_pkg_1 = r#"{"PKG1": "Hello, world!"}"#;
1053        let content_pkg_2 = r#"{"PKG2": "Hello, world!"}"#;
1054
1055        let env_var_d = env.join("etc/conda/env_vars.d");
1056        fs::create_dir_all(&env_var_d).expect("Could not create env vars directory");
1057
1058        let pkg1 = env_var_d.join("pkg1.json");
1059        let pkg2 = env_var_d.join("pkg2.json");
1060
1061        fs::write(pkg1, content_pkg_1).expect("could not write file");
1062        fs::write(pkg2, content_pkg_2).expect("could not write file");
1063
1064        // Write a script that emits a random environment variable via a shell
1065        let mut activation_script = String::new();
1066        shell
1067            .set_env_var(&mut activation_script, "SCRIPT_ENV", "Hello, world!")
1068            .unwrap();
1069
1070        let activation_script_dir = env.join("etc/conda/activate.d");
1071        fs::create_dir_all(&activation_script_dir).unwrap();
1072
1073        fs::write(
1074            activation_script_dir.join(format!("pkg1.{}", shell.extension())),
1075            activation_script,
1076        )
1077        .unwrap();
1078
1079        // Create an activator for the environment
1080        let activator = Activator::from_path(&env, shell.clone(), Platform::current()).unwrap();
1081        let activation_env = activator
1082            .run_activation(ActivationVariables::default(), None)
1083            .unwrap();
1084
1085        // Diff with the current environment
1086        let current_env = std::env::vars().collect::<HashMap<_, _>>();
1087
1088        let mut env_diff = activation_env
1089            .into_iter()
1090            .filter(|(key, value)| current_env.get(key) != Some(value))
1091            .collect::<BTreeMap<_, _>>();
1092
1093        // Remove system specific environment variables.
1094        env_diff.remove("CONDA_SHLVL");
1095        env_diff.remove("CONDA_PREFIX");
1096        env_diff.remove("Path");
1097        env_diff.remove("PATH");
1098        env_diff.remove("LINENO");
1099
1100        insta::assert_yaml_snapshot!("after_activation", env_diff);
1101    }
1102
1103    #[test]
1104    #[cfg(windows)]
1105    fn test_run_activation_powershell() {
1106        test_run_activation(crate::shell::PowerShell::default().into(), false);
1107        test_run_activation(crate::shell::PowerShell::default().into(), true);
1108    }
1109
1110    #[test]
1111    #[cfg(windows)]
1112    fn test_run_activation_cmd() {
1113        test_run_activation(crate::shell::CmdExe.into(), false);
1114        test_run_activation(crate::shell::CmdExe.into(), true);
1115    }
1116
1117    #[test]
1118    #[cfg(unix)]
1119    fn test_run_activation_bash() {
1120        test_run_activation(crate::shell::Bash.into(), false);
1121    }
1122
1123    #[test]
1124    #[cfg(target_os = "macos")]
1125    fn test_run_activation_zsh() {
1126        test_run_activation(crate::shell::Zsh.into(), false);
1127    }
1128
1129    #[test]
1130    #[cfg(unix)]
1131    #[ignore]
1132    fn test_run_activation_fish() {
1133        test_run_activation(crate::shell::Fish.into(), false);
1134    }
1135
1136    #[test]
1137    #[cfg(unix)]
1138    #[ignore]
1139    fn test_run_activation_xonsh() {
1140        test_run_activation(crate::shell::Xonsh.into(), false);
1141    }
1142
1143    #[test]
1144    fn test_deactivation() {
1145        let tmp_dir = TempDir::with_prefix("test_deactivation").unwrap();
1146        let tmp_dir_path = tmp_dir.path();
1147
1148        // Create an activator with some test environment variables
1149        let mut env_vars = IndexMap::new();
1150        env_vars.insert("TEST_VAR1".to_string(), "value1".to_string());
1151        env_vars.insert("TEST_VAR2".to_string(), "value2".to_string());
1152
1153        // Test all shell types
1154        let shell_types = vec![
1155            ("bash", ShellEnum::Bash(shell::Bash)),
1156            ("zsh", ShellEnum::Zsh(shell::Zsh)),
1157            ("fish", ShellEnum::Fish(shell::Fish)),
1158            ("xonsh", ShellEnum::Xonsh(shell::Xonsh)),
1159            ("cmd", ShellEnum::CmdExe(shell::CmdExe)),
1160            (
1161                "powershell",
1162                ShellEnum::PowerShell(shell::PowerShell::default()),
1163            ),
1164            ("nushell", ShellEnum::NuShell(shell::NuShell)),
1165        ];
1166
1167        for (shell_name, shell_type) in shell_types {
1168            let activator = Activator {
1169                target_prefix: tmp_dir_path.to_path_buf(),
1170                shell_type: shell_type.clone(),
1171                paths: vec![tmp_dir_path.join("bin")],
1172                activation_scripts: vec![],
1173                deactivation_scripts: vec![],
1174                env_vars: env_vars.clone(),
1175                post_activation_env_vars: IndexMap::new(),
1176                platform: Platform::current(),
1177            };
1178
1179            // Test edge case: CONDA_SHLVL not set (current behavior)
1180            let test_env = HashMap::new(); // Empty environment - no CONDA_SHLVL set
1181            let result = activator
1182                .deactivation(ActivationVariables {
1183                    conda_prefix: None,
1184                    path: None,
1185                    path_modification_behavior: PathModificationBehavior::Prepend,
1186                    current_env: test_env,
1187                })
1188                .unwrap();
1189            let mut script_contents = result.script.contents().unwrap();
1190
1191            // For cmd.exe, normalize line endings for snapshots
1192            if shell_name == "cmd" {
1193                script_contents = script_contents.replace("\r\n", "\n");
1194            }
1195
1196            insta::assert_snapshot!(format!("test_deactivation_{}", shell_name), script_contents);
1197        }
1198    }
1199
1200    #[test]
1201    fn test_deactivation_when_activated() {
1202        let tmp_dir = TempDir::with_prefix("test_deactivation").unwrap();
1203        let tmp_dir_path = tmp_dir.path();
1204
1205        // Create an activator with some test environment variables
1206        let mut env_vars = IndexMap::new();
1207        env_vars.insert("TEST_VAR1".to_string(), "value1".to_string());
1208        env_vars.insert("TEST_VAR2".to_string(), "value2".to_string());
1209
1210        // Test all shell types
1211        let shell_types = vec![
1212            ("bash", ShellEnum::Bash(shell::Bash)),
1213            ("zsh", ShellEnum::Zsh(shell::Zsh)),
1214            ("fish", ShellEnum::Fish(shell::Fish)),
1215            ("xonsh", ShellEnum::Xonsh(shell::Xonsh)),
1216            ("cmd", ShellEnum::CmdExe(shell::CmdExe)),
1217            (
1218                "powershell",
1219                ShellEnum::PowerShell(shell::PowerShell::default()),
1220            ),
1221            ("nushell", ShellEnum::NuShell(shell::NuShell)),
1222        ];
1223
1224        for (shell_name, shell_type) in shell_types {
1225            let activator = Activator {
1226                target_prefix: tmp_dir_path.to_path_buf(),
1227                shell_type: shell_type.clone(),
1228                paths: vec![tmp_dir_path.join("bin")],
1229                activation_scripts: vec![],
1230                deactivation_scripts: vec![],
1231                env_vars: env_vars.clone(),
1232                post_activation_env_vars: IndexMap::new(),
1233                platform: Platform::current(),
1234            };
1235
1236            // CONDA_SHLVL to set to the initial level ( 1 meaning that it's activated)
1237            let test_env = HashMap::from([
1238                ("CONDA_SHLVL".to_string(), "1".to_string()),
1239                (
1240                    "CONDA_PREFIX".to_string(),
1241                    tmp_dir_path.to_str().unwrap().to_string(),
1242                ),
1243            ]);
1244            let result = activator
1245                .deactivation(ActivationVariables {
1246                    conda_prefix: None,
1247                    path: None,
1248                    path_modification_behavior: PathModificationBehavior::Prepend,
1249                    current_env: test_env,
1250                })
1251                .unwrap();
1252            let mut script_contents = result.script.contents().unwrap();
1253
1254            // For cmd.exe, normalize line endings for snapshots
1255            if shell_name == "cmd" {
1256                script_contents = script_contents.replace("\r\n", "\n");
1257            }
1258
1259            insta::assert_snapshot!(
1260                format!("test_deactivation_when_activated{}", shell_name),
1261                script_contents
1262            );
1263        }
1264    }
1265
1266    #[test]
1267    fn test_nested_deactivation() {
1268        let tmp_dir = TempDir::with_prefix("test_deactivation").unwrap();
1269        let tmp_dir_path = tmp_dir.path();
1270
1271        // Create an activator with some test environment variables
1272        let mut first_env_vars = IndexMap::new();
1273        first_env_vars.insert("TEST_VAR1".to_string(), "first_value".to_string());
1274
1275        // Test all shell types
1276        let shell_types = vec![
1277            ("bash", ShellEnum::Bash(shell::Bash)),
1278            ("zsh", ShellEnum::Zsh(shell::Zsh)),
1279            ("fish", ShellEnum::Fish(shell::Fish)),
1280            ("xonsh", ShellEnum::Xonsh(shell::Xonsh)),
1281            ("cmd", ShellEnum::CmdExe(shell::CmdExe)),
1282            (
1283                "powershell",
1284                ShellEnum::PowerShell(shell::PowerShell::default()),
1285            ),
1286            ("nushell", ShellEnum::NuShell(shell::NuShell)),
1287        ];
1288
1289        // now lets activate again an environment
1290        // we reuse the same TEST_VAR1 variable to check that it is correctly restored
1291        let mut second_env_vars = IndexMap::new();
1292        second_env_vars.insert("TEST_VAR1".to_string(), "second_value".to_string());
1293
1294        for (shell_name, shell_type) in &shell_types {
1295            let activator = Activator {
1296                target_prefix: tmp_dir_path.to_path_buf(),
1297                shell_type: shell_type.clone(),
1298                paths: vec![tmp_dir_path.join("bin")],
1299                activation_scripts: vec![],
1300                deactivation_scripts: vec![],
1301                env_vars: second_env_vars.clone(),
1302                post_activation_env_vars: IndexMap::new(),
1303                platform: Platform::current(),
1304            };
1305
1306            let mut existing_env_vars = HashMap::new();
1307            existing_env_vars.insert("TEST_VAR1".to_string(), "first_value".to_string());
1308            existing_env_vars.insert("CONDA_SHLVL".to_string(), "1".to_string());
1309
1310            let result = activator
1311                .activation(ActivationVariables {
1312                    conda_prefix: None,
1313                    path: None,
1314                    path_modification_behavior: PathModificationBehavior::Prepend,
1315                    current_env: existing_env_vars,
1316                })
1317                .unwrap();
1318
1319            let mut script_contents = result.script.contents().unwrap();
1320
1321            // Normalize temporary directory paths for consistent snapshots
1322            let mut prefix = tmp_dir_path.to_str().unwrap().to_string();
1323
1324            if cfg!(windows) {
1325                // Replace backslashes with forward slashes for consistency in snapshots as well
1326                // as ; with :
1327                script_contents = script_contents.replace("\\\\", "\\");
1328                script_contents = script_contents.replace("\\", "/");
1329                script_contents = script_contents.replace(";", ":");
1330                prefix = prefix.replace("\\", "/");
1331            }
1332
1333            script_contents = script_contents.replace(&prefix, "__PREFIX__");
1334            // on windows and bash it will be quoted with shlex::try_quote
1335            if cfg!(windows) && *shell_name == "bash" {
1336                let unix_path = match native_path_to_unix(&prefix) {
1337                    Ok(str) => str,
1338                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => prefix,
1339                    Err(e) => panic!("Failed to convert path to unix: {e}"),
1340                };
1341                script_contents = script_contents.replace(&unix_path, "__PREFIX__");
1342                script_contents = script_contents.replace("=\"__PREFIX__\"", "=__PREFIX__");
1343            }
1344
1345            // on windows we need to replace Path with PATH
1346            script_contents = script_contents.replace("Path", "PATH");
1347
1348            // For cmd.exe, normalize line endings for snapshots
1349            if *shell_name == "cmd" {
1350                script_contents = script_contents.replace("\r\n", "\n");
1351            }
1352
1353            insta::assert_snapshot!(
1354                format!("test_nested_deactivation_first_round{}", shell_name),
1355                script_contents
1356            );
1357
1358            // and now lets deactivate the environment
1359            let activated_env = HashMap::from([("CONDA_SHLVL".to_string(), "2".to_string())]);
1360            let result = activator
1361                .deactivation(ActivationVariables {
1362                    conda_prefix: None,
1363                    path: None,
1364                    path_modification_behavior: PathModificationBehavior::Prepend,
1365                    current_env: activated_env,
1366                })
1367                .unwrap();
1368
1369            let mut script_contents = result.script.contents().unwrap();
1370
1371            let prefix = tmp_dir_path.to_str().unwrap();
1372            script_contents = script_contents.replace(prefix, "__PREFIX__");
1373
1374            // on windows we need to replace Path with PATH
1375            script_contents = script_contents.replace("Path", "PATH");
1376
1377            // For cmd.exe, normalize line endings for snapshots
1378            if *shell_name == "cmd" {
1379                script_contents = script_contents.replace("\r\n", "\n");
1380            }
1381
1382            insta::assert_snapshot!(
1383                format!("test_nested_deactivation_second_round{}", shell_name),
1384                script_contents
1385            );
1386        }
1387    }
1388
1389    #[test]
1390    fn test_resetting_conda_shlvl() {
1391        let tmp_dir = TempDir::with_prefix("test_deactivation").unwrap();
1392        let tmp_dir_path = tmp_dir.path();
1393
1394        // Create an activator with some test environment variables
1395        let mut first_env_vars = IndexMap::new();
1396        first_env_vars.insert("TEST_VAR1".to_string(), "first_value".to_string());
1397
1398        // Test all shell types
1399        let shell_types = vec![
1400            ("bash", ShellEnum::Bash(shell::Bash)),
1401            ("zsh", ShellEnum::Zsh(shell::Zsh)),
1402            ("fish", ShellEnum::Fish(shell::Fish)),
1403            ("xonsh", ShellEnum::Xonsh(shell::Xonsh)),
1404            ("cmd", ShellEnum::CmdExe(shell::CmdExe)),
1405            (
1406                "powershell",
1407                ShellEnum::PowerShell(shell::PowerShell::default()),
1408            ),
1409            ("nushell", ShellEnum::NuShell(shell::NuShell)),
1410        ];
1411
1412        // now lets activate again an environment
1413        // we reuse the same TEST_VAR1 variable to check that it is correctly restored
1414        let mut second_env_vars = IndexMap::new();
1415        second_env_vars.insert("TEST_VAR1".to_string(), "second_value".to_string());
1416
1417        for (shell_name, shell_type) in &shell_types {
1418            let activator = Activator {
1419                target_prefix: tmp_dir_path.to_path_buf(),
1420                shell_type: shell_type.clone(),
1421                paths: vec![tmp_dir_path.join("bin")],
1422                activation_scripts: vec![],
1423                deactivation_scripts: vec![],
1424                env_vars: second_env_vars.clone(),
1425                post_activation_env_vars: IndexMap::new(),
1426                platform: Platform::current(),
1427            };
1428
1429            let mut existing_env_vars = HashMap::new();
1430            existing_env_vars.insert("TEST_VAR1".to_string(), "first_value".to_string());
1431            existing_env_vars.insert("CONDA_SHLVL".to_string(), "1".to_string());
1432
1433            let result = activator
1434                .deactivation(ActivationVariables {
1435                    conda_prefix: None,
1436                    path: None,
1437                    path_modification_behavior: PathModificationBehavior::Prepend,
1438                    current_env: existing_env_vars,
1439                })
1440                .unwrap();
1441
1442            let mut script_contents = result.script.contents().unwrap();
1443
1444            // For cmd.exe, normalize line endings for snapshots
1445            if *shell_name == "cmd" {
1446                script_contents = script_contents.replace("\r\n", "\n");
1447            }
1448
1449            insta::assert_snapshot!(
1450                format!("test_resetting_conda_shlvl{}", shell_name),
1451                script_contents
1452            );
1453        }
1454    }
1455}