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