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 "CODEX_CLI_SESSION",
20 "GEMINI_SESSION",
21];
22
23const AGENT_ALIASES: &[(&str, &str)] = &[
24 ("claude", "claude"),
25 ("codex", "codex"),
26 ("gemini", "gemini"),
27];
28
29fn source_command_for_shell(shell: &str) -> Option<&'static str> {
34 if shell.contains("zsh") {
35 Some("source ~/.zshrc")
36 } else if shell.contains("fish") {
37 Some("source ~/.config/fish/config.fish")
38 } else if shell.contains("bash") {
39 Some("source ~/.bashrc")
40 } else {
41 None
42 }
43}
44
45pub fn shell_source_command() -> Option<&'static str> {
50 source_command_for_shell(&std::env::var("SHELL").unwrap_or_default())
51}
52
53fn rc_file_for_shell(shell: &str) -> &'static str {
55 if shell.contains("zsh") {
56 "~/.zshrc"
57 } else if shell.contains("fish") {
58 "~/.config/fish/config.fish"
59 } else if shell.contains("bash") {
60 "~/.bashrc"
61 } else {
62 "your shell config"
63 }
64}
65
66pub fn shell_rc_file() -> &'static str {
70 rc_file_for_shell(&std::env::var("SHELL").unwrap_or_default())
71}
72
73pub fn reload_aliases_hint() -> String {
76 match shell_source_command() {
77 Some(cmd) => format!("Run '{cmd}' (or restart terminal) for updated shell aliases."),
78 None => "Restart your terminal to load updated shell aliases.".to_string(),
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum Style {
90 Inline,
92 DropIn,
95 #[default]
97 Auto,
98}
99
100#[derive(Debug, Clone, Copy)]
104struct Slot {
105 rc_file: &'static str,
106 dropin_dir: &'static str,
107 dropin_file: &'static str,
108 marker_start: &'static str,
109 marker_end: &'static str,
110}
111
112const SLOT_ZSHENV: Slot = Slot {
113 rc_file: ".zshenv",
114 dropin_dir: ".zshenv.d",
115 dropin_file: DROPIN_ZSH,
116 marker_start: MARKER_START,
117 marker_end: MARKER_END,
118};
119
120const SLOT_BASHENV: Slot = Slot {
121 rc_file: ".bashenv",
122 dropin_dir: ".bashenv.d",
123 dropin_file: DROPIN_SH,
124 marker_start: MARKER_START,
125 marker_end: MARKER_END,
126};
127
128const SLOT_ZSHRC: Slot = Slot {
129 rc_file: ".zshrc",
130 dropin_dir: ".zshrc.d",
131 dropin_file: DROPIN_ZSH,
132 marker_start: ALIAS_START,
133 marker_end: ALIAS_END,
134};
135
136const SLOT_BASHRC: Slot = Slot {
137 rc_file: ".bashrc",
138 dropin_dir: ".bashrc.d",
139 dropin_file: DROPIN_SH,
140 marker_start: ALIAS_START,
141 marker_end: ALIAS_END,
142};
143
144enum InstallTarget {
146 Marked {
147 path: PathBuf,
148 start: &'static str,
149 end: &'static str,
150 },
151 DropIn {
152 dir: PathBuf,
153 filename: &'static str,
154 },
155}
156
157impl InstallTarget {
158 fn upsert(&self, content: &str, quiet: bool, label: &str) {
159 match self {
160 Self::Marked { path, start, end } => {
161 marked_block::upsert(path, start, end, content, quiet, label);
162 }
163 Self::DropIn { dir, filename } => dropin::write(dir, filename, content, quiet, label),
164 }
165 }
166}
167
168fn pick_target(home: &Path, slot: &Slot, style: Style) -> InstallTarget {
170 let inline = InstallTarget::Marked {
171 path: home.join(slot.rc_file),
172 start: slot.marker_start,
173 end: slot.marker_end,
174 };
175 match style {
176 Style::Inline => inline,
177 Style::DropIn | Style::Auto => match dropin::detect(home, slot.rc_file, slot.dropin_dir) {
182 Some(dir) => InstallTarget::DropIn {
183 dir,
184 filename: slot.dropin_file,
185 },
186 None => inline,
187 },
188 }
189}
190
191struct BackupStamp(String);
204
205impl BackupStamp {
206 fn now() -> Self {
209 Self::at(chrono::Utc::now())
210 }
211
212 fn at(stamp: chrono::DateTime<chrono::Utc>) -> Self {
216 Self(stamp.format("%Y%m%dT%H%M%SZ").to_string())
217 }
218
219 fn backup_path_for(&self, path: &Path) -> Option<PathBuf> {
221 let file_name = path.file_name().and_then(|n| n.to_str())?;
222 Some(path.with_file_name(format!("{file_name}.lean-ctx-{}.bak", self.0)))
223 }
224}
225
226fn save_migration_backup(path: &Path, quiet: bool, stamp: &BackupStamp) {
248 if !path.exists() {
249 return;
250 }
251 let Some(bak) = stamp.backup_path_for(path) else {
252 return;
253 };
254 match std::fs::copy(path, &bak) {
255 Ok(_) => {
256 if !quiet {
257 eprintln!(" Backup: {} -> {}", path.display(), bak.display());
258 }
259 }
260 Err(e) => {
261 tracing::warn!("Failed to back up {}: {e}", path.display());
262 }
263 }
264}
265
266fn strip_other_style(
279 home: &Path,
280 slot: &Slot,
281 target: &InstallTarget,
282 quiet: bool,
283 label: &str,
284 stamp: &BackupStamp,
285) {
286 match target {
287 InstallTarget::Marked { .. } => {
288 let dropin_dir = home.join(slot.dropin_dir);
290 let dropin_path = dropin_dir.join(slot.dropin_file);
291 if dropin_path.exists() {
292 save_migration_backup(&dropin_path, quiet, stamp);
296 dropin::remove(&dropin_dir, slot.dropin_file, quiet, label);
297 }
298 }
299 InstallTarget::DropIn { .. } => {
300 let rc_path = home.join(slot.rc_file);
305 if let Ok(existing) = std::fs::read_to_string(&rc_path) {
306 if existing.contains(slot.marker_start) {
307 save_migration_backup(&rc_path, quiet, stamp);
308 }
309 }
310 marked_block::remove_from_file(
311 &rc_path,
312 slot.marker_start,
313 slot.marker_end,
314 quiet,
315 label,
316 );
317 }
318 }
319}
320
321pub fn install_all(quiet: bool) {
325 install_all_with_style(quiet, Style::Auto);
326}
327
328pub fn install_all_with_style(quiet: bool, style: Style) {
335 let Some(home) = dirs::home_dir() else {
336 tracing::error!("Cannot resolve home directory");
337 return;
338 };
339
340 let stamp = BackupStamp::now();
341 if shell_available("zsh") {
342 install_zshenv(&home, quiet, style, &stamp);
343 }
344 if shell_available("bash") {
345 install_bashenv(&home, quiet, style, &stamp);
346 }
347 install_aliases(&home, quiet, style, &stamp);
348}
349
350#[cfg(unix)]
358fn shell_available(shell: &str) -> bool {
359 if let Ok(forced) = std::env::var("LEAN_CTX_SHELL_HOOK_FORCE") {
360 let forced = forced.trim();
361 if forced == "1"
362 || forced.eq_ignore_ascii_case("true")
363 || forced.eq_ignore_ascii_case("all")
364 {
365 return true;
366 }
367 if forced
368 .split(',')
369 .any(|s| s.trim().eq_ignore_ascii_case(shell))
370 {
371 return true;
372 }
373 }
374
375 let candidates: &[&str] = match shell {
376 "zsh" => &[
377 "/bin/zsh",
378 "/usr/bin/zsh",
379 "/usr/local/bin/zsh",
380 "/opt/homebrew/bin/zsh",
381 ],
382 "bash" => &[
383 "/bin/bash",
384 "/usr/bin/bash",
385 "/usr/local/bin/bash",
386 "/opt/homebrew/bin/bash",
387 ],
388 _ => return false,
389 };
390 candidates.iter().any(|p| Path::new(p).exists())
391}
392
393#[cfg(not(unix))]
394fn shell_available(_shell: &str) -> bool {
395 false
397}
398
399pub fn uninstall_all(quiet: bool) {
400 let Some(home) = dirs::home_dir() else { return };
401
402 let slots: &[(Slot, &str)] = &[
405 (SLOT_ZSHENV, "shell hook for ~/.zshenv"),
406 (SLOT_BASHENV, "shell hook for ~/.bashenv"),
407 (SLOT_ZSHRC, "agent aliases for ~/.zshrc"),
408 (SLOT_BASHRC, "agent aliases for ~/.bashrc"),
409 ];
410
411 for (slot, label) in slots {
412 marked_block::remove_from_file(
413 &home.join(slot.rc_file),
414 slot.marker_start,
415 slot.marker_end,
416 quiet,
417 label,
418 );
419 let dir_path = home.join(slot.dropin_dir);
420 if dir_path.exists() {
421 dropin::remove(&dir_path, slot.dropin_file, quiet, label);
422 }
423 }
424}
425
426fn install_zshenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
427 let env_check = build_env_check();
428 let hook = format!(
429 r#"{MARKER_START}
430# Passthrough stubs: ensure _lc/_lc_compress exist in ALL zsh contexts
431# (non-interactive subshells, eval, agent harnesses) so aliases that
432# reference them degrade gracefully instead of "command not found".
433# The full shell-hook.zsh overrides these when loaded via .zshrc.
434_lc() {{ command "$@"; }}
435_lc_compress() {{ command "$@"; }}
436if [[ -z "$LEAN_CTX_ACTIVE" && -n "$ZSH_EXECUTION_STRING" ]] && command -v lean-ctx &>/dev/null; then
437 if {env_check}; then
438 export LEAN_CTX_ACTIVE=1
439 exec lean-ctx -c "$ZSH_EXECUTION_STRING"
440 fi
441fi
442{MARKER_END}"#
443 );
444
445 let label = "shell hook in ~/.zshenv";
446 let target = pick_target(home, &SLOT_ZSHENV, style);
447 strip_other_style(home, &SLOT_ZSHENV, &target, quiet, label, stamp);
448 target.upsert(&hook, quiet, label);
449}
450
451fn install_bashenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
452 let env_check = build_env_check();
453 let hook = format!(
454 r#"{MARKER_START}
455_lc() {{ command "$@"; }}
456_lc_compress() {{ command "$@"; }}
457if [[ -z "$LEAN_CTX_ACTIVE" && -n "$BASH_EXECUTION_STRING" ]] && command -v lean-ctx &>/dev/null; then
458 if {env_check}; then
459 export LEAN_CTX_ACTIVE=1
460 exec lean-ctx -c "$BASH_EXECUTION_STRING"
461 fi
462fi
463{MARKER_END}"#
464 );
465
466 let label = "shell hook in ~/.bashenv";
467 let target = pick_target(home, &SLOT_BASHENV, style);
468 strip_other_style(home, &SLOT_BASHENV, &target, quiet, label, stamp);
469 target.upsert(&hook, quiet, label);
470}
471
472fn install_aliases(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
473 let mut lines = Vec::new();
474 lines.push(ALIAS_START.to_string());
475 for (alias_name, bin_name) in AGENT_ALIASES {
476 lines.push(format!(
477 "alias {alias_name}='LEAN_CTX_AGENT=1 BASH_ENV=\"$HOME/.bashenv\" {bin_name}'"
478 ));
479 }
480 lines.push(ALIAS_END.to_string());
481 let block = lines.join("\n");
482
483 for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
484 if !home.join(slot.rc_file).exists() {
487 continue;
488 }
489 let label = format!("agent aliases in ~/{}", slot.rc_file);
490 let target = pick_target(home, slot, style);
491 strip_other_style(home, slot, &target, quiet, &label, stamp);
492 target.upsert(&block, quiet, &label);
493 }
494}
495
496fn build_env_check() -> String {
497 let checks: Vec<String> = KNOWN_AGENT_ENV_VARS
498 .iter()
499 .map(|v| format!("-n \"${v}\""))
500 .collect();
501 format!("[[ {} ]]", checks.join(" || "))
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 fn test_stamp() -> BackupStamp {
512 BackupStamp::at(
513 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
514 .unwrap()
515 .with_timezone(&chrono::Utc),
516 )
517 }
518
519 #[test]
520 fn env_check_format() {
521 let check = build_env_check();
522 assert!(check.contains("LEAN_CTX_AGENT"));
523 assert!(check.contains("CLAUDECODE"));
524 assert!(check.contains("||"));
525 }
526
527 #[test]
528 fn source_command_matches_login_shell() {
529 assert_eq!(
531 source_command_for_shell("/usr/bin/bash"),
532 Some("source ~/.bashrc")
533 );
534 assert_eq!(
535 source_command_for_shell("/bin/zsh"),
536 Some("source ~/.zshrc")
537 );
538 assert_eq!(
539 source_command_for_shell("/usr/local/bin/fish"),
540 Some("source ~/.config/fish/config.fish")
541 );
542 assert_eq!(source_command_for_shell(""), None);
544 assert_eq!(source_command_for_shell("/bin/false"), None);
545 }
546
547 #[test]
548 fn rc_file_matches_login_shell() {
549 assert_eq!(rc_file_for_shell("/usr/bin/bash"), "~/.bashrc");
551 assert_eq!(rc_file_for_shell("/bin/zsh"), "~/.zshrc");
552 assert_eq!(
553 rc_file_for_shell("/usr/local/bin/fish"),
554 "~/.config/fish/config.fish"
555 );
556 assert_eq!(rc_file_for_shell(""), "your shell config");
557 assert_eq!(rc_file_for_shell("/bin/false"), "your shell config");
558 }
559
560 #[test]
561 fn pick_target_inline_when_forced() {
562 let tmp = tempfile::tempdir().unwrap();
563 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
565 std::fs::write(
566 tmp.path().join(".zshenv"),
567 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
568 )
569 .unwrap();
570 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Inline);
571 assert!(matches!(t, InstallTarget::Marked { .. }));
572 }
573
574 #[test]
575 fn pick_target_dropin_when_detected_under_auto() {
576 let tmp = tempfile::tempdir().unwrap();
577 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
578 std::fs::write(
579 tmp.path().join(".zshenv"),
580 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
581 )
582 .unwrap();
583 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
584 assert!(matches!(t, InstallTarget::DropIn { .. }));
585 }
586
587 #[test]
588 fn pick_target_inline_under_auto_when_no_dropin() {
589 let tmp = tempfile::tempdir().unwrap();
590 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
591 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
592 assert!(matches!(t, InstallTarget::Marked { .. }));
593 }
594
595 #[test]
596 fn pick_target_dropin_falls_back_to_inline_when_no_directory() {
597 let tmp = tempfile::tempdir().unwrap();
600 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
601 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::DropIn);
602 assert!(matches!(t, InstallTarget::Marked { .. }));
603 }
604
605 #[test]
606 fn install_zshenv_writes_inline_block() {
607 let tmp = tempfile::tempdir().unwrap();
608 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
609 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
610 assert!(body.contains(MARKER_START));
611 assert!(body.contains(MARKER_END));
612 assert!(body.contains("ZSH_EXECUTION_STRING"));
613 }
614
615 #[test]
616 fn install_zshenv_writes_dropin_when_loop_present() {
617 let tmp = tempfile::tempdir().unwrap();
618 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
619 std::fs::write(
620 tmp.path().join(".zshenv"),
621 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
622 )
623 .unwrap();
624 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
625
626 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
627 assert!(dropin_file.exists(), "expected drop-in file");
628 let dropin_body = std::fs::read_to_string(&dropin_file).unwrap();
629 assert!(dropin_body.contains("ZSH_EXECUTION_STRING"));
630
631 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
632 assert!(
633 !zshenv_body.contains(MARKER_START),
634 "drop-in install must not also leave the inline block"
635 );
636 }
637
638 fn find_migration_backups(path: &Path) -> Vec<PathBuf> {
641 let Some(parent) = path.parent() else {
642 return Vec::new();
643 };
644 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
645 return Vec::new();
646 };
647 let prefix = format!("{name}.lean-ctx-");
648 let mut out: Vec<PathBuf> = std::fs::read_dir(parent)
649 .into_iter()
650 .flatten()
651 .flatten()
652 .map(|e| e.path())
653 .filter(|p| {
654 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
655 n.starts_with(&prefix)
656 && std::path::Path::new(n)
657 .extension()
658 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
659 })
660 })
661 .collect();
662 out.sort();
663 out
664 }
665
666 #[test]
667 fn migration_inline_to_dropin_preserves_hand_edits_via_backup() {
668 let tmp = tempfile::tempdir().unwrap();
669 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
670 let edited_zshenv = format!(
673 "export PATH=/usr/bin\n\
674 \n\
675 {MARKER_START}\n\
676 # USER CUSTOM: bump zsh history size for this workstation\n\
677 export HISTSIZE=99999\n\
678 # original lean-ctx hook content lived here\n\
679 {MARKER_END}\n\
680 \n\
681 for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
682 );
683 std::fs::write(tmp.path().join(".zshenv"), &edited_zshenv).unwrap();
684
685 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
686
687 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
689 assert_eq!(baks.len(), 1, "expected one timestamped backup");
690 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
691 assert_eq!(bak_body, edited_zshenv);
692 assert!(bak_body.contains("USER CUSTOM"));
693 assert!(bak_body.contains("HISTSIZE=99999"));
694 }
695
696 #[test]
697 fn migration_dropin_to_inline_preserves_hand_edits_via_backup() {
698 let tmp = tempfile::tempdir().unwrap();
699 let dropin_dir = tmp.path().join(".zshenv.d");
700 std::fs::create_dir_all(&dropin_dir).unwrap();
701 let edited_dropin = "# USER CUSTOM addition to lean-ctx drop-in\nexport FAVOURITE_EDITOR=helix\n# canonical lean-ctx content would follow\n";
703 std::fs::write(dropin_dir.join(DROPIN_ZSH), edited_dropin).unwrap();
704 std::fs::write(tmp.path().join(".zshenv"), "# plain zshenv\n").unwrap();
707
708 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
709
710 let baks = find_migration_backups(&dropin_dir.join(DROPIN_ZSH));
711 assert_eq!(baks.len(), 1, "expected one timestamped backup");
712 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
713 assert_eq!(bak_body, edited_dropin);
714 assert!(bak_body.contains("USER CUSTOM"));
715 assert!(!dropin_dir.join(DROPIN_ZSH).exists());
717 let zshenv = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
718 assert!(zshenv.contains(MARKER_START));
719 }
720
721 #[test]
722 fn migration_skips_backup_when_no_prior_block_exists() {
723 let tmp = tempfile::tempdir().unwrap();
726 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
727 std::fs::write(
728 tmp.path().join(".zshenv"),
729 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
730 )
731 .unwrap();
732
733 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
734
735 assert!(
736 find_migration_backups(&tmp.path().join(".zshenv")).is_empty(),
737 "clean install should not create a .bak file"
738 );
739 }
740
741 #[test]
742 fn idempotent_dropin_reinstall_does_not_create_backup() {
743 let tmp = tempfile::tempdir().unwrap();
748 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
749 std::fs::write(
750 tmp.path().join(".zshenv"),
751 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
752 )
753 .unwrap();
754
755 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
756 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
757
758 assert!(find_migration_backups(&tmp.path().join(".zshenv")).is_empty());
759 }
760
761 #[test]
762 fn backup_filename_handles_dotfile_correctly() {
763 let tmp = tempfile::tempdir().unwrap();
767 std::fs::write(tmp.path().join(".zshenv"), "content\n").unwrap();
768 save_migration_backup(&tmp.path().join(".zshenv"), true, &test_stamp());
769 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
770 assert_eq!(baks.len(), 1);
771 let name = baks[0].file_name().unwrap().to_str().unwrap();
774 assert!(name.starts_with(".zshenv.lean-ctx-"), "got: {name}");
775 assert!(std::path::Path::new(name)
776 .extension()
777 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak")));
778 let stamp = name
780 .trim_start_matches(".zshenv.lean-ctx-")
781 .trim_end_matches(".bak");
782 assert_eq!(stamp.len(), 16, "stamp should be YYYYMMDDTHHMMSSZ: {stamp}");
783 assert!(stamp.contains('T'));
784 assert!(stamp.ends_with('Z'));
785 }
786
787 #[test]
788 fn repeated_migrations_never_clobber_prior_backups() {
789 let stamp_first = BackupStamp::at(
794 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
795 .unwrap()
796 .with_timezone(&chrono::Utc),
797 );
798 let stamp_later = BackupStamp::at(
799 chrono::DateTime::parse_from_rfc3339("2026-05-12T09:00:00Z")
800 .unwrap()
801 .with_timezone(&chrono::Utc),
802 );
803 let tmp = tempfile::tempdir().unwrap();
804 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
805
806 let with_block_v1 = format!(
807 "{MARKER_START}\n# first-era custom content\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
808 );
809 std::fs::write(tmp.path().join(".zshenv"), &with_block_v1).unwrap();
810 install_zshenv(tmp.path(), true, Style::Auto, &stamp_first);
811 let baks_after_first = find_migration_backups(&tmp.path().join(".zshenv"));
812 assert_eq!(baks_after_first.len(), 1);
813
814 let with_block_v2 = format!(
817 "{}{MARKER_START}\n# second-era custom content\n{MARKER_END}\n",
818 std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap(),
819 );
820 std::fs::write(tmp.path().join(".zshenv"), &with_block_v2).unwrap();
821 install_zshenv(tmp.path(), true, Style::Auto, &stamp_later);
822 let baks_after_second = find_migration_backups(&tmp.path().join(".zshenv"));
823
824 assert_eq!(
825 baks_after_second.len(),
826 2,
827 "second migration should leave a second backup, not overwrite"
828 );
829 assert_eq!(baks_after_second[0], baks_after_first[0]);
831 let first_body = std::fs::read_to_string(&baks_after_second[0]).unwrap();
832 let second_body = std::fs::read_to_string(&baks_after_second[1]).unwrap();
833 assert!(first_body.contains("first-era custom"));
834 assert!(second_body.contains("second-era custom"));
835 }
836
837 #[test]
838 fn install_migrates_inline_to_dropin() {
839 let tmp = tempfile::tempdir().unwrap();
840 std::fs::write(
842 tmp.path().join(".zshenv"),
843 format!(
844 "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",
845 ),
846 )
847 .unwrap();
848 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
849
850 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
851
852 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
853 assert!(
854 !zshenv_body.contains(MARKER_START),
855 "old inline block should be stripped after migration"
856 );
857 assert!(
858 zshenv_body.contains(".zshenv.d"),
859 "source loop must be preserved"
860 );
861 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
862 assert!(dropin_file.exists(), "new drop-in file should be present");
863 }
864
865 #[test]
866 fn install_migrates_dropin_to_inline() {
867 let tmp = tempfile::tempdir().unwrap();
868 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
871 std::fs::write(
872 tmp.path().join(".zshenv.d").join(DROPIN_ZSH),
873 "# stale lean-ctx drop-in\n",
874 )
875 .unwrap();
876 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
877
878 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
879
880 assert!(
881 !tmp.path().join(".zshenv.d").join(DROPIN_ZSH).exists(),
882 "drop-in file should be removed when installing inline"
883 );
884 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
885 assert!(body.contains(MARKER_START));
886 }
887
888 #[test]
889 fn install_is_idempotent_in_dropin_mode() {
890 let tmp = tempfile::tempdir().unwrap();
891 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
892 std::fs::write(
893 tmp.path().join(".zshenv"),
894 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
895 )
896 .unwrap();
897
898 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
899 let after_first = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
900
901 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
902 let after_second = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
903
904 assert_eq!(after_first, after_second);
905 }
906
907 #[test]
908 fn install_is_idempotent_in_inline_mode() {
909 let tmp = tempfile::tempdir().unwrap();
910 std::fs::write(tmp.path().join(".zshenv"), "# top\n").unwrap();
911
912 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
913 let after_first = std::fs::read(tmp.path().join(".zshenv")).unwrap();
914
915 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
916 let after_second = std::fs::read(tmp.path().join(".zshenv")).unwrap();
917
918 assert_eq!(after_first, after_second);
919 }
920
921 #[test]
922 fn install_aliases_skips_when_rc_missing() {
923 let tmp = tempfile::tempdir().unwrap();
924 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
926 assert!(!tmp.path().join(".zshrc").exists());
927 assert!(!tmp.path().join(".bashrc").exists());
928 }
929
930 #[test]
931 fn install_aliases_writes_dropin_when_zshrc_d_configured() {
932 let tmp = tempfile::tempdir().unwrap();
933 std::fs::create_dir_all(tmp.path().join(".zshrc.d")).unwrap();
934 std::fs::write(
935 tmp.path().join(".zshrc"),
936 "for f in $HOME/.zshrc.d/*.zsh; do source $f; done\n",
937 )
938 .unwrap();
939
940 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
941
942 let dropin_file = tmp.path().join(".zshrc.d").join(DROPIN_ZSH);
943 assert!(dropin_file.exists());
944 let body = std::fs::read_to_string(&dropin_file).unwrap();
945 assert!(body.contains("LEAN_CTX_AGENT=1"));
946 }
947
948 #[test]
951 fn zshenv_hook_contains_lc_passthrough_stubs() {
952 let tmp = tempfile::tempdir().unwrap();
953 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
954 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
955 assert!(
956 body.contains(r#"_lc() { command "$@"; }"#),
957 "zshenv must contain _lc passthrough stub"
958 );
959 assert!(
960 body.contains(r#"_lc_compress() { command "$@"; }"#),
961 "zshenv must contain _lc_compress passthrough stub"
962 );
963 }
964
965 #[test]
966 fn bashenv_hook_contains_lc_passthrough_stubs() {
967 let tmp = tempfile::tempdir().unwrap();
968 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
969 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
970 assert!(
971 body.contains(r#"_lc() { command "$@"; }"#),
972 "bashenv must contain _lc passthrough stub"
973 );
974 assert!(
975 body.contains(r#"_lc_compress() { command "$@"; }"#),
976 "bashenv must contain _lc_compress passthrough stub"
977 );
978 }
979
980 #[test]
981 fn stubs_appear_before_exec_guard() {
982 let tmp = tempfile::tempdir().unwrap();
983 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
984 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
985 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
986 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
987 assert!(
988 stub_pos < exec_pos,
989 "stubs must be defined BEFORE the exec guard"
990 );
991 }
992
993 #[test]
994 fn dropin_zshenv_also_contains_stubs() {
995 let tmp = tempfile::tempdir().unwrap();
996 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
997 std::fs::write(
998 tmp.path().join(".zshenv"),
999 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1000 )
1001 .unwrap();
1002 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1003
1004 let dropin = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
1005 let body = std::fs::read_to_string(&dropin).unwrap();
1006 assert!(body.contains("_lc()"), "drop-in must also contain stubs");
1007 }
1008
1009 #[cfg(unix)]
1014 static SHELL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1015
1016 #[cfg(unix)]
1017 #[test]
1018 fn shell_available_rejects_unknown_shell() {
1019 let _g = SHELL_ENV_LOCK
1020 .lock()
1021 .unwrap_or_else(std::sync::PoisonError::into_inner);
1022 std::env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1023 assert!(!shell_available("fish"));
1024 assert!(!shell_available("nushell"));
1025 assert!(!shell_available(""));
1026 }
1027
1028 #[cfg(unix)]
1029 #[test]
1030 fn shell_available_finds_installed_shells() {
1031 let _g = SHELL_ENV_LOCK
1032 .lock()
1033 .unwrap_or_else(std::sync::PoisonError::into_inner);
1034 std::env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1035 let has_bash = Path::new("/bin/bash").exists() || Path::new("/usr/bin/bash").exists();
1037 let has_zsh = Path::new("/bin/zsh").exists() || Path::new("/usr/bin/zsh").exists();
1038 assert!(
1039 shell_available("bash") == has_bash,
1040 "shell_available(bash) should match filesystem"
1041 );
1042 assert!(
1043 shell_available("zsh") == has_zsh,
1044 "shell_available(zsh) should match filesystem"
1045 );
1046 }
1047
1048 #[cfg(unix)]
1049 #[test]
1050 fn shell_hook_force_overrides_detection() {
1051 let _g = SHELL_ENV_LOCK
1052 .lock()
1053 .unwrap_or_else(std::sync::PoisonError::into_inner);
1054
1055 std::env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "all");
1057 assert!(shell_available("zsh"));
1058 assert!(shell_available("bash"));
1059
1060 std::env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "zsh");
1062 assert!(shell_available("zsh"));
1063 std::env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1067 }
1068}