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)]
300fn shell_available(shell: &str) -> bool {
301 let candidates: &[&str] = match shell {
302 "zsh" => &[
303 "/bin/zsh",
304 "/usr/bin/zsh",
305 "/usr/local/bin/zsh",
306 "/opt/homebrew/bin/zsh",
307 ],
308 "bash" => &[
309 "/bin/bash",
310 "/usr/bin/bash",
311 "/usr/local/bin/bash",
312 "/opt/homebrew/bin/bash",
313 ],
314 _ => return false,
315 };
316 candidates.iter().any(|p| Path::new(p).exists())
317}
318
319#[cfg(not(unix))]
320fn shell_available(_shell: &str) -> bool {
321 false
323}
324
325pub fn uninstall_all(quiet: bool) {
326 let Some(home) = dirs::home_dir() else { return };
327
328 let slots: &[(Slot, &str)] = &[
331 (SLOT_ZSHENV, "shell hook for ~/.zshenv"),
332 (SLOT_BASHENV, "shell hook for ~/.bashenv"),
333 (SLOT_ZSHRC, "agent aliases for ~/.zshrc"),
334 (SLOT_BASHRC, "agent aliases for ~/.bashrc"),
335 ];
336
337 for (slot, label) in slots {
338 marked_block::remove_from_file(
339 &home.join(slot.rc_file),
340 slot.marker_start,
341 slot.marker_end,
342 quiet,
343 label,
344 );
345 let dir_path = home.join(slot.dropin_dir);
346 if dir_path.exists() {
347 dropin::remove(&dir_path, slot.dropin_file, quiet, label);
348 }
349 }
350}
351
352fn install_zshenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
353 let env_check = build_env_check();
354 let hook = format!(
355 r#"{MARKER_START}
356# Passthrough stubs: ensure _lc/_lc_compress exist in ALL zsh contexts
357# (non-interactive subshells, eval, agent harnesses) so aliases that
358# reference them degrade gracefully instead of "command not found".
359# The full shell-hook.zsh overrides these when loaded via .zshrc.
360_lc() {{ command "$@"; }}
361_lc_compress() {{ command "$@"; }}
362if [[ -z "$LEAN_CTX_ACTIVE" && -n "$ZSH_EXECUTION_STRING" ]] && command -v lean-ctx &>/dev/null; then
363 if {env_check}; then
364 export LEAN_CTX_ACTIVE=1
365 exec lean-ctx -c "$ZSH_EXECUTION_STRING"
366 fi
367fi
368{MARKER_END}"#
369 );
370
371 let label = "shell hook in ~/.zshenv";
372 let target = pick_target(home, &SLOT_ZSHENV, style);
373 strip_other_style(home, &SLOT_ZSHENV, &target, quiet, label, stamp);
374 target.upsert(&hook, quiet, label);
375}
376
377fn install_bashenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
378 let env_check = build_env_check();
379 let hook = format!(
380 r#"{MARKER_START}
381_lc() {{ command "$@"; }}
382_lc_compress() {{ command "$@"; }}
383if [[ -z "$LEAN_CTX_ACTIVE" && -n "$BASH_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 "$BASH_EXECUTION_STRING"
387 fi
388fi
389{MARKER_END}"#
390 );
391
392 let label = "shell hook in ~/.bashenv";
393 let target = pick_target(home, &SLOT_BASHENV, style);
394 strip_other_style(home, &SLOT_BASHENV, &target, quiet, label, stamp);
395 target.upsert(&hook, quiet, label);
396}
397
398fn install_aliases(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
399 let mut lines = Vec::new();
400 lines.push(ALIAS_START.to_string());
401 for (alias_name, bin_name) in AGENT_ALIASES {
402 lines.push(format!(
403 "alias {alias_name}='LEAN_CTX_AGENT=1 BASH_ENV=\"$HOME/.bashenv\" {bin_name}'"
404 ));
405 }
406 lines.push(ALIAS_END.to_string());
407 let block = lines.join("\n");
408
409 for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
410 if !home.join(slot.rc_file).exists() {
413 continue;
414 }
415 let label = format!("agent aliases in ~/{}", slot.rc_file);
416 let target = pick_target(home, slot, style);
417 strip_other_style(home, slot, &target, quiet, &label, stamp);
418 target.upsert(&block, quiet, &label);
419 }
420}
421
422fn build_env_check() -> String {
423 let checks: Vec<String> = KNOWN_AGENT_ENV_VARS
424 .iter()
425 .map(|v| format!("-n \"${v}\""))
426 .collect();
427 format!("[[ {} ]]", checks.join(" || "))
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 fn test_stamp() -> BackupStamp {
438 BackupStamp::at(
439 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
440 .unwrap()
441 .with_timezone(&chrono::Utc),
442 )
443 }
444
445 #[test]
446 fn env_check_format() {
447 let check = build_env_check();
448 assert!(check.contains("LEAN_CTX_AGENT"));
449 assert!(check.contains("CLAUDECODE"));
450 assert!(check.contains("||"));
451 }
452
453 #[test]
454 fn pick_target_inline_when_forced() {
455 let tmp = tempfile::tempdir().unwrap();
456 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
458 std::fs::write(
459 tmp.path().join(".zshenv"),
460 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
461 )
462 .unwrap();
463 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Inline);
464 assert!(matches!(t, InstallTarget::Marked { .. }));
465 }
466
467 #[test]
468 fn pick_target_dropin_when_detected_under_auto() {
469 let tmp = tempfile::tempdir().unwrap();
470 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
471 std::fs::write(
472 tmp.path().join(".zshenv"),
473 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
474 )
475 .unwrap();
476 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
477 assert!(matches!(t, InstallTarget::DropIn { .. }));
478 }
479
480 #[test]
481 fn pick_target_inline_under_auto_when_no_dropin() {
482 let tmp = tempfile::tempdir().unwrap();
483 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
484 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
485 assert!(matches!(t, InstallTarget::Marked { .. }));
486 }
487
488 #[test]
489 fn pick_target_dropin_falls_back_to_inline_when_no_directory() {
490 let tmp = tempfile::tempdir().unwrap();
493 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
494 let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::DropIn);
495 assert!(matches!(t, InstallTarget::Marked { .. }));
496 }
497
498 #[test]
499 fn install_zshenv_writes_inline_block() {
500 let tmp = tempfile::tempdir().unwrap();
501 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
502 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
503 assert!(body.contains(MARKER_START));
504 assert!(body.contains(MARKER_END));
505 assert!(body.contains("ZSH_EXECUTION_STRING"));
506 }
507
508 #[test]
509 fn install_zshenv_writes_dropin_when_loop_present() {
510 let tmp = tempfile::tempdir().unwrap();
511 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
512 std::fs::write(
513 tmp.path().join(".zshenv"),
514 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
515 )
516 .unwrap();
517 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
518
519 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
520 assert!(dropin_file.exists(), "expected drop-in file");
521 let dropin_body = std::fs::read_to_string(&dropin_file).unwrap();
522 assert!(dropin_body.contains("ZSH_EXECUTION_STRING"));
523
524 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
525 assert!(
526 !zshenv_body.contains(MARKER_START),
527 "drop-in install must not also leave the inline block"
528 );
529 }
530
531 fn find_migration_backups(path: &Path) -> Vec<PathBuf> {
534 let Some(parent) = path.parent() else {
535 return Vec::new();
536 };
537 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
538 return Vec::new();
539 };
540 let prefix = format!("{name}.lean-ctx-");
541 let mut out: Vec<PathBuf> = std::fs::read_dir(parent)
542 .into_iter()
543 .flatten()
544 .flatten()
545 .map(|e| e.path())
546 .filter(|p| {
547 p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
548 n.starts_with(&prefix)
549 && std::path::Path::new(n)
550 .extension()
551 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
552 })
553 })
554 .collect();
555 out.sort();
556 out
557 }
558
559 #[test]
560 fn migration_inline_to_dropin_preserves_hand_edits_via_backup() {
561 let tmp = tempfile::tempdir().unwrap();
562 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
563 let edited_zshenv = format!(
566 "export PATH=/usr/bin\n\
567 \n\
568 {MARKER_START}\n\
569 # USER CUSTOM: bump zsh history size for this workstation\n\
570 export HISTSIZE=99999\n\
571 # original lean-ctx hook content lived here\n\
572 {MARKER_END}\n\
573 \n\
574 for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
575 );
576 std::fs::write(tmp.path().join(".zshenv"), &edited_zshenv).unwrap();
577
578 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
579
580 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
582 assert_eq!(baks.len(), 1, "expected one timestamped backup");
583 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
584 assert_eq!(bak_body, edited_zshenv);
585 assert!(bak_body.contains("USER CUSTOM"));
586 assert!(bak_body.contains("HISTSIZE=99999"));
587 }
588
589 #[test]
590 fn migration_dropin_to_inline_preserves_hand_edits_via_backup() {
591 let tmp = tempfile::tempdir().unwrap();
592 let dropin_dir = tmp.path().join(".zshenv.d");
593 std::fs::create_dir_all(&dropin_dir).unwrap();
594 let edited_dropin = "# USER CUSTOM addition to lean-ctx drop-in\nexport FAVOURITE_EDITOR=helix\n# canonical lean-ctx content would follow\n";
596 std::fs::write(dropin_dir.join(DROPIN_ZSH), edited_dropin).unwrap();
597 std::fs::write(tmp.path().join(".zshenv"), "# plain zshenv\n").unwrap();
600
601 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
602
603 let baks = find_migration_backups(&dropin_dir.join(DROPIN_ZSH));
604 assert_eq!(baks.len(), 1, "expected one timestamped backup");
605 let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
606 assert_eq!(bak_body, edited_dropin);
607 assert!(bak_body.contains("USER CUSTOM"));
608 assert!(!dropin_dir.join(DROPIN_ZSH).exists());
610 let zshenv = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
611 assert!(zshenv.contains(MARKER_START));
612 }
613
614 #[test]
615 fn migration_skips_backup_when_no_prior_block_exists() {
616 let tmp = tempfile::tempdir().unwrap();
619 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
620 std::fs::write(
621 tmp.path().join(".zshenv"),
622 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
623 )
624 .unwrap();
625
626 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
627
628 assert!(
629 find_migration_backups(&tmp.path().join(".zshenv")).is_empty(),
630 "clean install should not create a .bak file"
631 );
632 }
633
634 #[test]
635 fn idempotent_dropin_reinstall_does_not_create_backup() {
636 let tmp = tempfile::tempdir().unwrap();
641 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
642 std::fs::write(
643 tmp.path().join(".zshenv"),
644 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
645 )
646 .unwrap();
647
648 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
649 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
650
651 assert!(find_migration_backups(&tmp.path().join(".zshenv")).is_empty());
652 }
653
654 #[test]
655 fn backup_filename_handles_dotfile_correctly() {
656 let tmp = tempfile::tempdir().unwrap();
660 std::fs::write(tmp.path().join(".zshenv"), "content\n").unwrap();
661 save_migration_backup(&tmp.path().join(".zshenv"), true, &test_stamp());
662 let baks = find_migration_backups(&tmp.path().join(".zshenv"));
663 assert_eq!(baks.len(), 1);
664 let name = baks[0].file_name().unwrap().to_str().unwrap();
667 assert!(name.starts_with(".zshenv.lean-ctx-"), "got: {name}");
668 assert!(std::path::Path::new(name)
669 .extension()
670 .is_some_and(|ext| ext.eq_ignore_ascii_case("bak")));
671 let stamp = name
673 .trim_start_matches(".zshenv.lean-ctx-")
674 .trim_end_matches(".bak");
675 assert_eq!(stamp.len(), 16, "stamp should be YYYYMMDDTHHMMSSZ: {stamp}");
676 assert!(stamp.contains('T'));
677 assert!(stamp.ends_with('Z'));
678 }
679
680 #[test]
681 fn repeated_migrations_never_clobber_prior_backups() {
682 let stamp_first = BackupStamp::at(
687 chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
688 .unwrap()
689 .with_timezone(&chrono::Utc),
690 );
691 let stamp_later = BackupStamp::at(
692 chrono::DateTime::parse_from_rfc3339("2026-05-12T09:00:00Z")
693 .unwrap()
694 .with_timezone(&chrono::Utc),
695 );
696 let tmp = tempfile::tempdir().unwrap();
697 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
698
699 let with_block_v1 = format!(
700 "{MARKER_START}\n# first-era custom content\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
701 );
702 std::fs::write(tmp.path().join(".zshenv"), &with_block_v1).unwrap();
703 install_zshenv(tmp.path(), true, Style::Auto, &stamp_first);
704 let baks_after_first = find_migration_backups(&tmp.path().join(".zshenv"));
705 assert_eq!(baks_after_first.len(), 1);
706
707 let with_block_v2 = format!(
710 "{}{MARKER_START}\n# second-era custom content\n{MARKER_END}\n",
711 std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap(),
712 );
713 std::fs::write(tmp.path().join(".zshenv"), &with_block_v2).unwrap();
714 install_zshenv(tmp.path(), true, Style::Auto, &stamp_later);
715 let baks_after_second = find_migration_backups(&tmp.path().join(".zshenv"));
716
717 assert_eq!(
718 baks_after_second.len(),
719 2,
720 "second migration should leave a second backup, not overwrite"
721 );
722 assert_eq!(baks_after_second[0], baks_after_first[0]);
724 let first_body = std::fs::read_to_string(&baks_after_second[0]).unwrap();
725 let second_body = std::fs::read_to_string(&baks_after_second[1]).unwrap();
726 assert!(first_body.contains("first-era custom"));
727 assert!(second_body.contains("second-era custom"));
728 }
729
730 #[test]
731 fn install_migrates_inline_to_dropin() {
732 let tmp = tempfile::tempdir().unwrap();
733 std::fs::write(
735 tmp.path().join(".zshenv"),
736 format!(
737 "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",
738 ),
739 )
740 .unwrap();
741 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
742
743 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
744
745 let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
746 assert!(
747 !zshenv_body.contains(MARKER_START),
748 "old inline block should be stripped after migration"
749 );
750 assert!(
751 zshenv_body.contains(".zshenv.d"),
752 "source loop must be preserved"
753 );
754 let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
755 assert!(dropin_file.exists(), "new drop-in file should be present");
756 }
757
758 #[test]
759 fn install_migrates_dropin_to_inline() {
760 let tmp = tempfile::tempdir().unwrap();
761 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
764 std::fs::write(
765 tmp.path().join(".zshenv.d").join(DROPIN_ZSH),
766 "# stale lean-ctx drop-in\n",
767 )
768 .unwrap();
769 std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
770
771 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
772
773 assert!(
774 !tmp.path().join(".zshenv.d").join(DROPIN_ZSH).exists(),
775 "drop-in file should be removed when installing inline"
776 );
777 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
778 assert!(body.contains(MARKER_START));
779 }
780
781 #[test]
782 fn install_is_idempotent_in_dropin_mode() {
783 let tmp = tempfile::tempdir().unwrap();
784 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
785 std::fs::write(
786 tmp.path().join(".zshenv"),
787 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
788 )
789 .unwrap();
790
791 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
792 let after_first = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
793
794 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
795 let after_second = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
796
797 assert_eq!(after_first, after_second);
798 }
799
800 #[test]
801 fn install_is_idempotent_in_inline_mode() {
802 let tmp = tempfile::tempdir().unwrap();
803 std::fs::write(tmp.path().join(".zshenv"), "# top\n").unwrap();
804
805 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
806 let after_first = std::fs::read(tmp.path().join(".zshenv")).unwrap();
807
808 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
809 let after_second = std::fs::read(tmp.path().join(".zshenv")).unwrap();
810
811 assert_eq!(after_first, after_second);
812 }
813
814 #[test]
815 fn install_aliases_skips_when_rc_missing() {
816 let tmp = tempfile::tempdir().unwrap();
817 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
819 assert!(!tmp.path().join(".zshrc").exists());
820 assert!(!tmp.path().join(".bashrc").exists());
821 }
822
823 #[test]
824 fn install_aliases_writes_dropin_when_zshrc_d_configured() {
825 let tmp = tempfile::tempdir().unwrap();
826 std::fs::create_dir_all(tmp.path().join(".zshrc.d")).unwrap();
827 std::fs::write(
828 tmp.path().join(".zshrc"),
829 "for f in $HOME/.zshrc.d/*.zsh; do source $f; done\n",
830 )
831 .unwrap();
832
833 install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
834
835 let dropin_file = tmp.path().join(".zshrc.d").join(DROPIN_ZSH);
836 assert!(dropin_file.exists());
837 let body = std::fs::read_to_string(&dropin_file).unwrap();
838 assert!(body.contains("LEAN_CTX_AGENT=1"));
839 }
840
841 #[test]
844 fn zshenv_hook_contains_lc_passthrough_stubs() {
845 let tmp = tempfile::tempdir().unwrap();
846 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
847 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
848 assert!(
849 body.contains(r#"_lc() { command "$@"; }"#),
850 "zshenv must contain _lc passthrough stub"
851 );
852 assert!(
853 body.contains(r#"_lc_compress() { command "$@"; }"#),
854 "zshenv must contain _lc_compress passthrough stub"
855 );
856 }
857
858 #[test]
859 fn bashenv_hook_contains_lc_passthrough_stubs() {
860 let tmp = tempfile::tempdir().unwrap();
861 install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
862 let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
863 assert!(
864 body.contains(r#"_lc() { command "$@"; }"#),
865 "bashenv must contain _lc passthrough stub"
866 );
867 assert!(
868 body.contains(r#"_lc_compress() { command "$@"; }"#),
869 "bashenv must contain _lc_compress passthrough stub"
870 );
871 }
872
873 #[test]
874 fn stubs_appear_before_exec_guard() {
875 let tmp = tempfile::tempdir().unwrap();
876 install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
877 let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
878 let stub_pos = body.find("_lc()").expect("_lc stub must exist");
879 let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
880 assert!(
881 stub_pos < exec_pos,
882 "stubs must be defined BEFORE the exec guard"
883 );
884 }
885
886 #[test]
887 fn dropin_zshenv_also_contains_stubs() {
888 let tmp = tempfile::tempdir().unwrap();
889 std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
890 std::fs::write(
891 tmp.path().join(".zshenv"),
892 "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
893 )
894 .unwrap();
895 install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
896
897 let dropin = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
898 let body = std::fs::read_to_string(&dropin).unwrap();
899 assert!(body.contains("_lc()"), "drop-in must also contain stubs");
900 }
901
902 #[cfg(unix)]
905 #[test]
906 fn shell_available_rejects_unknown_shell() {
907 assert!(!shell_available("fish"));
908 assert!(!shell_available("nushell"));
909 assert!(!shell_available(""));
910 }
911
912 #[cfg(unix)]
913 #[test]
914 fn shell_available_finds_installed_shells() {
915 let has_bash = Path::new("/bin/bash").exists() || Path::new("/usr/bin/bash").exists();
917 let has_zsh = Path::new("/bin/zsh").exists() || Path::new("/usr/bin/zsh").exists();
918 assert!(
919 shell_available("bash") == has_bash,
920 "shell_available(bash) should match filesystem"
921 );
922 assert!(
923 shell_available("zsh") == has_zsh,
924 "shell_available(zsh) should match filesystem"
925 );
926 }
927}