1use std::path::{Path, PathBuf};
2use std::sync::OnceLock;
3
4use crate::{dropin, marked_block};
5
6static INSTALLED_HOOK_REFRESH: OnceLock<fn()> = OnceLock::new();
11
12pub fn register_installed_hook_refresh(refresh: fn()) {
14 let _ = INSTALLED_HOOK_REFRESH.set(refresh);
15}
16
17pub fn refresh_installed_shell_hooks() {
19 if let Some(refresh) = INSTALLED_HOOK_REFRESH.get() {
20 refresh();
21 }
22}
23
24const MARKER_START: &str = "# >>> lean-ctx shell hook >>>";
25const MARKER_END: &str = "# <<< lean-ctx shell hook <<<";
26const ALIAS_START: &str = "# >>> lean-ctx agent aliases >>>";
27const ALIAS_END: &str = "# <<< lean-ctx agent aliases <<<";
28
29const DROPIN_ZSH: &str = "00-lean-ctx.zsh";
33const DROPIN_SH: &str = "00-lean-ctx.sh";
34
35const KNOWN_AGENT_ENV_VARS: &[&str] = &[
36 "LEAN_CTX_AGENT",
37 "CLAUDECODE",
38 "CODEBUDDY",
39 "CODEX_CLI_SESSION",
40 "GEMINI_SESSION",
41];
42
43const AGENT_ALIASES: &[(&str, &str)] = &[
44 ("claude", "claude"),
45 ("codebuddy", "codebuddy"),
46 ("codex", "codex"),
47 ("gemini", "gemini"),
48];
49
50fn source_command_for_shell(shell: &str) -> Option<&'static str> {
55 if shell.contains("zsh") {
56 Some("source ~/.zshrc")
57 } else if shell.contains("fish") {
58 Some("source ~/.config/fish/config.fish")
59 } else if shell.contains("bash") {
60 Some("source ~/.bashrc")
61 } else {
62 None
63 }
64}
65
66pub fn shell_source_command() -> Option<&'static str> {
71 source_command_for_shell(&std::env::var("SHELL").unwrap_or_default())
72}
73
74fn rc_file_for_shell(shell: &str) -> &'static str {
76 if shell.contains("zsh") {
77 "~/.zshrc"
78 } else if shell.contains("fish") {
79 "~/.config/fish/config.fish"
80 } else if shell.contains("bash") {
81 "~/.bashrc"
82 } else {
83 "your shell config"
84 }
85}
86
87pub fn shell_rc_file() -> &'static str {
91 rc_file_for_shell(&std::env::var("SHELL").unwrap_or_default())
92}
93
94pub fn reload_aliases_hint() -> String {
97 match shell_source_command() {
98 Some(cmd) => format!("Run '{cmd}' (or restart terminal) for updated shell aliases."),
99 None => "Restart your terminal to load updated shell aliases.".to_string(),
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum Style {
111 Inline,
113 DropIn,
116 #[default]
118 Auto,
119}
120
121#[derive(Debug, Clone, Copy)]
125struct Slot {
126 rc_file: &'static str,
127 dropin_dir: &'static str,
128 dropin_file: &'static str,
129 marker_start: &'static str,
130 marker_end: &'static str,
131}
132
133const SLOT_ZSHENV: Slot = Slot {
134 rc_file: ".zshenv",
135 dropin_dir: ".zshenv.d",
136 dropin_file: DROPIN_ZSH,
137 marker_start: MARKER_START,
138 marker_end: MARKER_END,
139};
140
141const SLOT_BASHENV: Slot = Slot {
142 rc_file: ".bashenv",
143 dropin_dir: ".bashenv.d",
144 dropin_file: DROPIN_SH,
145 marker_start: MARKER_START,
146 marker_end: MARKER_END,
147};
148
149const SLOT_ZSHRC: Slot = Slot {
150 rc_file: ".zshrc",
151 dropin_dir: ".zshrc.d",
152 dropin_file: DROPIN_ZSH,
153 marker_start: ALIAS_START,
154 marker_end: ALIAS_END,
155};
156
157const SLOT_BASHRC: Slot = Slot {
158 rc_file: ".bashrc",
159 dropin_dir: ".bashrc.d",
160 dropin_file: DROPIN_SH,
161 marker_start: ALIAS_START,
162 marker_end: ALIAS_END,
163};
164
165enum InstallTarget {
167 Marked {
168 path: PathBuf,
169 start: &'static str,
170 end: &'static str,
171 },
172 DropIn {
173 dir: PathBuf,
174 filename: &'static str,
175 },
176}
177
178impl InstallTarget {
179 fn upsert(&self, content: &str, quiet: bool, label: &str) {
180 match self {
181 Self::Marked { path, start, end } => {
182 marked_block::upsert(path, start, end, content, quiet, label);
183 }
184 Self::DropIn { dir, filename } => dropin::write(dir, filename, content, quiet, label),
185 }
186 }
187}
188
189fn pick_target(home: &Path, slot: &Slot, style: Style) -> InstallTarget {
191 let inline = InstallTarget::Marked {
192 path: home.join(slot.rc_file),
193 start: slot.marker_start,
194 end: slot.marker_end,
195 };
196 match style {
197 Style::Inline => inline,
198 Style::DropIn | Style::Auto => match dropin::detect(home, slot.rc_file, slot.dropin_dir) {
203 Some(dir) => InstallTarget::DropIn {
204 dir,
205 filename: slot.dropin_file,
206 },
207 None => inline,
208 },
209 }
210}
211
212struct BackupStamp(String);
225
226impl BackupStamp {
227 fn now() -> Self {
230 Self::at(chrono::Utc::now())
231 }
232
233 fn at(stamp: chrono::DateTime<chrono::Utc>) -> Self {
237 Self(stamp.format("%Y%m%dT%H%M%SZ").to_string())
238 }
239
240 fn backup_path_for(&self, path: &Path) -> Option<PathBuf> {
242 let file_name = path.file_name().and_then(|n| n.to_str())?;
243 Some(path.with_file_name(format!("{file_name}.lean-ctx-{}.bak", self.0)))
244 }
245}
246
247fn save_migration_backup(path: &Path, quiet: bool, stamp: &BackupStamp) {
269 if !path.exists() {
270 return;
271 }
272 let Some(bak) = stamp.backup_path_for(path) else {
273 return;
274 };
275 match std::fs::copy(path, &bak) {
276 Ok(_) => {
277 if !quiet {
278 eprintln!(" Backup: {} -> {}", path.display(), bak.display());
279 }
280 }
281 Err(e) => {
282 tracing::warn!("Failed to back up {}: {e}", path.display());
283 }
284 }
285}
286
287fn strip_other_style(
300 home: &Path,
301 slot: &Slot,
302 target: &InstallTarget,
303 quiet: bool,
304 label: &str,
305 stamp: &BackupStamp,
306) {
307 match target {
308 InstallTarget::Marked { .. } => {
309 let dropin_dir = home.join(slot.dropin_dir);
311 let dropin_path = dropin_dir.join(slot.dropin_file);
312 if dropin_path.exists() {
313 save_migration_backup(&dropin_path, quiet, stamp);
317 dropin::remove(&dropin_dir, slot.dropin_file, quiet, label);
318 }
319 }
320 InstallTarget::DropIn { .. } => {
321 let rc_path = home.join(slot.rc_file);
326 if let Ok(existing) = std::fs::read_to_string(&rc_path)
327 && existing.contains(slot.marker_start)
328 {
329 save_migration_backup(&rc_path, quiet, stamp);
330 }
331 marked_block::remove_from_file(
332 &rc_path,
333 slot.marker_start,
334 slot.marker_end,
335 quiet,
336 label,
337 );
338 }
339 }
340}
341
342pub fn install_all(quiet: bool) {
346 install_all_with_style(quiet, Style::Auto);
347}
348
349pub fn install_all_with_style(quiet: bool, style: Style) {
356 let cfg = crate::core::config::Config::load();
357 if cfg.shell_hook_disabled_effective() {
358 if !quiet {
359 eprintln!(
360 "lean-ctx: shell hook disabled (shell_hook_disabled=true or LEAN_CTX_NO_HOOK). Skipping hook installation."
361 );
362 }
363 return;
364 }
365
366 let Some(home) = dirs::home_dir() else {
367 tracing::error!("Cannot resolve home directory");
368 return;
369 };
370
371 let stamp = BackupStamp::now();
372 if shell_available("zsh") {
373 install_zshenv(&home, quiet, style, &stamp);
374 }
375 if shell_available("bash") {
376 install_bashenv(&home, quiet, style, &stamp);
377 }
378 let cfg = crate::core::config::Config::load();
379 if cfg.skip_agent_aliases {
380 remove_agent_aliases(&home, quiet);
381 } else {
382 install_aliases(&home, quiet, style, &stamp);
383 }
384}
385
386#[cfg(unix)]
394fn shell_available(shell: &str) -> bool {
395 if let Ok(forced) = std::env::var("LEAN_CTX_SHELL_HOOK_FORCE") {
396 let forced = forced.trim();
397 if forced == "1"
398 || forced.eq_ignore_ascii_case("true")
399 || forced.eq_ignore_ascii_case("all")
400 {
401 return true;
402 }
403 if forced
404 .split(',')
405 .any(|s| s.trim().eq_ignore_ascii_case(shell))
406 {
407 return true;
408 }
409 }
410
411 let candidates: &[&str] = match shell {
412 "zsh" => &[
413 "/bin/zsh",
414 "/usr/bin/zsh",
415 "/usr/local/bin/zsh",
416 "/opt/homebrew/bin/zsh",
417 ],
418 "bash" => &[
419 "/bin/bash",
420 "/usr/bin/bash",
421 "/usr/local/bin/bash",
422 "/opt/homebrew/bin/bash",
423 ],
424 _ => return false,
425 };
426 candidates.iter().any(|p| Path::new(p).exists())
427}
428
429#[cfg(not(unix))]
430fn shell_available(_shell: &str) -> bool {
431 false
433}
434
435pub fn uninstall_all(quiet: bool) {
436 let Some(home) = dirs::home_dir() else { return };
437
438 let slots: &[(Slot, &str)] = &[
441 (SLOT_ZSHENV, "shell hook for ~/.zshenv"),
442 (SLOT_BASHENV, "shell hook for ~/.bashenv"),
443 (SLOT_ZSHRC, "agent aliases for ~/.zshrc"),
444 (SLOT_BASHRC, "agent aliases for ~/.bashrc"),
445 ];
446
447 for (slot, label) in slots {
448 marked_block::remove_from_file(
449 &home.join(slot.rc_file),
450 slot.marker_start,
451 slot.marker_end,
452 quiet,
453 label,
454 );
455 let dir_path = home.join(slot.dropin_dir);
456 if dir_path.exists() {
457 dropin::remove(&dir_path, slot.dropin_file, quiet, label);
458 }
459 }
460}
461
462const REDIRECT_SKIP_MARKERS: &[&str] = &[
472 "__CURSOR_SANDBOX", "dump_zsh_state", "lean-ctx hook ", ];
476
477fn redirect_block(exec_var: &str, env_check: &str) -> String {
484 let mut lines = vec![format!(
485 "if [[ -z \"$LEAN_CTX_ACTIVE\" && -z \"$LEAN_CTX_DISABLED\" && -z \"$LEAN_CTX_NO_HOOK\" && -n \"${exec_var}\" ]] \\"
486 )];
487 for marker in REDIRECT_SKIP_MARKERS {
488 lines.push(format!(" && [[ \"${exec_var}\" != *\"{marker}\"* ]] \\"));
489 }
490 lines.push(" && command -v lean-ctx &>/dev/null; then".to_string());
491 lines.push(format!(" if {env_check}; then"));
492 lines.push(" export LEAN_CTX_ACTIVE=1".to_string());
493 lines.push(format!(" exec lean-ctx -c \"${exec_var}\""));
494 lines.push(" fi".to_string());
495 lines.push("fi".to_string());
496 lines.join("\n")
497}
498
499fn install_zshenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
500 let redirect = redirect_block("ZSH_EXECUTION_STRING", &build_env_check());
501 let hook = format!(
502 r#"{MARKER_START}
503# Passthrough stubs: ensure the alias targets (lean_ctx_track/_compress,
504# legacy _lc/_lc_compress) exist in ALL zsh contexts
505# (non-interactive subshells, eval, agent harnesses) so aliases that
506# reference them degrade gracefully instead of "command not found".
507# The full shell-hook.zsh overrides these when loaded via .zshrc.
508_lc() {{ command "$@"; }}
509_lc_compress() {{ command "$@"; }}
510lean_ctx_track() {{ command "$@"; }}
511lean_ctx_compress() {{ command "$@"; }}
512{redirect}
513{MARKER_END}"#
514 );
515
516 let label = "shell hook in ~/.zshenv";
517 let target = pick_target(home, &SLOT_ZSHENV, style);
518 strip_other_style(home, &SLOT_ZSHENV, &target, quiet, label, stamp);
519 target.upsert(&hook, quiet, label);
520}
521
522fn install_bashenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
523 let redirect = redirect_block("BASH_EXECUTION_STRING", &build_env_check());
524 let hook = format!(
525 r#"{MARKER_START}
526_lc() {{ command "$@"; }}
527_lc_compress() {{ command "$@"; }}
528lean_ctx_track() {{ command "$@"; }}
529lean_ctx_compress() {{ command "$@"; }}
530{redirect}
531{MARKER_END}"#
532 );
533
534 let label = "shell hook in ~/.bashenv";
535 let target = pick_target(home, &SLOT_BASHENV, style);
536 strip_other_style(home, &SLOT_BASHENV, &target, quiet, label, stamp);
537 target.upsert(&hook, quiet, label);
538}
539
540fn install_aliases(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
541 let mut lines = Vec::new();
542 lines.push(ALIAS_START.to_string());
543 for (alias_name, bin_name) in AGENT_ALIASES {
544 lines.push(format!(
545 "alias {alias_name}='LEAN_CTX_AGENT=1 BASH_ENV=\"$HOME/.bashenv\" {bin_name}'"
546 ));
547 }
548 lines.push(ALIAS_END.to_string());
549 let block = lines.join("\n");
550
551 for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
552 if !home.join(slot.rc_file).exists() {
555 continue;
556 }
557 let label = format!("agent aliases in ~/{}", slot.rc_file);
558 let target = pick_target(home, slot, style);
559 strip_other_style(home, slot, &target, quiet, &label, stamp);
560 target.upsert(&block, quiet, &label);
561 }
562}
563
564fn remove_agent_aliases(home: &Path, quiet: bool) {
567 for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
568 let rc = home.join(slot.rc_file);
569 if !rc.exists() {
570 continue;
571 }
572 if let Ok(content) = std::fs::read_to_string(&rc)
573 && content.contains(ALIAS_START)
574 {
575 let filtered: Vec<&str> = content
576 .lines()
577 .scan(false, |inside, line| {
578 if line.trim() == ALIAS_START {
579 *inside = true;
580 return Some(None);
581 }
582 if *inside && line.trim() == ALIAS_END {
583 *inside = false;
584 return Some(None);
585 }
586 if *inside {
587 Some(None)
588 } else {
589 Some(Some(line))
590 }
591 })
592 .flatten()
593 .collect();
594 let _ = std::fs::write(&rc, filtered.join("\n") + "\n");
595 if !quiet {
596 println!(
597 " \x1b[33m⊖\x1b[0m Removed agent aliases from ~/{}",
598 slot.rc_file
599 );
600 }
601 }
602 let dropin = home.join(slot.dropin_dir).join(slot.dropin_file);
604 if dropin.exists() {
605 let _ = std::fs::remove_file(&dropin);
606 if !quiet {
607 println!(
608 " \x1b[33m⊖\x1b[0m Removed drop-in ~/{}/{}",
609 slot.dropin_dir, slot.dropin_file
610 );
611 }
612 }
613 }
614}
615
616fn build_env_check() -> String {
617 let checks: Vec<String> = KNOWN_AGENT_ENV_VARS
618 .iter()
619 .map(|v| format!("-n \"${v}\""))
620 .collect();
621 format!("[[ {} ]]", checks.join(" || "))
622}
623
624#[cfg(test)]
625pub mod test_helpers {
626 use super::*;
627 pub fn redirect_block_for_test(exec_var: &str, env_check: &str) -> String {
628 redirect_block(exec_var, env_check)
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 fn test_stamp() -> BackupStamp {
640 BackupStamp::at(
641 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
642 .unwrap()
643 .with_timezone(&chrono::Utc),
644 )
645 }
646
647 #[test]
648 fn env_check_format() {
649 let check = build_env_check();
650 assert!(check.contains("LEAN_CTX_AGENT"));
651 assert!(check.contains("CLAUDECODE"));
652 assert!(check.contains("CODEBUDDY"));
653 assert!(check.contains("||"));
654 }
655
656 #[test]
657 fn source_command_matches_login_shell() {
658 assert_eq!(
660 source_command_for_shell("/usr/bin/bash"),
661 Some("source ~/.bashrc")
662 );
663 assert_eq!(
664 source_command_for_shell("/bin/zsh"),
665 Some("source ~/.zshrc")
666 );
667 assert_eq!(
668 source_command_for_shell("/usr/local/bin/fish"),
669 Some("source ~/.config/fish/config.fish")
670 );
671 assert_eq!(source_command_for_shell(""), None);
673 assert_eq!(source_command_for_shell("/bin/false"), None);
674 }
675
676 #[test]
677 fn rc_file_matches_login_shell() {
678 assert_eq!(rc_file_for_shell("/usr/bin/bash"), "~/.bashrc");
680 assert_eq!(rc_file_for_shell("/bin/zsh"), "~/.zshrc");
681 assert_eq!(
682 rc_file_for_shell("/usr/local/bin/fish"),
683 "~/.config/fish/config.fish"
684 );
685 assert_eq!(rc_file_for_shell(""), "your shell config");
686 assert_eq!(rc_file_for_shell("/bin/false"), "your shell config");
687 }
688
689 #[test]
690 fn pick_target_inline_when_forced() {
691 let tmp = tempfile::tempdir().unwrap();
692 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
694 std::fs::write(
695 tmp.path().join(".zshenv"),
696 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
697 )
698 .unwrap();
699 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Inline);
700 assert!(matches!(t, InstallTarget::Marked { .. }));
701 }
702
703 #[test]
704 fn pick_target_dropin_when_detected_under_auto() {
705 let tmp = tempfile::tempdir().unwrap();
706 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
707 std::fs::write(
708 tmp.path().join(".zshenv"),
709 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
710 )
711 .unwrap();
712 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
713 assert!(matches!(t, InstallTarget::DropIn { .. }));
714 }
715
716 #[test]
717 fn pick_target_inline_under_auto_when_no_dropin() {
718 let tmp = tempfile::tempdir().unwrap();
719 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
720 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
721 assert!(matches!(t, InstallTarget::Marked { .. }));
722 }
723
724 #[test]
725 fn pick_target_dropin_falls_back_to_inline_when_no_directory() {
726 let tmp = tempfile::tempdir().unwrap();
729 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
730 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::DropIn);
731 assert!(matches!(t, InstallTarget::Marked { .. }));
732 }
733
734 #[test]
735 fn install_zshenv_writes_inline_block() {
736 let tmp = tempfile::tempdir().unwrap();
737 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
738 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
739 assert!(body.contains(MARKER_START));
740 assert!(body.contains(MARKER_END));
741 assert!(body.contains("ZSH_EXECUTION_STRING"));
742 }
743
744 #[test]
745 fn install_zshenv_writes_dropin_when_loop_present() {
746 let tmp = tempfile::tempdir().unwrap();
747 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
748 std::fs::write(
749 tmp.path().join(".zshenv"),
750 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
751 )
752 .unwrap();
753 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
754
755 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
756 assert!(dropin_file.exists(), "expected drop-in file");
757 let dropin_body = std::fs::read_to_string(&dropin_file).unwrap();
758 assert!(dropin_body.contains("ZSH_EXECUTION_STRING"));
759
760 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
761 assert!(
762 !zshenv_body.contains(MARKER_START),
763 "drop-in install must not also leave the inline block"
764 );
765 }
766
767 fn find_migration_backups(path: &Path) -> Vec<PathBuf> {
770 let Some(parent) = path.parent() else {
771 return Vec::new();
772 };
773 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
774 return Vec::new();
775 };
776 let prefix = format!("{name}.lean-ctx-");
777 let mut out: Vec<PathBuf> = std::fs::read_dir(parent)
778 .into_iter()
779 .flatten()
780 .flatten()
781 .map(|e| e.path())
782 .filter(|p| {
783 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
784 n.starts_with(&prefix)
785 && std::path::Path::new(n)
786 .extension()
787 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
788 })
789 })
790 .collect();
791 out.sort();
792 out
793 }
794
795 #[test]
796 fn migration_inline_to_dropin_preserves_hand_edits_via_backup() {
797 let tmp = tempfile::tempdir().unwrap();
798 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
799 let edited_zshenv = format!(
802 "export PATH=/usr/bin\n\
803 \n\
804 {MARKER_START}\n\
805 # USER CUSTOM: bump zsh history size for this workstation\n\
806 export HISTSIZE=99999\n\
807 # original lean-ctx hook content lived here\n\
808 {MARKER_END}\n\
809 \n\
810 for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
811 );
812 std::fs::write(tmp.path().join(".zshenv"), &edited_zshenv).unwrap();
813
814 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
815
816 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
818 assert_eq!(baks.len(), 1, "expected one timestamped backup");
819 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
820 assert_eq!(bak_body, edited_zshenv);
821 assert!(bak_body.contains("USER CUSTOM"));
822 assert!(bak_body.contains("HISTSIZE=99999"));
823 }
824
825 #[test]
826 fn migration_dropin_to_inline_preserves_hand_edits_via_backup() {
827 let tmp = tempfile::tempdir().unwrap();
828 let dropin_dir = tmp.path().join(".zshenv.d");
829 std::fs::create_dir_all(&dropin_dir).unwrap();
830 let edited_dropin = "# USER CUSTOM addition to lean-ctx drop-in\nexport FAVOURITE_EDITOR=helix\n# canonical lean-ctx content would follow\n";
832 std::fs::write(dropin_dir.join(DROPIN_ZSH), edited_dropin).unwrap();
833 std::fs::write(tmp.path().join(".zshenv"), "# plain zshenv\n").unwrap();
836
837 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
838
839 let baks = find_migration_backups(&dropin_dir.join(DROPIN_ZSH));
840 assert_eq!(baks.len(), 1, "expected one timestamped backup");
841 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
842 assert_eq!(bak_body, edited_dropin);
843 assert!(bak_body.contains("USER CUSTOM"));
844 assert!(!dropin_dir.join(DROPIN_ZSH).exists());
846 let zshenv = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
847 assert!(zshenv.contains(MARKER_START));
848 }
849
850 #[test]
851 fn migration_skips_backup_when_no_prior_block_exists() {
852 let tmp = tempfile::tempdir().unwrap();
855 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
856 std::fs::write(
857 tmp.path().join(".zshenv"),
858 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
859 )
860 .unwrap();
861
862 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
863
864 assert!(
865 find_migration_backups(&tmp.path().join(".zshenv")).is_empty(),
866 "clean install should not create a .bak file"
867 );
868 }
869
870 #[test]
871 fn idempotent_dropin_reinstall_does_not_create_backup() {
872 let tmp = tempfile::tempdir().unwrap();
877 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
878 std::fs::write(
879 tmp.path().join(".zshenv"),
880 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
881 )
882 .unwrap();
883
884 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
885 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
886
887 {
888 let found = &find_migration_backups(&tmp.path().join(".zshenv"));
889 assert!(found.is_empty(), "{found:?}");
890 }
891 }
892
893 #[test]
894 fn backup_filename_handles_dotfile_correctly() {
895 let tmp = tempfile::tempdir().unwrap();
899 std::fs::write(tmp.path().join(".zshenv"), "content\n").unwrap();
900 save_migration_backup(&tmp.path().join(".zshenv"), true, &test_stamp());
901 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
902 assert_eq!(baks.len(), 1);
903 let name = baks[0].file_name().unwrap().to_str().unwrap();
906 assert!(name.starts_with(".zshenv.lean-ctx-"), "got: {name}");
907 assert!(
908 std::path::Path::new(name)
909 .extension()
910 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
911 );
912 let stamp = name
914 .trim_start_matches(".zshenv.lean-ctx-")
915 .trim_end_matches(".bak");
916 assert_eq!(stamp.len(), 16, "stamp should be YYYYMMDDTHHMMSSZ: {stamp}");
917 assert!(stamp.contains('T'));
918 assert!(stamp.ends_with('Z'));
919 }
920
921 #[test]
922 fn repeated_migrations_never_clobber_prior_backups() {
923 let stamp_first = BackupStamp::at(
928 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
929 .unwrap()
930 .with_timezone(&chrono::Utc),
931 );
932 let stamp_later = BackupStamp::at(
933 chrono::DateTime::parse_from_rfc3339("2026-05-12T09:00:00Z")
934 .unwrap()
935 .with_timezone(&chrono::Utc),
936 );
937 let tmp = tempfile::tempdir().unwrap();
938 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
939
940 let with_block_v1 = format!(
941 "{MARKER_START}\n# first-era custom content\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
942 );
943 std::fs::write(tmp.path().join(".zshenv"), &with_block_v1).unwrap();
944 install_zshenv(tmp.path(), true, Style::Auto, &stamp_first);
945 let baks_after_first = find_migration_backups(&tmp.path().join(".zshenv"));
946 assert_eq!(baks_after_first.len(), 1);
947
948 let with_block_v2 = format!(
951 "{}{MARKER_START}\n# second-era custom content\n{MARKER_END}\n",
952 std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap(),
953 );
954 std::fs::write(tmp.path().join(".zshenv"), &with_block_v2).unwrap();
955 install_zshenv(tmp.path(), true, Style::Auto, &stamp_later);
956 let baks_after_second = find_migration_backups(&tmp.path().join(".zshenv"));
957
958 assert_eq!(
959 baks_after_second.len(),
960 2,
961 "second migration should leave a second backup, not overwrite"
962 );
963 assert_eq!(baks_after_second[0], baks_after_first[0]);
965 let first_body = std::fs::read_to_string(&baks_after_second[0]).unwrap();
966 let second_body = std::fs::read_to_string(&baks_after_second[1]).unwrap();
967 assert!(first_body.contains("first-era custom"));
968 assert!(second_body.contains("second-era custom"));
969 }
970
971 #[test]
972 fn install_migrates_inline_to_dropin() {
973 let tmp = tempfile::tempdir().unwrap();
974 std::fs::write(
976 tmp.path().join(".zshenv"),
977 format!(
978 "export PATH=/usr/bin\n\n{MARKER_START}\n# old hook\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
979 ),
980 )
981 .unwrap();
982 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
983
984 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
985
986 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
987 assert!(
988 !zshenv_body.contains(MARKER_START),
989 "old inline block should be stripped after migration"
990 );
991 assert!(
992 zshenv_body.contains(".zshenv.d"),
993 "source loop must be preserved"
994 );
995 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
996 assert!(dropin_file.exists(), "new drop-in file should be present");
997 }
998
999 #[test]
1000 fn install_migrates_dropin_to_inline() {
1001 let tmp = tempfile::tempdir().unwrap();
1002 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1005 std::fs::write(
1006 tmp.path().join(".zshenv.d").join(DROPIN_ZSH),
1007 "# stale lean-ctx drop-in\n",
1008 )
1009 .unwrap();
1010 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
1011
1012 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1013
1014 assert!(
1015 !tmp.path().join(".zshenv.d").join(DROPIN_ZSH).exists(),
1016 "drop-in file should be removed when installing inline"
1017 );
1018 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1019 assert!(body.contains(MARKER_START));
1020 }
1021
1022 #[test]
1023 fn install_is_idempotent_in_dropin_mode() {
1024 let tmp = tempfile::tempdir().unwrap();
1025 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1026 std::fs::write(
1027 tmp.path().join(".zshenv"),
1028 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1029 )
1030 .unwrap();
1031
1032 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1033 let after_first = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
1034
1035 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1036 let after_second = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
1037
1038 assert_eq!(after_first, after_second);
1039 }
1040
1041 #[test]
1042 fn install_is_idempotent_in_inline_mode() {
1043 let tmp = tempfile::tempdir().unwrap();
1044 std::fs::write(tmp.path().join(".zshenv"), "# top\n").unwrap();
1045
1046 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1047 let after_first = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1048
1049 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1050 let after_second = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1051
1052 assert_eq!(after_first, after_second);
1053 }
1054
1055 #[test]
1056 fn install_aliases_skips_when_rc_missing() {
1057 let tmp = tempfile::tempdir().unwrap();
1058 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1060 assert!(!tmp.path().join(".zshrc").exists());
1061 assert!(!tmp.path().join(".bashrc").exists());
1062 }
1063
1064 #[test]
1065 fn install_aliases_writes_dropin_when_zshrc_d_configured() {
1066 let tmp = tempfile::tempdir().unwrap();
1067 std::fs::create_dir_all(tmp.path().join(".zshrc.d")).unwrap();
1068 std::fs::write(
1069 tmp.path().join(".zshrc"),
1070 "for f in $HOME/.zshrc.d/*.zsh; do source $f; done\n",
1071 )
1072 .unwrap();
1073
1074 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1075
1076 let dropin_file = tmp.path().join(".zshrc.d").join(DROPIN_ZSH);
1077 assert!(dropin_file.exists());
1078 let body = std::fs::read_to_string(&dropin_file).unwrap();
1079 assert!(body.contains("LEAN_CTX_AGENT=1"));
1080 }
1081
1082 #[test]
1085 fn zshenv_hook_contains_lc_passthrough_stubs() {
1086 let tmp = tempfile::tempdir().unwrap();
1087 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1088 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1089 assert!(
1090 body.contains(r#"_lc() { command "$@"; }"#),
1091 "zshenv must contain _lc passthrough stub"
1092 );
1093 assert!(
1094 body.contains(r#"_lc_compress() { command "$@"; }"#),
1095 "zshenv must contain _lc_compress passthrough stub"
1096 );
1097 }
1098
1099 #[test]
1100 fn bashenv_hook_contains_lc_passthrough_stubs() {
1101 let tmp = tempfile::tempdir().unwrap();
1102 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1103 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1104 assert!(
1105 body.contains(r#"_lc() { command "$@"; }"#),
1106 "bashenv must contain _lc passthrough stub"
1107 );
1108 assert!(
1109 body.contains(r#"_lc_compress() { command "$@"; }"#),
1110 "bashenv must contain _lc_compress passthrough stub"
1111 );
1112 }
1113
1114 #[test]
1115 fn stubs_appear_before_exec_guard() {
1116 let tmp = tempfile::tempdir().unwrap();
1117 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1118 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1119 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1120 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1121 assert!(
1122 stub_pos < exec_pos,
1123 "stubs must be defined BEFORE the exec guard"
1124 );
1125 }
1126
1127 #[test]
1128 fn bash_stubs_appear_before_exec_guard() {
1129 let tmp = tempfile::tempdir().unwrap();
1135 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1136 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1137 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1138 let compress_pos = body
1139 .find("_lc_compress()")
1140 .expect("_lc_compress stub must exist");
1141 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1142 assert!(
1143 stub_pos < exec_pos && compress_pos < exec_pos,
1144 "bash stubs must be defined BEFORE the exec guard"
1145 );
1146 }
1147
1148 #[test]
1156 fn redirect_block_guards_every_skip_marker() {
1157 let block = redirect_block("ZSH_EXECUTION_STRING", "[[ -n \"$LEAN_CTX_AGENT\" ]]");
1158 let exec_pos = block
1159 .find("exec lean-ctx")
1160 .expect("redirect must exec lean-ctx");
1161 for marker in REDIRECT_SKIP_MARKERS {
1162 let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1163 let guard_pos = block
1164 .find(&guard)
1165 .unwrap_or_else(|| panic!("redirect must guard against {marker:?}:\n{block}"));
1166 assert!(
1167 guard_pos < exec_pos,
1168 "guard for {marker:?} must precede the exec redirect"
1169 );
1170 }
1171 }
1172
1173 #[test]
1174 fn zshenv_redirect_skips_ide_sandbox_and_hooks() {
1175 let tmp = tempfile::tempdir().unwrap();
1176 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1177 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1178 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1179 for marker in REDIRECT_SKIP_MARKERS {
1180 let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1181 let pos = body
1182 .find(&guard)
1183 .unwrap_or_else(|| panic!(".zshenv must guard against {marker:?}"));
1184 assert!(
1185 pos < exec_pos,
1186 "zshenv guard {marker:?} must precede the exec redirect"
1187 );
1188 }
1189 }
1190
1191 #[test]
1192 fn bashenv_redirect_skips_ide_sandbox_and_hooks() {
1193 let tmp = tempfile::tempdir().unwrap();
1194 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1195 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1196 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1197 for marker in REDIRECT_SKIP_MARKERS {
1198 let guard = format!("[[ \"$BASH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1199 let pos = body
1200 .find(&guard)
1201 .unwrap_or_else(|| panic!(".bashenv must guard against {marker:?}"));
1202 assert!(
1203 pos < exec_pos,
1204 "bashenv guard {marker:?} must precede the exec redirect"
1205 );
1206 }
1207 }
1208
1209 #[test]
1210 fn dropin_zshenv_also_contains_stubs() {
1211 let tmp = tempfile::tempdir().unwrap();
1212 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1213 std::fs::write(
1214 tmp.path().join(".zshenv"),
1215 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1216 )
1217 .unwrap();
1218 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1219
1220 let dropin = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
1221 let body = std::fs::read_to_string(&dropin).unwrap();
1222 assert!(body.contains("_lc()"), "drop-in must also contain stubs");
1223 }
1224
1225 #[cfg(unix)]
1230 static SHELL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1231
1232 #[cfg(unix)]
1233 #[test]
1234 fn shell_available_rejects_unknown_shell() {
1235 let _env_lock = crate::core::data_dir::test_env_lock();
1236 let _g = SHELL_ENV_LOCK
1237 .lock()
1238 .unwrap_or_else(std::sync::PoisonError::into_inner);
1239 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1240 assert!(!shell_available("fish"));
1241 assert!(!shell_available("nushell"));
1242 assert!(!shell_available(""));
1243 }
1244
1245 #[cfg(unix)]
1246 #[test]
1247 fn shell_available_finds_installed_shells() {
1248 let _env_lock = crate::core::data_dir::test_env_lock();
1249 let _g = SHELL_ENV_LOCK
1250 .lock()
1251 .unwrap_or_else(std::sync::PoisonError::into_inner);
1252 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1253 let has_bash = Path::new("/bin/bash").exists() || Path::new("/usr/bin/bash").exists();
1255 let has_zsh = Path::new("/bin/zsh").exists() || Path::new("/usr/bin/zsh").exists();
1256 assert!(
1257 shell_available("bash") == has_bash,
1258 "shell_available(bash) should match filesystem"
1259 );
1260 assert!(
1261 shell_available("zsh") == has_zsh,
1262 "shell_available(zsh) should match filesystem"
1263 );
1264 }
1265
1266 #[cfg(unix)]
1267 #[test]
1268 fn shell_hook_force_overrides_detection() {
1269 let _env_lock = crate::core::data_dir::test_env_lock();
1270 let _g = SHELL_ENV_LOCK
1271 .lock()
1272 .unwrap_or_else(std::sync::PoisonError::into_inner);
1273
1274 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "all");
1276 assert!(shell_available("zsh"));
1277 assert!(shell_available("bash"));
1278
1279 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "zsh");
1281 assert!(shell_available("zsh"));
1282 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1286 }
1287}