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 if existing.contains(slot.marker_start) {
309 save_migration_backup(&rc_path, quiet, stamp);
310 }
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
428fn install_zshenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
429 let env_check = build_env_check();
430 let hook = format!(
431 r#"{MARKER_START}
432# Passthrough stubs: ensure _lc/_lc_compress exist in ALL zsh contexts
433# (non-interactive subshells, eval, agent harnesses) so aliases that
434# reference them degrade gracefully instead of "command not found".
435# The full shell-hook.zsh overrides these when loaded via .zshrc.
436_lc() {{ command "$@"; }}
437_lc_compress() {{ command "$@"; }}
438if [[ -z "$LEAN_CTX_ACTIVE" && -n "$ZSH_EXECUTION_STRING" ]] && command -v lean-ctx &>/dev/null; then
439 if {env_check}; then
440 export LEAN_CTX_ACTIVE=1
441 exec lean-ctx -c "$ZSH_EXECUTION_STRING"
442 fi
443fi
444{MARKER_END}"#
445 );
446
447 let label = "shell hook in ~/.zshenv";
448 let target = pick_target(home, &SLOT_ZSHENV, style);
449 strip_other_style(home, &SLOT_ZSHENV, &target, quiet, label, stamp);
450 target.upsert(&hook, quiet, label);
451}
452
453fn install_bashenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
454 let env_check = build_env_check();
455 let hook = format!(
456 r#"{MARKER_START}
457_lc() {{ command "$@"; }}
458_lc_compress() {{ command "$@"; }}
459if [[ -z "$LEAN_CTX_ACTIVE" && -n "$BASH_EXECUTION_STRING" ]] && command -v lean-ctx &>/dev/null; then
460 if {env_check}; then
461 export LEAN_CTX_ACTIVE=1
462 exec lean-ctx -c "$BASH_EXECUTION_STRING"
463 fi
464fi
465{MARKER_END}"#
466 );
467
468 let label = "shell hook in ~/.bashenv";
469 let target = pick_target(home, &SLOT_BASHENV, style);
470 strip_other_style(home, &SLOT_BASHENV, &target, quiet, label, stamp);
471 target.upsert(&hook, quiet, label);
472}
473
474fn install_aliases(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
475 let mut lines = Vec::new();
476 lines.push(ALIAS_START.to_string());
477 for (alias_name, bin_name) in AGENT_ALIASES {
478 lines.push(format!(
479 "alias {alias_name}='LEAN_CTX_AGENT=1 BASH_ENV=\"$HOME/.bashenv\" {bin_name}'"
480 ));
481 }
482 lines.push(ALIAS_END.to_string());
483 let block = lines.join("\n");
484
485 for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
486 if !home.join(slot.rc_file).exists() {
489 continue;
490 }
491 let label = format!("agent aliases in ~/{}", slot.rc_file);
492 let target = pick_target(home, slot, style);
493 strip_other_style(home, slot, &target, quiet, &label, stamp);
494 target.upsert(&block, quiet, &label);
495 }
496}
497
498fn build_env_check() -> String {
499 let checks: Vec<String> = KNOWN_AGENT_ENV_VARS
500 .iter()
501 .map(|v| format!("-n \"${v}\""))
502 .collect();
503 format!("[[ {} ]]", checks.join(" || "))
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 fn test_stamp() -> BackupStamp {
514 BackupStamp::at(
515 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
516 .unwrap()
517 .with_timezone(&chrono::Utc),
518 )
519 }
520
521 #[test]
522 fn env_check_format() {
523 let check = build_env_check();
524 assert!(check.contains("LEAN_CTX_AGENT"));
525 assert!(check.contains("CLAUDECODE"));
526 assert!(check.contains("CODEBUDDY"));
527 assert!(check.contains("||"));
528 }
529
530 #[test]
531 fn source_command_matches_login_shell() {
532 assert_eq!(
534 source_command_for_shell("/usr/bin/bash"),
535 Some("source ~/.bashrc")
536 );
537 assert_eq!(
538 source_command_for_shell("/bin/zsh"),
539 Some("source ~/.zshrc")
540 );
541 assert_eq!(
542 source_command_for_shell("/usr/local/bin/fish"),
543 Some("source ~/.config/fish/config.fish")
544 );
545 assert_eq!(source_command_for_shell(""), None);
547 assert_eq!(source_command_for_shell("/bin/false"), None);
548 }
549
550 #[test]
551 fn rc_file_matches_login_shell() {
552 assert_eq!(rc_file_for_shell("/usr/bin/bash"), "~/.bashrc");
554 assert_eq!(rc_file_for_shell("/bin/zsh"), "~/.zshrc");
555 assert_eq!(
556 rc_file_for_shell("/usr/local/bin/fish"),
557 "~/.config/fish/config.fish"
558 );
559 assert_eq!(rc_file_for_shell(""), "your shell config");
560 assert_eq!(rc_file_for_shell("/bin/false"), "your shell config");
561 }
562
563 #[test]
564 fn pick_target_inline_when_forced() {
565 let tmp = tempfile::tempdir().unwrap();
566 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
568 std::fs::write(
569 tmp.path().join(".zshenv"),
570 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
571 )
572 .unwrap();
573 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Inline);
574 assert!(matches!(t, InstallTarget::Marked { .. }));
575 }
576
577 #[test]
578 fn pick_target_dropin_when_detected_under_auto() {
579 let tmp = tempfile::tempdir().unwrap();
580 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
581 std::fs::write(
582 tmp.path().join(".zshenv"),
583 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
584 )
585 .unwrap();
586 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
587 assert!(matches!(t, InstallTarget::DropIn { .. }));
588 }
589
590 #[test]
591 fn pick_target_inline_under_auto_when_no_dropin() {
592 let tmp = tempfile::tempdir().unwrap();
593 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
594 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
595 assert!(matches!(t, InstallTarget::Marked { .. }));
596 }
597
598 #[test]
599 fn pick_target_dropin_falls_back_to_inline_when_no_directory() {
600 let tmp = tempfile::tempdir().unwrap();
603 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
604 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::DropIn);
605 assert!(matches!(t, InstallTarget::Marked { .. }));
606 }
607
608 #[test]
609 fn install_zshenv_writes_inline_block() {
610 let tmp = tempfile::tempdir().unwrap();
611 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
612 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
613 assert!(body.contains(MARKER_START));
614 assert!(body.contains(MARKER_END));
615 assert!(body.contains("ZSH_EXECUTION_STRING"));
616 }
617
618 #[test]
619 fn install_zshenv_writes_dropin_when_loop_present() {
620 let tmp = tempfile::tempdir().unwrap();
621 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
622 std::fs::write(
623 tmp.path().join(".zshenv"),
624 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
625 )
626 .unwrap();
627 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
628
629 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
630 assert!(dropin_file.exists(), "expected drop-in file");
631 let dropin_body = std::fs::read_to_string(&dropin_file).unwrap();
632 assert!(dropin_body.contains("ZSH_EXECUTION_STRING"));
633
634 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
635 assert!(
636 !zshenv_body.contains(MARKER_START),
637 "drop-in install must not also leave the inline block"
638 );
639 }
640
641 fn find_migration_backups(path: &Path) -> Vec<PathBuf> {
644 let Some(parent) = path.parent() else {
645 return Vec::new();
646 };
647 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
648 return Vec::new();
649 };
650 let prefix = format!("{name}.lean-ctx-");
651 let mut out: Vec<PathBuf> = std::fs::read_dir(parent)
652 .into_iter()
653 .flatten()
654 .flatten()
655 .map(|e| e.path())
656 .filter(|p| {
657 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
658 n.starts_with(&prefix)
659 && std::path::Path::new(n)
660 .extension()
661 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
662 })
663 })
664 .collect();
665 out.sort();
666 out
667 }
668
669 #[test]
670 fn migration_inline_to_dropin_preserves_hand_edits_via_backup() {
671 let tmp = tempfile::tempdir().unwrap();
672 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
673 let edited_zshenv = format!(
676 "export PATH=/usr/bin\n\
677 \n\
678 {MARKER_START}\n\
679 # USER CUSTOM: bump zsh history size for this workstation\n\
680 export HISTSIZE=99999\n\
681 # original lean-ctx hook content lived here\n\
682 {MARKER_END}\n\
683 \n\
684 for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
685 );
686 std::fs::write(tmp.path().join(".zshenv"), &edited_zshenv).unwrap();
687
688 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
689
690 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
692 assert_eq!(baks.len(), 1, "expected one timestamped backup");
693 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
694 assert_eq!(bak_body, edited_zshenv);
695 assert!(bak_body.contains("USER CUSTOM"));
696 assert!(bak_body.contains("HISTSIZE=99999"));
697 }
698
699 #[test]
700 fn migration_dropin_to_inline_preserves_hand_edits_via_backup() {
701 let tmp = tempfile::tempdir().unwrap();
702 let dropin_dir = tmp.path().join(".zshenv.d");
703 std::fs::create_dir_all(&dropin_dir).unwrap();
704 let edited_dropin = "# USER CUSTOM addition to lean-ctx drop-in\nexport FAVOURITE_EDITOR=helix\n# canonical lean-ctx content would follow\n";
706 std::fs::write(dropin_dir.join(DROPIN_ZSH), edited_dropin).unwrap();
707 std::fs::write(tmp.path().join(".zshenv"), "# plain zshenv\n").unwrap();
710
711 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
712
713 let baks = find_migration_backups(&dropin_dir.join(DROPIN_ZSH));
714 assert_eq!(baks.len(), 1, "expected one timestamped backup");
715 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
716 assert_eq!(bak_body, edited_dropin);
717 assert!(bak_body.contains("USER CUSTOM"));
718 assert!(!dropin_dir.join(DROPIN_ZSH).exists());
720 let zshenv = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
721 assert!(zshenv.contains(MARKER_START));
722 }
723
724 #[test]
725 fn migration_skips_backup_when_no_prior_block_exists() {
726 let tmp = tempfile::tempdir().unwrap();
729 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
730 std::fs::write(
731 tmp.path().join(".zshenv"),
732 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
733 )
734 .unwrap();
735
736 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
737
738 assert!(
739 find_migration_backups(&tmp.path().join(".zshenv")).is_empty(),
740 "clean install should not create a .bak file"
741 );
742 }
743
744 #[test]
745 fn idempotent_dropin_reinstall_does_not_create_backup() {
746 let tmp = tempfile::tempdir().unwrap();
751 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
752 std::fs::write(
753 tmp.path().join(".zshenv"),
754 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
755 )
756 .unwrap();
757
758 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
759 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
760
761 assert!(find_migration_backups(&tmp.path().join(".zshenv")).is_empty());
762 }
763
764 #[test]
765 fn backup_filename_handles_dotfile_correctly() {
766 let tmp = tempfile::tempdir().unwrap();
770 std::fs::write(tmp.path().join(".zshenv"), "content\n").unwrap();
771 save_migration_backup(&tmp.path().join(".zshenv"), true, &test_stamp());
772 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
773 assert_eq!(baks.len(), 1);
774 let name = baks[0].file_name().unwrap().to_str().unwrap();
777 assert!(name.starts_with(".zshenv.lean-ctx-"), "got: {name}");
778 assert!(std::path::Path::new(name)
779 .extension()
780 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak")));
781 let stamp = name
783 .trim_start_matches(".zshenv.lean-ctx-")
784 .trim_end_matches(".bak");
785 assert_eq!(stamp.len(), 16, "stamp should be YYYYMMDDTHHMMSSZ: {stamp}");
786 assert!(stamp.contains('T'));
787 assert!(stamp.ends_with('Z'));
788 }
789
790 #[test]
791 fn repeated_migrations_never_clobber_prior_backups() {
792 let stamp_first = BackupStamp::at(
797 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
798 .unwrap()
799 .with_timezone(&chrono::Utc),
800 );
801 let stamp_later = BackupStamp::at(
802 chrono::DateTime::parse_from_rfc3339("2026-05-12T09:00:00Z")
803 .unwrap()
804 .with_timezone(&chrono::Utc),
805 );
806 let tmp = tempfile::tempdir().unwrap();
807 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
808
809 let with_block_v1 = format!(
810 "{MARKER_START}\n# first-era custom content\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
811 );
812 std::fs::write(tmp.path().join(".zshenv"), &with_block_v1).unwrap();
813 install_zshenv(tmp.path(), true, Style::Auto, &stamp_first);
814 let baks_after_first = find_migration_backups(&tmp.path().join(".zshenv"));
815 assert_eq!(baks_after_first.len(), 1);
816
817 let with_block_v2 = format!(
820 "{}{MARKER_START}\n# second-era custom content\n{MARKER_END}\n",
821 std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap(),
822 );
823 std::fs::write(tmp.path().join(".zshenv"), &with_block_v2).unwrap();
824 install_zshenv(tmp.path(), true, Style::Auto, &stamp_later);
825 let baks_after_second = find_migration_backups(&tmp.path().join(".zshenv"));
826
827 assert_eq!(
828 baks_after_second.len(),
829 2,
830 "second migration should leave a second backup, not overwrite"
831 );
832 assert_eq!(baks_after_second[0], baks_after_first[0]);
834 let first_body = std::fs::read_to_string(&baks_after_second[0]).unwrap();
835 let second_body = std::fs::read_to_string(&baks_after_second[1]).unwrap();
836 assert!(first_body.contains("first-era custom"));
837 assert!(second_body.contains("second-era custom"));
838 }
839
840 #[test]
841 fn install_migrates_inline_to_dropin() {
842 let tmp = tempfile::tempdir().unwrap();
843 std::fs::write(
845 tmp.path().join(".zshenv"),
846 format!(
847 "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",
848 ),
849 )
850 .unwrap();
851 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
852
853 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
854
855 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
856 assert!(
857 !zshenv_body.contains(MARKER_START),
858 "old inline block should be stripped after migration"
859 );
860 assert!(
861 zshenv_body.contains(".zshenv.d"),
862 "source loop must be preserved"
863 );
864 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
865 assert!(dropin_file.exists(), "new drop-in file should be present");
866 }
867
868 #[test]
869 fn install_migrates_dropin_to_inline() {
870 let tmp = tempfile::tempdir().unwrap();
871 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
874 std::fs::write(
875 tmp.path().join(".zshenv.d").join(DROPIN_ZSH),
876 "# stale lean-ctx drop-in\n",
877 )
878 .unwrap();
879 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
880
881 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
882
883 assert!(
884 !tmp.path().join(".zshenv.d").join(DROPIN_ZSH).exists(),
885 "drop-in file should be removed when installing inline"
886 );
887 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
888 assert!(body.contains(MARKER_START));
889 }
890
891 #[test]
892 fn install_is_idempotent_in_dropin_mode() {
893 let tmp = tempfile::tempdir().unwrap();
894 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
895 std::fs::write(
896 tmp.path().join(".zshenv"),
897 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
898 )
899 .unwrap();
900
901 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
902 let after_first = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
903
904 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
905 let after_second = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
906
907 assert_eq!(after_first, after_second);
908 }
909
910 #[test]
911 fn install_is_idempotent_in_inline_mode() {
912 let tmp = tempfile::tempdir().unwrap();
913 std::fs::write(tmp.path().join(".zshenv"), "# top\n").unwrap();
914
915 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
916 let after_first = std::fs::read(tmp.path().join(".zshenv")).unwrap();
917
918 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
919 let after_second = std::fs::read(tmp.path().join(".zshenv")).unwrap();
920
921 assert_eq!(after_first, after_second);
922 }
923
924 #[test]
925 fn install_aliases_skips_when_rc_missing() {
926 let tmp = tempfile::tempdir().unwrap();
927 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
929 assert!(!tmp.path().join(".zshrc").exists());
930 assert!(!tmp.path().join(".bashrc").exists());
931 }
932
933 #[test]
934 fn install_aliases_writes_dropin_when_zshrc_d_configured() {
935 let tmp = tempfile::tempdir().unwrap();
936 std::fs::create_dir_all(tmp.path().join(".zshrc.d")).unwrap();
937 std::fs::write(
938 tmp.path().join(".zshrc"),
939 "for f in $HOME/.zshrc.d/*.zsh; do source $f; done\n",
940 )
941 .unwrap();
942
943 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
944
945 let dropin_file = tmp.path().join(".zshrc.d").join(DROPIN_ZSH);
946 assert!(dropin_file.exists());
947 let body = std::fs::read_to_string(&dropin_file).unwrap();
948 assert!(body.contains("LEAN_CTX_AGENT=1"));
949 }
950
951 #[test]
954 fn zshenv_hook_contains_lc_passthrough_stubs() {
955 let tmp = tempfile::tempdir().unwrap();
956 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
957 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
958 assert!(
959 body.contains(r#"_lc() { command "$@"; }"#),
960 "zshenv must contain _lc passthrough stub"
961 );
962 assert!(
963 body.contains(r#"_lc_compress() { command "$@"; }"#),
964 "zshenv must contain _lc_compress passthrough stub"
965 );
966 }
967
968 #[test]
969 fn bashenv_hook_contains_lc_passthrough_stubs() {
970 let tmp = tempfile::tempdir().unwrap();
971 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
972 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
973 assert!(
974 body.contains(r#"_lc() { command "$@"; }"#),
975 "bashenv must contain _lc passthrough stub"
976 );
977 assert!(
978 body.contains(r#"_lc_compress() { command "$@"; }"#),
979 "bashenv must contain _lc_compress passthrough stub"
980 );
981 }
982
983 #[test]
984 fn stubs_appear_before_exec_guard() {
985 let tmp = tempfile::tempdir().unwrap();
986 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
987 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
988 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
989 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
990 assert!(
991 stub_pos < exec_pos,
992 "stubs must be defined BEFORE the exec guard"
993 );
994 }
995
996 #[test]
997 fn dropin_zshenv_also_contains_stubs() {
998 let tmp = tempfile::tempdir().unwrap();
999 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1000 std::fs::write(
1001 tmp.path().join(".zshenv"),
1002 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1003 )
1004 .unwrap();
1005 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1006
1007 let dropin = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
1008 let body = std::fs::read_to_string(&dropin).unwrap();
1009 assert!(body.contains("_lc()"), "drop-in must also contain stubs");
1010 }
1011
1012 #[cfg(unix)]
1017 static SHELL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1018
1019 #[cfg(unix)]
1020 #[test]
1021 fn shell_available_rejects_unknown_shell() {
1022 let _g = SHELL_ENV_LOCK
1023 .lock()
1024 .unwrap_or_else(std::sync::PoisonError::into_inner);
1025 std::env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1026 assert!(!shell_available("fish"));
1027 assert!(!shell_available("nushell"));
1028 assert!(!shell_available(""));
1029 }
1030
1031 #[cfg(unix)]
1032 #[test]
1033 fn shell_available_finds_installed_shells() {
1034 let _g = SHELL_ENV_LOCK
1035 .lock()
1036 .unwrap_or_else(std::sync::PoisonError::into_inner);
1037 std::env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1038 let has_bash = Path::new("/bin/bash").exists() || Path::new("/usr/bin/bash").exists();
1040 let has_zsh = Path::new("/bin/zsh").exists() || Path::new("/usr/bin/zsh").exists();
1041 assert!(
1042 shell_available("bash") == has_bash,
1043 "shell_available(bash) should match filesystem"
1044 );
1045 assert!(
1046 shell_available("zsh") == has_zsh,
1047 "shell_available(zsh) should match filesystem"
1048 );
1049 }
1050
1051 #[cfg(unix)]
1052 #[test]
1053 fn shell_hook_force_overrides_detection() {
1054 let _g = SHELL_ENV_LOCK
1055 .lock()
1056 .unwrap_or_else(std::sync::PoisonError::into_inner);
1057
1058 std::env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "all");
1060 assert!(shell_available("zsh"));
1061 assert!(shell_available("bash"));
1062
1063 std::env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "zsh");
1065 assert!(shell_available("zsh"));
1066 std::env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1070 }
1071}