fnm 1.39.0

Fast and simple Node.js version manager
use super::command::Command;
use super::r#use::Use;
use crate::config::FnmConfig;
use crate::fs::symlink_dir;
use crate::outln;
use crate::path_ext::PathExt;
use crate::shell::{infer_shell, Shell, Shells};
use clap::ValueEnum;
use colored::Colorize;
use std::collections::HashMap;
use std::fmt::Debug;
use std::io::IsTerminal;
use thiserror::Error;

#[derive(clap::Parser, Debug, Default)]
pub struct Env {
    /// The shell syntax to use. Infers when missing.
    #[clap(long)]
    shell: Option<Shells>,
    /// Print JSON instead of shell commands.
    #[clap(long, conflicts_with = "shell")]
    json: bool,
    /// Deprecated. This is the default now.
    #[clap(long, hide = true)]
    multi: bool,
    /// Print the script to change Node versions every directory change
    #[clap(long)]
    use_on_cd: bool,
}

fn generate_symlink_path() -> String {
    format!(
        "{}_{}",
        std::process::id(),
        chrono::Utc::now().timestamp_millis(),
    )
}

fn make_symlink(config: &FnmConfig) -> Result<std::path::PathBuf, Error> {
    let base_dir = config.multishell_storage().ensure_exists_silently();
    let mut temp_dir = base_dir.join(generate_symlink_path());

    while temp_dir.exists() {
        temp_dir = base_dir.join(generate_symlink_path());
    }

    match symlink_dir(config.default_version_dir(), &temp_dir) {
        Ok(()) => Ok(temp_dir),
        Err(source) => Err(Error::CantCreateSymlink { source, temp_dir }),
    }
}

#[inline]
fn bool_as_str(value: bool) -> &'static str {
    if value {
        "true"
    } else {
        "false"
    }
}

fn set_path_for_multishell(multishell_path: &std::path::Path) {
    let path_for_node = if cfg!(windows) {
        multishell_path.to_path_buf()
    } else {
        multishell_path.join("bin")
    };

    let current_path = std::env::var_os("PATH").unwrap_or_default();
    let mut split_paths: Vec<_> = std::env::split_paths(&current_path).collect();
    split_paths.insert(0, path_for_node);
    if let Ok(new_path) = std::env::join_paths(split_paths) {
        unsafe {
            std::env::set_var("PATH", new_path);
        }
    }
}

impl Command for Env {
    type Error = Error;

    fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> {
        if self.multi {
            outln!(
                config,
                Error,
                "{} {} is deprecated. This is now the default.",
                "warning:".yellow().bold(),
                "--multi".italic()
            );
        }

        let multishell_path = make_symlink(config)?;
        let base_dir = config.base_dir_with_default();

        let env_vars = [
            ("FNM_MULTISHELL_PATH", multishell_path.to_str().unwrap()),
            (
                "FNM_VERSION_FILE_STRATEGY",
                config.version_file_strategy().as_str(),
            ),
            ("FNM_DIR", base_dir.to_str().unwrap()),
            ("FNM_LOGLEVEL", config.log_level().as_str()),
            ("FNM_NODE_DIST_MIRROR", config.node_dist_mirror.as_str()),
            (
                "FNM_COREPACK_ENABLED",
                bool_as_str(config.corepack_enabled()),
            ),
            ("FNM_RESOLVE_ENGINES", bool_as_str(config.resolve_engines())),
            ("FNM_ARCH", config.arch.as_str()),
        ];

        if self.json {
            println!(
                "{}",
                serde_json::to_string(&HashMap::from(env_vars)).unwrap()
            );
            return Ok(());
        }

        let shell: Box<dyn Shell> = self
            .shell
            .map(Into::into)
            .or_else(infer_shell)
            .ok_or(Error::CantInferShell)?;

        let binary_path = if cfg!(windows) {
            shell.path(&multishell_path)
        } else {
            shell.path(&multishell_path.join("bin"))
        };

        println!("{}", binary_path?);

        for (name, value) in &env_vars {
            println!("{}", shell.set_env_var(name, value));
        }

        if self.use_on_cd {
            // Call `use` internally for the initial directory, so the shell doesn't
            // need to spawn a subprocess after evaluating the env output.
            set_path_for_multishell(&multishell_path);
            let config_with_multishell =
                config.clone().with_multishell_path(multishell_path.clone());
            let use_cmd = Use {
                version: None,
                install_if_missing: false,
                silent_if_unchanged: true,
                info_to_stderr: true,
            };
            let should_force_stderr_color = !std::io::stdout().is_terminal()
                && std::io::stderr().is_terminal()
                && std::env::var_os("NO_COLOR").is_none();
            if should_force_stderr_color {
                colored::control::set_override(true);
            }
            // Ignore errors - if there's no version file, that's fine
            let _ = use_cmd.apply(&config_with_multishell);
            if should_force_stderr_color {
                colored::control::unset_override();
            }

            println!("{}", shell.use_on_cd(config)?);
        }
        if let Some(v) = shell.rehash() {
            println!("{v}");
        }

        Ok(())
    }
}

#[derive(Debug, Error)]
pub enum Error {
    #[error(
        "{}\n{}\n{}\n{}",
        "Can't infer shell!",
        "fnm can't infer your shell based on the process tree.",
        "Maybe it is unsupported? we support the following shells:",
        shells_as_string()
    )]
    CantInferShell,
    #[error("Can't create the symlink for multishells at {temp_dir:?}. Maybe there are some issues with permissions for the directory? {source}")]
    CantCreateSymlink {
        #[source]
        source: std::io::Error,
        temp_dir: std::path::PathBuf,
    },
    #[error(transparent)]
    ShellError {
        #[from]
        source: anyhow::Error,
    },
}

fn shells_as_string() -> String {
    Shells::value_variants()
        .iter()
        .map(|x| format!("* {x}"))
        .collect::<Vec<_>>()
        .join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_smoke() {
        let config = FnmConfig::default();
        Env {
            #[cfg(windows)]
            shell: Some(Shells::Cmd),
            #[cfg(not(windows))]
            shell: Some(Shells::Bash),
            ..Default::default()
        }
        .call(config);
    }
}