use std::fs;
use std::path::{Path, PathBuf};
use crate::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shell {
Bash,
Zsh,
Fish,
}
impl Shell {
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"bash" => Some(Self::Bash),
"zsh" => Some(Self::Zsh),
"fish" => Some(Self::Fish),
_ => None,
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Bash => "bash",
Self::Zsh => "zsh",
Self::Fish => "fish",
}
}
pub fn default_rc_path(&self) -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
let home = PathBuf::from(home);
Some(match self {
Self::Bash => home.join(".bashrc"),
Self::Zsh => home.join(".zshrc"),
Self::Fish => home.join(".config").join("fish").join("config.fish"),
})
}
pub fn snippet(&self) -> &'static str {
match self {
Self::Bash => BASH_SNIPPET,
Self::Zsh => ZSH_SNIPPET,
Self::Fish => FISH_SNIPPET,
}
}
}
pub const BEGIN_MARKER: &str = "# >>> envseal preexec hook (auto-managed — do not edit) >>>";
pub const END_MARKER: &str = "# <<< envseal preexec hook <<<";
pub const BASH_SNIPPET: &str = r#"if command -v envseal >/dev/null 2>&1; then
__envseal_armed=1
__envseal_preexec() {
[[ "$__envseal_armed" -ne 1 ]] && return
__envseal_armed=0
envseal __preexec "$BASH_COMMAND" </dev/null >/dev/null 2>&1 &
disown 2>/dev/null
}
__envseal_post() { __envseal_armed=1; }
trap '__envseal_preexec' DEBUG
case "$PROMPT_COMMAND" in
*__envseal_post*) ;;
*) PROMPT_COMMAND="__envseal_post${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;;
esac
fi"#;
pub const ZSH_SNIPPET: &str = r#"if command -v envseal >/dev/null 2>&1; then
__envseal_preexec() {
envseal __preexec "$1" </dev/null >/dev/null 2>&1 &!
}
typeset -a preexec_functions
if [[ -z "${preexec_functions[(r)__envseal_preexec]}" ]]; then
preexec_functions+=( __envseal_preexec )
fi
fi"#;
pub const FISH_SNIPPET: &str = r#"if command -v envseal >/dev/null 2>&1
function __envseal_preexec --on-event fish_preexec
envseal __preexec "$argv" >/dev/null 2>&1 &
disown 2>/dev/null
end
end"#;
#[derive(Debug, Clone)]
pub struct InstallReport {
pub rc_path: PathBuf,
pub replaced_existing: bool,
pub created_file: bool,
}
pub fn install(shell: Shell, rc_path: &Path) -> Result<InstallReport, Error> {
let block = build_block(shell);
let (existed, content) = match fs::read_to_string(rc_path) {
Ok(c) => (true, c),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (false, String::new()),
Err(e) => return Err(Error::StorageIo(e)),
};
let (had_blocks, mut stripped) = strip_all_blocks(&content);
if !stripped.is_empty() && !stripped.ends_with('\n') {
stripped.push('\n');
}
if !stripped.is_empty() {
stripped.push('\n');
}
stripped.push_str(&block);
if let Some(parent) = rc_path.parent() {
fs::create_dir_all(parent).map_err(Error::StorageIo)?;
}
fs::write(rc_path, stripped).map_err(Error::StorageIo)?;
Ok(InstallReport {
rc_path: rc_path.to_path_buf(),
replaced_existing: had_blocks,
created_file: !existed,
})
}
pub fn uninstall(rc_path: &Path) -> Result<bool, Error> {
let content = match fs::read_to_string(rc_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(Error::StorageIo(e)),
};
let (had_blocks, stripped) = strip_all_blocks(&content);
if !had_blocks {
return Ok(false);
}
fs::write(rc_path, stripped).map_err(Error::StorageIo)?;
Ok(true)
}
fn strip_all_blocks(content: &str) -> (bool, String) {
let mut current = content.to_string();
let mut removed_any = false;
while let Some((before, after)) = split_around_block(¤t) {
removed_any = true;
let mut next = String::with_capacity(before.len() + after.len() + 1);
next.push_str(before.trim_end_matches('\n'));
if !after.is_empty() {
next.push('\n');
next.push_str(after.trim_start_matches('\n'));
} else if !next.is_empty() {
next.push('\n');
}
current = next;
}
(removed_any, current)
}
fn build_block(shell: Shell) -> String {
let mut out = String::new();
out.push_str(BEGIN_MARKER);
out.push('\n');
out.push_str(shell.snippet());
out.push('\n');
out.push_str(END_MARKER);
out.push('\n');
out
}
fn split_around_block(content: &str) -> Option<(&str, &str)> {
let begin_idx = content.find(BEGIN_MARKER)?;
let line_start = content[..begin_idx].rfind('\n').map_or(0, |i| i + 1);
let after_begin = begin_idx + BEGIN_MARKER.len();
let end_search_start = after_begin;
let end_idx = content[end_search_start..]
.find(END_MARKER)
.map(|i| end_search_start + i)?;
let after_end = end_idx + END_MARKER.len();
let after_end = if content.as_bytes().get(after_end) == Some(&b'\n') {
after_end + 1
} else {
after_end
};
Some((&content[..line_start], &content[after_end..]))
}
pub fn detect_user_shell() -> Option<Shell> {
let s = std::env::var_os("SHELL")?;
let path = PathBuf::from(s);
let stem = path.file_name()?.to_str()?;
Shell::parse(stem)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parse_round_trip() {
for s in ["bash", "zsh", "fish"] {
let sh = Shell::parse(s).unwrap();
assert_eq!(sh.name(), s);
}
assert!(Shell::parse("ksh").is_none());
assert_eq!(Shell::parse("BASH"), Some(Shell::Bash));
}
#[test]
fn snippets_mention_envseal_and_preexec_subcommand() {
for sh in [Shell::Bash, Shell::Zsh, Shell::Fish] {
let s = sh.snippet();
assert!(s.contains("envseal"), "{}: must call envseal", sh.name());
assert!(
s.contains("__preexec"),
"{}: must invoke __preexec",
sh.name()
);
}
}
#[test]
fn install_creates_missing_file() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".bashrc");
let report = install(Shell::Bash, &rc).unwrap();
assert!(report.created_file);
assert!(!report.replaced_existing);
let content = fs::read_to_string(&rc).unwrap();
assert!(content.contains(BEGIN_MARKER));
assert!(content.contains(END_MARKER));
assert!(content.contains("__envseal_preexec"));
}
#[test]
fn install_appends_to_existing_file() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
fs::write(&rc, "alias ll='ls -la'\nexport PATH=\"$HOME/bin:$PATH\"\n").unwrap();
let report = install(Shell::Zsh, &rc).unwrap();
assert!(!report.created_file);
assert!(!report.replaced_existing);
let content = fs::read_to_string(&rc).unwrap();
assert!(content.contains("alias ll"));
assert!(content.contains(BEGIN_MARKER));
}
#[test]
fn install_replaces_existing_block() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
let initial = format!(
"alias ll='ls -la'\n\n{BEGIN_MARKER}\nold contents that should disappear\n{END_MARKER}\n\nexport PATH=foo\n"
);
fs::write(&rc, &initial).unwrap();
let report = install(Shell::Zsh, &rc).unwrap();
assert!(report.replaced_existing);
let content = fs::read_to_string(&rc).unwrap();
assert!(!content.contains("old contents"));
assert!(content.contains("__envseal_preexec"));
assert!(content.contains("alias ll"));
assert!(content.contains("export PATH=foo"));
}
#[test]
fn uninstall_removes_block_only() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".bashrc");
let initial = format!(
"alias ll='ls -la'\n\n{BEGIN_MARKER}\nstuff\n{END_MARKER}\n\nexport PATH=foo\n"
);
fs::write(&rc, &initial).unwrap();
let removed = uninstall(&rc).unwrap();
assert!(removed);
let content = fs::read_to_string(&rc).unwrap();
assert!(content.contains("alias ll"));
assert!(content.contains("export PATH=foo"));
assert!(!content.contains(BEGIN_MARKER));
}
#[test]
fn uninstall_no_block_returns_false() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
fs::write(&rc, "alias ll='ls -la'\n").unwrap();
let removed = uninstall(&rc).unwrap();
assert!(!removed);
}
#[test]
fn uninstall_missing_file_returns_false() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
let removed = uninstall(&rc).unwrap();
assert!(!removed);
}
#[test]
fn install_then_uninstall_restores_file_close_to_original() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".bashrc");
let original = "alias ll='ls -la'\nexport PATH=\"$HOME/bin:$PATH\"\n";
fs::write(&rc, original).unwrap();
install(Shell::Bash, &rc).unwrap();
uninstall(&rc).unwrap();
let content = fs::read_to_string(&rc).unwrap();
assert!(content.contains("alias ll"));
assert!(content.contains("export PATH"));
assert!(!content.contains("__envseal_preexec"));
}
#[test]
fn install_collapses_duplicate_blocks() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".bashrc");
let block = build_block(Shell::Bash);
let initial = format!("alias ll='ls -la'\n\n{block}\n{block}\n# trailing line\n");
fs::write(&rc, &initial).unwrap();
let report = install(Shell::Bash, &rc).unwrap();
assert!(report.replaced_existing);
let content = fs::read_to_string(&rc).unwrap();
let begin_count = content.matches(BEGIN_MARKER).count();
let end_count = content.matches(END_MARKER).count();
assert_eq!(begin_count, 1, "should have exactly one BEGIN marker");
assert_eq!(end_count, 1, "should have exactly one END marker");
assert!(content.contains("alias ll"));
assert!(content.contains("# trailing line"));
}
#[test]
fn uninstall_removes_all_duplicate_blocks() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
let block = build_block(Shell::Zsh);
let initial = format!("alias ll='ls -la'\n\n{block}\nbetween\n{block}\nfinal\n");
fs::write(&rc, &initial).unwrap();
let removed = uninstall(&rc).unwrap();
assert!(removed);
let content = fs::read_to_string(&rc).unwrap();
assert!(!content.contains(BEGIN_MARKER));
assert!(!content.contains(END_MARKER));
assert!(content.contains("alias ll"));
assert!(content.contains("between"));
assert!(content.contains("final"));
}
#[test]
fn install_is_idempotent() {
let dir = tempdir().unwrap();
let rc = dir.path().join(".zshrc");
install(Shell::Zsh, &rc).unwrap();
let after_first = fs::read_to_string(&rc).unwrap();
install(Shell::Zsh, &rc).unwrap();
let after_second = fs::read_to_string(&rc).unwrap();
assert_eq!(after_first, after_second);
}
#[test]
fn fish_default_path_is_under_config_fish() {
let old_home = std::env::var_os("HOME");
std::env::set_var("HOME", "/home/test");
let p = Shell::Fish.default_rc_path().unwrap();
assert!(p.ends_with("config.fish"));
assert!(p.to_string_lossy().contains("fish"));
match old_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}