fnm 1.35.0

Fast and simple Node.js version manager
use super::command::Command;
use super::install::Install;
use crate::current_version::current_version;
use crate::fs;
use crate::installed_versions;
use crate::outln;
use crate::shell;
use crate::system_version;
use crate::user_version::UserVersion;
use crate::version::Version;
use crate::version_file_strategy::VersionFileStrategy;
use crate::{config::FnmConfig, user_version_reader::UserVersionReader};
use colored::Colorize;
use std::path::Path;
use thiserror::Error;

#[derive(clap::Parser, Debug)]
pub struct Use {
    version: Option<UserVersionReader>,
    /// Install the version if it isn't installed yet
    #[clap(long)]
    install_if_missing: bool,

    /// Don't output a message identifying the version being used
    /// if it will not change due to execution of this command
    #[clap(long)]
    silent_if_unchanged: bool,
}

impl Command for Use {
    type Error = Error;

    fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> {
        let multishell_path = config.multishell_path().ok_or(Error::FnmEnvWasNotSourced)?;
        warn_if_multishell_path_not_in_path_env_var(multishell_path, config);

        let all_versions = installed_versions::list(config.installations_dir())
            .map_err(|source| Error::VersionListingError { source })?;
        let requested_version = self
            .version
            .unwrap_or_else(|| {
                let current_dir = std::env::current_dir().unwrap();
                UserVersionReader::Path(current_dir)
            })
            .into_user_version(config)
            .ok_or_else(|| match config.version_file_strategy() {
                VersionFileStrategy::Local => InferVersionError::Local,
                VersionFileStrategy::Recursive => InferVersionError::Recursive,
            })
            .map_err(|source| Error::CantInferVersion { source })?;

        let (message, version_path) = if let UserVersion::Full(Version::Bypassed) =
            requested_version
        {
            let message = format!(
                "Bypassing fnm: using {} node",
                system_version::display_name().cyan()
            );
            (message, system_version::path())
        } else if let Some(alias_name) = requested_version.alias_name() {
            let alias_path = config.aliases_dir().join(&alias_name);
            let system_path = system_version::path();
            if matches!(fs::shallow_read_symlink(&alias_path), Ok(shallow_path) if shallow_path == system_path)
            {
                let message = format!(
                    "Bypassing fnm: using {} node",
                    system_version::display_name().cyan()
                );
                (message, system_path)
            } else if alias_path.exists() {
                let message = format!("Using Node for alias {}", alias_name.cyan());
                (message, alias_path)
            } else {
                install_new_version(requested_version, config, self.install_if_missing)?;
                return Ok(());
            }
        } else {
            let current_version = requested_version.to_version(&all_versions, config);
            if let Some(version) = current_version {
                let version_path = config
                    .installations_dir()
                    .join(version.to_string())
                    .join("installation");
                let message = format!("Using Node {}", version.to_string().cyan());
                (message, version_path)
            } else {
                install_new_version(requested_version, config, self.install_if_missing)?;
                return Ok(());
            }
        };

        if !self.silent_if_unchanged || will_version_change(&version_path, config) {
            outln!(config, Info, "{}", message);
        }

        if let Some(multishells_path) = multishell_path.parent() {
            std::fs::create_dir_all(multishells_path).map_err(|_err| {
                Error::MultishellDirectoryCreationIssue {
                    path: multishells_path.to_path_buf(),
                }
            })?;
        }

        replace_symlink(&version_path, multishell_path)
            .map_err(|source| Error::SymlinkingCreationIssue { source })?;

        Ok(())
    }
}

fn will_version_change(resolved_path: &Path, config: &FnmConfig) -> bool {
    let current_version_path = current_version(config)
        .unwrap_or(None)
        .map(|v| v.installation_path(config));

    current_version_path.as_deref() != Some(resolved_path)
}

fn install_new_version(
    requested_version: UserVersion,
    config: &FnmConfig,
    install_if_missing: bool,
) -> Result<(), Error> {
    if !install_if_missing && !should_install_interactively(&requested_version) {
        return Err(Error::CantFindVersion {
            version: requested_version,
        });
    }

    Install {
        version: Some(requested_version.clone()),
        ..Install::default()
    }
    .apply(config)
    .map_err(|source| Error::InstallError { source })?;

    Use {
        version: Some(UserVersionReader::Direct(requested_version)),
        install_if_missing: true,
        silent_if_unchanged: false,
    }
    .apply(config)?;

    Ok(())
}

/// Tries to delete `from`, and then tries to symlink `from` to `to` anyway.
/// If the symlinking fails, it will return the errors in the following order:
/// * The deletion error (if exists)
/// * The creation error
///
/// This way, we can create a symlink if it is missing.
fn replace_symlink(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
    let symlink_deletion_result = fs::remove_symlink_dir(to);
    match fs::symlink_dir(from, to) {
        ok @ Ok(_) => ok,
        err @ Err(_) => symlink_deletion_result.and(err),
    }
}

fn should_install_interactively(requested_version: &UserVersion) -> bool {
    use std::io::{IsTerminal, Write};

    if !(std::io::stdout().is_terminal() && std::io::stdin().is_terminal()) {
        return false;
    }

    let error_message = format!(
        "Can't find an installed Node version matching {}.",
        requested_version.to_string().italic()
    );
    eprintln!("{}", error_message.red());
    let do_you_want = format!("Do you want to install it? {} [y/N]:", "answer".bold());
    eprint!("{} ", do_you_want.yellow());
    std::io::stdout().flush().unwrap();
    let mut s = String::new();
    std::io::stdin()
        .read_line(&mut s)
        .expect("Can't read user input");

    s.trim().to_lowercase() == "y"
}

fn warn_if_multishell_path_not_in_path_env_var(
    multishell_path: &std::path::Path,
    config: &FnmConfig,
) {
    let bin_path = if cfg!(unix) {
        multishell_path.join("bin")
    } else {
        multishell_path.to_path_buf()
    };

    let fixed_path = bin_path.to_str().and_then(shell::maybe_fix_windows_path);
    let fixed_path = fixed_path.as_ref().map(|x| &x[..]);

    for path in std::env::split_paths(&std::env::var("PATH").unwrap_or_default()) {
        if bin_path == path || fixed_path == path.to_str() {
            return;
        }
    }

    outln!(
        config, Error,
        "{} {}\n{}\n{}",
        "warning:".yellow().bold(),
        "The current Node.js path is not on your PATH environment variable.".yellow(),
        "You should setup your shell profile to evaluate `fnm env`, see https://github.com/Schniz/fnm#shell-setup on how to do this".yellow(),
        "Check out our documentation for more information: https://fnm.vercel.app".yellow()
    );
}

#[derive(Debug, Error)]
pub enum Error {
    #[error("Can't create the symlink: {}", source)]
    SymlinkingCreationIssue { source: std::io::Error },
    #[error(transparent)]
    InstallError { source: <Install as Command>::Error },
    #[error("Can't get locally installed versions: {}", source)]
    VersionListingError { source: installed_versions::Error },
    #[error("Requested version {} is not currently installed", version)]
    CantFindVersion { version: UserVersion },
    #[error(transparent)]
    CantInferVersion {
        #[from]
        source: InferVersionError,
    },
    #[error(
        "{}\n{}\n{}",
        "We can't find the necessary environment variables to replace the Node version.",
        "You should setup your shell profile to evaluate `fnm env`, see https://github.com/Schniz/fnm#shell-setup on how to do this",
        "Check out our documentation for more information: https://fnm.vercel.app"
    )]
    FnmEnvWasNotSourced,
    #[error("Can't create the multishell directory: {}", path.display())]
    MultishellDirectoryCreationIssue { path: std::path::PathBuf },
}

#[derive(Debug, Error)]
pub enum InferVersionError {
    #[error("Can't find version in dotfiles. Please provide a version manually to the command.")]
    Local,
    #[error("Could not find any version to use. Maybe you don't have a default version set?\nTry running `fnm default <VERSION>` to set one,\nor create a .node-version file inside your project to declare a Node.js version.")]
    Recursive,
}