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