use std::io::{self, BufRead, Write};
use anyhow::Result;
use crate::hooks;
const SHIM_EXPORT_FRAGMENT: &str = ".local/share/git-prism/bin";
const SHIM_EXPORT_LINE: &str = r#"export PATH="$HOME/.local/share/git-prism/bin:$PATH""#;
pub fn run_install(home: &std::path::Path, force: bool) -> Result<()> {
let rc_path = detect_rc_file(home);
run_install_with_io(
home,
force,
&mut io::stdin().lock(),
&mut io::stdout(),
&rc_path,
)
}
pub fn run_install_with_io(
home: &std::path::Path,
force: bool,
stdin: &mut dyn BufRead,
stdout: &mut dyn Write,
rc_path: &std::path::Path,
) -> Result<()> {
let symlink_path = hooks::install_path_shim(home, force)?;
writeln!(stdout, "Created symlink: {}", symlink_path.display())?;
let shim_dir = home.join(SHIM_EXPORT_FRAGMENT);
let shim_dir_str = shim_dir.to_string_lossy().into_owned();
if shim_dir_is_in_path(&shim_dir_str) {
return Ok(());
}
offer_path_setup(home, rc_path, stdin, stdout)?;
Ok(())
}
fn shim_dir_is_in_path(shim_dir: &str) -> bool {
let path_env = std::env::var("PATH").unwrap_or_default();
let normalized_shim = shim_dir.trim_end_matches('/');
path_env.split(':').any(|entry| {
let normalized_entry = entry.trim_end_matches('/');
normalized_entry == normalized_shim
})
}
fn offer_path_setup(
home: &std::path::Path,
rc_path: &std::path::Path,
stdin: &mut dyn BufRead,
stdout: &mut dyn Write,
) -> Result<()> {
writeln!(
stdout,
"\nThe shim directory is not in your PATH.\nAdd it automatically? [y/N] "
)?;
stdout.flush()?;
let mut answer = String::new();
stdin.read_line(&mut answer)?;
if answer.trim().eq_ignore_ascii_case("y") {
append_to_rc_idempotent(home, rc_path, stdout)?;
} else {
print_manual_instructions(stdout)?;
}
Ok(())
}
fn append_to_rc_idempotent(
home: &std::path::Path,
rc_path: &std::path::Path,
stdout: &mut dyn Write,
) -> Result<()> {
let existing = if rc_path.exists() {
std::fs::read_to_string(rc_path)?
} else {
String::new()
};
let export_line_already_present = existing.lines().any(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with('#') && trimmed.trim() == SHIM_EXPORT_LINE.trim()
});
if !export_line_already_present {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(rc_path)?;
writeln!(file, "\n{SHIM_EXPORT_LINE}")?;
}
let rc_display = rc_path
.strip_prefix(home)
.map(|p| format!("~/{}", p.display()))
.unwrap_or_else(|_| rc_path.display().to_string());
writeln!(
stdout,
"Added to {rc_display}. Please restart Claude Code so the new PATH takes effect in its shell snapshot."
)?;
Ok(())
}
fn print_manual_instructions(stdout: &mut dyn Write) -> Result<()> {
writeln!(
stdout,
"To complete setup, add this line to your shell rc manually:\n {SHIM_EXPORT_LINE}"
)?;
Ok(())
}
fn detect_rc_file(home: &std::path::Path) -> std::path::PathBuf {
let shell = std::env::var("SHELL").unwrap_or_default();
let rc_name = if shell.contains("bash") {
".bashrc"
} else {
".zshrc"
};
home.join(rc_name)
}
pub fn run_uninstall(home: &std::path::Path) -> Result<()> {
hooks::uninstall_path_shim(home)?;
println!("Removed git-prism shim.");
Ok(())
}
pub fn run_status(home: &std::path::Path) -> Result<()> {
let shim_dir = home.join(hooks::PATH_SHIM_REL_DIR);
let shim_dir_str = shim_dir.to_string_lossy();
match hooks::path_shim_status(home) {
hooks::PathShimStatus::Installed {
target,
staleness_warning,
} => {
println!(
"shim: installed at {} -> {}",
shim_dir.join(hooks::PATH_SHIM_LINK_NAME).display(),
target.display()
);
println!("shim directory: {shim_dir_str}");
if let Some(warning) = staleness_warning {
println!("warning: {warning}");
}
}
hooks::PathShimStatus::NotInstalled => {
println!("shim: not installed");
println!("shim directory: {shim_dir_str}");
}
hooks::PathShimStatus::BrokenLink { reason } => {
println!("shim: broken link ({reason})");
println!("shim directory: {shim_dir_str}");
}
}
Ok(())
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use std::io::Cursor;
use super::*;
use tempfile::TempDir;
fn install_with_consent(home: &std::path::Path) {
let rc = home.join(".zshrc");
let mut stdin = Cursor::new("y\n");
let mut stdout = Vec::new();
run_install_with_io(home, false, &mut stdin, &mut stdout, &rc).unwrap();
}
fn install_with_decline(home: &std::path::Path) {
let rc = home.join(".zshrc");
let mut stdin = Cursor::new("n\n");
let mut stdout = Vec::new();
run_install_with_io(home, false, &mut stdin, &mut stdout, &rc).unwrap();
}
#[test]
fn run_install_creates_symlink_under_home() {
let dir = TempDir::new().unwrap();
install_with_decline(dir.path());
let link = dir.path().join(".local/share/git-prism/bin/git");
assert!(link.is_symlink(), "symlink must exist after shim install");
}
#[test]
fn run_install_is_idempotent() {
let dir = TempDir::new().unwrap();
install_with_decline(dir.path());
install_with_decline(dir.path());
let link = dir.path().join(".local/share/git-prism/bin/git");
assert!(
link.is_symlink(),
"symlink must remain after second install"
);
}
#[test]
fn consent_appends_export_line_to_rc_file() {
let dir = TempDir::new().unwrap();
let rc = dir.path().join(".zshrc");
std::fs::write(&rc, "# shell rc\n").unwrap();
install_with_consent(dir.path());
let content = std::fs::read_to_string(&rc).unwrap();
assert!(
content.contains(SHIM_EXPORT_FRAGMENT),
"rc file must contain the export fragment after consent"
);
}
#[test]
fn consent_appends_export_line_exactly_once_on_repeat() {
let dir = TempDir::new().unwrap();
let rc = dir.path().join(".zshrc");
std::fs::write(&rc, "# shell rc\n").unwrap();
install_with_consent(dir.path());
install_with_consent(dir.path());
let content = std::fs::read_to_string(&rc).unwrap();
let count = content.matches(SHIM_EXPORT_FRAGMENT).count();
assert_eq!(count, 1, "export fragment must appear exactly once");
}
#[test]
fn consent_output_contains_restart() {
let dir = TempDir::new().unwrap();
let rc = dir.path().join(".zshrc");
std::fs::write(&rc, "# shell rc\n").unwrap();
let mut stdin = Cursor::new("y\n");
let mut stdout = Vec::new();
run_install_with_io(dir.path(), false, &mut stdin, &mut stdout, &rc).unwrap();
let out = String::from_utf8(stdout).unwrap();
assert!(
out.contains("restart"),
"output must tell user to restart Claude Code; got: {out:?}"
);
}
#[test]
fn decline_leaves_rc_file_unchanged() {
let dir = TempDir::new().unwrap();
let rc = dir.path().join(".zshrc");
std::fs::write(&rc, "# shell rc\n").unwrap();
let original = std::fs::read_to_string(&rc).unwrap();
install_with_decline(dir.path());
let after = std::fs::read_to_string(&rc).unwrap();
assert_eq!(original, after, "rc file must be unchanged after decline");
}
#[test]
fn decline_output_contains_shim_dir_path() {
let dir = TempDir::new().unwrap();
let rc = dir.path().join(".zshrc");
let mut stdin = Cursor::new("n\n");
let mut stdout = Vec::new();
run_install_with_io(dir.path(), false, &mut stdin, &mut stdout, &rc).unwrap();
let out = String::from_utf8(stdout).unwrap();
assert!(
out.contains(SHIM_EXPORT_FRAGMENT),
"output must contain the shim dir path; got: {out:?}"
);
}
#[test]
fn run_uninstall_removes_symlink() {
let dir = TempDir::new().unwrap();
install_with_decline(dir.path());
run_uninstall(dir.path()).unwrap();
let link = dir.path().join(".local/share/git-prism/bin/git");
assert!(!link.is_symlink() && !link.exists(), "symlink must be gone");
}
#[test]
fn run_status_reports_not_installed_when_absent() {
let dir = TempDir::new().unwrap();
run_status(dir.path()).unwrap();
}
#[test]
fn run_status_succeeds_after_install() {
let dir = TempDir::new().unwrap();
install_with_decline(dir.path());
run_status(dir.path()).unwrap();
}
}