use anyhow::Result;
use colored::Colorize;
use crate::{shell, shell::ShellConfig};
pub fn run(shell_str: Option<&str>, reset: bool) -> Result<()> {
let sh: Box<dyn ShellConfig> = match shell_str {
Some(s) => {
let sh = shell::from_str(s)?;
if !shell::is_available(sh.as_ref()) {
let hint = hint_for_missing_explicit_shell(&shell::available_shells());
anyhow::bail!(
"Shell '{}' is not installed or not found in PATH.\n {}",
s,
hint
);
}
sh
}
None => match shell::detect() {
Some(sh) => {
if !shell::is_available(sh.as_ref()) {
let hint = hint_for_stale_detected_shell(&shell::available_shells());
anyhow::bail!(
"Detected shell '{}' but its binary was not found in PATH.\n {}",
sh.name(),
hint
);
}
sh
}
None => {
let hint = hint_for_no_shell_detected(&shell::available_shells());
anyhow::bail!("Could not detect current shell.\n {}", hint);
}
},
};
println!("Setting up gvsn for {}...", sh.name().bold());
if reset {
strip_all(sh.as_ref())?;
println!();
println!(" Previous configuration removed. Re-applying...");
println!();
}
let gvsn_bin_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(std::path::PathBuf::from));
shell::inject_profile(sh.as_ref(), gvsn_bin_dir.as_deref())?;
#[cfg(not(target_os = "windows"))]
shell::inject_login_profile(sh.as_ref())?;
#[cfg(target_os = "windows")]
inject_windows_registry()?;
if !shell::gvsn_in_path() {
if let Ok(exe) = std::env::current_exe() {
let dir = exe
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
println!();
println!("{} gvsn is not in PATH yet.", "!".yellow());
println!(
" Add {} to your PATH so the shell hook can call 'gvsn path'.",
dir.cyan()
);
}
}
println!();
println!(
"{} Restart your shell or run: {}",
"✓".green(),
sh.init_line().cyan()
);
Ok(())
}
fn hint_for_missing_explicit_shell(available: &[&str]) -> String {
if available.is_empty() {
"No supported shells found in PATH.".to_string()
} else {
format!("Shells available on this system: {}", available.join(", "))
}
}
fn hint_for_stale_detected_shell(available: &[&str]) -> String {
if available.is_empty() {
"No supported shells found in PATH. Install bash, zsh, or fish first.".to_string()
} else {
format!(
"Try: gvsn setup --shell {}",
available.first().copied().unwrap_or("bash")
)
}
}
fn hint_for_no_shell_detected(available: &[&str]) -> String {
if available.is_empty() {
"No supported shells found. Install bash, zsh, fish, or PowerShell first.".to_string()
} else {
format!(
"Detected shells: {}. Use --shell <name> to select one.",
available.join(", ")
)
}
}
fn strip_all(sh: &dyn ShellConfig) -> Result<()> {
if let Some(p) = sh.profile_path() {
match shell::strip_profile(&p) {
Ok(true) => println!(" {} Cleaned {}", "✓".green(), p.display()),
Ok(false) => println!(" No gvsn config found in {}", p.display()),
Err(e) => println!(" {} Could not clean {}: {e}", "!".yellow(), p.display()),
}
}
#[cfg(not(target_os = "windows"))]
if let Some(p) = sh.login_profile_path() {
match shell::strip_profile(&p) {
Ok(true) => println!(" {} Cleaned {}", "✓".green(), p.display()),
Ok(false) => println!(" No gvsn config found in {}", p.display()),
Err(e) => println!(" {} Could not clean {}: {e}", "!".yellow(), p.display()),
}
}
#[cfg(target_os = "windows")]
strip_windows_registry()?;
Ok(())
}
#[cfg_attr(not(windows), allow(dead_code))]
fn path_already_present(entries: &[String], candidate: &std::path::Path) -> bool {
let candidate = candidate.to_string_lossy();
entries
.iter()
.any(|e| e.eq_ignore_ascii_case(candidate.as_ref()))
}
#[cfg(target_os = "windows")]
fn broadcast_environment_change() {
use windows_sys::Win32::Foundation::HWND;
use windows_sys::Win32::UI::WindowsAndMessaging::{
SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE,
};
let param: Vec<u16> = "Environment\0".encode_utf16().collect();
let mut result: usize = 0;
unsafe {
SendMessageTimeoutW(
HWND_BROADCAST as HWND,
WM_SETTINGCHANGE,
0,
param.as_ptr() as isize,
SMTO_ABORTIFHUNG,
5000,
&mut result,
);
}
}
#[cfg(target_os = "windows")]
fn inject_windows_registry() -> Result<()> {
use anyhow::Context;
use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_WRITE};
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let env = hkcu
.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
.context("Cannot open HKCU\\Environment registry key")?;
let current_path: String = env.get_value("PATH").unwrap_or_default();
let mut entries: Vec<String> = current_path
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let mut changed = false;
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let dir_str = dir.to_string_lossy().to_string();
if !path_already_present(&entries, dir) {
entries.insert(0, dir_str);
println!(" Added {} to user PATH (registry)", dir.display());
changed = true;
} else {
println!(" {} already in user PATH", dir.display());
}
}
}
if let Some(home) = dirs::home_dir() {
let current_bin = home.join(".gvsn").join("current").join("bin");
let current_bin_str = current_bin.to_string_lossy().to_string();
if !path_already_present(&entries, ¤t_bin) {
entries.insert(0, current_bin_str);
println!(" Added {} to user PATH (registry)", current_bin.display());
changed = true;
} else {
println!(" {} already in user PATH", current_bin.display());
}
}
if changed {
let new_path = entries.join(";");
env.set_value("PATH", &new_path)
.context("Cannot write PATH to HKCU\\Environment")?;
broadcast_environment_change();
println!(" {} User PATH updated in registry", "✓".green());
println!(
" New terminals and GUI apps (VSCode, GoLand, ...) will pick this up automatically."
);
println!(" Already-open ones need to be restarted to see it.");
}
Ok(())
}
#[cfg(target_os = "windows")]
fn strip_windows_registry() -> Result<()> {
use anyhow::Context;
use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_WRITE};
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let env = hkcu
.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
.context("Cannot open HKCU\\Environment registry key")?;
let current_path: String = env.get_value("PATH").unwrap_or_default();
let home = dirs::home_dir().unwrap_or_default();
let gvsn_root = home.join(".gvsn");
let filtered: Vec<&str> = current_path
.split(';')
.filter(|e| {
let p = std::path::Path::new(e);
!p.starts_with(&gvsn_root)
})
.collect();
let new_path = filtered.join(";");
if new_path != current_path {
env.set_value("PATH", &new_path)
.context("Cannot write PATH to HKCU\\Environment")?;
broadcast_environment_change();
println!(
" {} Removed gvsn entries from user PATH (registry)",
"✓".green()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_os = "windows"))]
use std::path::PathBuf;
#[cfg(target_os = "windows")]
#[test]
fn broadcast_environment_change_does_not_panic() {
broadcast_environment_change();
}
#[test]
fn path_already_present_matches_case_insensitively() {
let entries = vec![r"C:\Users\jhon\.gvsn\current\bin".to_string()];
let candidate = std::path::Path::new(r"c:\users\jhon\.gvsn\current\bin");
assert!(path_already_present(&entries, candidate));
}
#[test]
fn path_already_present_is_false_for_unrelated_entry() {
let entries = vec![r"C:\Users\jhon\.cargo\bin".to_string()];
let candidate = std::path::Path::new(r"C:\Users\jhon\.gvsn\current\bin");
assert!(!path_already_present(&entries, candidate));
}
#[test]
fn hint_for_missing_explicit_shell_lists_available() {
assert_eq!(
hint_for_missing_explicit_shell(&["bash", "zsh"]),
"Shells available on this system: bash, zsh"
);
}
#[test]
fn hint_for_missing_explicit_shell_handles_empty() {
assert_eq!(
hint_for_missing_explicit_shell(&[]),
"No supported shells found in PATH."
);
}
#[test]
fn hint_for_stale_detected_shell_suggests_first_available() {
assert_eq!(
hint_for_stale_detected_shell(&["zsh", "fish"]),
"Try: gvsn setup --shell zsh"
);
}
#[test]
fn hint_for_stale_detected_shell_handles_empty() {
assert_eq!(
hint_for_stale_detected_shell(&[]),
"No supported shells found in PATH. Install bash, zsh, or fish first."
);
}
#[test]
fn hint_for_no_shell_detected_lists_available() {
assert_eq!(
hint_for_no_shell_detected(&["bash", "fish"]),
"Detected shells: bash, fish. Use --shell <name> to select one."
);
}
#[test]
fn hint_for_no_shell_detected_handles_empty() {
assert_eq!(
hint_for_no_shell_detected(&[]),
"No supported shells found. Install bash, zsh, fish, or PowerShell first."
);
}
#[cfg(not(target_os = "windows"))]
#[derive(Debug)]
struct FakeShell {
profile: PathBuf,
login_profile: PathBuf,
}
#[cfg(not(target_os = "windows"))]
impl ShellConfig for FakeShell {
fn name(&self) -> &'static str {
"bash"
}
fn env_script(&self, _ctx: &shell::EnvContext<'_>) -> String {
String::new()
}
fn profile_path(&self) -> Option<PathBuf> {
Some(self.profile.clone())
}
fn login_profile_path(&self) -> Option<PathBuf> {
Some(self.login_profile.clone())
}
fn init_line(&self) -> &'static str {
"eval gvsn"
}
fn wrapper_function(&self) -> &'static str {
"gvsn() { command gvsn \"$@\"; }"
}
fn shell_version_script(
&self,
_tag: &str,
_bin: &std::path::Path,
_root: &std::path::Path,
) -> String {
String::new()
}
fn shell_unset_script(&self) -> &'static str {
""
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn strip_all_cleans_both_profiles() {
let dir = tempfile::tempdir().unwrap();
let sh = FakeShell {
profile: dir.path().join("profile"),
login_profile: dir.path().join("login_profile"),
};
std::fs::write(
&sh.profile,
"# user\nexport FOO=bar\n\n# gvsn init\neval gvsn\n",
)
.unwrap();
std::fs::write(
&sh.login_profile,
"# gvsn path\nexport PATH=\"$HOME/.gvsn/current/bin:$PATH\"\n",
)
.unwrap();
strip_all(&sh).unwrap();
let profile_content = std::fs::read_to_string(&sh.profile).unwrap();
assert!(!profile_content.contains("# gvsn init"));
assert!(profile_content.contains("export FOO=bar"));
let login_content = std::fs::read_to_string(&sh.login_profile).unwrap();
assert!(!login_content.contains("# gvsn path"));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn strip_all_is_a_noop_on_files_without_gvsn_config() {
let dir = tempfile::tempdir().unwrap();
let sh = FakeShell {
profile: dir.path().join("profile"),
login_profile: dir.path().join("login_profile"),
};
std::fs::write(&sh.profile, "export FOO=bar\n").unwrap();
strip_all(&sh).unwrap();
let content = std::fs::read_to_string(&sh.profile).unwrap();
assert_eq!(content, "export FOO=bar\n");
}
}