use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
pub const MARKER_START: &str = "# >>> vetto shim environment >>>";
pub const MARKER_END: &str = "# <<< vetto shim environment <<<";
pub const CMD_MARKER_START: &str = "rem >>> vetto shim environment >>>";
pub const CMD_MARKER_END: &str = "rem <<< vetto shim environment <<<";
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, clap::ValueEnum, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum ShellKind {
Bash,
Zsh,
Fish,
#[value(name = "powershell", alias = "pwsh")]
PowerShell,
Cmd,
}
impl ShellKind {
pub fn all() -> &'static [ShellKind] {
&[
ShellKind::Bash,
ShellKind::Zsh,
ShellKind::Fish,
ShellKind::PowerShell,
ShellKind::Cmd,
]
}
pub fn name(&self) -> &'static str {
match self {
ShellKind::Bash => "bash",
ShellKind::Zsh => "zsh",
ShellKind::Fish => "fish",
ShellKind::PowerShell => "powershell",
ShellKind::Cmd => "cmd",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ShellHookStatus {
pub shell: ShellKind,
pub profile_path: PathBuf,
pub profile_exists: bool,
pub is_installed: bool,
}
pub fn generate_snippet(shell: ShellKind, shims_dir: &Path) -> String {
let shims_str = shims_dir.display().to_string();
match shell {
ShellKind::Bash | ShellKind::Zsh => {
format!(
"{MARKER_START}\n\
# Automatically generated by `vetto hook install`. Do not edit manually.\n\
if [ -d \"{shims_str}\" ]; then\n\
case \":$PATH:\" in\n\
*\":{shims_str}:\"*) ;;\n\
*) export PATH=\"{shims_str}:$PATH\" ;;\n\
esac\n\
fi\n\
{MARKER_END}\n"
)
}
ShellKind::Fish => {
format!(
"{MARKER_START}\n\
# Automatically generated by `vetto hook install`. Do not edit manually.\n\
if test -d \"{shims_str}\"\n\
if not contains \"{shims_str}\" $PATH\n\
set -gx PATH \"{shims_str}\" $PATH\n\
end\n\
end\n\
{MARKER_END}\n"
)
}
ShellKind::PowerShell => {
format!(
"{MARKER_START}\n\
# Automatically generated by `vetto hook install`. Do not edit manually.\n\
$vettoShims = \"{shims_str}\"\n\
if (Test-Path $vettoShims) {{\n\
if (-not ($env:PATH -split ';' -contains $vettoShims)) {{\n\
$env:PATH = \"$vettoShims;$env:PATH\"\n\
}}\n\
}}\n\
{MARKER_END}\n"
)
}
ShellKind::Cmd => {
format!(
"{CMD_MARKER_START}\n\
rem Automatically generated by `vetto hook install`. Do not edit manually.\n\
if exist \"{shims_str}\" (\n\
echo %PATH% | findstr /i /c:\"{shims_str}\" >nul || set PATH={shims_str};%PATH%\n\
)\n\
{CMD_MARKER_END}\n"
)
}
}
}
pub fn profile_paths_for_shell(shell: ShellKind, home_dir: &Path) -> Vec<PathBuf> {
match shell {
ShellKind::Bash => {
vec![
home_dir.join(".bashrc"),
home_dir.join(".bash_profile"),
home_dir.join(".profile"),
]
}
ShellKind::Zsh => {
vec![home_dir.join(".zshrc")]
}
ShellKind::Fish => {
vec![home_dir.join(".config").join("fish").join("config.fish")]
}
ShellKind::PowerShell => {
#[cfg(windows)]
{
vec![
home_dir
.join("Documents")
.join("PowerShell")
.join("Microsoft.PowerShell_profile.ps1"),
home_dir
.join("Documents")
.join("WindowsPowerShell")
.join("Microsoft.PowerShell_profile.ps1"),
]
}
#[cfg(not(windows))]
{
vec![home_dir
.join(".config")
.join("powershell")
.join("Microsoft.PowerShell_profile.ps1")]
}
}
ShellKind::Cmd => {
vec![home_dir.join(".vetto").join("vetto_env.cmd")]
}
}
}
pub fn primary_profile_path(shell: ShellKind, home_dir: &Path) -> PathBuf {
let candidates = profile_paths_for_shell(shell, home_dir);
for candidate in &candidates {
if candidate.exists() {
return candidate.clone();
}
}
candidates[0].clone()
}
pub fn install_shell_hook(
shell: ShellKind,
shims_dir: &Path,
home_dir: &Path,
force: bool,
) -> Result<PathBuf> {
let profile_path = primary_profile_path(shell, home_dir);
if let Some(parent) = profile_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
}
let existing_content = if profile_path.exists() {
fs::read_to_string(&profile_path)
.with_context(|| format!("failed to read {}", profile_path.display()))?
} else {
String::new()
};
let (start_marker, end_marker) = if shell == ShellKind::Cmd {
(CMD_MARKER_START, CMD_MARKER_END)
} else {
(MARKER_START, MARKER_END)
};
let snippet = generate_snippet(shell, shims_dir);
let new_content = if let (Some(start_idx), Some(end_idx)) = (
existing_content.find(start_marker),
existing_content.find(end_marker),
) {
if !force {
return Ok(profile_path);
}
let after_end = end_idx + end_marker.len();
let end_of_line = existing_content[after_end..]
.find('\n')
.map(|i| after_end + i + 1)
.unwrap_or(existing_content.len());
let mut content = existing_content[..start_idx].to_string();
content.push_str(&snippet);
content.push_str(&existing_content[end_of_line..]);
content
} else {
let mut content = existing_content;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(&snippet);
content
};
fs::write(&profile_path, new_content)
.with_context(|| format!("failed to write {}", profile_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&profile_path, fs::Permissions::from_mode(0o644));
}
Ok(profile_path)
}
pub fn uninstall_shell_hook(shell: ShellKind, home_dir: &Path) -> Result<Option<PathBuf>> {
let candidates = profile_paths_for_shell(shell, home_dir);
let (start_marker, end_marker) = if shell == ShellKind::Cmd {
(CMD_MARKER_START, CMD_MARKER_END)
} else {
(MARKER_START, MARKER_END)
};
for profile_path in candidates {
if !profile_path.exists() {
continue;
}
let content = fs::read_to_string(&profile_path)
.with_context(|| format!("failed to read {}", profile_path.display()))?;
if let (Some(start_idx), Some(end_idx)) =
(content.find(start_marker), content.find(end_marker))
{
let after_end = end_idx + end_marker.len();
let end_of_line = content[after_end..]
.find('\n')
.map(|i| after_end + i + 1)
.unwrap_or(content.len());
let mut cleaned = content[..start_idx].to_string();
cleaned.push_str(&content[end_of_line..]);
fs::write(&profile_path, cleaned)
.with_context(|| format!("failed to update {}", profile_path.display()))?;
return Ok(Some(profile_path));
}
}
Ok(None)
}
pub fn check_shell_hook_status(
shell: ShellKind,
home_dir: &Path,
_shims_dir: &Path,
) -> ShellHookStatus {
let profile_path = primary_profile_path(shell, home_dir);
let (start_marker, end_marker) = if shell == ShellKind::Cmd {
(CMD_MARKER_START, CMD_MARKER_END)
} else {
(MARKER_START, MARKER_END)
};
let mut is_installed = false;
let profile_exists = profile_path.exists();
if profile_exists {
if let Ok(content) = fs::read_to_string(&profile_path) {
if content.contains(start_marker) && content.contains(end_marker) {
is_installed = true;
}
}
}
ShellHookStatus {
shell,
profile_path,
profile_exists,
is_installed,
}
}
pub fn detect_available_shells(home_dir: &Path) -> Vec<ShellKind> {
let mut detected = Vec::new();
for &shell in ShellKind::all() {
let candidates = profile_paths_for_shell(shell, home_dir);
let config_exists = candidates.iter().any(|c| c.exists());
let binary_exists = {
#[cfg(unix)]
{
let name = shell.name();
Path::new(&format!("/bin/{name}")).exists()
|| Path::new(&format!("/usr/bin/{name}")).exists()
|| Path::new(&format!("/usr/local/bin/{name}")).exists()
}
#[cfg(windows)]
{
matches!(shell, ShellKind::PowerShell | ShellKind::Cmd)
}
};
if config_exists || binary_exists {
detected.push(shell);
}
}
if detected.is_empty() {
#[cfg(unix)]
detected.push(ShellKind::Bash);
#[cfg(windows)]
detected.push(ShellKind::PowerShell);
}
detected
}
pub fn emit_shell_env(
session_id: Option<&str>,
tier: Option<&str>,
profile: Option<&str>,
) -> String {
let sid = session_id.unwrap_or("active");
let t = tier.unwrap_or("full");
let p = profile.unwrap_or("default");
format!(
"export VETTO_SANDBOX=1\n\
export VETTO_SESSION_ID=\"{sid}\"\n\
export VETTO_TIER=\"{t}\"\n\
export VETTO_PROFILE=\"{p}\"\n\
export VETTO_VERSION=\"{}\"\n",
env!("CARGO_PKG_VERSION")
)
}
pub fn run_shell_env(
session_id: Option<&str>,
tier: Option<&str>,
profile: Option<&str>,
) -> Result<()> {
print!("{}", emit_shell_env(session_id, tier, profile));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"vetto-shell-env-{name}-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn generates_snippets_for_all_shells() {
let shims = Path::new("/home/user/.vetto/shims");
for &shell in ShellKind::all() {
let snippet = generate_snippet(shell, shims);
assert!(snippet.contains("/home/user/.vetto/shims"));
if shell == ShellKind::Cmd {
assert!(snippet.contains(CMD_MARKER_START));
assert!(snippet.contains(CMD_MARKER_END));
} else {
assert!(snippet.contains(MARKER_START));
assert!(snippet.contains(MARKER_END));
}
}
}
#[test]
fn installs_and_uninstalls_bash_hook_cleanly() {
let home = temp_test_dir("bash-test");
let shims = home.join(".vetto").join("shims");
let bashrc = home.join(".bashrc");
fs::write(&bashrc, "export FOO=bar\n").unwrap();
let path = install_shell_hook(ShellKind::Bash, &shims, &home, false).unwrap();
assert_eq!(path, bashrc);
let content = fs::read_to_string(&bashrc).unwrap();
assert!(content.starts_with("export FOO=bar\n"));
assert!(content.contains(MARKER_START));
let status = check_shell_hook_status(ShellKind::Bash, &home, &shims);
assert!(status.is_installed);
assert!(status.profile_exists);
let uninstalled = uninstall_shell_hook(ShellKind::Bash, &home).unwrap();
assert_eq!(uninstalled, Some(bashrc.clone()));
let cleaned_content = fs::read_to_string(&bashrc).unwrap();
assert_eq!(cleaned_content.trim(), "export FOO=bar");
let status_after = check_shell_hook_status(ShellKind::Bash, &home, &shims);
assert!(!status_after.is_installed);
let _ = fs::remove_dir_all(&home);
}
}