mod bash;
mod fish;
mod powershell;
mod zsh;
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
pub struct EnvContext<'a> {
pub gvsn_dir: &'a Path,
pub active_bin: Option<&'a Path>,
pub active_root: Option<&'a Path>,
}
pub trait ShellConfig: std::fmt::Debug {
fn name(&self) -> &'static str;
fn env_script(&self, ctx: &EnvContext<'_>) -> String;
fn profile_path(&self) -> Option<PathBuf>;
fn login_profile_path(&self) -> Option<PathBuf> {
None
}
fn init_line(&self) -> &'static str;
fn wrapper_function(&self) -> &'static str;
fn shell_version_script(&self, version_tag: &str, bin: &Path, root: &Path) -> String;
fn shell_unset_script(&self) -> &'static str;
fn binary_name(&self) -> &'static str {
self.name()
}
fn needs_bin_path_in_init(&self) -> bool {
false
}
}
#[derive(Debug)]
pub struct Bash;
#[derive(Debug)]
pub struct Zsh;
#[derive(Debug)]
pub struct Fish;
#[derive(Debug)]
pub struct PowerShell;
impl ShellConfig for Bash {
fn name(&self) -> &'static str {
"bash"
}
fn env_script(&self, ctx: &EnvContext<'_>) -> String {
bash::env_script(ctx)
}
fn profile_path(&self) -> Option<PathBuf> {
bash::profile_path()
}
fn login_profile_path(&self) -> Option<PathBuf> {
#[cfg(not(target_os = "windows"))]
return dirs::home_dir().map(|h| h.join(".profile"));
#[cfg(target_os = "windows")]
return None;
}
fn init_line(&self) -> &'static str {
r#"eval "$(gvsn env --shell bash)""#
}
fn wrapper_function(&self) -> &'static str {
bash::wrapper_function()
}
fn shell_version_script(&self, tag: &str, bin: &Path, root: &Path) -> String {
bash::shell_version_script(tag, bin, root)
}
fn shell_unset_script(&self) -> &'static str {
bash::shell_unset_script()
}
fn needs_bin_path_in_init(&self) -> bool {
true
}
}
impl ShellConfig for Zsh {
fn name(&self) -> &'static str {
"zsh"
}
fn env_script(&self, ctx: &EnvContext<'_>) -> String {
zsh::env_script(ctx)
}
fn profile_path(&self) -> Option<PathBuf> {
zsh::profile_path()
}
fn login_profile_path(&self) -> Option<PathBuf> {
#[cfg(not(target_os = "windows"))]
return dirs::home_dir().map(|h| h.join(".zprofile"));
#[cfg(target_os = "windows")]
return None;
}
fn init_line(&self) -> &'static str {
r#"eval "$(gvsn env --shell zsh)""#
}
fn wrapper_function(&self) -> &'static str {
zsh::wrapper_function()
}
fn shell_version_script(&self, tag: &str, bin: &Path, root: &Path) -> String {
zsh::shell_version_script(tag, bin, root)
}
fn shell_unset_script(&self) -> &'static str {
zsh::shell_unset_script()
}
fn needs_bin_path_in_init(&self) -> bool {
true
}
}
impl ShellConfig for Fish {
fn name(&self) -> &'static str {
"fish"
}
fn env_script(&self, ctx: &EnvContext<'_>) -> String {
fish::env_script(ctx)
}
fn profile_path(&self) -> Option<PathBuf> {
fish::profile_path()
}
fn init_line(&self) -> &'static str {
"if command -q gvsn; gvsn env --shell fish | source; end"
}
fn wrapper_function(&self) -> &'static str {
fish::wrapper_function()
}
fn shell_version_script(&self, tag: &str, bin: &Path, root: &Path) -> String {
fish::shell_version_script(tag, bin, root)
}
fn shell_unset_script(&self) -> &'static str {
fish::shell_unset_script()
}
}
impl ShellConfig for PowerShell {
fn name(&self) -> &'static str {
"powershell"
}
fn binary_name(&self) -> &'static str {
"pwsh"
}
fn env_script(&self, ctx: &EnvContext<'_>) -> String {
powershell::env_script(ctx)
}
fn profile_path(&self) -> Option<PathBuf> {
powershell::profile_path()
}
fn init_line(&self) -> &'static str {
"if (Get-Command gvsn -ErrorAction SilentlyContinue) { try { gvsn env --shell powershell | Out-String | Invoke-Expression } catch {} }"
}
fn wrapper_function(&self) -> &'static str {
powershell::wrapper_function()
}
fn shell_version_script(&self, tag: &str, bin: &Path, root: &Path) -> String {
powershell::shell_version_script(tag, bin, root)
}
fn shell_unset_script(&self) -> &'static str {
powershell::shell_unset_script()
}
}
pub fn detect() -> Option<Box<dyn ShellConfig>> {
if std::env::var("PSModulePath").is_ok() {
return Some(Box::new(PowerShell));
}
if let Ok(shell) = std::env::var("SHELL") {
if shell.contains("zsh") {
return Some(Box::new(Zsh));
}
if shell.contains("fish") {
return Some(Box::new(Fish));
}
if shell.contains("bash") {
return Some(Box::new(Bash));
}
}
if cfg!(target_os = "windows") {
return Some(Box::new(PowerShell));
}
None
}
pub fn from_str(s: &str) -> Result<Box<dyn ShellConfig>> {
match s.to_lowercase().replace('-', "").as_str() {
"powershell" | "pwsh" => Ok(Box::new(PowerShell)),
"bash" => Ok(Box::new(Bash)),
"zsh" => Ok(Box::new(Zsh)),
"fish" => Ok(Box::new(Fish)),
_ => bail!(
"Unknown shell '{}'. Supported: powershell, bash, zsh, fish",
s
),
}
}
pub fn is_available(shell: &dyn ShellConfig) -> bool {
#[cfg(windows)]
if shell.name() == "powershell" {
return true;
}
find_binary(shell.binary_name())
}
pub fn available_shells() -> Vec<&'static str> {
let candidates: &[(&dyn ShellConfig, &'static str)] = &[
(&PowerShell, "powershell"),
(&Bash, "bash"),
(&Zsh, "zsh"),
(&Fish, "fish"),
];
candidates
.iter()
.filter(|(sh, _)| is_available(*sh))
.map(|(_, name)| *name)
.collect()
}
fn find_binary(name: &str) -> bool {
let sep = if cfg!(windows) { ';' } else { ':' };
let Ok(path_var) = std::env::var("PATH") else {
return false;
};
for dir in path_var.split(sep).filter(|s| !s.is_empty()) {
let base = Path::new(dir).join(name);
if base.exists() {
return true;
}
#[cfg(windows)]
if Path::new(dir).join(format!("{name}.exe")).exists() {
return true;
}
}
false
}
fn build_init_content(shell: &dyn ShellConfig, gvsn_bin_dir: Option<&Path>) -> String {
if shell.needs_bin_path_in_init() {
#[cfg(not(target_os = "windows"))]
if let Some(dir) = gvsn_bin_dir {
let path_expr = home_relative_path(dir);
return format!("export PATH=\"{path_expr}:$PATH\"\n{}", shell.init_line());
}
}
let _ = gvsn_bin_dir;
shell.init_line().to_string()
}
#[cfg(not(target_os = "windows"))]
fn home_relative_path(path: &Path) -> String {
if let Some(home) = dirs::home_dir() {
if let Ok(rel) = path.strip_prefix(&home) {
return format!("$HOME/{}", rel.display());
}
}
path.display().to_string()
}
pub fn inject_profile(shell: &dyn ShellConfig, gvsn_bin_dir: Option<&Path>) -> Result<()> {
use crate::profile;
let init_content = build_init_content(shell, gvsn_bin_dir);
let wrapper_content = shell.wrapper_function().to_string();
let profile_path = shell
.profile_path()
.ok_or_else(|| anyhow::anyhow!("Cannot determine profile path for {}", shell.name()))?;
profile::ensure_profile(&profile_path, &init_content, &wrapper_content)
.with_context(|| format!("Failed to update profile {}", profile_path.display()))?;
println!(" gvsn hook configured in {}", profile_path.display());
Ok(())
}
#[cfg(not(target_os = "windows"))]
pub fn inject_login_profile(shell: &dyn ShellConfig) -> Result<()> {
use crate::profile;
let Some(profile_path) = shell.login_profile_path() else {
return Ok(());
};
profile::update_path_block(&profile_path)
.with_context(|| format!("Failed to update PATH block in {}", profile_path.display()))?;
println!(" gvsn PATH entry configured in {}", profile_path.display());
Ok(())
}
pub fn strip_profile(path: &Path) -> Result<bool> {
use crate::profile;
if !path.exists() {
return Ok(false);
}
profile::strip_gvsn_blocks(path)
}
pub fn gvsn_in_path() -> bool {
let Ok(exe) = std::env::current_exe() else {
return false;
};
let Some(dir) = exe.parent() else {
return false;
};
let Ok(path_var) = std::env::var("PATH") else {
return false;
};
let sep = if cfg!(windows) { ';' } else { ':' };
path_var.split(sep).any(|p| Path::new(p) == dir)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[derive(Debug)]
struct TempShell {
profile: PathBuf,
login_profile: PathBuf,
}
impl ShellConfig for TempShell {
fn name(&self) -> &'static str {
"bash"
}
fn env_script(&self, ctx: &EnvContext<'_>) -> String {
bash::env_script(ctx)
}
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 {
r#"eval "$(gvsn env --shell bash)""#
}
fn wrapper_function(&self) -> &'static str {
bash::wrapper_function()
}
fn shell_version_script(&self, tag: &str, bin: &Path, root: &Path) -> String {
bash::shell_version_script(tag, bin, root)
}
fn shell_unset_script(&self) -> &'static str {
bash::shell_unset_script()
}
fn needs_bin_path_in_init(&self) -> bool {
true
}
}
#[test]
fn inject_profile_writes_init_and_wrapper_blocks() {
let dir = tempdir().unwrap();
let sh = TempShell {
profile: dir.path().join("profile"),
login_profile: dir.path().join("login_profile"),
};
inject_profile(&sh, None).unwrap();
let content = fs::read_to_string(&sh.profile).unwrap();
assert!(content.contains("# gvsn init"));
assert!(content.contains("# gvsn wrapper"));
assert!(content.contains(r#"eval "$(gvsn env --shell bash)""#));
}
#[test]
fn inject_profile_is_idempotent() {
let dir = tempdir().unwrap();
let sh = TempShell {
profile: dir.path().join("profile"),
login_profile: dir.path().join("login_profile"),
};
inject_profile(&sh, None).unwrap();
let first = fs::read_to_string(&sh.profile).unwrap();
inject_profile(&sh, None).unwrap();
let second = fs::read_to_string(&sh.profile).unwrap();
assert_eq!(first, second, "second run must not change the file");
}
#[cfg(not(target_os = "windows"))]
#[test]
fn inject_profile_prepends_bin_path_when_needed() {
let dir = tempdir().unwrap();
let sh = TempShell {
profile: dir.path().join("profile"),
login_profile: dir.path().join("login_profile"),
};
let bin_dir = dir.path().join("bin");
inject_profile(&sh, Some(&bin_dir)).unwrap();
let content = fs::read_to_string(&sh.profile).unwrap();
assert!(content.contains("export PATH="));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn inject_login_profile_writes_path_block() {
let dir = tempdir().unwrap();
let sh = TempShell {
profile: dir.path().join("profile"),
login_profile: dir.path().join("login_profile"),
};
inject_login_profile(&sh).unwrap();
let content = fs::read_to_string(&sh.login_profile).unwrap();
assert!(content.contains("# gvsn path"));
assert!(content.contains(r#"export PATH="$HOME/.gvsn/current/bin:$PATH""#));
}
#[test]
fn strip_profile_removes_gvsn_blocks_after_inject() {
let dir = tempdir().unwrap();
let sh = TempShell {
profile: dir.path().join("profile"),
login_profile: dir.path().join("login_profile"),
};
fs::write(&sh.profile, "# user config\nexport FOO=bar\n").unwrap();
inject_profile(&sh, None).unwrap();
assert!(fs::read_to_string(&sh.profile)
.unwrap()
.contains("# gvsn init"));
let changed = strip_profile(&sh.profile).unwrap();
assert!(changed);
let content = fs::read_to_string(&sh.profile).unwrap();
assert!(!content.contains("# gvsn init"));
assert!(!content.contains("# gvsn wrapper"));
assert!(
content.contains("export FOO=bar"),
"user content must survive"
);
}
#[test]
fn strip_profile_returns_false_for_missing_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("does-not-exist");
let changed = strip_profile(&path).unwrap();
assert!(!changed);
}
fn run_inject(shell: &dyn ShellConfig, existing: &str) -> String {
const INIT_MARKER: &str = "# gvsn init";
const WRAPPER_MARKER: &str = "# gvsn wrapper";
let dir = tempdir().unwrap();
let path = dir.path().join("profile");
fs::write(&path, existing).unwrap();
let src = fs::read_to_string(&path).unwrap();
let has_init = src.contains(INIT_MARKER);
let has_wrapper = src.contains(WRAPPER_MARKER);
let init_content = build_init_content(shell, None);
if has_init && has_wrapper {
let expected_init = format!("{INIT_MARKER}\n{init_content}\n");
let expected_wrapper = format!("{WRAPPER_MARKER}\n{}\n", shell.wrapper_function());
if src.contains(&expected_init) && src.contains(&expected_wrapper) {
return src;
}
let mut content = src.clone();
if !content.contains(&expected_init) {
if let Some(pos) = content.find(INIT_MARKER) {
let after = &content[pos + INIT_MARKER.len()..];
let end = pos
+ INIT_MARKER.len()
+ after
.find("\n# gvsn ")
.map(|i| i + 1)
.unwrap_or(after.len());
let new_block = format!("{INIT_MARKER}\n{init_content}\n");
content = format!("{}{}{}", &content[..pos], new_block, &content[end..]);
}
}
if !content.contains(&expected_wrapper) {
let pos = content.rfind(WRAPPER_MARKER).unwrap();
let before = content[..pos].trim_end().to_string();
content = format!(
"{before}\n\n{WRAPPER_MARKER}\n{}\n",
shell.wrapper_function()
);
}
fs::write(&path, &content).unwrap();
return content;
}
let mut content = src.trim_end().to_string();
if !has_init {
if !content.is_empty() {
content.push_str("\n\n");
}
content.push_str(&format!("{INIT_MARKER}\n{init_content}\n"));
}
if !has_wrapper {
content.push_str(&format!(
"\n{WRAPPER_MARKER}\n{}\n",
shell.wrapper_function()
));
}
fs::write(&path, &content).unwrap();
content
}
#[test]
fn setup_injects_both_blocks_into_empty_profile() {
let result = run_inject(&Bash, "");
assert!(result.contains("# gvsn init"));
assert!(result.contains("# gvsn wrapper"));
assert!(result.contains("shell)"));
}
#[test]
fn setup_is_idempotent_when_wrapper_is_current() {
let sh = Bash;
let first = run_inject(&sh, "");
let second = run_inject(&sh, &first);
assert_eq!(first, second, "second run must not change the file");
}
#[test]
fn setup_updates_stale_bash_wrapper() {
let stale = "# gvsn init\neval \"$(gvsn env --shell bash)\"\n\n# gvsn wrapper\ngvsn() { command gvsn \"$@\"; }\n";
let result = run_inject(&Bash, stale);
assert!(result.contains("shell)"), "shell case must be injected");
assert!(
!result.contains("command gvsn \"$@\"; }"),
"old stub must be removed"
);
assert!(result.contains("# gvsn init"), "init block must survive");
}
#[test]
fn setup_updates_stale_zsh_wrapper() {
let stale = "# gvsn init\neval \"$(gvsn env --shell zsh)\"\n\n# gvsn wrapper\ngvsn() { command gvsn \"$@\"; }\n";
let result = run_inject(&Zsh, stale);
assert!(result.contains("shell)"));
assert!(result.contains("--shell zsh"));
}
#[test]
fn setup_updates_stale_fish_wrapper() {
let stale = "# gvsn init\ngvsn env --shell fish | source\n\n# gvsn wrapper\nfunction gvsn\n command gvsn $argv\nend\n";
let result = run_inject(&Fish, stale);
assert!(result.contains("contains -- $argv[1] shell"));
assert!(
result.contains("string join"),
"updated fish wrapper must use string join"
);
}
#[test]
fn setup_does_not_duplicate_init_block() {
let existing = "# gvsn init\neval \"$(gvsn env --shell bash)\"\n";
let result = run_inject(&Bash, existing);
let count = result.matches("# gvsn init").count();
assert_eq!(count, 1, "init marker must appear exactly once");
}
#[test]
fn setup_updates_stale_fish_init_line() {
let stale = format!(
"# gvsn init\ngvsn env --shell fish | source\n\n# gvsn wrapper\n{}\n",
Fish.wrapper_function()
);
let result = run_inject(&Fish, &stale);
assert!(
result.contains("command -q gvsn"),
"new guard must be present"
);
assert!(
!result.contains("\ngvsn env --shell fish | source\n"),
"bare unguarded line must be replaced"
);
let count = result.matches("# gvsn init").count();
assert_eq!(
count, 1,
"init marker must appear exactly once after update"
);
}
#[test]
fn setup_is_idempotent_for_fish_after_update() {
let sh = Fish;
let first = run_inject(&sh, "");
let second = run_inject(&sh, &first);
assert_eq!(first, second, "fish: second run must not change the file");
}
}