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 Some(home) = dirs::home_dir() else {
338 tracing::error!("Cannot resolve home directory");
339 return;
340 };
341
342 let stamp = BackupStamp::now();
343 if shell_available("zsh") {
344 install_zshenv(&home, quiet, style, &stamp);
345 }
346 if shell_available("bash") {
347 install_bashenv(&home, quiet, style, &stamp);
348 }
349 let cfg = crate::core::config::Config::load();
350 if cfg.skip_agent_aliases {
351 remove_agent_aliases(&home, quiet);
352 } else {
353 install_aliases(&home, quiet, style, &stamp);
354 }
355}
356
357#[cfg(unix)]
365fn shell_available(shell: &str) -> bool {
366 if let Ok(forced) = std::env::var("LEAN_CTX_SHELL_HOOK_FORCE") {
367 let forced = forced.trim();
368 if forced == "1"
369 || forced.eq_ignore_ascii_case("true")
370 || forced.eq_ignore_ascii_case("all")
371 {
372 return true;
373 }
374 if forced
375 .split(',')
376 .any(|s| s.trim().eq_ignore_ascii_case(shell))
377 {
378 return true;
379 }
380 }
381
382 let candidates: &[&str] = match shell {
383 "zsh" => &[
384 "/bin/zsh",
385 "/usr/bin/zsh",
386 "/usr/local/bin/zsh",
387 "/opt/homebrew/bin/zsh",
388 ],
389 "bash" => &[
390 "/bin/bash",
391 "/usr/bin/bash",
392 "/usr/local/bin/bash",
393 "/opt/homebrew/bin/bash",
394 ],
395 _ => return false,
396 };
397 candidates.iter().any(|p| Path::new(p).exists())
398}
399
400#[cfg(not(unix))]
401fn shell_available(_shell: &str) -> bool {
402 false
404}
405
406pub fn uninstall_all(quiet: bool) {
407 let Some(home) = dirs::home_dir() else { return };
408
409 let slots: &[(Slot, &str)] = &[
412 (SLOT_ZSHENV, "shell hook for ~/.zshenv"),
413 (SLOT_BASHENV, "shell hook for ~/.bashenv"),
414 (SLOT_ZSHRC, "agent aliases for ~/.zshrc"),
415 (SLOT_BASHRC, "agent aliases for ~/.bashrc"),
416 ];
417
418 for (slot, label) in slots {
419 marked_block::remove_from_file(
420 &home.join(slot.rc_file),
421 slot.marker_start,
422 slot.marker_end,
423 quiet,
424 label,
425 );
426 let dir_path = home.join(slot.dropin_dir);
427 if dir_path.exists() {
428 dropin::remove(&dir_path, slot.dropin_file, quiet, label);
429 }
430 }
431}
432
433const REDIRECT_SKIP_MARKERS: &[&str] = &[
443 "__CURSOR_SANDBOX", "dump_zsh_state", "lean-ctx hook ", ];
447
448fn redirect_block(exec_var: &str, env_check: &str) -> String {
455 let mut lines = vec![format!(
456 "if [[ -z \"$LEAN_CTX_ACTIVE\" && -n \"${exec_var}\" ]] \\"
457 )];
458 for marker in REDIRECT_SKIP_MARKERS {
459 lines.push(format!(" && [[ \"${exec_var}\" != *\"{marker}\"* ]] \\"));
460 }
461 lines.push(" && command -v lean-ctx &>/dev/null; then".to_string());
462 lines.push(format!(" if {env_check}; then"));
463 lines.push(" export LEAN_CTX_ACTIVE=1".to_string());
464 lines.push(format!(" exec lean-ctx -c \"${exec_var}\""));
465 lines.push(" fi".to_string());
466 lines.push("fi".to_string());
467 lines.join("\n")
468}
469
470fn install_zshenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
471 let redirect = redirect_block("ZSH_EXECUTION_STRING", &build_env_check());
472 let hook = format!(
473 r#"{MARKER_START}
474# Passthrough stubs: ensure _lc/_lc_compress exist in ALL zsh contexts
475# (non-interactive subshells, eval, agent harnesses) so aliases that
476# reference them degrade gracefully instead of "command not found".
477# The full shell-hook.zsh overrides these when loaded via .zshrc.
478_lc() {{ command "$@"; }}
479_lc_compress() {{ command "$@"; }}
480{redirect}
481{MARKER_END}"#
482 );
483
484 let label = "shell hook in ~/.zshenv";
485 let target = pick_target(home, &SLOT_ZSHENV, style);
486 strip_other_style(home, &SLOT_ZSHENV, &target, quiet, label, stamp);
487 target.upsert(&hook, quiet, label);
488}
489
490fn install_bashenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
491 let redirect = redirect_block("BASH_EXECUTION_STRING", &build_env_check());
492 let hook = format!(
493 r#"{MARKER_START}
494_lc() {{ command "$@"; }}
495_lc_compress() {{ command "$@"; }}
496{redirect}
497{MARKER_END}"#
498 );
499
500 let label = "shell hook in ~/.bashenv";
501 let target = pick_target(home, &SLOT_BASHENV, style);
502 strip_other_style(home, &SLOT_BASHENV, &target, quiet, label, stamp);
503 target.upsert(&hook, quiet, label);
504}
505
506fn install_aliases(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
507 let mut lines = Vec::new();
508 lines.push(ALIAS_START.to_string());
509 for (alias_name, bin_name) in AGENT_ALIASES {
510 lines.push(format!(
511 "alias {alias_name}='LEAN_CTX_AGENT=1 BASH_ENV=\"$HOME/.bashenv\" {bin_name}'"
512 ));
513 }
514 lines.push(ALIAS_END.to_string());
515 let block = lines.join("\n");
516
517 for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
518 if !home.join(slot.rc_file).exists() {
521 continue;
522 }
523 let label = format!("agent aliases in ~/{}", slot.rc_file);
524 let target = pick_target(home, slot, style);
525 strip_other_style(home, slot, &target, quiet, &label, stamp);
526 target.upsert(&block, quiet, &label);
527 }
528}
529
530fn remove_agent_aliases(home: &Path, quiet: bool) {
533 for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
534 let rc = home.join(slot.rc_file);
535 if !rc.exists() {
536 continue;
537 }
538 if let Ok(content) = std::fs::read_to_string(&rc)
539 && content.contains(ALIAS_START)
540 {
541 let filtered: Vec<&str> = content
542 .lines()
543 .scan(false, |inside, line| {
544 if line.trim() == ALIAS_START {
545 *inside = true;
546 return Some(None);
547 }
548 if *inside && line.trim() == ALIAS_END {
549 *inside = false;
550 return Some(None);
551 }
552 if *inside {
553 Some(None)
554 } else {
555 Some(Some(line))
556 }
557 })
558 .flatten()
559 .collect();
560 let _ = std::fs::write(&rc, filtered.join("\n") + "\n");
561 if !quiet {
562 println!(
563 " \x1b[33m⊖\x1b[0m Removed agent aliases from ~/{}",
564 slot.rc_file
565 );
566 }
567 }
568 let dropin = home.join(slot.dropin_dir).join(slot.dropin_file);
570 if dropin.exists() {
571 let _ = std::fs::remove_file(&dropin);
572 if !quiet {
573 println!(
574 " \x1b[33m⊖\x1b[0m Removed drop-in ~/{}/{}",
575 slot.dropin_dir, slot.dropin_file
576 );
577 }
578 }
579 }
580}
581
582fn build_env_check() -> String {
583 let checks: Vec<String> = KNOWN_AGENT_ENV_VARS
584 .iter()
585 .map(|v| format!("-n \"${v}\""))
586 .collect();
587 format!("[[ {} ]]", checks.join(" || "))
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 fn test_stamp() -> BackupStamp {
598 BackupStamp::at(
599 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
600 .unwrap()
601 .with_timezone(&chrono::Utc),
602 )
603 }
604
605 #[test]
606 fn env_check_format() {
607 let check = build_env_check();
608 assert!(check.contains("LEAN_CTX_AGENT"));
609 assert!(check.contains("CLAUDECODE"));
610 assert!(check.contains("CODEBUDDY"));
611 assert!(check.contains("||"));
612 }
613
614 #[test]
615 fn source_command_matches_login_shell() {
616 assert_eq!(
618 source_command_for_shell("/usr/bin/bash"),
619 Some("source ~/.bashrc")
620 );
621 assert_eq!(
622 source_command_for_shell("/bin/zsh"),
623 Some("source ~/.zshrc")
624 );
625 assert_eq!(
626 source_command_for_shell("/usr/local/bin/fish"),
627 Some("source ~/.config/fish/config.fish")
628 );
629 assert_eq!(source_command_for_shell(""), None);
631 assert_eq!(source_command_for_shell("/bin/false"), None);
632 }
633
634 #[test]
635 fn rc_file_matches_login_shell() {
636 assert_eq!(rc_file_for_shell("/usr/bin/bash"), "~/.bashrc");
638 assert_eq!(rc_file_for_shell("/bin/zsh"), "~/.zshrc");
639 assert_eq!(
640 rc_file_for_shell("/usr/local/bin/fish"),
641 "~/.config/fish/config.fish"
642 );
643 assert_eq!(rc_file_for_shell(""), "your shell config");
644 assert_eq!(rc_file_for_shell("/bin/false"), "your shell config");
645 }
646
647 #[test]
648 fn pick_target_inline_when_forced() {
649 let tmp = tempfile::tempdir().unwrap();
650 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
652 std::fs::write(
653 tmp.path().join(".zshenv"),
654 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
655 )
656 .unwrap();
657 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Inline);
658 assert!(matches!(t, InstallTarget::Marked { .. }));
659 }
660
661 #[test]
662 fn pick_target_dropin_when_detected_under_auto() {
663 let tmp = tempfile::tempdir().unwrap();
664 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
665 std::fs::write(
666 tmp.path().join(".zshenv"),
667 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
668 )
669 .unwrap();
670 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
671 assert!(matches!(t, InstallTarget::DropIn { .. }));
672 }
673
674 #[test]
675 fn pick_target_inline_under_auto_when_no_dropin() {
676 let tmp = tempfile::tempdir().unwrap();
677 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
678 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
679 assert!(matches!(t, InstallTarget::Marked { .. }));
680 }
681
682 #[test]
683 fn pick_target_dropin_falls_back_to_inline_when_no_directory() {
684 let tmp = tempfile::tempdir().unwrap();
687 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
688 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::DropIn);
689 assert!(matches!(t, InstallTarget::Marked { .. }));
690 }
691
692 #[test]
693 fn install_zshenv_writes_inline_block() {
694 let tmp = tempfile::tempdir().unwrap();
695 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
696 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
697 assert!(body.contains(MARKER_START));
698 assert!(body.contains(MARKER_END));
699 assert!(body.contains("ZSH_EXECUTION_STRING"));
700 }
701
702 #[test]
703 fn install_zshenv_writes_dropin_when_loop_present() {
704 let tmp = tempfile::tempdir().unwrap();
705 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
706 std::fs::write(
707 tmp.path().join(".zshenv"),
708 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
709 )
710 .unwrap();
711 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
712
713 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
714 assert!(dropin_file.exists(), "expected drop-in file");
715 let dropin_body = std::fs::read_to_string(&dropin_file).unwrap();
716 assert!(dropin_body.contains("ZSH_EXECUTION_STRING"));
717
718 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
719 assert!(
720 !zshenv_body.contains(MARKER_START),
721 "drop-in install must not also leave the inline block"
722 );
723 }
724
725 fn find_migration_backups(path: &Path) -> Vec<PathBuf> {
728 let Some(parent) = path.parent() else {
729 return Vec::new();
730 };
731 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
732 return Vec::new();
733 };
734 let prefix = format!("{name}.lean-ctx-");
735 let mut out: Vec<PathBuf> = std::fs::read_dir(parent)
736 .into_iter()
737 .flatten()
738 .flatten()
739 .map(|e| e.path())
740 .filter(|p| {
741 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
742 n.starts_with(&prefix)
743 && std::path::Path::new(n)
744 .extension()
745 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
746 })
747 })
748 .collect();
749 out.sort();
750 out
751 }
752
753 #[test]
754 fn migration_inline_to_dropin_preserves_hand_edits_via_backup() {
755 let tmp = tempfile::tempdir().unwrap();
756 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
757 let edited_zshenv = format!(
760 "export PATH=/usr/bin\n\
761 \n\
762 {MARKER_START}\n\
763 # USER CUSTOM: bump zsh history size for this workstation\n\
764 export HISTSIZE=99999\n\
765 # original lean-ctx hook content lived here\n\
766 {MARKER_END}\n\
767 \n\
768 for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
769 );
770 std::fs::write(tmp.path().join(".zshenv"), &edited_zshenv).unwrap();
771
772 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
773
774 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
776 assert_eq!(baks.len(), 1, "expected one timestamped backup");
777 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
778 assert_eq!(bak_body, edited_zshenv);
779 assert!(bak_body.contains("USER CUSTOM"));
780 assert!(bak_body.contains("HISTSIZE=99999"));
781 }
782
783 #[test]
784 fn migration_dropin_to_inline_preserves_hand_edits_via_backup() {
785 let tmp = tempfile::tempdir().unwrap();
786 let dropin_dir = tmp.path().join(".zshenv.d");
787 std::fs::create_dir_all(&dropin_dir).unwrap();
788 let edited_dropin = "# USER CUSTOM addition to lean-ctx drop-in\nexport FAVOURITE_EDITOR=helix\n# canonical lean-ctx content would follow\n";
790 std::fs::write(dropin_dir.join(DROPIN_ZSH), edited_dropin).unwrap();
791 std::fs::write(tmp.path().join(".zshenv"), "# plain zshenv\n").unwrap();
794
795 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
796
797 let baks = find_migration_backups(&dropin_dir.join(DROPIN_ZSH));
798 assert_eq!(baks.len(), 1, "expected one timestamped backup");
799 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
800 assert_eq!(bak_body, edited_dropin);
801 assert!(bak_body.contains("USER CUSTOM"));
802 assert!(!dropin_dir.join(DROPIN_ZSH).exists());
804 let zshenv = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
805 assert!(zshenv.contains(MARKER_START));
806 }
807
808 #[test]
809 fn migration_skips_backup_when_no_prior_block_exists() {
810 let tmp = tempfile::tempdir().unwrap();
813 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
814 std::fs::write(
815 tmp.path().join(".zshenv"),
816 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
817 )
818 .unwrap();
819
820 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
821
822 assert!(
823 find_migration_backups(&tmp.path().join(".zshenv")).is_empty(),
824 "clean install should not create a .bak file"
825 );
826 }
827
828 #[test]
829 fn idempotent_dropin_reinstall_does_not_create_backup() {
830 let tmp = tempfile::tempdir().unwrap();
835 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
836 std::fs::write(
837 tmp.path().join(".zshenv"),
838 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
839 )
840 .unwrap();
841
842 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
843 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
844
845 assert!(find_migration_backups(&tmp.path().join(".zshenv")).is_empty());
846 }
847
848 #[test]
849 fn backup_filename_handles_dotfile_correctly() {
850 let tmp = tempfile::tempdir().unwrap();
854 std::fs::write(tmp.path().join(".zshenv"), "content\n").unwrap();
855 save_migration_backup(&tmp.path().join(".zshenv"), true, &test_stamp());
856 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
857 assert_eq!(baks.len(), 1);
858 let name = baks[0].file_name().unwrap().to_str().unwrap();
861 assert!(name.starts_with(".zshenv.lean-ctx-"), "got: {name}");
862 assert!(
863 std::path::Path::new(name)
864 .extension()
865 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
866 );
867 let stamp = name
869 .trim_start_matches(".zshenv.lean-ctx-")
870 .trim_end_matches(".bak");
871 assert_eq!(stamp.len(), 16, "stamp should be YYYYMMDDTHHMMSSZ: {stamp}");
872 assert!(stamp.contains('T'));
873 assert!(stamp.ends_with('Z'));
874 }
875
876 #[test]
877 fn repeated_migrations_never_clobber_prior_backups() {
878 let stamp_first = BackupStamp::at(
883 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
884 .unwrap()
885 .with_timezone(&chrono::Utc),
886 );
887 let stamp_later = BackupStamp::at(
888 chrono::DateTime::parse_from_rfc3339("2026-05-12T09:00:00Z")
889 .unwrap()
890 .with_timezone(&chrono::Utc),
891 );
892 let tmp = tempfile::tempdir().unwrap();
893 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
894
895 let with_block_v1 = format!(
896 "{MARKER_START}\n# first-era custom content\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
897 );
898 std::fs::write(tmp.path().join(".zshenv"), &with_block_v1).unwrap();
899 install_zshenv(tmp.path(), true, Style::Auto, &stamp_first);
900 let baks_after_first = find_migration_backups(&tmp.path().join(".zshenv"));
901 assert_eq!(baks_after_first.len(), 1);
902
903 let with_block_v2 = format!(
906 "{}{MARKER_START}\n# second-era custom content\n{MARKER_END}\n",
907 std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap(),
908 );
909 std::fs::write(tmp.path().join(".zshenv"), &with_block_v2).unwrap();
910 install_zshenv(tmp.path(), true, Style::Auto, &stamp_later);
911 let baks_after_second = find_migration_backups(&tmp.path().join(".zshenv"));
912
913 assert_eq!(
914 baks_after_second.len(),
915 2,
916 "second migration should leave a second backup, not overwrite"
917 );
918 assert_eq!(baks_after_second[0], baks_after_first[0]);
920 let first_body = std::fs::read_to_string(&baks_after_second[0]).unwrap();
921 let second_body = std::fs::read_to_string(&baks_after_second[1]).unwrap();
922 assert!(first_body.contains("first-era custom"));
923 assert!(second_body.contains("second-era custom"));
924 }
925
926 #[test]
927 fn install_migrates_inline_to_dropin() {
928 let tmp = tempfile::tempdir().unwrap();
929 std::fs::write(
931 tmp.path().join(".zshenv"),
932 format!(
933 "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",
934 ),
935 )
936 .unwrap();
937 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
938
939 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
940
941 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
942 assert!(
943 !zshenv_body.contains(MARKER_START),
944 "old inline block should be stripped after migration"
945 );
946 assert!(
947 zshenv_body.contains(".zshenv.d"),
948 "source loop must be preserved"
949 );
950 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
951 assert!(dropin_file.exists(), "new drop-in file should be present");
952 }
953
954 #[test]
955 fn install_migrates_dropin_to_inline() {
956 let tmp = tempfile::tempdir().unwrap();
957 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
960 std::fs::write(
961 tmp.path().join(".zshenv.d").join(DROPIN_ZSH),
962 "# stale lean-ctx drop-in\n",
963 )
964 .unwrap();
965 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
966
967 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
968
969 assert!(
970 !tmp.path().join(".zshenv.d").join(DROPIN_ZSH).exists(),
971 "drop-in file should be removed when installing inline"
972 );
973 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
974 assert!(body.contains(MARKER_START));
975 }
976
977 #[test]
978 fn install_is_idempotent_in_dropin_mode() {
979 let tmp = tempfile::tempdir().unwrap();
980 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
981 std::fs::write(
982 tmp.path().join(".zshenv"),
983 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
984 )
985 .unwrap();
986
987 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
988 let after_first = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
989
990 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
991 let after_second = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
992
993 assert_eq!(after_first, after_second);
994 }
995
996 #[test]
997 fn install_is_idempotent_in_inline_mode() {
998 let tmp = tempfile::tempdir().unwrap();
999 std::fs::write(tmp.path().join(".zshenv"), "# top\n").unwrap();
1000
1001 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1002 let after_first = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1003
1004 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1005 let after_second = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1006
1007 assert_eq!(after_first, after_second);
1008 }
1009
1010 #[test]
1011 fn install_aliases_skips_when_rc_missing() {
1012 let tmp = tempfile::tempdir().unwrap();
1013 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1015 assert!(!tmp.path().join(".zshrc").exists());
1016 assert!(!tmp.path().join(".bashrc").exists());
1017 }
1018
1019 #[test]
1020 fn install_aliases_writes_dropin_when_zshrc_d_configured() {
1021 let tmp = tempfile::tempdir().unwrap();
1022 std::fs::create_dir_all(tmp.path().join(".zshrc.d")).unwrap();
1023 std::fs::write(
1024 tmp.path().join(".zshrc"),
1025 "for f in $HOME/.zshrc.d/*.zsh; do source $f; done\n",
1026 )
1027 .unwrap();
1028
1029 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1030
1031 let dropin_file = tmp.path().join(".zshrc.d").join(DROPIN_ZSH);
1032 assert!(dropin_file.exists());
1033 let body = std::fs::read_to_string(&dropin_file).unwrap();
1034 assert!(body.contains("LEAN_CTX_AGENT=1"));
1035 }
1036
1037 #[test]
1040 fn zshenv_hook_contains_lc_passthrough_stubs() {
1041 let tmp = tempfile::tempdir().unwrap();
1042 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1043 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1044 assert!(
1045 body.contains(r#"_lc() { command "$@"; }"#),
1046 "zshenv must contain _lc passthrough stub"
1047 );
1048 assert!(
1049 body.contains(r#"_lc_compress() { command "$@"; }"#),
1050 "zshenv must contain _lc_compress passthrough stub"
1051 );
1052 }
1053
1054 #[test]
1055 fn bashenv_hook_contains_lc_passthrough_stubs() {
1056 let tmp = tempfile::tempdir().unwrap();
1057 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1058 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1059 assert!(
1060 body.contains(r#"_lc() { command "$@"; }"#),
1061 "bashenv must contain _lc passthrough stub"
1062 );
1063 assert!(
1064 body.contains(r#"_lc_compress() { command "$@"; }"#),
1065 "bashenv must contain _lc_compress passthrough stub"
1066 );
1067 }
1068
1069 #[test]
1070 fn stubs_appear_before_exec_guard() {
1071 let tmp = tempfile::tempdir().unwrap();
1072 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1073 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1074 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1075 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1076 assert!(
1077 stub_pos < exec_pos,
1078 "stubs must be defined BEFORE the exec guard"
1079 );
1080 }
1081
1082 #[test]
1083 fn bash_stubs_appear_before_exec_guard() {
1084 let tmp = tempfile::tempdir().unwrap();
1090 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1091 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1092 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1093 let compress_pos = body
1094 .find("_lc_compress()")
1095 .expect("_lc_compress stub must exist");
1096 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1097 assert!(
1098 stub_pos < exec_pos && compress_pos < exec_pos,
1099 "bash stubs must be defined BEFORE the exec guard"
1100 );
1101 }
1102
1103 #[test]
1111 fn redirect_block_guards_every_skip_marker() {
1112 let block = redirect_block("ZSH_EXECUTION_STRING", "[[ -n \"$LEAN_CTX_AGENT\" ]]");
1113 let exec_pos = block
1114 .find("exec lean-ctx")
1115 .expect("redirect must exec lean-ctx");
1116 for marker in REDIRECT_SKIP_MARKERS {
1117 let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1118 let guard_pos = block
1119 .find(&guard)
1120 .unwrap_or_else(|| panic!("redirect must guard against {marker:?}:\n{block}"));
1121 assert!(
1122 guard_pos < exec_pos,
1123 "guard for {marker:?} must precede the exec redirect"
1124 );
1125 }
1126 }
1127
1128 #[test]
1129 fn zshenv_redirect_skips_ide_sandbox_and_hooks() {
1130 let tmp = tempfile::tempdir().unwrap();
1131 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1132 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1133 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1134 for marker in REDIRECT_SKIP_MARKERS {
1135 let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1136 let pos = body
1137 .find(&guard)
1138 .unwrap_or_else(|| panic!(".zshenv must guard against {marker:?}"));
1139 assert!(
1140 pos < exec_pos,
1141 "zshenv guard {marker:?} must precede the exec redirect"
1142 );
1143 }
1144 }
1145
1146 #[test]
1147 fn bashenv_redirect_skips_ide_sandbox_and_hooks() {
1148 let tmp = tempfile::tempdir().unwrap();
1149 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1150 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).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!("[[ \"$BASH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1154 let pos = body
1155 .find(&guard)
1156 .unwrap_or_else(|| panic!(".bashenv must guard against {marker:?}"));
1157 assert!(
1158 pos < exec_pos,
1159 "bashenv guard {marker:?} must precede the exec redirect"
1160 );
1161 }
1162 }
1163
1164 #[test]
1165 fn dropin_zshenv_also_contains_stubs() {
1166 let tmp = tempfile::tempdir().unwrap();
1167 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1168 std::fs::write(
1169 tmp.path().join(".zshenv"),
1170 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1171 )
1172 .unwrap();
1173 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1174
1175 let dropin = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
1176 let body = std::fs::read_to_string(&dropin).unwrap();
1177 assert!(body.contains("_lc()"), "drop-in must also contain stubs");
1178 }
1179
1180 #[cfg(unix)]
1185 static SHELL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1186
1187 #[cfg(unix)]
1188 #[test]
1189 fn shell_available_rejects_unknown_shell() {
1190 let _g = SHELL_ENV_LOCK
1191 .lock()
1192 .unwrap_or_else(std::sync::PoisonError::into_inner);
1193 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1194 assert!(!shell_available("fish"));
1195 assert!(!shell_available("nushell"));
1196 assert!(!shell_available(""));
1197 }
1198
1199 #[cfg(unix)]
1200 #[test]
1201 fn shell_available_finds_installed_shells() {
1202 let _g = SHELL_ENV_LOCK
1203 .lock()
1204 .unwrap_or_else(std::sync::PoisonError::into_inner);
1205 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1206 let has_bash = Path::new("/bin/bash").exists() || Path::new("/usr/bin/bash").exists();
1208 let has_zsh = Path::new("/bin/zsh").exists() || Path::new("/usr/bin/zsh").exists();
1209 assert!(
1210 shell_available("bash") == has_bash,
1211 "shell_available(bash) should match filesystem"
1212 );
1213 assert!(
1214 shell_available("zsh") == has_zsh,
1215 "shell_available(zsh) should match filesystem"
1216 );
1217 }
1218
1219 #[cfg(unix)]
1220 #[test]
1221 fn shell_hook_force_overrides_detection() {
1222 let _g = SHELL_ENV_LOCK
1223 .lock()
1224 .unwrap_or_else(std::sync::PoisonError::into_inner);
1225
1226 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "all");
1228 assert!(shell_available("zsh"));
1229 assert!(shell_available("bash"));
1230
1231 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "zsh");
1233 assert!(shell_available("zsh"));
1234 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1238 }
1239}