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