use serde::Serialize;
use std::path::PathBuf;
use crate::packs::orchestration::ExecutionContext;
use crate::{DodotError, Result};
pub(crate) const ALIAS_GUARD_START: &str =
"# >>> dodot git alias (managed by `dodot git-install-alias`) >>>";
pub(crate) const ALIAS_GUARD_END: &str = "# <<< dodot git alias <<<";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Shell {
Bash,
Zsh,
}
impl Shell {
pub fn detect() -> Option<Self> {
std::env::var("SHELL").ok().and_then(|s| {
if s.ends_with("/zsh") || s == "zsh" {
Some(Shell::Zsh)
} else if s.ends_with("/bash") || s == "bash" {
Some(Shell::Bash)
} else {
None
}
})
}
pub fn from_str_opt(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"bash" => Some(Shell::Bash),
"zsh" => Some(Shell::Zsh),
_ => None,
}
}
pub fn rc_relative_path(self) -> &'static str {
match self {
Shell::Bash => ".bashrc",
Shell::Zsh => ".zshrc",
}
}
pub fn alias_line(self) -> &'static str {
match self {
Shell::Bash | Shell::Zsh => "alias git='dodot refresh --quiet && command git'",
}
}
}
pub fn managed_block(shell: Shell) -> String {
format!(
"{guard_start}\n\
# Wraps `git` to run `dodot refresh` first, so `git status` and\n\
# `git diff` show deployed-side template edits between commits.\n\
# Only affects interactive shells. Remove this block to opt out.\n\
{alias}\n\
{guard_end}\n",
guard_start = ALIAS_GUARD_START,
guard_end = ALIAS_GUARD_END,
alias = shell.alias_line(),
)
}
#[derive(Debug, Clone, Serialize)]
pub struct ShowAliasResult {
pub shell: Shell,
pub alias_block: String,
pub alias_block_lines: Vec<String>,
pub rc_path_display: String,
pub already_installed: bool,
}
pub fn show_alias(ctx: &ExecutionContext, shell: Shell) -> Result<ShowAliasResult> {
let rc_path = ctx.paths.home_dir().join(shell.rc_relative_path());
let already_installed = if ctx.fs.exists(&rc_path) {
ctx.fs
.read_to_string(&rc_path)
.map(|s| s.contains(ALIAS_GUARD_START))
.unwrap_or(false)
} else {
false
};
let alias_block = managed_block(shell);
let alias_block_lines: Vec<String> = alias_block
.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
Ok(ShowAliasResult {
shell,
alias_block,
alias_block_lines,
rc_path_display: render_home_relative(&rc_path, ctx.paths.home_dir()),
already_installed,
})
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallAliasOutcome {
Created,
Appended,
AlreadyInstalled,
Updated,
}
#[derive(Debug, Clone, Serialize)]
pub struct InstallAliasResult {
pub shell: Shell,
pub outcome: InstallAliasOutcome,
pub rc_path: String,
pub rc_path_display: String,
pub source_command: String,
}
pub fn install_alias(ctx: &ExecutionContext, shell: Shell) -> Result<InstallAliasResult> {
let rc_path = ctx.paths.home_dir().join(shell.rc_relative_path());
let block = managed_block(shell);
let outcome = if ctx.fs.exists(&rc_path) {
let existing = ctx.fs.read_to_string(&rc_path)?;
if let Some((start_byte, end_byte)) = find_managed_block(&existing) {
let current_block = &existing[start_byte..end_byte];
if current_block == block {
InstallAliasOutcome::AlreadyInstalled
} else {
let mut new_content = String::with_capacity(existing.len() + block.len());
new_content.push_str(&existing[..start_byte]);
new_content.push_str(&block);
new_content.push_str(&existing[end_byte..]);
ctx.fs.write_file(&rc_path, new_content.as_bytes())?;
InstallAliasOutcome::Updated
}
} else {
let mut new_content = existing.clone();
if !new_content.ends_with('\n') {
new_content.push('\n');
}
if !new_content.ends_with("\n\n") {
new_content.push('\n');
}
new_content.push_str(&block);
ctx.fs.write_file(&rc_path, new_content.as_bytes())?;
InstallAliasOutcome::Appended
}
} else {
ctx.fs.write_file(&rc_path, block.as_bytes())?;
InstallAliasOutcome::Created
};
Ok(InstallAliasResult {
shell,
outcome,
rc_path: rc_path.display().to_string(),
rc_path_display: render_home_relative(&rc_path, ctx.paths.home_dir()),
source_command: format!(
"source {}",
render_home_relative(&rc_path, ctx.paths.home_dir())
),
})
}
fn render_home_relative(p: &std::path::Path, home: &std::path::Path) -> String {
if let Ok(rel) = p.strip_prefix(home) {
format!("~/{}", rel.display())
} else {
p.display().to_string()
}
}
fn find_managed_block(text: &str) -> Option<(usize, usize)> {
let start = text.find(ALIAS_GUARD_START)?;
let after_start = start + ALIAS_GUARD_START.len();
let end_rel = text[after_start..].find(ALIAS_GUARD_END)?;
let end_guard_start = after_start + end_rel;
let end_byte = end_guard_start + ALIAS_GUARD_END.len();
let end_byte = if text.as_bytes().get(end_byte) == Some(&b'\n') {
end_byte + 1
} else {
end_byte
};
Some((start, end_byte))
}
pub fn resolve_shell(explicit: Option<&str>) -> Result<Shell> {
if let Some(name) = explicit {
return Shell::from_str_opt(name).ok_or_else(|| {
DodotError::Other(format!(
"unsupported shell {name:?}: dodot can install the git alias for `bash` or `zsh`. \
For other shells, run `dodot git-show-alias --shell bash` and adapt the snippet."
))
});
}
Shell::detect().ok_or_else(|| {
let detected = std::env::var("SHELL").unwrap_or_default();
if detected.is_empty() {
DodotError::Other(
"$SHELL is unset; pass `--shell bash` or `--shell zsh` so dodot knows which \
rc file to write."
.into(),
)
} else {
DodotError::Other(format!(
"could not detect shell from $SHELL ({detected:?}): dodot can install the git \
alias for `bash` or `zsh`. Pass `--shell bash` or `--shell zsh` explicitly, or \
run `dodot git-show-alias --shell bash` and adapt the snippet for your shell."
))
}
})
}
pub fn is_installed(ctx: &ExecutionContext, shell: Shell) -> bool {
let rc_path = ctx.paths.home_dir().join(shell.rc_relative_path());
if !ctx.fs.exists(&rc_path) {
return false;
}
ctx.fs
.read_to_string(&rc_path)
.map(|s| s.contains(ALIAS_GUARD_START))
.unwrap_or(false)
}
#[allow(dead_code)]
fn _path_buf_anchor(_: PathBuf) {}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::testing::TempEnvironment;
fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
use crate::config::ConfigManager;
use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
use crate::fs::Fs;
use crate::paths::Pather;
use std::sync::Arc;
struct NoopRunner;
impl CommandRunner for NoopRunner {
fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner.clone(),
));
let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
ExecutionContext {
fs: env.fs.clone() as Arc<dyn Fs>,
datastore,
paths: env.paths.clone() as Arc<dyn Pather>,
config_manager,
syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
command_runner: runner,
dry_run: false,
no_provision: true,
provision_rerun: false,
force: false,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: false,
}
}
#[test]
fn from_str_opt_recognises_known_shells() {
assert_eq!(Shell::from_str_opt("bash"), Some(Shell::Bash));
assert_eq!(Shell::from_str_opt("BASH"), Some(Shell::Bash));
assert_eq!(Shell::from_str_opt("zsh"), Some(Shell::Zsh));
assert_eq!(Shell::from_str_opt("fish"), None);
assert_eq!(Shell::from_str_opt("Powershell"), None);
}
#[test]
fn rc_paths_match_shell_conventions() {
assert_eq!(Shell::Bash.rc_relative_path(), ".bashrc");
assert_eq!(Shell::Zsh.rc_relative_path(), ".zshrc");
}
#[test]
fn alias_line_runs_refresh_then_command_git() {
for sh in [Shell::Bash, Shell::Zsh] {
assert_eq!(
sh.alias_line(),
"alias git='dodot refresh --quiet && command git'"
);
}
}
#[test]
fn resolve_shell_explicit_unknown_returns_error() {
let err = resolve_shell(Some("fish")).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("fish"), "msg: {msg}");
assert!(
msg.contains("bash"),
"msg should suggest supported shells: {msg}"
);
}
#[test]
fn resolve_shell_explicit_known_returns_match() {
assert_eq!(resolve_shell(Some("bash")).unwrap(), Shell::Bash);
assert_eq!(resolve_shell(Some("Zsh")).unwrap(), Shell::Zsh);
}
use crate::testing::ShellEnvGuard;
#[test]
fn detect_returns_some_for_bash() {
let _g = ShellEnvGuard::set("/bin/bash");
assert_eq!(Shell::detect(), Some(Shell::Bash));
}
#[test]
fn detect_returns_some_for_zsh() {
let _g = ShellEnvGuard::set("/usr/local/bin/zsh");
assert_eq!(Shell::detect(), Some(Shell::Zsh));
}
#[test]
fn detect_returns_none_for_unknown_shell() {
let _g = ShellEnvGuard::set("/usr/bin/fish");
assert_eq!(Shell::detect(), None);
}
#[test]
fn resolve_shell_no_explicit_unsupported_shell_errors() {
let _g = ShellEnvGuard::set("/usr/bin/fish");
let err = resolve_shell(None).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("fish"), "msg: {msg}");
assert!(msg.contains("--shell"), "msg should suggest --shell: {msg}");
}
#[test]
fn resolve_shell_no_explicit_unset_shell_errors() {
let _g = ShellEnvGuard::unset();
let err = resolve_shell(None).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("$SHELL"), "msg: {msg}");
assert!(msg.contains("--shell"), "msg: {msg}");
}
#[test]
fn managed_block_is_self_contained_and_grep_detectable() {
let block = managed_block(Shell::Bash);
assert!(block.starts_with(ALIAS_GUARD_START));
assert!(block.trim_end().ends_with(ALIAS_GUARD_END));
assert!(block.contains(Shell::Bash.alias_line()));
}
#[test]
fn find_managed_block_locates_byte_range() {
let block = managed_block(Shell::Bash);
let text = format!("# rc preamble\n{block}# rc postamble\n");
let (start, end) = find_managed_block(&text).expect("must find block");
assert_eq!(&text[start..end], block);
}
#[test]
fn find_managed_block_returns_none_when_absent() {
assert!(find_managed_block("nothing here").is_none());
let only_start = format!("{ALIAS_GUARD_START}\nstuff\n");
assert!(find_managed_block(&only_start).is_none());
}
#[test]
fn install_alias_creates_rc_file_when_absent() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let rc_path = env.paths.home_dir().join(".zshrc");
assert!(!env.fs.exists(&rc_path));
let r = install_alias(&ctx, Shell::Zsh).unwrap();
assert!(matches!(r.outcome, InstallAliasOutcome::Created));
assert!(env.fs.exists(&rc_path));
let body = env.fs.read_to_string(&rc_path).unwrap();
assert!(body.contains(ALIAS_GUARD_START));
assert!(body.contains(Shell::Zsh.alias_line()));
}
#[test]
fn install_alias_appends_to_existing_rc() {
let env = TempEnvironment::builder().build();
let rc_path = env.paths.home_dir().join(".bashrc");
let existing = "export PATH=\"/usr/local/bin:$PATH\"\nalias ll='ls -l'\n";
env.fs.mkdir_all(rc_path.parent().unwrap()).unwrap();
env.fs.write_file(&rc_path, existing.as_bytes()).unwrap();
let ctx = make_ctx(&env);
let r = install_alias(&ctx, Shell::Bash).unwrap();
assert!(matches!(r.outcome, InstallAliasOutcome::Appended));
let body = env.fs.read_to_string(&rc_path).unwrap();
assert!(body.starts_with(existing), "user content lost: {body:?}");
assert!(body.contains(Shell::Bash.alias_line()));
}
#[test]
fn install_alias_is_idempotent_on_current_block() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let r1 = install_alias(&ctx, Shell::Zsh).unwrap();
assert!(matches!(r1.outcome, InstallAliasOutcome::Created));
let body_after_first = env
.fs
.read_to_string(&env.paths.home_dir().join(".zshrc"))
.unwrap();
let r2 = install_alias(&ctx, Shell::Zsh).unwrap();
assert!(matches!(r2.outcome, InstallAliasOutcome::AlreadyInstalled));
let body_after_second = env
.fs
.read_to_string(&env.paths.home_dir().join(".zshrc"))
.unwrap();
assert_eq!(body_after_first, body_after_second);
}
#[test]
fn install_alias_updates_a_stale_block() {
let env = TempEnvironment::builder().build();
let rc_path = env.paths.home_dir().join(".zshrc");
let stale = format!(
"export PATH=\"/usr/local/bin:$PATH\"\n\
\n\
{start}\n\
# An old, simpler form of the alias block.\n\
alias git='dodot refresh && git'\n\
{end}\n\
alias ll='ls -l'\n",
start = ALIAS_GUARD_START,
end = ALIAS_GUARD_END,
);
env.fs.mkdir_all(rc_path.parent().unwrap()).unwrap();
env.fs.write_file(&rc_path, stale.as_bytes()).unwrap();
let ctx = make_ctx(&env);
let r = install_alias(&ctx, Shell::Zsh).unwrap();
assert!(matches!(r.outcome, InstallAliasOutcome::Updated));
let body = env.fs.read_to_string(&rc_path).unwrap();
assert!(body.contains(Shell::Zsh.alias_line()));
assert!(body.contains("export PATH"));
assert!(body.contains("alias ll='ls -l'"));
assert_eq!(body.matches(ALIAS_GUARD_START).count(), 1);
}
#[test]
fn is_installed_reflects_state() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
assert!(!is_installed(&ctx, Shell::Bash));
install_alias(&ctx, Shell::Bash).unwrap();
assert!(is_installed(&ctx, Shell::Bash));
assert!(!is_installed(&ctx, Shell::Zsh));
}
#[test]
fn show_alias_renders_block_without_writing() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let rc_path = env.paths.home_dir().join(".zshrc");
assert!(!env.fs.exists(&rc_path));
let r = show_alias(&ctx, Shell::Zsh).unwrap();
assert!(r.alias_block.contains(Shell::Zsh.alias_line()));
assert_eq!(r.rc_path_display, "~/.zshrc");
assert!(!r.already_installed);
assert!(!env.fs.exists(&rc_path));
}
#[test]
fn show_alias_reports_already_installed_when_block_present() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
install_alias(&ctx, Shell::Bash).unwrap();
let r = show_alias(&ctx, Shell::Bash).unwrap();
assert!(r.already_installed);
}
}