1use serde::Serialize;
27use std::path::PathBuf;
28
29use crate::packs::orchestration::ExecutionContext;
30use crate::{DodotError, Result};
31
32pub(crate) const ALIAS_GUARD_START: &str =
36 "# >>> dodot git alias (managed by `dodot git-install-alias`) >>>";
37
38pub(crate) const ALIAS_GUARD_END: &str = "# <<< dodot git alias <<<";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Shell {
47 Bash,
48 Zsh,
49}
50
51impl Shell {
52 pub fn detect() -> Option<Self> {
62 std::env::var("SHELL").ok().and_then(|s| {
63 if s.ends_with("/zsh") || s == "zsh" {
64 Some(Shell::Zsh)
65 } else if s.ends_with("/bash") || s == "bash" {
66 Some(Shell::Bash)
67 } else {
68 None
69 }
70 })
71 }
72
73 pub fn from_str_opt(s: &str) -> Option<Self> {
76 match s.to_ascii_lowercase().as_str() {
77 "bash" => Some(Shell::Bash),
78 "zsh" => Some(Shell::Zsh),
79 _ => None,
80 }
81 }
82
83 pub fn rc_relative_path(self) -> &'static str {
89 match self {
90 Shell::Bash => ".bashrc",
91 Shell::Zsh => ".zshrc",
92 }
93 }
94
95 pub fn alias_line(self) -> &'static str {
101 match self {
102 Shell::Bash | Shell::Zsh => "alias git='dodot refresh --quiet && command git'",
103 }
104 }
105}
106
107pub fn managed_block(shell: Shell) -> String {
111 format!(
112 "{guard_start}\n\
113 # Wraps `git` to run `dodot refresh` first, so `git status` and\n\
114 # `git diff` show deployed-side template edits between commits.\n\
115 # Only affects interactive shells. Remove this block to opt out.\n\
116 {alias}\n\
117 {guard_end}\n",
118 guard_start = ALIAS_GUARD_START,
119 guard_end = ALIAS_GUARD_END,
120 alias = shell.alias_line(),
121 )
122}
123
124#[derive(Debug, Clone, Serialize)]
130pub struct ShowAliasResult {
131 pub shell: Shell,
132 pub alias_block: String,
133 pub alias_block_lines: Vec<String>,
137 pub rc_path_display: String,
138 pub already_installed: bool,
142}
143
144pub fn show_alias(ctx: &ExecutionContext, shell: Shell) -> Result<ShowAliasResult> {
145 let rc_path = ctx.paths.home_dir().join(shell.rc_relative_path());
146 let already_installed = if ctx.fs.exists(&rc_path) {
147 ctx.fs
148 .read_to_string(&rc_path)
149 .map(|s| s.contains(ALIAS_GUARD_START))
150 .unwrap_or(false)
151 } else {
152 false
153 };
154 let alias_block = managed_block(shell);
155 let alias_block_lines: Vec<String> = alias_block
156 .lines()
157 .filter(|l| !l.is_empty())
158 .map(str::to_string)
159 .collect();
160 Ok(ShowAliasResult {
161 shell,
162 alias_block,
163 alias_block_lines,
164 rc_path_display: render_home_relative(&rc_path, ctx.paths.home_dir()),
165 already_installed,
166 })
167}
168
169#[derive(Debug, Clone, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum InstallAliasOutcome {
175 Created,
177 Appended,
180 AlreadyInstalled,
183 Updated,
186}
187
188#[derive(Debug, Clone, Serialize)]
189pub struct InstallAliasResult {
190 pub shell: Shell,
191 pub outcome: InstallAliasOutcome,
192 pub rc_path: String,
193 pub rc_path_display: String,
194 pub source_command: String,
198}
199
200pub fn install_alias(ctx: &ExecutionContext, shell: Shell) -> Result<InstallAliasResult> {
201 let rc_path = ctx.paths.home_dir().join(shell.rc_relative_path());
202 let block = managed_block(shell);
203
204 let outcome = if ctx.fs.exists(&rc_path) {
205 let existing = ctx.fs.read_to_string(&rc_path)?;
206 if let Some((start_byte, end_byte)) = find_managed_block(&existing) {
207 let current_block = &existing[start_byte..end_byte];
208 if current_block == block {
209 InstallAliasOutcome::AlreadyInstalled
210 } else {
211 let mut new_content = String::with_capacity(existing.len() + block.len());
212 new_content.push_str(&existing[..start_byte]);
213 new_content.push_str(&block);
214 new_content.push_str(&existing[end_byte..]);
215 ctx.fs.write_file(&rc_path, new_content.as_bytes())?;
216 InstallAliasOutcome::Updated
217 }
218 } else {
219 let mut new_content = existing.clone();
223 if !new_content.ends_with('\n') {
224 new_content.push('\n');
225 }
226 if !new_content.ends_with("\n\n") {
227 new_content.push('\n');
228 }
229 new_content.push_str(&block);
230 ctx.fs.write_file(&rc_path, new_content.as_bytes())?;
231 InstallAliasOutcome::Appended
232 }
233 } else {
234 ctx.fs.write_file(&rc_path, block.as_bytes())?;
238 InstallAliasOutcome::Created
239 };
240
241 Ok(InstallAliasResult {
242 shell,
243 outcome,
244 rc_path: rc_path.display().to_string(),
245 rc_path_display: render_home_relative(&rc_path, ctx.paths.home_dir()),
246 source_command: format!(
247 "source {}",
248 render_home_relative(&rc_path, ctx.paths.home_dir())
249 ),
250 })
251}
252
253fn render_home_relative(p: &std::path::Path, home: &std::path::Path) -> String {
256 if let Ok(rel) = p.strip_prefix(home) {
257 format!("~/{}", rel.display())
258 } else {
259 p.display().to_string()
260 }
261}
262
263fn find_managed_block(text: &str) -> Option<(usize, usize)> {
269 let start = text.find(ALIAS_GUARD_START)?;
270 let after_start = start + ALIAS_GUARD_START.len();
271 let end_rel = text[after_start..].find(ALIAS_GUARD_END)?;
272 let end_guard_start = after_start + end_rel;
273 let end_byte = end_guard_start + ALIAS_GUARD_END.len();
274 let end_byte = if text.as_bytes().get(end_byte) == Some(&b'\n') {
275 end_byte + 1
276 } else {
277 end_byte
278 };
279 Some((start, end_byte))
280}
281
282pub fn resolve_shell(explicit: Option<&str>) -> Result<Shell> {
292 if let Some(name) = explicit {
293 return Shell::from_str_opt(name).ok_or_else(|| {
294 DodotError::Other(format!(
295 "unsupported shell {name:?}: dodot can install the git alias for `bash` or `zsh`. \
296 For other shells, run `dodot git-show-alias --shell bash` and adapt the snippet."
297 ))
298 });
299 }
300 Shell::detect().ok_or_else(|| {
301 let detected = std::env::var("SHELL").unwrap_or_default();
302 if detected.is_empty() {
303 DodotError::Other(
304 "$SHELL is unset; pass `--shell bash` or `--shell zsh` so dodot knows which \
305 rc file to write."
306 .into(),
307 )
308 } else {
309 DodotError::Other(format!(
310 "could not detect shell from $SHELL ({detected:?}): dodot can install the git \
311 alias for `bash` or `zsh`. Pass `--shell bash` or `--shell zsh` explicitly, or \
312 run `dodot git-show-alias --shell bash` and adapt the snippet for your shell."
313 ))
314 }
315 })
316}
317
318pub fn is_installed(ctx: &ExecutionContext, shell: Shell) -> bool {
323 let rc_path = ctx.paths.home_dir().join(shell.rc_relative_path());
324 if !ctx.fs.exists(&rc_path) {
325 return false;
326 }
327 ctx.fs
328 .read_to_string(&rc_path)
329 .map(|s| s.contains(ALIAS_GUARD_START))
330 .unwrap_or(false)
331}
332
333#[allow(dead_code)]
336fn _path_buf_anchor(_: PathBuf) {}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::fs::Fs;
342 use crate::paths::Pather;
343 use crate::testing::TempEnvironment;
344
345 fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
346 use crate::config::ConfigManager;
347 use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
348 use crate::fs::Fs;
349 use crate::paths::Pather;
350 use std::sync::Arc;
351
352 struct NoopRunner;
353 impl CommandRunner for NoopRunner {
354 fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
355 Ok(CommandOutput {
356 exit_code: 0,
357 stdout: String::new(),
358 stderr: String::new(),
359 })
360 }
361 }
362 let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
363 let datastore = Arc::new(FilesystemDataStore::new(
364 env.fs.clone(),
365 env.paths.clone(),
366 runner.clone(),
367 ));
368 let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
369 ExecutionContext {
370 fs: env.fs.clone() as Arc<dyn Fs>,
371 datastore,
372 paths: env.paths.clone() as Arc<dyn Pather>,
373 config_manager,
374 syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
375 command_runner: runner,
376 dry_run: false,
377 no_provision: true,
378 provision_rerun: false,
379 force: false,
380 view_mode: crate::commands::ViewMode::Full,
381 group_mode: crate::commands::GroupMode::Name,
382 verbose: false,
383 }
384 }
385
386 #[test]
389 fn from_str_opt_recognises_known_shells() {
390 assert_eq!(Shell::from_str_opt("bash"), Some(Shell::Bash));
391 assert_eq!(Shell::from_str_opt("BASH"), Some(Shell::Bash));
392 assert_eq!(Shell::from_str_opt("zsh"), Some(Shell::Zsh));
393 assert_eq!(Shell::from_str_opt("fish"), None);
394 assert_eq!(Shell::from_str_opt("Powershell"), None);
395 }
396
397 #[test]
398 fn rc_paths_match_shell_conventions() {
399 assert_eq!(Shell::Bash.rc_relative_path(), ".bashrc");
400 assert_eq!(Shell::Zsh.rc_relative_path(), ".zshrc");
401 }
402
403 #[test]
404 fn alias_line_runs_refresh_then_command_git() {
405 for sh in [Shell::Bash, Shell::Zsh] {
408 assert_eq!(
409 sh.alias_line(),
410 "alias git='dodot refresh --quiet && command git'"
411 );
412 }
413 }
414
415 #[test]
416 fn resolve_shell_explicit_unknown_returns_error() {
417 let err = resolve_shell(Some("fish")).unwrap_err();
418 let msg = format!("{err}");
419 assert!(msg.contains("fish"), "msg: {msg}");
420 assert!(
421 msg.contains("bash"),
422 "msg should suggest supported shells: {msg}"
423 );
424 }
425
426 #[test]
427 fn resolve_shell_explicit_known_returns_match() {
428 assert_eq!(resolve_shell(Some("bash")).unwrap(), Shell::Bash);
429 assert_eq!(resolve_shell(Some("Zsh")).unwrap(), Shell::Zsh);
430 }
431
432 use crate::testing::ShellEnvGuard;
442
443 #[test]
444 fn detect_returns_some_for_bash() {
445 let _g = ShellEnvGuard::set("/bin/bash");
446 assert_eq!(Shell::detect(), Some(Shell::Bash));
447 }
448
449 #[test]
450 fn detect_returns_some_for_zsh() {
451 let _g = ShellEnvGuard::set("/usr/local/bin/zsh");
452 assert_eq!(Shell::detect(), Some(Shell::Zsh));
453 }
454
455 #[test]
456 fn detect_returns_none_for_unknown_shell() {
457 let _g = ShellEnvGuard::set("/usr/bin/fish");
459 assert_eq!(Shell::detect(), None);
460 }
461
462 #[test]
463 fn resolve_shell_no_explicit_unsupported_shell_errors() {
464 let _g = ShellEnvGuard::set("/usr/bin/fish");
469 let err = resolve_shell(None).unwrap_err();
470 let msg = format!("{err}");
471 assert!(msg.contains("fish"), "msg: {msg}");
472 assert!(msg.contains("--shell"), "msg should suggest --shell: {msg}");
473 }
474
475 #[test]
476 fn resolve_shell_no_explicit_unset_shell_errors() {
477 let _g = ShellEnvGuard::unset();
480 let err = resolve_shell(None).unwrap_err();
481 let msg = format!("{err}");
482 assert!(msg.contains("$SHELL"), "msg: {msg}");
483 assert!(msg.contains("--shell"), "msg: {msg}");
484 }
485
486 #[test]
489 fn managed_block_is_self_contained_and_grep_detectable() {
490 let block = managed_block(Shell::Bash);
491 assert!(block.starts_with(ALIAS_GUARD_START));
492 assert!(block.trim_end().ends_with(ALIAS_GUARD_END));
493 assert!(block.contains(Shell::Bash.alias_line()));
494 }
495
496 #[test]
497 fn find_managed_block_locates_byte_range() {
498 let block = managed_block(Shell::Bash);
499 let text = format!("# rc preamble\n{block}# rc postamble\n");
500 let (start, end) = find_managed_block(&text).expect("must find block");
501 assert_eq!(&text[start..end], block);
502 }
503
504 #[test]
505 fn find_managed_block_returns_none_when_absent() {
506 assert!(find_managed_block("nothing here").is_none());
507 let only_start = format!("{ALIAS_GUARD_START}\nstuff\n");
508 assert!(find_managed_block(&only_start).is_none());
509 }
510
511 #[test]
514 fn install_alias_creates_rc_file_when_absent() {
515 let env = TempEnvironment::builder().build();
519 let ctx = make_ctx(&env);
520 let rc_path = env.paths.home_dir().join(".zshrc");
521 assert!(!env.fs.exists(&rc_path));
522
523 let r = install_alias(&ctx, Shell::Zsh).unwrap();
524 assert!(matches!(r.outcome, InstallAliasOutcome::Created));
525 assert!(env.fs.exists(&rc_path));
526 let body = env.fs.read_to_string(&rc_path).unwrap();
527 assert!(body.contains(ALIAS_GUARD_START));
528 assert!(body.contains(Shell::Zsh.alias_line()));
529 }
530
531 #[test]
532 fn install_alias_appends_to_existing_rc() {
533 let env = TempEnvironment::builder().build();
534 let rc_path = env.paths.home_dir().join(".bashrc");
535 let existing = "export PATH=\"/usr/local/bin:$PATH\"\nalias ll='ls -l'\n";
536 env.fs.mkdir_all(rc_path.parent().unwrap()).unwrap();
537 env.fs.write_file(&rc_path, existing.as_bytes()).unwrap();
538
539 let ctx = make_ctx(&env);
540 let r = install_alias(&ctx, Shell::Bash).unwrap();
541 assert!(matches!(r.outcome, InstallAliasOutcome::Appended));
542
543 let body = env.fs.read_to_string(&rc_path).unwrap();
544 assert!(body.starts_with(existing), "user content lost: {body:?}");
545 assert!(body.contains(Shell::Bash.alias_line()));
546 }
547
548 #[test]
549 fn install_alias_is_idempotent_on_current_block() {
550 let env = TempEnvironment::builder().build();
551 let ctx = make_ctx(&env);
552
553 let r1 = install_alias(&ctx, Shell::Zsh).unwrap();
554 assert!(matches!(r1.outcome, InstallAliasOutcome::Created));
555
556 let body_after_first = env
557 .fs
558 .read_to_string(&env.paths.home_dir().join(".zshrc"))
559 .unwrap();
560
561 let r2 = install_alias(&ctx, Shell::Zsh).unwrap();
562 assert!(matches!(r2.outcome, InstallAliasOutcome::AlreadyInstalled));
563
564 let body_after_second = env
565 .fs
566 .read_to_string(&env.paths.home_dir().join(".zshrc"))
567 .unwrap();
568 assert_eq!(body_after_first, body_after_second);
569 }
570
571 #[test]
572 fn install_alias_updates_a_stale_block() {
573 let env = TempEnvironment::builder().build();
578 let rc_path = env.paths.home_dir().join(".zshrc");
579 let stale = format!(
580 "export PATH=\"/usr/local/bin:$PATH\"\n\
581 \n\
582 {start}\n\
583 # An old, simpler form of the alias block.\n\
584 alias git='dodot refresh && git'\n\
585 {end}\n\
586 alias ll='ls -l'\n",
587 start = ALIAS_GUARD_START,
588 end = ALIAS_GUARD_END,
589 );
590 env.fs.mkdir_all(rc_path.parent().unwrap()).unwrap();
591 env.fs.write_file(&rc_path, stale.as_bytes()).unwrap();
592
593 let ctx = make_ctx(&env);
594 let r = install_alias(&ctx, Shell::Zsh).unwrap();
595 assert!(matches!(r.outcome, InstallAliasOutcome::Updated));
596
597 let body = env.fs.read_to_string(&rc_path).unwrap();
598 assert!(body.contains(Shell::Zsh.alias_line()));
600 assert!(body.contains("export PATH"));
602 assert!(body.contains("alias ll='ls -l'"));
603 assert_eq!(body.matches(ALIAS_GUARD_START).count(), 1);
605 }
606
607 #[test]
608 fn is_installed_reflects_state() {
609 let env = TempEnvironment::builder().build();
610 let ctx = make_ctx(&env);
611 assert!(!is_installed(&ctx, Shell::Bash));
612 install_alias(&ctx, Shell::Bash).unwrap();
613 assert!(is_installed(&ctx, Shell::Bash));
614 assert!(!is_installed(&ctx, Shell::Zsh));
616 }
617
618 #[test]
621 fn show_alias_renders_block_without_writing() {
622 let env = TempEnvironment::builder().build();
623 let ctx = make_ctx(&env);
624 let rc_path = env.paths.home_dir().join(".zshrc");
625 assert!(!env.fs.exists(&rc_path));
626
627 let r = show_alias(&ctx, Shell::Zsh).unwrap();
628 assert!(r.alias_block.contains(Shell::Zsh.alias_line()));
629 assert_eq!(r.rc_path_display, "~/.zshrc");
630 assert!(!r.already_installed);
631 assert!(!env.fs.exists(&rc_path));
633 }
634
635 #[test]
636 fn show_alias_reports_already_installed_when_block_present() {
637 let env = TempEnvironment::builder().build();
638 let ctx = make_ctx(&env);
639 install_alias(&ctx, Shell::Bash).unwrap();
640 let r = show_alias(&ctx, Shell::Bash).unwrap();
641 assert!(r.already_installed);
642 }
643}