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