From 72f419c8b0e551427a5c988ae5f09090ac5a5097 Mon Sep 17 00:00:00 2001 From: Rongjian Zhang Date: Sat, 7 Mar 2026 22:17:48 +0800 Subject: [PATCH 1/2] feat(env): add --install-if-missing flag to --use-on-cd hook --- src/commands/env.rs | 7 +++++-- src/shell/bash.rs | 16 +++++++++++++--- src/shell/fish.rs | 16 +++++++++++++--- src/shell/powershell.rs | 16 +++++++++++++--- src/shell/shell.rs | 6 +++++- src/shell/windows_cmd/mod.rs | 25 +++++++++++++++++++------ src/shell/zsh.rs | 21 +++++++++++++++++---- 7 files changed, 85 insertions(+), 22 deletions(-) diff --git a/src/commands/env.rs b/src/commands/env.rs index 60d8dbe6d..d50eb4b09 100644 --- a/src/commands/env.rs +++ b/src/commands/env.rs @@ -26,6 +26,9 @@ pub struct Env { /// Print the script to change Node versions every directory change #[clap(long)] use_on_cd: bool, + /// Install the target version if missing (only relevant with --use-on-cd) + #[clap(long, requires = "use_on_cd")] + install_if_missing: bool, } fn generate_symlink_path() -> String { @@ -144,7 +147,7 @@ impl Command for Env { config.clone().with_multishell_path(multishell_path.clone()); let use_cmd = Use { version: None, - install_if_missing: false, + install_if_missing: self.install_if_missing, silent_if_unchanged: true, info_to_stderr: true, }; @@ -160,7 +163,7 @@ impl Command for Env { colored::control::unset_override(); } - println!("{}", shell.use_on_cd(config)?); + println!("{}", shell.use_on_cd(config, self.install_if_missing)?); } if let Some(v) = shell.rehash() { println!("{v}"); diff --git a/src/shell/bash.rs b/src/shell/bash.rs index 667c96b8b..97bd90bc3 100644 --- a/src/shell/bash.rs +++ b/src/shell/bash.rs @@ -25,22 +25,32 @@ impl Shell for Bash { format!("export {name}={value:?}") } - fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result { + fn use_on_cd( + &self, + config: &crate::config::FnmConfig, + install_if_missing: bool, + ) -> anyhow::Result { let version_file_exists_condition = if config.resolve_engines() { "-f .node-version || -f .nvmrc || -f package.json" } else { "-f .node-version || -f .nvmrc" }; + let fnm_use_cmd = if install_if_missing { + "fnm use --silent-if-unchanged --install-if-missing" + } else { + "fnm use --silent-if-unchanged" + }; let autoload_hook = match config.version_file_strategy() { VersionFileStrategy::Local => formatdoc!( r" if [[ {version_file_exists_condition} ]]; then - fnm use --silent-if-unchanged + {fnm_use_cmd} fi ", version_file_exists_condition = version_file_exists_condition, + fnm_use_cmd = fnm_use_cmd, ), - VersionFileStrategy::Recursive => String::from(r"fnm use --silent-if-unchanged"), + VersionFileStrategy::Recursive => String::from(fnm_use_cmd), }; Ok(formatdoc!( r#" diff --git a/src/shell/fish.rs b/src/shell/fish.rs index 90078aab2..0d6d6e9f1 100644 --- a/src/shell/fish.rs +++ b/src/shell/fish.rs @@ -25,22 +25,32 @@ impl Shell for Fish { format!("set -gx {name} {value:?};") } - fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result { + fn use_on_cd( + &self, + config: &crate::config::FnmConfig, + install_if_missing: bool, + ) -> anyhow::Result { let version_file_exists_condition = if config.resolve_engines() { "test -f .node-version -o -f .nvmrc -o -f package.json" } else { "test -f .node-version -o -f .nvmrc" }; + let fnm_use_cmd = if install_if_missing { + "fnm use --silent-if-unchanged --install-if-missing" + } else { + "fnm use --silent-if-unchanged" + }; let autoload_hook = match config.version_file_strategy() { VersionFileStrategy::Local => formatdoc!( r" if {version_file_exists_condition} - fnm use --silent-if-unchanged + {fnm_use_cmd} end ", version_file_exists_condition = version_file_exists_condition, + fnm_use_cmd = fnm_use_cmd, ), - VersionFileStrategy::Recursive => String::from(r"fnm use --silent-if-unchanged"), + VersionFileStrategy::Recursive => String::from(fnm_use_cmd), }; Ok(formatdoc!( r" diff --git a/src/shell/powershell.rs b/src/shell/powershell.rs index 5468daa82..b7a8dfb7a 100644 --- a/src/shell/powershell.rs +++ b/src/shell/powershell.rs @@ -25,20 +25,30 @@ impl Shell for PowerShell { format!(r#"$env:{name} = "{value}""#) } - fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result { + fn use_on_cd( + &self, + config: &crate::config::FnmConfig, + install_if_missing: bool, + ) -> anyhow::Result { let version_file_exists_condition = if config.resolve_engines() { "(Test-Path .nvmrc) -Or (Test-Path .node-version) -Or (Test-Path package.json)" } else { "(Test-Path .nvmrc) -Or (Test-Path .node-version)" }; + let fnm_use_cmd = if install_if_missing { + "fnm use --silent-if-unchanged --install-if-missing" + } else { + "fnm use --silent-if-unchanged" + }; let autoload_hook = match config.version_file_strategy() { VersionFileStrategy::Local => formatdoc!( r" - If ({version_file_exists_condition}) {{ & fnm use --silent-if-unchanged }} + If ({version_file_exists_condition}) {{ & {fnm_use_cmd} }} ", version_file_exists_condition = version_file_exists_condition, + fnm_use_cmd = fnm_use_cmd, ), - VersionFileStrategy::Recursive => String::from(r"fnm use --silent-if-unchanged"), + VersionFileStrategy::Recursive => String::from(fnm_use_cmd), }; Ok(formatdoc!( r" diff --git a/src/shell/shell.rs b/src/shell/shell.rs index 481b5e04d..aa304bbef 100644 --- a/src/shell/shell.rs +++ b/src/shell/shell.rs @@ -6,7 +6,11 @@ use clap::ValueEnum; pub trait Shell: Debug { fn path(&self, path: &Path) -> anyhow::Result; fn set_env_var(&self, name: &str, value: &str) -> String; - fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result; + fn use_on_cd( + &self, + config: &crate::config::FnmConfig, + install_if_missing: bool, + ) -> anyhow::Result; fn rehash(&self) -> Option<&'static str> { None } diff --git a/src/shell/windows_cmd/mod.rs b/src/shell/windows_cmd/mod.rs index cb968e437..1659c9671 100644 --- a/src/shell/windows_cmd/mod.rs +++ b/src/shell/windows_cmd/mod.rs @@ -27,9 +27,13 @@ impl Shell for WindowsCmd { format!("SET {name}={value}") } - fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result { + fn use_on_cd( + &self, + config: &crate::config::FnmConfig, + install_if_missing: bool, + ) -> anyhow::Result { let path = config.base_dir_with_default().join("cd.cmd"); - create_cd_file_at(&path).map_err(|source| { + create_cd_file_at(&path, install_if_missing).map_err(|source| { anyhow::anyhow!( "Can't create cd.cmd file for use-on-cd at {}: {}", path.display(), @@ -43,11 +47,19 @@ impl Shell for WindowsCmd { } } -fn create_cd_file_at(path: &std::path::Path) -> std::io::Result<()> { +fn create_cd_file_at(path: &std::path::Path, install_if_missing: bool) -> std::io::Result<()> { use std::io::Write; - let cmd_contents = include_bytes!("./cd.cmd"); + let extra_flags = if install_if_missing { + " --install-if-missing" + } else { + "" + }; + let cmd_contents = format!( + "@echo off\r\ncd /d %*\r\nif \"%FNM_VERSION_FILE_STRATEGY%\" == \"recursive\" (\r\n fnm use --silent-if-unchanged{extra_flags}\r\n) else (\r\n if exist .nvmrc (\r\n fnm use --silent-if-unchanged{extra_flags}\r\n ) else (\r\n if exist .node-version (\r\n fnm use --silent-if-unchanged{extra_flags}\r\n )\r\n )\r\n)\r\n@echo on\r\n", + extra_flags = extra_flags, + ); let mut file = std::fs::File::create(path)?; - file.write_all(cmd_contents)?; + file.write_all(cmd_contents.as_bytes())?; Ok(()) } @@ -60,7 +72,7 @@ mod tests { let base_dir = std::env::temp_dir().join("fnm cmd test with spaces"); std::fs::create_dir_all(&base_dir).unwrap(); let config = crate::config::FnmConfig::default().with_base_dir(Some(base_dir.clone())); - let output = WindowsCmd.use_on_cd(&config).unwrap(); + let output = WindowsCmd.use_on_cd(&config, false).unwrap(); assert!(output.starts_with("doskey cd=\"")); assert!(output.ends_with("\" $*")); @@ -70,4 +82,5 @@ mod tests { std::fs::remove_file(base_dir.join("cd.cmd")).unwrap(); std::fs::remove_dir_all(base_dir).unwrap(); } + } diff --git a/src/shell/zsh.rs b/src/shell/zsh.rs index 401ca2eb8..6421fabf8 100644 --- a/src/shell/zsh.rs +++ b/src/shell/zsh.rs @@ -29,22 +29,32 @@ impl Shell for Zsh { Some("rehash") } - fn use_on_cd(&self, config: &crate::config::FnmConfig) -> anyhow::Result { + fn use_on_cd( + &self, + config: &crate::config::FnmConfig, + install_if_missing: bool, + ) -> anyhow::Result { let version_file_exists_condition = if config.resolve_engines() { "-f .node-version || -f .nvmrc || -f package.json" } else { "-f .node-version || -f .nvmrc" }; + let fnm_use_cmd = if install_if_missing { + "fnm use --silent-if-unchanged --install-if-missing" + } else { + "fnm use --silent-if-unchanged" + }; let autoload_hook = match config.version_file_strategy() { VersionFileStrategy::Local => formatdoc!( r" if [[ {version_file_exists_condition} ]]; then - fnm use --silent-if-unchanged + {fnm_use_cmd} fi ", version_file_exists_condition = version_file_exists_condition, + fnm_use_cmd = fnm_use_cmd, ), - VersionFileStrategy::Recursive => String::from(r"fnm use --silent-if-unchanged"), + VersionFileStrategy::Recursive => String::from(fnm_use_cmd), }; Ok(formatdoc!( r" @@ -67,7 +77,10 @@ mod tests { #[test] fn use_on_cd_removes_existing_hook_before_adding() { - let output = Zsh.use_on_cd(&crate::config::FnmConfig::default()).unwrap(); + let output = Zsh + .use_on_cd(&crate::config::FnmConfig::default(), false) + .unwrap(); assert!(output.contains("add-zsh-hook -D chpwd _fnm_autoload_hook")); } + } From ec21f55e9bd385e3407804f6cc81c72fdaafe7ea Mon Sep 17 00:00:00 2001 From: Rongjian Zhang Date: Sat, 7 Mar 2026 22:17:56 +0800 Subject: [PATCH 2/2] test: add use_on_cd install-if-missing tests for all shells --- src/shell/bash.rs | 22 ++++++++++++++++++++++ src/shell/fish.rs | 22 ++++++++++++++++++++++ src/shell/powershell.rs | 22 ++++++++++++++++++++++ src/shell/windows_cmd/mod.rs | 28 ++++++++++++++++++++++++++++ src/shell/zsh.rs | 16 ++++++++++++++++ 5 files changed, 110 insertions(+) diff --git a/src/shell/bash.rs b/src/shell/bash.rs index 97bd90bc3..f5baed30c 100644 --- a/src/shell/bash.rs +++ b/src/shell/bash.rs @@ -69,3 +69,25 @@ impl Shell for Bash { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn use_on_cd_without_install_if_missing() { + let output = Bash + .use_on_cd(&crate::config::FnmConfig::default(), false) + .unwrap(); + assert!(output.contains("fnm use --silent-if-unchanged")); + assert!(!output.contains("--install-if-missing")); + } + + #[test] + fn use_on_cd_with_install_if_missing() { + let output = Bash + .use_on_cd(&crate::config::FnmConfig::default(), true) + .unwrap(); + assert!(output.contains("fnm use --silent-if-unchanged --install-if-missing")); + } +} diff --git a/src/shell/fish.rs b/src/shell/fish.rs index 0d6d6e9f1..2f241f0b4 100644 --- a/src/shell/fish.rs +++ b/src/shell/fish.rs @@ -63,3 +63,25 @@ impl Shell for Fish { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn use_on_cd_without_install_if_missing() { + let output = Fish + .use_on_cd(&crate::config::FnmConfig::default(), false) + .unwrap(); + assert!(output.contains("fnm use --silent-if-unchanged")); + assert!(!output.contains("--install-if-missing")); + } + + #[test] + fn use_on_cd_with_install_if_missing() { + let output = Fish + .use_on_cd(&crate::config::FnmConfig::default(), true) + .unwrap(); + assert!(output.contains("fnm use --silent-if-unchanged --install-if-missing")); + } +} diff --git a/src/shell/powershell.rs b/src/shell/powershell.rs index b7a8dfb7a..cd4e421d8 100644 --- a/src/shell/powershell.rs +++ b/src/shell/powershell.rs @@ -64,3 +64,25 @@ impl Shell for PowerShell { clap_complete::Shell::PowerShell } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn use_on_cd_without_install_if_missing() { + let output = PowerShell + .use_on_cd(&crate::config::FnmConfig::default(), false) + .unwrap(); + assert!(output.contains("fnm use --silent-if-unchanged")); + assert!(!output.contains("--install-if-missing")); + } + + #[test] + fn use_on_cd_with_install_if_missing() { + let output = PowerShell + .use_on_cd(&crate::config::FnmConfig::default(), true) + .unwrap(); + assert!(output.contains("fnm use --silent-if-unchanged --install-if-missing")); + } +} diff --git a/src/shell/windows_cmd/mod.rs b/src/shell/windows_cmd/mod.rs index 1659c9671..1d9d193db 100644 --- a/src/shell/windows_cmd/mod.rs +++ b/src/shell/windows_cmd/mod.rs @@ -83,4 +83,32 @@ mod tests { std::fs::remove_dir_all(base_dir).unwrap(); } + #[test] + fn use_on_cd_without_install_if_missing() { + let base_dir = std::env::temp_dir().join("fnm cmd test no install"); + std::fs::create_dir_all(&base_dir).unwrap(); + let config = crate::config::FnmConfig::default().with_base_dir(Some(base_dir.clone())); + WindowsCmd.use_on_cd(&config, false).unwrap(); + + let cd_cmd = std::fs::read_to_string(base_dir.join("cd.cmd")).unwrap(); + assert!(cd_cmd.contains("fnm use --silent-if-unchanged")); + assert!(!cd_cmd.contains("--install-if-missing")); + + std::fs::remove_file(base_dir.join("cd.cmd")).unwrap(); + std::fs::remove_dir_all(base_dir).unwrap(); + } + + #[test] + fn use_on_cd_with_install_if_missing() { + let base_dir = std::env::temp_dir().join("fnm cmd test install"); + std::fs::create_dir_all(&base_dir).unwrap(); + let config = crate::config::FnmConfig::default().with_base_dir(Some(base_dir.clone())); + WindowsCmd.use_on_cd(&config, true).unwrap(); + + let cd_cmd = std::fs::read_to_string(base_dir.join("cd.cmd")).unwrap(); + assert!(cd_cmd.contains("fnm use --silent-if-unchanged --install-if-missing")); + + std::fs::remove_file(base_dir.join("cd.cmd")).unwrap(); + std::fs::remove_dir_all(base_dir).unwrap(); + } } diff --git a/src/shell/zsh.rs b/src/shell/zsh.rs index 6421fabf8..657d0162d 100644 --- a/src/shell/zsh.rs +++ b/src/shell/zsh.rs @@ -83,4 +83,20 @@ mod tests { assert!(output.contains("add-zsh-hook -D chpwd _fnm_autoload_hook")); } + #[test] + fn use_on_cd_without_install_if_missing() { + let output = Zsh + .use_on_cd(&crate::config::FnmConfig::default(), false) + .unwrap(); + assert!(output.contains("fnm use --silent-if-unchanged")); + assert!(!output.contains("--install-if-missing")); + } + + #[test] + fn use_on_cd_with_install_if_missing() { + let output = Zsh + .use_on_cd(&crate::config::FnmConfig::default(), true) + .unwrap(); + assert!(output.contains("fnm use --silent-if-unchanged --install-if-missing")); + } }