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
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!(
779 std::path::Path::new(name)
780 .extension()
781 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
782 );
783 let stamp = name
785 .trim_start_matches(".zshenv.lean-ctx-")
786 .trim_end_matches(".bak");
787 assert_eq!(stamp.len(), 16, "stamp should be YYYYMMDDTHHMMSSZ: {stamp}");
788 assert!(stamp.contains('T'));
789 assert!(stamp.ends_with('Z'));
790 }
791
792 #[test]
793 fn repeated_migrations_never_clobber_prior_backups() {
794 let stamp_first = BackupStamp::at(
799 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
800 .unwrap()
801 .with_timezone(&chrono::Utc),
802 );
803 let stamp_later = BackupStamp::at(
804 chrono::DateTime::parse_from_rfc3339("2026-05-12T09:00:00Z")
805 .unwrap()
806 .with_timezone(&chrono::Utc),
807 );
808 let tmp = tempfile::tempdir().unwrap();
809 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
810
811 let with_block_v1 = format!(
812 "{MARKER_START}\n# first-era custom content\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
813 );
814 std::fs::write(tmp.path().join(".zshenv"), &with_block_v1).unwrap();
815 install_zshenv(tmp.path(), true, Style::Auto, &stamp_first);
816 let baks_after_first = find_migration_backups(&tmp.path().join(".zshenv"));
817 assert_eq!(baks_after_first.len(), 1);
818
819 let with_block_v2 = format!(
822 "{}{MARKER_START}\n# second-era custom content\n{MARKER_END}\n",
823 std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap(),
824 );
825 std::fs::write(tmp.path().join(".zshenv"), &with_block_v2).unwrap();
826 install_zshenv(tmp.path(), true, Style::Auto, &stamp_later);
827 let baks_after_second = find_migration_backups(&tmp.path().join(".zshenv"));
828
829 assert_eq!(
830 baks_after_second.len(),
831 2,
832 "second migration should leave a second backup, not overwrite"
833 );
834 assert_eq!(baks_after_second[0], baks_after_first[0]);
836 let first_body = std::fs::read_to_string(&baks_after_second[0]).unwrap();
837 let second_body = std::fs::read_to_string(&baks_after_second[1]).unwrap();
838 assert!(first_body.contains("first-era custom"));
839 assert!(second_body.contains("second-era custom"));
840 }
841
842 #[test]
843 fn install_migrates_inline_to_dropin() {
844 let tmp = tempfile::tempdir().unwrap();
845 std::fs::write(
847 tmp.path().join(".zshenv"),
848 format!(
849 "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",
850 ),
851 )
852 .unwrap();
853 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
854
855 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
856
857 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
858 assert!(
859 !zshenv_body.contains(MARKER_START),
860 "old inline block should be stripped after migration"
861 );
862 assert!(
863 zshenv_body.contains(".zshenv.d"),
864 "source loop must be preserved"
865 );
866 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
867 assert!(dropin_file.exists(), "new drop-in file should be present");
868 }
869
870 #[test]
871 fn install_migrates_dropin_to_inline() {
872 let tmp = tempfile::tempdir().unwrap();
873 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
876 std::fs::write(
877 tmp.path().join(".zshenv.d").join(DROPIN_ZSH),
878 "# stale lean-ctx drop-in\n",
879 )
880 .unwrap();
881 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
882
883 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
884
885 assert!(
886 !tmp.path().join(".zshenv.d").join(DROPIN_ZSH).exists(),
887 "drop-in file should be removed when installing inline"
888 );
889 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
890 assert!(body.contains(MARKER_START));
891 }
892
893 #[test]
894 fn install_is_idempotent_in_dropin_mode() {
895 let tmp = tempfile::tempdir().unwrap();
896 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
897 std::fs::write(
898 tmp.path().join(".zshenv"),
899 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
900 )
901 .unwrap();
902
903 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
904 let after_first = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
905
906 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
907 let after_second = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
908
909 assert_eq!(after_first, after_second);
910 }
911
912 #[test]
913 fn install_is_idempotent_in_inline_mode() {
914 let tmp = tempfile::tempdir().unwrap();
915 std::fs::write(tmp.path().join(".zshenv"), "# top\n").unwrap();
916
917 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
918 let after_first = std::fs::read(tmp.path().join(".zshenv")).unwrap();
919
920 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
921 let after_second = std::fs::read(tmp.path().join(".zshenv")).unwrap();
922
923 assert_eq!(after_first, after_second);
924 }
925
926 #[test]
927 fn install_aliases_skips_when_rc_missing() {
928 let tmp = tempfile::tempdir().unwrap();
929 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
931 assert!(!tmp.path().join(".zshrc").exists());
932 assert!(!tmp.path().join(".bashrc").exists());
933 }
934
935 #[test]
936 fn install_aliases_writes_dropin_when_zshrc_d_configured() {
937 let tmp = tempfile::tempdir().unwrap();
938 std::fs::create_dir_all(tmp.path().join(".zshrc.d")).unwrap();
939 std::fs::write(
940 tmp.path().join(".zshrc"),
941 "for f in $HOME/.zshrc.d/*.zsh; do source $f; done\n",
942 )
943 .unwrap();
944
945 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
946
947 let dropin_file = tmp.path().join(".zshrc.d").join(DROPIN_ZSH);
948 assert!(dropin_file.exists());
949 let body = std::fs::read_to_string(&dropin_file).unwrap();
950 assert!(body.contains("LEAN_CTX_AGENT=1"));
951 }
952
953 #[test]
956 fn zshenv_hook_contains_lc_passthrough_stubs() {
957 let tmp = tempfile::tempdir().unwrap();
958 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
959 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
960 assert!(
961 body.contains(r#"_lc() { command "$@"; }"#),
962 "zshenv must contain _lc passthrough stub"
963 );
964 assert!(
965 body.contains(r#"_lc_compress() { command "$@"; }"#),
966 "zshenv must contain _lc_compress passthrough stub"
967 );
968 }
969
970 #[test]
971 fn bashenv_hook_contains_lc_passthrough_stubs() {
972 let tmp = tempfile::tempdir().unwrap();
973 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
974 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
975 assert!(
976 body.contains(r#"_lc() { command "$@"; }"#),
977 "bashenv must contain _lc passthrough stub"
978 );
979 assert!(
980 body.contains(r#"_lc_compress() { command "$@"; }"#),
981 "bashenv must contain _lc_compress passthrough stub"
982 );
983 }
984
985 #[test]
986 fn stubs_appear_before_exec_guard() {
987 let tmp = tempfile::tempdir().unwrap();
988 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
989 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
990 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
991 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
992 assert!(
993 stub_pos < exec_pos,
994 "stubs must be defined BEFORE the exec guard"
995 );
996 }
997
998 #[test]
999 fn dropin_zshenv_also_contains_stubs() {
1000 let tmp = tempfile::tempdir().unwrap();
1001 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1002 std::fs::write(
1003 tmp.path().join(".zshenv"),
1004 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1005 )
1006 .unwrap();
1007 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1008
1009 let dropin = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
1010 let body = std::fs::read_to_string(&dropin).unwrap();
1011 assert!(body.contains("_lc()"), "drop-in must also contain stubs");
1012 }
1013
1014 #[cfg(unix)]
1019 static SHELL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1020
1021 #[cfg(unix)]
1022 #[test]
1023 fn shell_available_rejects_unknown_shell() {
1024 let _g = SHELL_ENV_LOCK
1025 .lock()
1026 .unwrap_or_else(std::sync::PoisonError::into_inner);
1027 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1028 assert!(!shell_available("fish"));
1029 assert!(!shell_available("nushell"));
1030 assert!(!shell_available(""));
1031 }
1032
1033 #[cfg(unix)]
1034 #[test]
1035 fn shell_available_finds_installed_shells() {
1036 let _g = SHELL_ENV_LOCK
1037 .lock()
1038 .unwrap_or_else(std::sync::PoisonError::into_inner);
1039 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1040 let has_bash = Path::new("/bin/bash").exists() || Path::new("/usr/bin/bash").exists();
1042 let has_zsh = Path::new("/bin/zsh").exists() || Path::new("/usr/bin/zsh").exists();
1043 assert!(
1044 shell_available("bash") == has_bash,
1045 "shell_available(bash) should match filesystem"
1046 );
1047 assert!(
1048 shell_available("zsh") == has_zsh,
1049 "shell_available(zsh) should match filesystem"
1050 );
1051 }
1052
1053 #[cfg(unix)]
1054 #[test]
1055 fn shell_hook_force_overrides_detection() {
1056 let _g = SHELL_ENV_LOCK
1057 .lock()
1058 .unwrap_or_else(std::sync::PoisonError::into_inner);
1059
1060 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "all");
1062 assert!(shell_available("zsh"));
1063 assert!(shell_available("bash"));
1064
1065 crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "zsh");
1067 assert!(shell_available("zsh"));
1068 crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1072 }
1073}