use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::fs::Fs;
use crate::Result;
pub const BLOCK_START: &str = "# >>> dodot shell hookup >>>";
pub const BLOCK_END: &str = "# <<< dodot shell hookup <<<";
pub const BASH_CHAIN_LINE: &str = "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"";
const MAX_SYMLINK_HOPS: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum HookupShell {
Bash,
Zsh,
}
impl HookupShell {
pub fn from_shell_path(path: &str) -> Option<Self> {
let base = Path::new(path).file_name()?.to_str()?;
match base.strip_prefix('-').unwrap_or(base) {
"bash" => Some(HookupShell::Bash),
"zsh" => Some(HookupShell::Zsh),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
HookupShell::Bash => "bash",
HookupShell::Zsh => "zsh",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ShellEnv {
pub shell: Option<String>,
pub zdotdir: Option<String>,
}
impl ShellEnv {
pub fn from_process() -> Self {
let shell = std::env::var("SHELL")
.ok()
.filter(|s| !s.is_empty())
.or_else(login_shell_from_user_db);
Self {
shell,
zdotdir: std::env::var("ZDOTDIR").ok().filter(|s| !s.is_empty()),
}
}
pub fn hookup_shell(&self) -> Option<HookupShell> {
self.shell.as_deref().and_then(HookupShell::from_shell_path)
}
}
fn login_shell_from_user_db() -> Option<String> {
unsafe {
let entry = libc::getpwuid(libc::getuid());
if entry.is_null() || (*entry).pw_shell.is_null() {
return None;
}
std::ffi::CStr::from_ptr((*entry).pw_shell)
.to_str()
.ok()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RcTarget {
pub path: PathBuf,
pub link_source: Option<PathBuf>,
pub exists: bool,
pub notes: Vec<String>,
}
impl RcTarget {
pub fn nominal(&self) -> &Path {
self.link_source.as_deref().unwrap_or(&self.path)
}
}
pub fn resolve_rc(
fs: &dyn Fs,
home: &Path,
shell: Option<HookupShell>,
env: &ShellEnv,
explicit: Option<&Path>,
) -> RcTarget {
let mut notes = Vec::new();
let nominal = match (explicit, shell) {
(Some(p), _) => absolutize(p, home),
(None, Some(HookupShell::Bash)) => home.join(".bashrc"),
(None, Some(HookupShell::Zsh)) => {
let (dir, note) = zdotdir(fs, home, env);
if let Some(note) = note {
notes.push(note);
}
dir.join(".zshrc")
}
(None, None) => home.join(".profile"),
};
let resolved = resolve_symlinks(fs, &nominal);
let link_source = (resolved != nominal).then(|| nominal.clone());
if let Some(src) = &link_source {
notes.push(format!(
"{} → {} — writing there",
display_home_relative(src, home),
display_home_relative(&resolved, home)
));
}
RcTarget {
exists: fs.exists(&resolved),
link_source,
notes,
path: resolved,
}
}
fn zdotdir(fs: &dyn Fs, home: &Path, env: &ShellEnv) -> (PathBuf, Option<String>) {
if let Some(z) = env.zdotdir.as_deref() {
let dir = expand_home(z, home);
return (
dir.clone(),
Some(format!("ZDOTDIR is set: zsh reads {}", dir.display())),
);
}
let zshenv = home.join(".zshenv");
if fs.exists(&zshenv) {
if let Ok(text) = fs.read_to_string(&zshenv) {
if let Some(raw) = parse_zdotdir_assignment(&text) {
let dir = expand_home(&raw, home);
return (
dir.clone(),
Some(format!(
"~/.zshenv sets ZDOTDIR={}: zsh reads {}",
raw,
dir.display()
)),
);
}
}
}
(home.to_path_buf(), None)
}
pub fn parse_zdotdir_assignment(text: &str) -> Option<String> {
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with('#') {
return None;
}
let rest = line
.strip_prefix("export ")
.unwrap_or(line)
.trim_start()
.strip_prefix("ZDOTDIR=")?;
let value = rest.split(['#', ';']).next().unwrap_or(rest).trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
(!value.is_empty()).then(|| value.to_string())
})
.next_back()
}
fn expand_home(raw: &str, home: &Path) -> PathBuf {
for prefix in ["${HOME}", "$HOME", "~"] {
if let Some(rest) = raw.strip_prefix(prefix) {
let rest = rest.trim_start_matches('/');
return if rest.is_empty() {
home.to_path_buf()
} else {
home.join(rest)
};
}
}
absolutize(Path::new(raw), home)
}
fn absolutize(path: &Path, home: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
home.join(path)
}
}
fn resolve_symlinks(fs: &dyn Fs, path: &Path) -> PathBuf {
let mut current = path.to_path_buf();
for _ in 0..MAX_SYMLINK_HOPS {
if !fs.is_symlink(¤t) {
break;
}
let Ok(target) = fs.readlink(¤t) else {
break;
};
let next = if target.is_absolute() {
target
} else {
current
.parent()
.map(|p| p.join(&target))
.unwrap_or_else(|| target.clone())
};
if next == current {
break;
}
current = next;
}
current
}
pub fn display_home_relative(path: &Path, home: &Path) -> String {
match path.strip_prefix(home) {
Ok(rel) => format!("~/{}", rel.display()),
Err(_) => path.display().to_string(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum BlockOutcome {
Created,
Appended,
Replaced,
Unchanged,
}
impl BlockOutcome {
pub fn as_str(self) -> &'static str {
match self {
BlockOutcome::Created => "created",
BlockOutcome::Appended => "appended",
BlockOutcome::Replaced => "replaced",
BlockOutcome::Unchanged => "unchanged",
}
}
}
pub fn render_block(body: &[&str]) -> String {
let mut out = String::with_capacity(BLOCK_START.len() + BLOCK_END.len() + 128);
out.push_str(BLOCK_START);
out.push('\n');
for line in body {
out.push_str(line);
out.push('\n');
}
out.push_str(BLOCK_END);
out.push('\n');
out
}
pub fn find_block(text: &str) -> Option<(usize, usize)> {
let start = text.find(BLOCK_START)?;
let after_start = start + BLOCK_START.len();
let end_rel = text[after_start..].find(BLOCK_END)?;
let end = after_start + end_rel + BLOCK_END.len();
let end = if text.as_bytes().get(end) == Some(&b'\n') {
end + 1
} else {
end
};
Some((start, end))
}
pub fn apply_block(existing: Option<&str>, block: &str) -> (String, BlockOutcome) {
let Some(existing) = existing else {
return (block.to_string(), BlockOutcome::Created);
};
if let Some((start, end)) = find_block(existing) {
if &existing[start..end] == block {
return (existing.to_string(), BlockOutcome::Unchanged);
}
let mut out = String::with_capacity(existing.len() + block.len());
out.push_str(&existing[..start]);
out.push_str(block);
out.push_str(&existing[end..]);
return (out, BlockOutcome::Replaced);
}
let mut out = existing.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(block);
(out, BlockOutcome::Appended)
}
pub fn write_block(fs: &dyn Fs, path: &Path, block: &str) -> Result<BlockOutcome> {
let existing = if fs.exists(path) {
Some(fs.read_to_string(path)?)
} else {
None
};
let (content, outcome) = apply_block(existing.as_deref(), block);
if outcome != BlockOutcome::Unchanged {
if let Some(parent) = path.parent() {
fs.mkdir_all(parent)?;
}
fs.write_file(path, content.as_bytes())?;
}
Ok(outcome)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum HookPresence {
ManagedBlock,
Manual,
Absent,
}
impl HookPresence {
pub fn as_str(self) -> &'static str {
match self {
HookPresence::ManagedBlock => "managed-block",
HookPresence::Manual => "manual",
HookPresence::Absent => "absent",
}
}
pub fn is_present(self) -> bool {
!matches!(self, HookPresence::Absent)
}
}
pub fn scan_hook(text: &str) -> HookPresence {
if find_block(text).is_some() {
return HookPresence::ManagedBlock;
}
if text.lines().any(is_manual_hook_line) {
return HookPresence::Manual;
}
HookPresence::Absent
}
pub fn scan_hook_file(fs: &dyn Fs, path: &Path) -> HookPresence {
if !fs.exists(path) {
return HookPresence::Absent;
}
fs.read_to_string(path)
.map(|t| scan_hook(&t))
.unwrap_or(HookPresence::Absent)
}
pub fn scan_expected_rc(
fs: &dyn Fs,
home: &Path,
env: &ShellEnv,
rc_override: Option<&Path>,
) -> Option<(HookPresence, String)> {
let path = match rc_override {
Some(p) => p.to_path_buf(),
None => resolve_rc(fs, home, Some(env.hookup_shell()?), env, None).path,
};
Some((
scan_hook_file(fs, &path),
display_home_relative(&path, home),
))
}
fn is_manual_hook_line(line: &str) -> bool {
let line = line.trim();
if line.starts_with('#') {
return false;
}
line.contains("dodot init-sh") || line.contains("dodot-init.sh")
}
pub fn bash_chain_target(fs: &dyn Fs, home: &Path) -> Option<PathBuf> {
for name in [".bash_profile", ".profile"] {
let path = home.join(name);
if !fs.exists(&path) {
continue;
}
let chains = fs
.read_to_string(&path)
.map(|t| {
t.lines()
.any(|l| !l.trim().starts_with('#') && l.contains(".bashrc"))
})
.unwrap_or(false);
if chains {
return None;
}
}
Some(home.join(".bash_profile"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempEnvironment;
fn env_zsh() -> ShellEnv {
ShellEnv {
shell: Some("/bin/zsh".into()),
zdotdir: None,
}
}
#[test]
fn shell_detection_reads_the_basename_and_refuses_the_rest() {
let cases = [
("/bin/zsh", Some(HookupShell::Zsh)),
("/usr/local/bin/bash", Some(HookupShell::Bash)),
("zsh", Some(HookupShell::Zsh)),
("-zsh", Some(HookupShell::Zsh)),
("/usr/bin/fish", None),
("/bin/sh", None),
("/opt/nu", None),
("", None),
];
for (raw, expected) in cases {
assert_eq!(HookupShell::from_shell_path(raw), expected, "raw={raw:?}");
}
}
#[test]
fn zsh_targets_zshrc_and_bash_targets_bashrc() {
let env = TempEnvironment::builder().build();
let home = env.home.clone();
let zsh = resolve_rc(
env.fs.as_ref(),
&home,
Some(HookupShell::Zsh),
&env_zsh(),
None,
);
assert_eq!(zsh.path, home.join(".zshrc"));
assert!(!zsh.exists, "a fresh home has no .zshrc — still the target");
assert!(zsh.notes.is_empty());
let bash = resolve_rc(
env.fs.as_ref(),
&home,
Some(HookupShell::Bash),
&ShellEnv {
shell: Some("/bin/bash".into()),
zdotdir: None,
},
None,
);
assert_eq!(bash.path, home.join(".bashrc"));
}
#[test]
fn zdotdir_from_the_environment_moves_the_target_and_is_announced() {
let env = TempEnvironment::builder().build();
let home = env.home.clone();
let shell_env = ShellEnv {
shell: Some("/bin/zsh".into()),
zdotdir: Some("$HOME/.config/zsh".into()),
};
let target = resolve_rc(
env.fs.as_ref(),
&home,
Some(HookupShell::Zsh),
&shell_env,
None,
);
assert_eq!(target.path, home.join(".config/zsh/.zshrc"));
assert!(
target.notes.iter().any(|n| n.contains("ZDOTDIR")),
"the move must be announced: {:?}",
target.notes
);
}
#[test]
fn zdotdir_is_peeked_out_of_zshenv_when_the_environment_is_silent() {
let env = TempEnvironment::builder().build();
let home = env.home.clone();
env.fs
.write_file(
&home.join(".zshenv"),
b"# my zshenv\nexport ZDOTDIR=\"$HOME/dotfiles/zsh\"\n",
)
.unwrap();
let target = resolve_rc(
env.fs.as_ref(),
&home,
Some(HookupShell::Zsh),
&env_zsh(),
None,
);
assert_eq!(target.path, home.join("dotfiles/zsh/.zshrc"));
assert!(
target.notes.iter().any(|n| n.contains(".zshenv")),
"notes should say where ZDOTDIR came from: {:?}",
target.notes
);
}
#[test]
fn zdotdir_parsing_handles_quotes_comments_and_overrides() {
assert_eq!(
parse_zdotdir_assignment("export ZDOTDIR=\"$HOME/a\"\n"),
Some("$HOME/a".into())
);
assert_eq!(
parse_zdotdir_assignment("ZDOTDIR='/tmp/z'\n"),
Some("/tmp/z".into())
);
assert_eq!(
parse_zdotdir_assignment("ZDOTDIR=~/zsh # trailing comment\n"),
Some("~/zsh".into())
);
assert_eq!(parse_zdotdir_assignment("# ZDOTDIR=/nope\n"), None);
assert_eq!(parse_zdotdir_assignment("export PATH=/bin\n"), None);
assert_eq!(
parse_zdotdir_assignment("ZDOTDIR=/first\nZDOTDIR=/second\n"),
Some("/second".into())
);
}
#[test]
fn a_symlinked_rc_is_written_through_and_announced() {
let env = TempEnvironment::builder().build();
let home = env.home.clone();
let repo_rc = home.join("dotfiles/zsh/zshrc");
env.fs.mkdir_all(repo_rc.parent().unwrap()).unwrap();
env.fs.write_file(&repo_rc, b"# real rc\n").unwrap();
env.fs.symlink(&repo_rc, &home.join(".zshrc")).unwrap();
let target = resolve_rc(
env.fs.as_ref(),
&home,
Some(HookupShell::Zsh),
&env_zsh(),
None,
);
assert_eq!(
target.path, repo_rc,
"the write must land in the repo, not replace the link"
);
assert_eq!(target.link_source, Some(home.join(".zshrc")));
assert!(target.exists);
let note = target.notes.join(" ");
assert!(
note.contains("~/.zshrc") && note.contains("dotfiles/zsh/zshrc"),
"write-through must be announced, not silent: {note}"
);
}
#[test]
fn explicit_rc_overrides_the_whole_ladder() {
let env = TempEnvironment::builder().build();
let home = env.home.clone();
let custom = home.join("elsewhere/rc.sh");
let target = resolve_rc(
env.fs.as_ref(),
&home,
Some(HookupShell::Zsh),
&ShellEnv {
shell: Some("/bin/zsh".into()),
zdotdir: Some("/somewhere/else".into()),
},
Some(&custom),
);
assert_eq!(target.path, custom);
}
#[test]
fn block_is_created_appended_replaced_and_then_left_alone() {
let block = render_block(&["HOOK v1"]);
let (created, outcome) = apply_block(None, &block);
assert_eq!(outcome, BlockOutcome::Created);
assert_eq!(created, block);
let (appended, outcome) = apply_block(Some("export PATH=/bin\n"), &block);
assert_eq!(outcome, BlockOutcome::Appended);
assert!(appended.starts_with("export PATH=/bin\n"));
assert!(appended.contains("HOOK v1"));
let (unchanged, outcome) = apply_block(Some(&appended), &block);
assert_eq!(outcome, BlockOutcome::Unchanged);
assert_eq!(unchanged, appended);
let v2 = render_block(&["HOOK v2"]);
let (replaced, outcome) = apply_block(Some(&appended), &v2);
assert_eq!(outcome, BlockOutcome::Replaced);
assert!(replaced.contains("HOOK v2"));
assert!(!replaced.contains("HOOK v1"));
assert_eq!(
replaced.matches(BLOCK_START).count(),
1,
"a re-run replaces, it never duplicates"
);
assert!(replaced.starts_with("export PATH=/bin\n"));
}
#[test]
fn content_around_the_block_survives_a_replacement() {
let before = "# top\n";
let after = "# bottom\n";
let text = format!("{before}{}{after}", render_block(&["OLD"]));
let (out, outcome) = apply_block(Some(&text), &render_block(&["NEW"]));
assert_eq!(outcome, BlockOutcome::Replaced);
assert!(out.starts_with(before), "{out:?}");
assert!(out.ends_with(after), "{out:?}");
}
#[test]
fn a_half_written_block_is_appended_to_not_spliced_into() {
let broken = format!("{BLOCK_START}\ntruncated\n");
assert!(find_block(&broken).is_none());
let (out, outcome) = apply_block(Some(&broken), &render_block(&["HOOK"]));
assert_eq!(outcome, BlockOutcome::Appended);
assert!(out.contains("HOOK"));
}
#[test]
fn write_block_creates_the_file_and_stays_idempotent_on_disk() {
let env = TempEnvironment::builder().build();
let rc = env.home.join(".config/zsh/.zshrc");
let block = render_block(&["HOOK"]);
assert_eq!(
write_block(env.fs.as_ref(), &rc, &block).unwrap(),
BlockOutcome::Created
);
let first = env.fs.read_to_string(&rc).unwrap();
assert_eq!(
write_block(env.fs.as_ref(), &rc, &block).unwrap(),
BlockOutcome::Unchanged
);
assert_eq!(env.fs.read_to_string(&rc).unwrap(), first);
}
#[test]
fn scan_tells_managed_manual_and_absent_apart() {
assert_eq!(
scan_hook(&render_block(&["whatever"])),
HookPresence::ManagedBlock
);
assert_eq!(
scan_hook("eval \"$(dodot init-sh)\"\n"),
HookPresence::Manual
);
assert_eq!(
scan_hook(". \"$HOME/.local/share/dodot/shell/dodot-init.sh\"\n"),
HookPresence::Manual
);
assert_eq!(scan_hook("alias ll='ls -l'\n"), HookPresence::Absent);
assert_eq!(
scan_hook("# eval \"$(dodot init-sh)\"\n"),
HookPresence::Absent
);
}
#[test]
fn scanning_a_missing_file_is_absent_not_an_error() {
let env = TempEnvironment::builder().build();
assert_eq!(
scan_hook_file(env.fs.as_ref(), &env.home.join(".zshrc")),
HookPresence::Absent
);
}
#[test]
fn bash_chain_is_needed_until_some_profile_sources_bashrc() {
let env = TempEnvironment::builder().build();
let home = env.home.clone();
assert_eq!(
bash_chain_target(env.fs.as_ref(), &home),
Some(home.join(".bash_profile"))
);
env.fs
.write_file(&home.join(".profile"), b". \"$HOME/.bashrc\"\n")
.unwrap();
assert_eq!(bash_chain_target(env.fs.as_ref(), &home), None);
}
#[test]
fn a_commented_chain_does_not_count() {
let env = TempEnvironment::builder().build();
env.fs
.write_file(&env.home.join(".bash_profile"), b"# . ~/.bashrc\n")
.unwrap();
assert!(bash_chain_target(env.fs.as_ref(), &env.home).is_some());
}
}