1use std::path::{Path, PathBuf};
2
3use crate::{dropin, marked_block};
4
5const MARKER_START: &str = "# >>> lean-ctx shell hook >>>";
6const MARKER_END: &str = "# <<< lean-ctx shell hook <<<";
7const ALIAS_START: &str = "# >>> lean-ctx agent aliases >>>";
8const ALIAS_END: &str = "# <<< lean-ctx agent aliases <<<";
9
10const DROPIN_ZSH: &str = "00-lean-ctx.zsh";
14const DROPIN_SH: &str = "00-lean-ctx.sh";
15
16const KNOWN_AGENT_ENV_VARS: &[&str] = &[
17 "LEAN_CTX_AGENT",
18 "CLAUDECODE",
19 "CODEBUDDY",
20 "CODEX_CLI_SESSION",
21 "GEMINI_SESSION",
22];
23
24const AGENT_ALIASES: &[(&str, &str)] = &[
25 ("claude", "claude"),
26 ("codebuddy", "codebuddy"),
27 ("codex", "codex"),
28 ("gemini", "gemini"),
29];
30
31fn source_command_for_shell(shell: &str) -> Option<&'static str> {
36 if shell.contains("zsh") {
37 Some("source ~/.zshrc")
38 } else if shell.contains("fish") {
39 Some("source ~/.config/fish/config.fish")
40 } else if shell.contains("bash") {
41 Some("source ~/.bashrc")
42 } else {
43 None
44 }
45}
46
47pub fn shell_source_command() -> Option<&'static str> {
52 source_command_for_shell(&std::env::var("SHELL").unwrap_or_default())
53}
54
55fn rc_file_for_shell(shell: &str) -> &'static str {
57 if shell.contains("zsh") {
58 "~/.zshrc"
59 } else if shell.contains("fish") {
60 "~/.config/fish/config.fish"
61 } else if shell.contains("bash") {
62 "~/.bashrc"
63 } else {
64 "your shell config"
65 }
66}
67
68pub fn shell_rc_file() -> &'static str {
72 rc_file_for_shell(&std::env::var("SHELL").unwrap_or_default())
73}
74
75pub fn reload_aliases_hint() -> String {
78 match shell_source_command() {
79 Some(cmd) => format!("Run '{cmd}' (or restart terminal) for updated shell aliases."),
80 None => "Restart your terminal to load updated shell aliases.".to_string(),
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum Style {
92 Inline,
94 DropIn,
97 #[default]
99 Auto,
100}
101
102#[derive(Debug, Clone, Copy)]
106struct Slot {
107 rc_file: &'static str,
108 dropin_dir: &'static str,
109 dropin_file: &'static str,
110 marker_start: &'static str,
111 marker_end: &'static str,
112}
113
114const SLOT_ZSHENV: Slot = Slot {
115 rc_file: ".zshenv",
116 dropin_dir: ".zshenv.d",
117 dropin_file: DROPIN_ZSH,
118 marker_start: MARKER_START,
119 marker_end: MARKER_END,
120};
121
122const SLOT_BASHENV: Slot = Slot {
123 rc_file: ".bashenv",
124 dropin_dir: ".bashenv.d",
125 dropin_file: DROPIN_SH,
126 marker_start: MARKER_START,
127 marker_end: MARKER_END,
128};
129
130const SLOT_ZSHRC: Slot = Slot {
131 rc_file: ".zshrc",
132 dropin_dir: ".zshrc.d",
133 dropin_file: DROPIN_ZSH,
134 marker_start: ALIAS_START,
135 marker_end: ALIAS_END,
136};
137
138const SLOT_BASHRC: Slot = Slot {
139 rc_file: ".bashrc",
140 dropin_dir: ".bashrc.d",
141 dropin_file: DROPIN_SH,
142 marker_start: ALIAS_START,
143 marker_end: ALIAS_END,
144};
145
146enum InstallTarget {
148 Marked {
149 path: PathBuf,
150 start: &'static str,
151 end: &'static str,
152 },
153 DropIn {
154 dir: PathBuf,
155 filename: &'static str,
156 },
157}
158
159impl InstallTarget {
160 fn upsert(&self, content: &str, quiet: bool, label: &str) {
161 match self {
162 Self::Marked { path, start, end } => {
163 marked_block::upsert(path, start, end, content, quiet, label);
164 }
165 Self::DropIn { dir, filename } => dropin::write(dir, filename, content, quiet, label),
166 }
167 }
168}
169
170fn pick_target(home: &Path, slot: &Slot, style: Style) -> InstallTarget {
172 let inline = InstallTarget::Marked {
173 path: home.join(slot.rc_file),
174 start: slot.marker_start,
175 end: slot.marker_end,
176 };
177 match style {
178 Style::Inline => inline,
179 Style::DropIn | Style::Auto => match dropin::detect(home, slot.rc_file, slot.dropin_dir) {
184 Some(dir) => InstallTarget::DropIn {
185 dir,
186 filename: slot.dropin_file,
187 },
188 None => inline,
189 },
190 }
191}
192
193struct BackupStamp(String);
206
207impl BackupStamp {
208 fn now() -> Self {
211 Self::at(chrono::Utc::now())
212 }
213
214 fn at(stamp: chrono::DateTime<chrono::Utc>) -> Self {
218 Self(stamp.format("%Y%m%dT%H%M%SZ").to_string())
219 }
220
221 fn backup_path_for(&self, path: &Path) -> Option<PathBuf> {
223 let file_name = path.file_name().and_then(|n| n.to_str())?;
224 Some(path.with_file_name(format!("{file_name}.lean-ctx-{}.bak", self.0)))
225 }
226}
227
228fn save_migration_backup(path: &Path, quiet: bool, stamp: &BackupStamp) {
250 if !path.exists() {
251 return;
252 }
253 let Some(bak) = stamp.backup_path_for(path) else {
254 return;
255 };
256 match std::fs::copy(path, &bak) {
257 Ok(_) => {
258 if !quiet {
259 eprintln!(" Backup: {} -> {}", path.display(), bak.display());
260 }
261 }
262 Err(e) => {
263 tracing::warn!("Failed to back up {}: {e}", path.display());
264 }
265 }
266}
267
268fn strip_other_style(
281 home: &Path,
282 slot: &Slot,
283 target: &InstallTarget,
284 quiet: bool,
285 label: &str,
286 stamp: &BackupStamp,
287) {
288 match target {
289 InstallTarget::Marked { .. } => {
290 let dropin_dir = home.join(slot.dropin_dir);
292 let dropin_path = dropin_dir.join(slot.dropin_file);
293 if dropin_path.exists() {
294 save_migration_backup(&dropin_path, quiet, stamp);
298 dropin::remove(&dropin_dir, slot.dropin_file, quiet, label);
299 }
300 }
301 InstallTarget::DropIn { .. } => {
302 let rc_path = home.join(slot.rc_file);
307 if let Ok(existing) = std::fs::read_to_string(&rc_path)
308 && existing.contains(slot.marker_start)
309 {
310 save_migration_backup(&rc_path, quiet, stamp);
311 }
312 marked_block::remove_from_file(
313 &rc_path,
314 slot.marker_start,
315 slot.marker_end,
316 quiet,
317 label,
318 );
319 }
320 }
321}
322
323pub fn install_all(quiet: bool) {
327 install_all_with_style(quiet, Style::Auto);
328}
329
330pub fn install_all_with_style(quiet: bool, style: Style) {
337 let cfg = crate::core::config::Config::load();
338 if cfg.shell_hook_disabled_effective() {
339 if !quiet {
340 eprintln!(
341 "lean-ctx: shell hook disabled (shell_hook_disabled=true or LEAN_CTX_NO_HOOK). Skipping hook installation."
342 );
343 }
344 return;
345 }
346
347 let Some(home) = dirs::home_dir() else {
348 tracing::error!("Cannot resolve home directory");
349 return;
350 };
351
352 let stamp = BackupStamp::now();
353 if shell_available("zsh") {
354 install_zshenv(&home, quiet, style, &stamp);
355 }
356 if shell_available("bash") {
357 install_bashenv(&home, quiet, style, &stamp);
358 }
359 let cfg = crate::core::config::Config::load();
360 if cfg.skip_agent_aliases {
361 remove_agent_aliases(&home, quiet);
362 } else {
363 install_aliases(&home, quiet, style, &stamp);
364 }
365}
366
367#[cfg(unix)]
375fn shell_available(shell: &str) -> bool {
376 if let Ok(forced) = std::env::var("LEAN_CTX_SHELL_HOOK_FORCE") {
377 let forced = forced.trim();
378 if forced == "1"
379 || forced.eq_ignore_ascii_case("true")
380 || forced.eq_ignore_ascii_case("all")
381 {
382 return true;
383 }
384 if forced
385 .split(',')
386 .any(|s| s.trim().eq_ignore_ascii_case(shell))
387 {
388 return true;
389 }
390 }
391
392 let candidates: &[&str] = match shell {
393 "zsh" => &[
394 "/bin/zsh",
395 "/usr/bin/zsh",
396 "/usr/local/bin/zsh",
397 "/opt/homebrew/bin/zsh",
398 ],
399 "bash" => &[
400 "/bin/bash",
401 "/usr/bin/bash",
402 "/usr/local/bin/bash",
403 "/opt/homebrew/bin/bash",
404 ],
405 _ => return false,
406 };
407 candidates.iter().any(|p| Path::new(p).exists())
408}
409
410#[cfg(not(unix))]
411fn shell_available(_shell: &str) -> bool {
412 false
414}
415
416pub fn uninstall_all(quiet: bool) {
417 let Some(home) = dirs::home_dir() else { return };
418
419 let slots: &[(Slot, &str)] = &[
422 (SLOT_ZSHENV, "shell hook for ~/.zshenv"),
423 (SLOT_BASHENV, "shell hook for ~/.bashenv"),
424 (SLOT_ZSHRC, "agent aliases for ~/.zshrc"),
425 (SLOT_BASHRC, "agent aliases for ~/.bashrc"),
426 ];
427
428 for (slot, label) in slots {
429 marked_block::remove_from_file(
430 &home.join(slot.rc_file),
431 slot.marker_start,
432 slot.marker_end,
433 quiet,
434 label,
435 );
436 let dir_path = home.join(slot.dropin_dir);
437 if dir_path.exists() {
438 dropin::remove(&dir_path, slot.dropin_file, quiet, label);
439 }
440 }
441}
442
443const REDIRECT_SKIP_MARKERS: &[&str] = &[
453 "__CURSOR_SANDBOX", "dump_zsh_state", "lean-ctx hook ", ];
457
458fn redirect_block(exec_var: &str, env_check: &str) -> String {
465 let mut lines = vec![format!(
466 "if [[ -z \"$LEAN_CTX_ACTIVE\" && -z \"$LEAN_CTX_NO_HOOK\" && -n \"${exec_var}\" ]] \\"
467 )];
468 for marker in REDIRECT_SKIP_MARKERS {
469 lines.push(format!(" && [[ \"${exec_var}\" != *\"{marker}\"* ]] \\"));
470 }
471 lines.push(" && command -v lean-ctx &>/dev/null; then".to_string());
472 lines.push(format!(" if {env_check}; then"));
473 lines.push(" export LEAN_CTX_ACTIVE=1".to_string());
474 lines.push(format!(" exec lean-ctx -c \"${exec_var}\""));
475 lines.push(" fi".to_string());
476 lines.push("fi".to_string());
477 lines.join("\n")
478}
479
480fn install_zshenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
481 let redirect = redirect_block("ZSH_EXECUTION_STRING", &build_env_check());
482 let hook = format!(
483 r#"{MARKER_START}
484# Passthrough stubs: ensure _lc/_lc_compress exist in ALL zsh contexts
485# (non-interactive subshells, eval, agent harnesses) so aliases that
486# reference them degrade gracefully instead of "command not found".
487# The full shell-hook.zsh overrides these when loaded via .zshrc.
488_lc() {{ command "$@"; }}
489_lc_compress() {{ command "$@"; }}
490{redirect}
491{MARKER_END}"#
492 );
493
494 let label = "shell hook in ~/.zshenv";
495 let target = pick_target(home, &SLOT_ZSHENV, style);
496 strip_other_style(home, &SLOT_ZSHENV, &target, quiet, label, stamp);
497 target.upsert(&hook, quiet, label);
498}
499
500fn install_bashenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
501 let redirect = redirect_block("BASH_EXECUTION_STRING", &build_env_check());
502 let hook = format!(
503 r#"{MARKER_START}
504_lc() {{ command "$@"; }}
505_lc_compress() {{ command "$@"; }}
506{redirect}
507{MARKER_END}"#
508 );
509
510 let label = "shell hook in ~/.bashenv";
511 let target = pick_target(home, &SLOT_BASHENV, style);
512 strip_other_style(home, &SLOT_BASHENV, &target, quiet, label, stamp);
513 target.upsert(&hook, quiet, label);
514}
515
516fn install_aliases(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
517 let mut lines = Vec::new();
518 lines.push(ALIAS_START.to_string());
519 for (alias_name, bin_name) in AGENT_ALIASES {
520 lines.push(format!(
521 "alias {alias_name}='LEAN_CTX_AGENT=1 BASH_ENV=\"$HOME/.bashenv\" {bin_name}'"
522 ));
523 }
524 lines.push(ALIAS_END.to_string());
525 let block = lines.join("\n");
526
527 for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
528 if !home.join(slot.rc_file).exists() {
531 continue;
532 }
533 let label = format!("agent aliases in ~/{}", slot.rc_file);
534 let target = pick_target(home, slot, style);
535 strip_other_style(home, slot, &target, quiet, &label, stamp);
536 target.upsert(&block, quiet, &label);
537 }
538}
539
540fn remove_agent_aliases(home: &Path, quiet: bool) {
543 for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
544 let rc = home.join(slot.rc_file);
545 if !rc.exists() {
546 continue;
547 }
548 if let Ok(content) = std::fs::read_to_string(&rc)
549 && content.contains(ALIAS_START)
550 {
551 let filtered: Vec<&str> = content
552 .lines()
553 .scan(false, |inside, line| {
554 if line.trim() == ALIAS_START {
555 *inside = true;
556 return Some(None);
557 }
558 if *inside && line.trim() == ALIAS_END {
559 *inside = false;
560 return Some(None);
561 }
562 if *inside {
563 Some(None)
564 } else {
565 Some(Some(line))
566 }
567 })
568 .flatten()
569 .collect();
570 let _ = std::fs::write(&rc, filtered.join("\n") + "\n");
571 if !quiet {
572 println!(
573 " \x1b[33m⊖\x1b[0m Removed agent aliases from ~/{}",
574 slot.rc_file
575 );
576 }
577 }
578 let dropin = home.join(slot.dropin_dir).join(slot.dropin_file);
580 if dropin.exists() {
581 let _ = std::fs::remove_file(&dropin);
582 if !quiet {
583 println!(
584 " \x1b[33m⊖\x1b[0m Removed drop-in ~/{}/{}",
585 slot.dropin_dir, slot.dropin_file
586 );
587 }
588 }
589 }
590}
591
592fn build_env_check() -> String {
593 let checks: Vec<String> = KNOWN_AGENT_ENV_VARS
594 .iter()
595 .map(|v| format!("-n \"${v}\""))
596 .collect();
597 format!("[[ {} ]]", checks.join(" || "))
598}
599
600#[cfg(test)]
601pub mod test_helpers {
602 use super::*;
603 pub fn redirect_block_for_test(exec_var: &str, env_check: &str) -> String {
604 redirect_block(exec_var, env_check)
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 fn test_stamp() -> BackupStamp {
616 BackupStamp::at(
617 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
618 .unwrap()
619 .with_timezone(&chrono::Utc),
620 )
621 }
622
623 #[test]
624 fn env_check_format() {
625 let check = build_env_check();
626 assert!(check.contains("LEAN_CTX_AGENT"));
627 assert!(check.contains("CLAUDECODE"));
628 assert!(check.contains("CODEBUDDY"));
629 assert!(check.contains("||"));
630 }
631
632 #[test]
633 fn source_command_matches_login_shell() {
634 assert_eq!(
636 source_command_for_shell("/usr/bin/bash"),
637 Some("source ~/.bashrc")
638 );
639 assert_eq!(
640 source_command_for_shell("/bin/zsh"),
641 Some("source ~/.zshrc")
642 );
643 assert_eq!(
644 source_command_for_shell("/usr/local/bin/fish"),
645 Some("source ~/.config/fish/config.fish")
646 );
647 assert_eq!(source_command_for_shell(""), None);
649 assert_eq!(source_command_for_shell("/bin/false"), None);
650 }
651
652 #[test]
653 fn rc_file_matches_login_shell() {
654 assert_eq!(rc_file_for_shell("/usr/bin/bash"), "~/.bashrc");
656 assert_eq!(rc_file_for_shell("/bin/zsh"), "~/.zshrc");
657 assert_eq!(
658 rc_file_for_shell("/usr/local/bin/fish"),
659 "~/.config/fish/config.fish"
660 );
661 assert_eq!(rc_file_for_shell(""), "your shell config");
662 assert_eq!(rc_file_for_shell("/bin/false"), "your shell config");
663 }
664
665 #[test]
666 fn pick_target_inline_when_forced() {
667 let tmp = tempfile::tempdir().unwrap();
668 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
670 std::fs::write(
671 tmp.path().join(".zshenv"),
672 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
673 )
674 .unwrap();
675 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Inline);
676 assert!(matches!(t, InstallTarget::Marked { .. }));
677 }
678
679 #[test]
680 fn pick_target_dropin_when_detected_under_auto() {
681 let tmp = tempfile::tempdir().unwrap();
682 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
683 std::fs::write(
684 tmp.path().join(".zshenv"),
685 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
686 )
687 .unwrap();
688 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
689 assert!(matches!(t, InstallTarget::DropIn { .. }));
690 }
691
692 #[test]
693 fn pick_target_inline_under_auto_when_no_dropin() {
694 let tmp = tempfile::tempdir().unwrap();
695 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
696 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
697 assert!(matches!(t, InstallTarget::Marked { .. }));
698 }
699
700 #[test]
701 fn pick_target_dropin_falls_back_to_inline_when_no_directory() {
702 let tmp = tempfile::tempdir().unwrap();
705 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
706 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::DropIn);
707 assert!(matches!(t, InstallTarget::Marked { .. }));
708 }
709
710 #[test]
711 fn install_zshenv_writes_inline_block() {
712 let tmp = tempfile::tempdir().unwrap();
713 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
714 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
715 assert!(body.contains(MARKER_START));
716 assert!(body.contains(MARKER_END));
717 assert!(body.contains("ZSH_EXECUTION_STRING"));
718 }
719
720 #[test]
721 fn install_zshenv_writes_dropin_when_loop_present() {
722 let tmp = tempfile::tempdir().unwrap();
723 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
724 std::fs::write(
725 tmp.path().join(".zshenv"),
726 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
727 )
728 .unwrap();
729 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
730
731 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
732 assert!(dropin_file.exists(), "expected drop-in file");
733 let dropin_body = std::fs::read_to_string(&dropin_file).unwrap();
734 assert!(dropin_body.contains("ZSH_EXECUTION_STRING"));
735
736 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
737 assert!(
738 !zshenv_body.contains(MARKER_START),
739 "drop-in install must not also leave the inline block"
740 );
741 }
742
743 fn find_migration_backups(path: &Path) -> Vec<PathBuf> {
746 let Some(parent) = path.parent() else {
747 return Vec::new();
748 };
749 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
750 return Vec::new();
751 };
752 let prefix = format!("{name}.lean-ctx-");
753 let mut out: Vec<PathBuf> = std::fs::read_dir(parent)
754 .into_iter()
755 .flatten()
756 .flatten()
757 .map(|e| e.path())
758 .filter(|p| {
759 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
760 n.starts_with(&prefix)
761 && std::path::Path::new(n)
762 .extension()
763 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
764 })
765 })
766 .collect();
767 out.sort();
768 out
769 }
770
771 #[test]
772 fn migration_inline_to_dropin_preserves_hand_edits_via_backup() {
773 let tmp = tempfile::tempdir().unwrap();
774 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
775 let edited_zshenv = format!(
778 "export PATH=/usr/bin\n\
779 \n\
780 {MARKER_START}\n\
781 # USER CUSTOM: bump zsh history size for this workstation\n\
782 export HISTSIZE=99999\n\
783 # original lean-ctx hook content lived here\n\
784 {MARKER_END}\n\
785 \n\
786 for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
787 );
788 std::fs::write(tmp.path().join(".zshenv"), &edited_zshenv).unwrap();
789
790 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
791
792 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
794 assert_eq!(baks.len(), 1, "expected one timestamped backup");
795 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
796 assert_eq!(bak_body, edited_zshenv);
797 assert!(bak_body.contains("USER CUSTOM"));
798 assert!(bak_body.contains("HISTSIZE=99999"));
799 }
800
801 #[test]
802 fn migration_dropin_to_inline_preserves_hand_edits_via_backup() {
803 let tmp = tempfile::tempdir().unwrap();
804 let dropin_dir = tmp.path().join(".zshenv.d");
805 std::fs::create_dir_all(&dropin_dir).unwrap();
806 let edited_dropin = "# USER CUSTOM addition to lean-ctx drop-in\nexport FAVOURITE_EDITOR=helix\n# canonical lean-ctx content would follow\n";
808 std::fs::write(dropin_dir.join(DROPIN_ZSH), edited_dropin).unwrap();
809 std::fs::write(tmp.path().join(".zshenv"), "# plain zshenv\n").unwrap();
812
813 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
814
815 let baks = find_migration_backups(&dropin_dir.join(DROPIN_ZSH));
816 assert_eq!(baks.len(), 1, "expected one timestamped backup");
817 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
818 assert_eq!(bak_body, edited_dropin);
819 assert!(bak_body.contains("USER CUSTOM"));
820 assert!(!dropin_dir.join(DROPIN_ZSH).exists());
822 let zshenv = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
823 assert!(zshenv.contains(MARKER_START));
824 }
825
826 #[test]
827 fn migration_skips_backup_when_no_prior_block_exists() {
828 let tmp = tempfile::tempdir().unwrap();
831 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
832 std::fs::write(
833 tmp.path().join(".zshenv"),
834 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
835 )
836 .unwrap();
837
838 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
839
840 assert!(
841 find_migration_backups(&tmp.path().join(".zshenv")).is_empty(),
842 "clean install should not create a .bak file"
843 );
844 }
845
846 #[test]
847 fn idempotent_dropin_reinstall_does_not_create_backup() {
848 let tmp = tempfile::tempdir().unwrap();
853 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
854 std::fs::write(
855 tmp.path().join(".zshenv"),
856 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
857 )
858 .unwrap();
859
860 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
861 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
862
863 assert!(find_migration_backups(&tmp.path().join(".zshenv")).is_empty());
864 }
865
866 #[test]
867 fn backup_filename_handles_dotfile_correctly() {
868 let tmp = tempfile::tempdir().unwrap();
872 std::fs::write(tmp.path().join(".zshenv"), "content\n").unwrap();
873 save_migration_backup(&tmp.path().join(".zshenv"), true, &test_stamp());
874 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
875 assert_eq!(baks.len(), 1);
876 let name = baks[0].file_name().unwrap().to_str().unwrap();
879 assert!(name.starts_with(".zshenv.lean-ctx-"), "got: {name}");
880 assert!(
881 std::path::Path::new(name)
882 .extension()
883 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
884 );
885 let stamp = name
887 .trim_start_matches(".zshenv.lean-ctx-")
888 .trim_end_matches(".bak");
889 assert_eq!(stamp.len(), 16, "stamp should be YYYYMMDDTHHMMSSZ: {stamp}");
890 assert!(stamp.contains('T'));
891 assert!(stamp.ends_with('Z'));
892 }
893
894 #[test]
895 fn repeated_migrations_never_clobber_prior_backups() {
896 let stamp_first = BackupStamp::at(
901 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
902 .unwrap()
903 .with_timezone(&chrono::Utc),
904 );
905 let stamp_later = BackupStamp::at(
906 chrono::DateTime::parse_from_rfc3339("2026-05-12T09:00:00Z")
907 .unwrap()
908 .with_timezone(&chrono::Utc),
909 );
910 let tmp = tempfile::tempdir().unwrap();
911 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
912
913 let with_block_v1 = format!(
914 "{MARKER_START}\n# first-era custom content\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
915 );
916 std::fs::write(tmp.path().join(".zshenv"), &with_block_v1).unwrap();
917 install_zshenv(tmp.path(), true, Style::Auto, &stamp_first);
918 let baks_after_first = find_migration_backups(&tmp.path().join(".zshenv"));
919 assert_eq!(baks_after_first.len(), 1);
920
921 let with_block_v2 = format!(
924 "{}{MARKER_START}\n# second-era custom content\n{MARKER_END}\n",
925 std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap(),
926 );
927 std::fs::write(tmp.path().join(".zshenv"), &with_block_v2).unwrap();
928 install_zshenv(tmp.path(), true, Style::Auto, &stamp_later);
929 let baks_after_second = find_migration_backups(&tmp.path().join(".zshenv"));
930
931 assert_eq!(
932 baks_after_second.len(),
933 2,
934 "second migration should leave a second backup, not overwrite"
935 );
936 assert_eq!(baks_after_second[0], baks_after_first[0]);
938 let first_body = std::fs::read_to_string(&baks_after_second[0]).unwrap();
939 let second_body = std::fs::read_to_string(&baks_after_second[1]).unwrap();
940 assert!(first_body.contains("first-era custom"));
941 assert!(second_body.contains("second-era custom"));
942 }
943
944 #[test]
945 fn install_migrates_inline_to_dropin() {
946 let tmp = tempfile::tempdir().unwrap();
947 std::fs::write(
949 tmp.path().join(".zshenv"),
950 format!(
951 "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",
952 ),
953 )
954 .unwrap();
955 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
956
957 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
958
959 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
960 assert!(
961 !zshenv_body.contains(MARKER_START),
962 "old inline block should be stripped after migration"
963 );
964 assert!(
965 zshenv_body.contains(".zshenv.d"),
966 "source loop must be preserved"
967 );
968 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
969 assert!(dropin_file.exists(), "new drop-in file should be present");
970 }
971
972 #[test]
973 fn install_migrates_dropin_to_inline() {
974 let tmp = tempfile::tempdir().unwrap();
975 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
978 std::fs::write(
979 tmp.path().join(".zshenv.d").join(DROPIN_ZSH),
980 "# stale lean-ctx drop-in\n",
981 )
982 .unwrap();
983 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
984
985 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
986
987 assert!(
988 !tmp.path().join(".zshenv.d").join(DROPIN_ZSH).exists(),
989 "drop-in file should be removed when installing inline"
990 );
991 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
992 assert!(body.contains(MARKER_START));
993 }
994
995 #[test]
996 fn install_is_idempotent_in_dropin_mode() {
997 let tmp = tempfile::tempdir().unwrap();
998 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
999 std::fs::write(
1000 tmp.path().join(".zshenv"),
1001 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1002 )
1003 .unwrap();
1004
1005 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1006 let after_first = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
1007
1008 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1009 let after_second = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
1010
1011 assert_eq!(after_first, after_second);
1012 }
1013
1014 #[test]
1015 fn install_is_idempotent_in_inline_mode() {
1016 let tmp = tempfile::tempdir().unwrap();
1017 std::fs::write(tmp.path().join(".zshenv"), "# top\n").unwrap();
1018
1019 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1020 let after_first = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1021
1022 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1023 let after_second = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1024
1025 assert_eq!(after_first, after_second);
1026 }
1027
1028 #[test]
1029 fn install_aliases_skips_when_rc_missing() {
1030 let tmp = tempfile::tempdir().unwrap();
1031 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1033 assert!(!tmp.path().join(".zshrc").exists());
1034 assert!(!tmp.path().join(".bashrc").exists());
1035 }
1036
1037 #[test]
1038 fn install_aliases_writes_dropin_when_zshrc_d_configured() {
1039 let tmp = tempfile::tempdir().unwrap();
1040 std::fs::create_dir_all(tmp.path().join(".zshrc.d")).unwrap();
1041 std::fs::write(
1042 tmp.path().join(".zshrc"),
1043 "for f in $HOME/.zshrc.d/*.zsh; do source $f; done\n",
1044 )
1045 .unwrap();
1046
1047 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1048
1049 let dropin_file = tmp.path().join(".zshrc.d").join(DROPIN_ZSH);
1050 assert!(dropin_file.exists());
1051 let body = std::fs::read_to_string(&dropin_file).unwrap();
1052 assert!(body.contains("LEAN_CTX_AGENT=1"));
1053 }
1054
1055 #[test]
1058 fn zshenv_hook_contains_lc_passthrough_stubs() {
1059 let tmp = tempfile::tempdir().unwrap();
1060 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1061 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1062 assert!(
1063 body.contains(r#"_lc() { command "$@"; }"#),
1064 "zshenv must contain _lc passthrough stub"
1065 );
1066 assert!(
1067 body.contains(r#"_lc_compress() { command "$@"; }"#),
1068 "zshenv must contain _lc_compress passthrough stub"
1069 );
1070 }
1071
1072 #[test]
1073 fn bashenv_hook_contains_lc_passthrough_stubs() {
1074 let tmp = tempfile::tempdir().unwrap();
1075 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1076 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1077 assert!(
1078 body.contains(r#"_lc() { command "$@"; }"#),
1079 "bashenv must contain _lc passthrough stub"
1080 );
1081 assert!(
1082 body.contains(r#"_lc_compress() { command "$@"; }"#),
1083 "bashenv must contain _lc_compress passthrough stub"
1084 );
1085 }
1086
1087 #[test]
1088 fn stubs_appear_before_exec_guard() {
1089 let tmp = tempfile::tempdir().unwrap();
1090 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1091 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1092 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1093 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1094 assert!(
1095 stub_pos < exec_pos,
1096 "stubs must be defined BEFORE the exec guard"
1097 );
1098 }
1099
1100 #[test]
1101 fn bash_stubs_appear_before_exec_guard() {
1102 let tmp = tempfile::tempdir().unwrap();
1108 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1109 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1110 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1111 let compress_pos = body
1112 .find("_lc_compress()")
1113 .expect("_lc_compress stub must exist");
1114 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1115 assert!(
1116 stub_pos < exec_pos && compress_pos < exec_pos,
1117 "bash stubs must be defined BEFORE the exec guard"
1118 );
1119 }
1120
1121 #[test]
1129 fn redirect_block_guards_every_skip_marker() {
1130 let block = redirect_block("ZSH_EXECUTION_STRING", "[[ -n \"$LEAN_CTX_AGENT\" ]]");
1131 let exec_pos = block
1132 .find("exec lean-ctx")
1133 .expect("redirect must exec lean-ctx");
1134 for marker in REDIRECT_SKIP_MARKERS {
1135 let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1136 let guard_pos = block
1137 .find(&guard)
1138 .unwrap_or_else(|| panic!("redirect must guard against {marker:?}:\n{block}"));
1139 assert!(
1140 guard_pos < exec_pos,
1141 "guard for {marker:?} must precede the exec redirect"
1142 );
1143 }
1144 }
1145
1146 #[test]
1147 fn zshenv_redirect_skips_ide_sandbox_and_hooks() {
1148 let tmp = tempfile::tempdir().unwrap();
1149 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1150 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1151 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1152 for marker in REDIRECT_SKIP_MARKERS {
1153 let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1154 let pos = body
1155 .find(&guard)
1156 .unwrap_or_else(|| panic!(".zshenv must guard against {marker:?}"));
1157 assert!(
1158 pos < exec_pos,
1159 "zshenv guard {marker:?} must precede the exec redirect"
1160 );
1161 }
1162 }
1163
1164 #[test]
1165 fn bashenv_redirect_skips_ide_sandbox_and_hooks() {
1166 let tmp = tempfile::tempdir().unwrap();
1167 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1168 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1169 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1170 for marker in REDIRECT_SKIP_MARKERS {
1171 let guard = format!("[[ \"$BASH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1172 let pos = body
1173 .find(&guard)
1174 .unwrap_or_else(|| panic!(".bashenv must guard against {marker:?}"));
1175 assert!(
1176 pos < exec_pos,
1177 "bashenv guard {marker:?} must precede the exec redirect"
1178 );
1179 }
1180 }
1181
1182 #[test]
1183 fn dropin_zshenv_also_contains_stubs() {
1184 let tmp = tempfile::tempdir().unwrap();
1185 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1186 std::fs::write(
1187 tmp.path().join(".zshenv"),
1188 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1189 )
1190 .unwrap();
1191 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1192
1193 let dropin = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
1194 let body = std::fs::read_to_string(&dropin).unwrap();
1195 assert!(body.contains("_lc()"), "drop-in must also contain stubs");
1196 }
1197
1198 #[cfg(unix)]
1203 static SHELL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1204
1205 #[cfg(unix)]
1206 #[test]
1207 fn shell_available_rejects_unknown_shell() {
1208 let _env_lock = crate::core::data_dir::test_env_lock();
1209 let _g = SHELL_ENV_LOCK
1210 .lock()
1211 .unwrap_or_else(std::sync::PoisonError::into_inner);
1212 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1213 assert!(!shell_available("fish"));
1214 assert!(!shell_available("nushell"));
1215 assert!(!shell_available(""));
1216 }
1217
1218 #[cfg(unix)]
1219 #[test]
1220 fn shell_available_finds_installed_shells() {
1221 let _env_lock = crate::core::data_dir::test_env_lock();
1222 let _g = SHELL_ENV_LOCK
1223 .lock()
1224 .unwrap_or_else(std::sync::PoisonError::into_inner);
1225 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1226 let has_bash = Path::new("/bin/bash").exists() || Path::new("/usr/bin/bash").exists();
1228 let has_zsh = Path::new("/bin/zsh").exists() || Path::new("/usr/bin/zsh").exists();
1229 assert!(
1230 shell_available("bash") == has_bash,
1231 "shell_available(bash) should match filesystem"
1232 );
1233 assert!(
1234 shell_available("zsh") == has_zsh,
1235 "shell_available(zsh) should match filesystem"
1236 );
1237 }
1238
1239 #[cfg(unix)]
1240 #[test]
1241 fn shell_hook_force_overrides_detection() {
1242 let _env_lock = crate::core::data_dir::test_env_lock();
1243 let _g = SHELL_ENV_LOCK
1244 .lock()
1245 .unwrap_or_else(std::sync::PoisonError::into_inner);
1246
1247 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "all");
1249 assert!(shell_available("zsh"));
1250 assert!(shell_available("bash"));
1251
1252 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "zsh");
1254 assert!(shell_available("zsh"));
1255 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1259 }
1260}