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(any(test, feature = "test-support"))]
625#[doc(hidden)]
626pub mod test_helpers {
627 use super::redirect_block;
628 pub fn redirect_block_for_test(exec_var: &str, env_check: &str) -> String {
629 redirect_block(exec_var, env_check)
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 fn test_stamp() -> BackupStamp {
641 BackupStamp::at(
642 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
643 .unwrap()
644 .with_timezone(&chrono::Utc),
645 )
646 }
647
648 #[test]
649 fn env_check_format() {
650 let check = build_env_check();
651 assert!(check.contains("LEAN_CTX_AGENT"));
652 assert!(check.contains("CLAUDECODE"));
653 assert!(check.contains("CODEBUDDY"));
654 assert!(check.contains("||"));
655 }
656
657 #[test]
658 fn source_command_matches_login_shell() {
659 assert_eq!(
661 source_command_for_shell("/usr/bin/bash"),
662 Some("source ~/.bashrc")
663 );
664 assert_eq!(
665 source_command_for_shell("/bin/zsh"),
666 Some("source ~/.zshrc")
667 );
668 assert_eq!(
669 source_command_for_shell("/usr/local/bin/fish"),
670 Some("source ~/.config/fish/config.fish")
671 );
672 assert_eq!(source_command_for_shell(""), None);
674 assert_eq!(source_command_for_shell("/bin/false"), None);
675 }
676
677 #[test]
678 fn rc_file_matches_login_shell() {
679 assert_eq!(rc_file_for_shell("/usr/bin/bash"), "~/.bashrc");
681 assert_eq!(rc_file_for_shell("/bin/zsh"), "~/.zshrc");
682 assert_eq!(
683 rc_file_for_shell("/usr/local/bin/fish"),
684 "~/.config/fish/config.fish"
685 );
686 assert_eq!(rc_file_for_shell(""), "your shell config");
687 assert_eq!(rc_file_for_shell("/bin/false"), "your shell config");
688 }
689
690 #[test]
691 fn pick_target_inline_when_forced() {
692 let tmp = tempfile::tempdir().unwrap();
693 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
695 std::fs::write(
696 tmp.path().join(".zshenv"),
697 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
698 )
699 .unwrap();
700 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Inline);
701 assert!(matches!(t, InstallTarget::Marked { .. }));
702 }
703
704 #[test]
705 fn pick_target_dropin_when_detected_under_auto() {
706 let tmp = tempfile::tempdir().unwrap();
707 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
708 std::fs::write(
709 tmp.path().join(".zshenv"),
710 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
711 )
712 .unwrap();
713 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
714 assert!(matches!(t, InstallTarget::DropIn { .. }));
715 }
716
717 #[test]
718 fn pick_target_inline_under_auto_when_no_dropin() {
719 let tmp = tempfile::tempdir().unwrap();
720 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
721 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
722 assert!(matches!(t, InstallTarget::Marked { .. }));
723 }
724
725 #[test]
726 fn pick_target_dropin_falls_back_to_inline_when_no_directory() {
727 let tmp = tempfile::tempdir().unwrap();
730 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
731 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::DropIn);
732 assert!(matches!(t, InstallTarget::Marked { .. }));
733 }
734
735 #[test]
736 fn install_zshenv_writes_inline_block() {
737 let tmp = tempfile::tempdir().unwrap();
738 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
739 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
740 assert!(body.contains(MARKER_START));
741 assert!(body.contains(MARKER_END));
742 assert!(body.contains("ZSH_EXECUTION_STRING"));
743 }
744
745 #[test]
746 fn install_zshenv_writes_dropin_when_loop_present() {
747 let tmp = tempfile::tempdir().unwrap();
748 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
749 std::fs::write(
750 tmp.path().join(".zshenv"),
751 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
752 )
753 .unwrap();
754 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
755
756 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
757 assert!(dropin_file.exists(), "expected drop-in file");
758 let dropin_body = std::fs::read_to_string(&dropin_file).unwrap();
759 assert!(dropin_body.contains("ZSH_EXECUTION_STRING"));
760
761 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
762 assert!(
763 !zshenv_body.contains(MARKER_START),
764 "drop-in install must not also leave the inline block"
765 );
766 }
767
768 fn find_migration_backups(path: &Path) -> Vec<PathBuf> {
771 let Some(parent) = path.parent() else {
772 return Vec::new();
773 };
774 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
775 return Vec::new();
776 };
777 let prefix = format!("{name}.lean-ctx-");
778 let mut out: Vec<PathBuf> = std::fs::read_dir(parent)
779 .into_iter()
780 .flatten()
781 .flatten()
782 .map(|e| e.path())
783 .filter(|p| {
784 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
785 n.starts_with(&prefix)
786 && std::path::Path::new(n)
787 .extension()
788 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
789 })
790 })
791 .collect();
792 out.sort();
793 out
794 }
795
796 #[test]
797 fn migration_inline_to_dropin_preserves_hand_edits_via_backup() {
798 let tmp = tempfile::tempdir().unwrap();
799 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
800 let edited_zshenv = format!(
803 "export PATH=/usr/bin\n\
804 \n\
805 {MARKER_START}\n\
806 # USER CUSTOM: bump zsh history size for this workstation\n\
807 export HISTSIZE=99999\n\
808 # original lean-ctx hook content lived here\n\
809 {MARKER_END}\n\
810 \n\
811 for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
812 );
813 std::fs::write(tmp.path().join(".zshenv"), &edited_zshenv).unwrap();
814
815 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
816
817 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
819 assert_eq!(baks.len(), 1, "expected one timestamped backup");
820 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
821 assert_eq!(bak_body, edited_zshenv);
822 assert!(bak_body.contains("USER CUSTOM"));
823 assert!(bak_body.contains("HISTSIZE=99999"));
824 }
825
826 #[test]
827 fn migration_dropin_to_inline_preserves_hand_edits_via_backup() {
828 let tmp = tempfile::tempdir().unwrap();
829 let dropin_dir = tmp.path().join(".zshenv.d");
830 std::fs::create_dir_all(&dropin_dir).unwrap();
831 let edited_dropin = "# USER CUSTOM addition to lean-ctx drop-in\nexport FAVOURITE_EDITOR=helix\n# canonical lean-ctx content would follow\n";
833 std::fs::write(dropin_dir.join(DROPIN_ZSH), edited_dropin).unwrap();
834 std::fs::write(tmp.path().join(".zshenv"), "# plain zshenv\n").unwrap();
837
838 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
839
840 let baks = find_migration_backups(&dropin_dir.join(DROPIN_ZSH));
841 assert_eq!(baks.len(), 1, "expected one timestamped backup");
842 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
843 assert_eq!(bak_body, edited_dropin);
844 assert!(bak_body.contains("USER CUSTOM"));
845 assert!(!dropin_dir.join(DROPIN_ZSH).exists());
847 let zshenv = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
848 assert!(zshenv.contains(MARKER_START));
849 }
850
851 #[test]
852 fn migration_skips_backup_when_no_prior_block_exists() {
853 let tmp = tempfile::tempdir().unwrap();
856 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
857 std::fs::write(
858 tmp.path().join(".zshenv"),
859 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
860 )
861 .unwrap();
862
863 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
864
865 assert!(
866 find_migration_backups(&tmp.path().join(".zshenv")).is_empty(),
867 "clean install should not create a .bak file"
868 );
869 }
870
871 #[test]
872 fn idempotent_dropin_reinstall_does_not_create_backup() {
873 let tmp = tempfile::tempdir().unwrap();
878 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
879 std::fs::write(
880 tmp.path().join(".zshenv"),
881 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
882 )
883 .unwrap();
884
885 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
886 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
887
888 {
889 let found = &find_migration_backups(&tmp.path().join(".zshenv"));
890 assert!(found.is_empty(), "{found:?}");
891 }
892 }
893
894 #[test]
895 fn backup_filename_handles_dotfile_correctly() {
896 let tmp = tempfile::tempdir().unwrap();
900 std::fs::write(tmp.path().join(".zshenv"), "content\n").unwrap();
901 save_migration_backup(&tmp.path().join(".zshenv"), true, &test_stamp());
902 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
903 assert_eq!(baks.len(), 1);
904 let name = baks[0].file_name().unwrap().to_str().unwrap();
907 assert!(name.starts_with(".zshenv.lean-ctx-"), "got: {name}");
908 assert!(
909 std::path::Path::new(name)
910 .extension()
911 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
912 );
913 let stamp = name
915 .trim_start_matches(".zshenv.lean-ctx-")
916 .trim_end_matches(".bak");
917 assert_eq!(stamp.len(), 16, "stamp should be YYYYMMDDTHHMMSSZ: {stamp}");
918 assert!(stamp.contains('T'));
919 assert!(stamp.ends_with('Z'));
920 }
921
922 #[test]
923 fn repeated_migrations_never_clobber_prior_backups() {
924 let stamp_first = BackupStamp::at(
929 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
930 .unwrap()
931 .with_timezone(&chrono::Utc),
932 );
933 let stamp_later = BackupStamp::at(
934 chrono::DateTime::parse_from_rfc3339("2026-05-12T09:00:00Z")
935 .unwrap()
936 .with_timezone(&chrono::Utc),
937 );
938 let tmp = tempfile::tempdir().unwrap();
939 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
940
941 let with_block_v1 = format!(
942 "{MARKER_START}\n# first-era custom content\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
943 );
944 std::fs::write(tmp.path().join(".zshenv"), &with_block_v1).unwrap();
945 install_zshenv(tmp.path(), true, Style::Auto, &stamp_first);
946 let baks_after_first = find_migration_backups(&tmp.path().join(".zshenv"));
947 assert_eq!(baks_after_first.len(), 1);
948
949 let with_block_v2 = format!(
952 "{}{MARKER_START}\n# second-era custom content\n{MARKER_END}\n",
953 std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap(),
954 );
955 std::fs::write(tmp.path().join(".zshenv"), &with_block_v2).unwrap();
956 install_zshenv(tmp.path(), true, Style::Auto, &stamp_later);
957 let baks_after_second = find_migration_backups(&tmp.path().join(".zshenv"));
958
959 assert_eq!(
960 baks_after_second.len(),
961 2,
962 "second migration should leave a second backup, not overwrite"
963 );
964 assert_eq!(baks_after_second[0], baks_after_first[0]);
966 let first_body = std::fs::read_to_string(&baks_after_second[0]).unwrap();
967 let second_body = std::fs::read_to_string(&baks_after_second[1]).unwrap();
968 assert!(first_body.contains("first-era custom"));
969 assert!(second_body.contains("second-era custom"));
970 }
971
972 #[test]
973 fn install_migrates_inline_to_dropin() {
974 let tmp = tempfile::tempdir().unwrap();
975 std::fs::write(
977 tmp.path().join(".zshenv"),
978 format!(
979 "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",
980 ),
981 )
982 .unwrap();
983 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
984
985 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
986
987 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
988 assert!(
989 !zshenv_body.contains(MARKER_START),
990 "old inline block should be stripped after migration"
991 );
992 assert!(
993 zshenv_body.contains(".zshenv.d"),
994 "source loop must be preserved"
995 );
996 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
997 assert!(dropin_file.exists(), "new drop-in file should be present");
998 }
999
1000 #[test]
1001 fn install_migrates_dropin_to_inline() {
1002 let tmp = tempfile::tempdir().unwrap();
1003 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1006 std::fs::write(
1007 tmp.path().join(".zshenv.d").join(DROPIN_ZSH),
1008 "# stale lean-ctx drop-in\n",
1009 )
1010 .unwrap();
1011 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
1012
1013 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1014
1015 assert!(
1016 !tmp.path().join(".zshenv.d").join(DROPIN_ZSH).exists(),
1017 "drop-in file should be removed when installing inline"
1018 );
1019 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1020 assert!(body.contains(MARKER_START));
1021 }
1022
1023 #[test]
1024 fn install_is_idempotent_in_dropin_mode() {
1025 let tmp = tempfile::tempdir().unwrap();
1026 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1027 std::fs::write(
1028 tmp.path().join(".zshenv"),
1029 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1030 )
1031 .unwrap();
1032
1033 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1034 let after_first = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
1035
1036 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1037 let after_second = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
1038
1039 assert_eq!(after_first, after_second);
1040 }
1041
1042 #[test]
1043 fn install_is_idempotent_in_inline_mode() {
1044 let tmp = tempfile::tempdir().unwrap();
1045 std::fs::write(tmp.path().join(".zshenv"), "# top\n").unwrap();
1046
1047 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1048 let after_first = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1049
1050 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1051 let after_second = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1052
1053 assert_eq!(after_first, after_second);
1054 }
1055
1056 #[test]
1057 fn install_aliases_skips_when_rc_missing() {
1058 let tmp = tempfile::tempdir().unwrap();
1059 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1061 assert!(!tmp.path().join(".zshrc").exists());
1062 assert!(!tmp.path().join(".bashrc").exists());
1063 }
1064
1065 #[test]
1066 fn install_aliases_writes_dropin_when_zshrc_d_configured() {
1067 let tmp = tempfile::tempdir().unwrap();
1068 std::fs::create_dir_all(tmp.path().join(".zshrc.d")).unwrap();
1069 std::fs::write(
1070 tmp.path().join(".zshrc"),
1071 "for f in $HOME/.zshrc.d/*.zsh; do source $f; done\n",
1072 )
1073 .unwrap();
1074
1075 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1076
1077 let dropin_file = tmp.path().join(".zshrc.d").join(DROPIN_ZSH);
1078 assert!(dropin_file.exists());
1079 let body = std::fs::read_to_string(&dropin_file).unwrap();
1080 assert!(body.contains("LEAN_CTX_AGENT=1"));
1081 }
1082
1083 #[test]
1086 fn zshenv_hook_contains_lc_passthrough_stubs() {
1087 let tmp = tempfile::tempdir().unwrap();
1088 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1089 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1090 assert!(
1091 body.contains(r#"_lc() { command "$@"; }"#),
1092 "zshenv must contain _lc passthrough stub"
1093 );
1094 assert!(
1095 body.contains(r#"_lc_compress() { command "$@"; }"#),
1096 "zshenv must contain _lc_compress passthrough stub"
1097 );
1098 }
1099
1100 #[test]
1101 fn bashenv_hook_contains_lc_passthrough_stubs() {
1102 let tmp = tempfile::tempdir().unwrap();
1103 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1104 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1105 assert!(
1106 body.contains(r#"_lc() { command "$@"; }"#),
1107 "bashenv must contain _lc passthrough stub"
1108 );
1109 assert!(
1110 body.contains(r#"_lc_compress() { command "$@"; }"#),
1111 "bashenv must contain _lc_compress passthrough stub"
1112 );
1113 }
1114
1115 #[test]
1116 fn stubs_appear_before_exec_guard() {
1117 let tmp = tempfile::tempdir().unwrap();
1118 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1119 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1120 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1121 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1122 assert!(
1123 stub_pos < exec_pos,
1124 "stubs must be defined BEFORE the exec guard"
1125 );
1126 }
1127
1128 #[test]
1129 fn bash_stubs_appear_before_exec_guard() {
1130 let tmp = tempfile::tempdir().unwrap();
1136 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1137 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1138 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1139 let compress_pos = body
1140 .find("_lc_compress()")
1141 .expect("_lc_compress stub must exist");
1142 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1143 assert!(
1144 stub_pos < exec_pos && compress_pos < exec_pos,
1145 "bash stubs must be defined BEFORE the exec guard"
1146 );
1147 }
1148
1149 #[test]
1157 fn redirect_block_guards_every_skip_marker() {
1158 let block = redirect_block("ZSH_EXECUTION_STRING", "[[ -n \"$LEAN_CTX_AGENT\" ]]");
1159 let exec_pos = block
1160 .find("exec lean-ctx")
1161 .expect("redirect must exec lean-ctx");
1162 for marker in REDIRECT_SKIP_MARKERS {
1163 let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1164 let guard_pos = block
1165 .find(&guard)
1166 .unwrap_or_else(|| panic!("redirect must guard against {marker:?}:\n{block}"));
1167 assert!(
1168 guard_pos < exec_pos,
1169 "guard for {marker:?} must precede the exec redirect"
1170 );
1171 }
1172 }
1173
1174 #[test]
1175 fn zshenv_redirect_skips_ide_sandbox_and_hooks() {
1176 let tmp = tempfile::tempdir().unwrap();
1177 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1178 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1179 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1180 for marker in REDIRECT_SKIP_MARKERS {
1181 let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1182 let pos = body
1183 .find(&guard)
1184 .unwrap_or_else(|| panic!(".zshenv must guard against {marker:?}"));
1185 assert!(
1186 pos < exec_pos,
1187 "zshenv guard {marker:?} must precede the exec redirect"
1188 );
1189 }
1190 }
1191
1192 #[test]
1193 fn bashenv_redirect_skips_ide_sandbox_and_hooks() {
1194 let tmp = tempfile::tempdir().unwrap();
1195 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1196 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1197 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1198 for marker in REDIRECT_SKIP_MARKERS {
1199 let guard = format!("[[ \"$BASH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1200 let pos = body
1201 .find(&guard)
1202 .unwrap_or_else(|| panic!(".bashenv must guard against {marker:?}"));
1203 assert!(
1204 pos < exec_pos,
1205 "bashenv guard {marker:?} must precede the exec redirect"
1206 );
1207 }
1208 }
1209
1210 #[test]
1211 fn dropin_zshenv_also_contains_stubs() {
1212 let tmp = tempfile::tempdir().unwrap();
1213 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1214 std::fs::write(
1215 tmp.path().join(".zshenv"),
1216 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1217 )
1218 .unwrap();
1219 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1220
1221 let dropin = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
1222 let body = std::fs::read_to_string(&dropin).unwrap();
1223 assert!(body.contains("_lc()"), "drop-in must also contain stubs");
1224 }
1225
1226 #[cfg(unix)]
1231 static SHELL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1232
1233 #[cfg(unix)]
1234 #[test]
1235 fn shell_available_rejects_unknown_shell() {
1236 let _env_lock = crate::core::data_dir::test_env_lock();
1237 let _g = SHELL_ENV_LOCK
1238 .lock()
1239 .unwrap_or_else(std::sync::PoisonError::into_inner);
1240 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1241 assert!(!shell_available("fish"));
1242 assert!(!shell_available("nushell"));
1243 assert!(!shell_available(""));
1244 }
1245
1246 #[cfg(unix)]
1247 #[test]
1248 fn shell_available_finds_installed_shells() {
1249 let _env_lock = crate::core::data_dir::test_env_lock();
1250 let _g = SHELL_ENV_LOCK
1251 .lock()
1252 .unwrap_or_else(std::sync::PoisonError::into_inner);
1253 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1254 let has_bash = Path::new("/bin/bash").exists() || Path::new("/usr/bin/bash").exists();
1256 let has_zsh = Path::new("/bin/zsh").exists() || Path::new("/usr/bin/zsh").exists();
1257 assert!(
1258 shell_available("bash") == has_bash,
1259 "shell_available(bash) should match filesystem"
1260 );
1261 assert!(
1262 shell_available("zsh") == has_zsh,
1263 "shell_available(zsh) should match filesystem"
1264 );
1265 }
1266
1267 #[cfg(unix)]
1268 #[test]
1269 fn shell_hook_force_overrides_detection() {
1270 let _env_lock = crate::core::data_dir::test_env_lock();
1271 let _g = SHELL_ENV_LOCK
1272 .lock()
1273 .unwrap_or_else(std::sync::PoisonError::into_inner);
1274
1275 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "all");
1277 assert!(shell_available("zsh"));
1278 assert!(shell_available("bash"));
1279
1280 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "zsh");
1282 assert!(shell_available("zsh"));
1283 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1287 }
1288}