1macro_rules! qprintln {
2 ($($t:tt)*) => {
3 if !super::quiet_enabled() {
4 println!($($t)*);
5 }
6 };
7}
8
9pub fn print_hook_stdout(shell: &str) {
10 let binary = crate::core::portable_binary::resolve_portable_binary();
11 let binary = hook_binary_for_shell(shell, &binary);
12
13 let code = match shell {
14 "bash" | "zsh" => generate_hook_posix(&binary),
15 "fish" => generate_hook_fish(&binary),
16 "powershell" | "pwsh" => generate_hook_powershell(&binary),
17 _ => {
18 tracing::error!("lean-ctx: unsupported shell '{shell}'");
19 eprintln!("Supported: bash, zsh, fish, powershell");
20 std::process::exit(1);
21 }
22 };
23 print!("{code}");
24}
25
26fn hook_binary_for_shell(shell: &str, binary: &str) -> String {
34 match shell {
35 "powershell" | "pwsh" => binary.to_string(),
36 _ => crate::hooks::to_bash_compatible_path(binary),
37 }
38}
39
40fn backup_shell_config(path: &std::path::Path) {
41 if !path.exists() {
42 return;
43 }
44 let bak = path.with_extension("lean-ctx.bak");
45 if std::fs::copy(path, &bak).is_ok() {
46 qprintln!(
47 " Backup: {}",
48 bak.file_name().map_or_else(
49 || bak.display().to_string(),
50 |n| format!("~/{}", n.to_string_lossy())
51 )
52 );
53 }
54}
55
56fn config_artifact_dir() -> Option<std::path::PathBuf> {
61 crate::core::paths::config_dir().ok()
62}
63
64fn write_hook_file(filename: &str, content: &str) -> Option<std::path::PathBuf> {
65 let dir = config_artifact_dir()?;
66 let _ = std::fs::create_dir_all(&dir);
67 let path = dir.join(filename);
68 match std::fs::write(&path, content) {
69 Ok(()) => {
70 #[cfg(unix)]
71 {
72 use std::os::unix::fs::PermissionsExt;
73 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
74 }
75 Some(path)
76 }
77 Err(e) => {
78 tracing::error!("Error writing {}: {e}", path.display());
79 None
80 }
81 }
82}
83
84fn resolved_hook_dir_display() -> String {
85 config_artifact_dir().map_or_else(
86 || "$HOME/.config/lean-ctx".to_string(),
87 |p| p.to_string_lossy().to_string(),
88 )
89}
90
91fn source_line_posix(shell_ext: &str) -> String {
92 let mut dir = resolved_hook_dir_display();
93 if cfg!(windows) {
95 dir = crate::hooks::to_bash_compatible_path(&dir);
96 }
97 format!(
98 "# lean-ctx shell hook — begin\n\
99 if [ -f \"{dir}/shell-hook.{shell_ext}\" ]; then\n\
100 . \"{dir}/shell-hook.{shell_ext}\"\n\
101 fi\n\
102 # lean-ctx shell hook — end\n"
103 )
104}
105
106fn source_line_fish() -> String {
107 let mut dir = resolved_hook_dir_display();
108 if cfg!(windows) {
110 dir = crate::hooks::to_bash_compatible_path(&dir);
111 }
112 format!(
113 "# lean-ctx shell hook — begin\n\
114 if test -f \"{dir}/shell-hook.fish\"\n\
115 source \"{dir}/shell-hook.fish\"\n\
116 end\n\
117 # lean-ctx shell hook — end\n"
118 )
119}
120
121fn source_line_powershell() -> String {
122 let dir = resolved_hook_dir_display();
123 let dir_ps = dir.replace('/', "\\");
124 format!(
125 "# lean-ctx shell hook — begin\n\
126 $leanCtxHook = \"{dir_ps}\\shell-hook.ps1\"\n\
127 if ((Test-Path $leanCtxHook) -and -not [Console]::IsOutputRedirected) {{ . $leanCtxHook }}\n"
128 )
129}
130
131fn upsert_source_line(rc_path: &std::path::Path, source_line: &str) {
132 backup_shell_config(rc_path);
133
134 if let Ok(existing) = std::fs::read_to_string(rc_path) {
135 if existing.contains(source_line.trim()) {
136 return;
137 }
138
139 let cleaned = remove_lean_ctx_block(&existing);
141 let cleaned = cleaned
142 .lines()
143 .filter(|line| {
144 !line.contains("lean-ctx/shell-hook.")
145 && !line.contains("lean-ctx\\shell-hook.")
146 && line.trim() != "lean-ctx shell hook"
147 })
148 .collect::<Vec<_>>()
149 .join("\n");
150 let cleaned = if cleaned.ends_with('\n') {
151 cleaned
152 } else {
153 format!("{cleaned}\n")
154 };
155
156 match std::fs::write(rc_path, format!("{cleaned}{source_line}")) {
157 Ok(()) => {
158 qprintln!("Updated lean-ctx hook in {}", rc_path.display());
159 }
160 Err(e) => {
161 tracing::error!("Error updating {}: {e}", rc_path.display());
162 print_shell_write_error(rc_path, source_line, &e);
163 }
164 }
165 return;
166 }
167
168 match std::fs::OpenOptions::new()
169 .append(true)
170 .create(true)
171 .open(rc_path)
172 {
173 Ok(mut f) => {
174 use std::io::Write;
175 let _ = f.write_all(source_line.as_bytes());
176 qprintln!("Added lean-ctx hook to {}", rc_path.display());
177 }
178 Err(e) => {
179 tracing::error!("Error writing {}: {e}", rc_path.display());
180 print_shell_write_error(rc_path, source_line, &e);
181 }
182 }
183}
184
185fn print_shell_write_error(rc_path: &std::path::Path, source_line: &str, err: &std::io::Error) {
186 eprintln!();
187 eprintln!(" \x1B[33m⚠ Cannot write to {}\x1B[0m", rc_path.display());
188 eprintln!(" Error: {err}");
189 if err.kind() == std::io::ErrorKind::PermissionDenied {
190 eprintln!();
191 eprintln!(" Your shell config is read-only (nix-darwin, Home Manager, or similar).");
192 eprintln!(" Add the following to a writable shell config file manually:");
193 } else {
194 eprintln!();
195 eprintln!(" Add the following to your shell config manually:");
196 }
197 eprintln!();
198 for line in source_line.lines() {
199 eprintln!(" {line}");
200 }
201 eprintln!();
202 eprintln!(" Or source it from a writable file (e.g. ~/.zshrc.local):");
203 eprintln!(" echo 'source ~/.zshrc.local' # (add to nix config)");
204 eprintln!(" Then add the hook lines to ~/.zshrc.local");
205 eprintln!();
206}
207
208pub fn generate_hook_powershell(binary: &str) -> String {
209 let config = crate::core::config::Config::load();
210 let activation = config.shell_activation_effective();
211 let baked_default = match activation {
212 crate::core::config::ShellActivation::Always => "always",
213 crate::core::config::ShellActivation::AgentsOnly => "agents-only",
214 crate::core::config::ShellActivation::Off => "off",
215 };
216 let binary_escaped = binary.replace('\\', "\\\\");
217 format!(
218 r#"# lean-ctx shell hook — transparent CLI compression (95+ patterns)
219$_leanCtxActivation = if ($env:LEAN_CTX_SHELL_ACTIVATION) {{ $env:LEAN_CTX_SHELL_ACTIVATION }} else {{ "{baked_default}" }}
220$_leanCtxShouldActivate = $false
221if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED -and -not $env:LEAN_CTX_NO_HOOK) {{
222 switch ($_leanCtxActivation) {{
223 {{ $_ -in 'off','none','manual' }} {{ $_leanCtxShouldActivate = $false }}
224 {{ $_ -in 'agents-only','agents_only','agentsonly' }} {{
225 $_leanCtxShouldActivate = $env:LEAN_CTX_AGENT -or $env:CURSOR_AGENT -or $env:CLAUDECODE -or $env:CODEBUDDY -or $env:CODEX_CLI_SESSION -or $env:GEMINI_SESSION
226 }}
227 default {{ $_leanCtxShouldActivate = $true }}
228 }}
229}}
230if ($_leanCtxShouldActivate) {{
231 $LeanCtxBin = "{binary_escaped}"
232 function _lc {{
233 $nativeCmd = Get-Command $args[0] -CommandType Application -ErrorAction SilentlyContinue
234 if ($env:LEAN_CTX_DISABLED -or $env:LEAN_CTX_NO_HOOK -or [Console]::IsOutputRedirected) {{
235 if ($nativeCmd) {{ & $nativeCmd.Source $args[1..$args.Length] }} else {{ Write-Error "Command not found: $($args[0])" }}
236 return
237 }}
238 & $LeanCtxBin -c @args
239 if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
240 if ($nativeCmd) {{ & $nativeCmd.Source $args[1..$args.Length] }} else {{ Write-Error "Command not found: $($args[0])" }}
241 }}
242 }}
243 function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
244 if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
245 function git {{ _lc git @args }}
246 function cargo {{ _lc cargo @args }}
247 function docker {{ _lc docker @args }}
248 function kubectl {{ _lc kubectl @args }}
249 function gh {{ _lc gh @args }}
250 function pip {{ _lc pip @args }}
251 function pip3 {{ _lc pip3 @args }}
252 function ruff {{ _lc ruff @args }}
253 function go {{ _lc go @args }}
254 function curl {{ _lc curl @args }}
255 function wget {{ _lc wget @args }}
256 foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
257 if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
258 $body = "_lc $c `@args"
259 New-Item -Path "function:$c" -Value ([scriptblock]::Create($body)) -Force | Out-Null
260 }}
261 }}
262 }}
263}}
264"#
265 )
266}
267
268pub fn init_powershell(binary: &str) {
269 let profile_path = if let Some(home) = dirs::home_dir() {
273 let path = crate::shell::platform::resolve_powershell_profile_path(&home);
274 if let Some(dir) = path.parent() {
275 let _ = std::fs::create_dir_all(dir);
276 }
277 path
278 } else {
279 tracing::error!("Could not resolve PowerShell profile directory");
280 return;
281 };
282
283 let hook_content = generate_hook_powershell(binary);
284
285 if write_hook_file("shell-hook.ps1", &hook_content).is_some() {
286 upsert_source_line(&profile_path, &source_line_powershell());
287 qprintln!(" Binary: {binary}");
288 }
289}
290
291pub fn remove_lean_ctx_block_ps(content: &str) -> String {
292 let mut result = String::new();
293 let mut in_block = false;
294 let mut brace_depth = 0i32;
295
296 for line in content.lines() {
297 if line.contains("lean-ctx shell hook") {
298 in_block = true;
299 continue;
300 }
301 if in_block {
302 brace_depth += line.matches('{').count() as i32;
303 brace_depth -= line.matches('}').count() as i32;
304 if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
305 if line.trim() == "}" {
306 in_block = false;
307 brace_depth = 0;
308 }
309 continue;
310 }
311 continue;
312 }
313 result.push_str(line);
314 result.push('\n');
315 }
316 result
317}
318
319pub fn generate_hook_fish(binary: &str) -> String {
320 let config = crate::core::config::Config::load();
321 let activation = config.shell_activation_effective();
322 let baked_default = match activation {
323 crate::core::config::ShellActivation::Always => "always",
324 crate::core::config::ShellActivation::AgentsOnly => "agents-only",
325 crate::core::config::ShellActivation::Off => "off",
326 };
327 let alias_list = crate::rewrite_registry::shell_alias_list();
328 format!(
329 "# lean-ctx shell hook — smart shell mode (track-by-default)\n\
330 set -g _lean_ctx_cmds {alias_list}\n\
331 \n\
332 function _lc_is_agent\n\
333 \tset -q LEAN_CTX_AGENT; or set -q CURSOR_AGENT; or set -q CODEX_CLI_SESSION; or set -q CLAUDECODE; or set -q CODEBUDDY; or set -q GEMINI_SESSION\n\
334 end\n\
335 \n\
336 function _lean_ctx_notice\n\
337 \tif isatty stdout; and set -q LEAN_CTX_DEBUG\n\
338 \t\techo $argv\n\
339 \tend\n\
340 end\n\
341 \n\
342 function _lc\n\
343 \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
344 \t\tcommand $argv\n\
345 \t\treturn\n\
346 \tend\n\
347 \tif not isatty stdout; and not _lc_is_agent\n\
348 \t\tcommand $argv\n\
349 \t\treturn\n\
350 \tend\n\
351 \t'{binary}' -t $argv\n\
352 \tset -l _lc_rc $status\n\
353 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
354 \t\tcommand $argv\n\
355 \telse\n\
356 \t\treturn $_lc_rc\n\
357 \tend\n\
358 end\n\
359 \n\
360 function _lc_compress\n\
361 \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK\n\
362 \t\tcommand $argv\n\
363 \t\treturn\n\
364 \tend\n\
365 \tif not isatty stdout; and not _lc_is_agent\n\
366 \t\tcommand $argv\n\
367 \t\treturn\n\
368 \tend\n\
369 \t'{binary}' -c $argv\n\
370 \tset -l _lc_rc $status\n\
371 \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
372 \t\tcommand $argv\n\
373 \telse\n\
374 \t\treturn $_lc_rc\n\
375 \tend\n\
376 end\n\
377 \n\
378 function lean-ctx-on\n\
379 \tfor _lc_cmd in $_lean_ctx_cmds\n\
380 \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
381 \tend\n\
382 \talias k '_lc kubectl'\n\
383 \tset -gx LEAN_CTX_ENABLED 1\n\
384 \t_lean_ctx_notice 'lean-ctx: ON (track mode — output unchanged, token savings recorded)'\n\
385 end\n\
386 \n\
387 function lean-ctx-off\n\
388 \tfor _lc_cmd in $_lean_ctx_cmds\n\
389 \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
390 \tend\n\
391 \tfunctions --erase k 2>/dev/null; true\n\
392 \tset -gx LEAN_CTX_ENABLED 0\n\
393 \t_lean_ctx_notice 'lean-ctx: OFF'\n\
394 end\n\
395 \n\
396 function lean-ctx-mode\n\
397 \tswitch $argv[1]\n\
398 \t\tcase compress\n\
399 \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
400 \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
401 \t\t\t\tend\n\
402 \t\t\talias k '_lc_compress kubectl'\n\
403 \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
404 \t\t\t_lean_ctx_notice 'lean-ctx: COMPRESS mode (all output compressed)'\n\
405 \t\tcase track\n\
406 \t\t\tlean-ctx-on\n\
407 \t\tcase off\n\
408 \t\t\tlean-ctx-off\n\
409 \t\tcase '*'\n\
410 \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
411 \t\t\techo ' track — Full output, stats recorded (default)'\n\
412 \t\t\techo ' compress — Compressed output for all commands'\n\
413 \t\t\techo ' off — No aliases, raw shell'\n\
414 \tend\n\
415 end\n\
416 \n\
417 function lean-ctx-raw\n\
418 \tset -lx LEAN_CTX_RAW 1\n\
419 \tcommand $argv\n\
420 end\n\
421 \n\
422 function lean-ctx-status\n\
423 \tif set -q LEAN_CTX_DISABLED\n\
424 \t\tisatty stdout; and echo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
425 \telse if set -q LEAN_CTX_ENABLED\n\
426 \t\tisatty stdout; and echo 'lean-ctx: ON'\n\
427 \telse\n\
428 \t\tisatty stdout; and echo 'lean-ctx: OFF'\n\
429 \tend\n\
430 end\n\
431 \n\
432 function _lean_ctx_should_activate\n\
433 \tif set -q LEAN_CTX_ACTIVE; or set -q LEAN_CTX_DISABLED; or test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) = '0'\n\
434 \t\treturn 1\n\
435 \tend\n\
436 \tset -l _lc_mode (set -q LEAN_CTX_SHELL_ACTIVATION; and echo $LEAN_CTX_SHELL_ACTIVATION; or echo '{baked_default}')\n\
437 \tswitch $_lc_mode\n\
438 \t\tcase off none manual\n\
439 \t\t\treturn 1\n\
440 \t\tcase 'agents-only' agents_only agentsonly\n\
441 \t\t\tif _lc_is_agent\n\
442 \t\t\t\treturn 0\n\
443 \t\t\tend\n\
444 \t\t\treturn 1\n\
445 \t\tcase '*'\n\
446 \t\t\treturn 0\n\
447 \tend\n\
448 end\n\
449 \n\
450 if _lean_ctx_should_activate\n\
451 \tif command -q lean-ctx\n\
452 \t\tlean-ctx-on\n\
453 \tend\n\
454 end\n"
455 )
456}
457
458pub fn init_fish(binary: &str) {
459 let config = dirs::home_dir()
460 .map(|h| h.join(".config/fish/config.fish"))
461 .unwrap_or_default();
462
463 let hook_content = generate_hook_fish(binary);
464
465 if write_hook_file("shell-hook.fish", &hook_content).is_some() {
466 upsert_source_line(&config, &source_line_fish());
467 qprintln!(" Binary: {binary}");
468 }
469}
470
471pub fn generate_hook_posix(binary: &str) -> String {
472 let config = crate::core::config::Config::load();
473 let activation = config.shell_activation_effective();
474 let baked_default = match activation {
475 crate::core::config::ShellActivation::Always => "always",
476 crate::core::config::ShellActivation::AgentsOnly => "agents-only",
477 crate::core::config::ShellActivation::Off => "off",
478 };
479 let alias_list = crate::rewrite_registry::shell_alias_list();
480 format!(
481 r#"# lean-ctx shell hook — smart shell mode (track-by-default)
482_lean_ctx_cmds=({alias_list})
483
484_lc_is_agent() {{
485 [ -n "${{LEAN_CTX_AGENT:-}}" ] || [ -n "${{CURSOR_AGENT:-}}" ] || [ -n "${{CODEX_CLI_SESSION:-}}" ] || [ -n "${{CLAUDECODE:-}}" ] || [ -n "${{CODEBUDDY:-}}" ] || [ -n "${{GEMINI_SESSION:-}}" ]
486}}
487
488_lean_ctx_notice() {{
489 [ -n "${{LEAN_CTX_DEBUG:-}}" ] && [ -t 1 ] && echo "$@"
490}}
491
492_lc() {{
493 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
494 command "$@"
495 return
496 fi
497 if [ ! -t 1 ] && ! _lc_is_agent; then
498 command "$@"
499 return
500 fi
501 '{binary}' -t "$@"
502 local _lc_rc=$?
503 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
504 command "$@"
505 else
506 return "$_lc_rc"
507 fi
508}}
509
510_lc_compress() {{
511 if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ]; then
512 command "$@"
513 return
514 fi
515 if [ ! -t 1 ] && ! _lc_is_agent; then
516 command "$@"
517 return
518 fi
519 '{binary}' -c "$@"
520 local _lc_rc=$?
521 if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
522 command "$@"
523 else
524 return "$_lc_rc"
525 fi
526}}
527
528lean-ctx-on() {{
529 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
530 # shellcheck disable=SC2139
531 alias "$_lc_cmd"='_lc '"$_lc_cmd"
532 done
533 alias k='_lc kubectl'
534 export LEAN_CTX_ENABLED=1
535 _lean_ctx_notice "lean-ctx: ON (track mode — output unchanged, token savings recorded)"
536}}
537
538lean-ctx-off() {{
539 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
540 unalias "$_lc_cmd" 2>/dev/null || true
541 done
542 unalias k 2>/dev/null || true
543 export LEAN_CTX_ENABLED=0
544 _lean_ctx_notice "lean-ctx: OFF"
545}}
546
547lean-ctx-mode() {{
548 case "${{1:-}}" in
549 compress)
550 for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
551 # shellcheck disable=SC2139
552 alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
553 done
554 alias k='_lc_compress kubectl'
555 export LEAN_CTX_ENABLED=1
556 _lean_ctx_notice "lean-ctx: COMPRESS mode (all output compressed)"
557 ;;
558 track)
559 lean-ctx-on
560 ;;
561 off)
562 lean-ctx-off
563 ;;
564 *)
565 echo "Usage: lean-ctx-mode <track|compress|off>"
566 echo " track — Full output, stats recorded (default)"
567 echo " compress — Compressed output for all commands"
568 echo " off — No aliases, raw shell"
569 ;;
570 esac
571}}
572
573lean-ctx-raw() {{
574 LEAN_CTX_RAW=1 command "$@"
575}}
576
577lean-ctx-status() {{
578 if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
579 [ -t 1 ] && echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
580 elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
581 [ -t 1 ] && echo "lean-ctx: ON"
582 else
583 [ -t 1 ] && echo "lean-ctx: OFF"
584 fi
585}}
586
587if [ -n "${{ZSH_VERSION:-}}" ]; then
588 _lean-ctx() {{
589 local -a completions
590 local IFS=$'\n'
591 completions=(${{(f)"$(lean-ctx __complete zsh -- "${{words[@]:1}}")" }})
592 if (( ${{#completions}} )); then
593 _describe -t commands 'lean-ctx' completions
594 fi
595 }}
596 compdef _lean-ctx lean-ctx 2>/dev/null
597 compdef _lean-ctx lctx 2>/dev/null
598 compdef _lean-ctx _lc 2>/dev/null
599 compdef _lean-ctx _lc_compress 2>/dev/null
600fi
601
602_lean_ctx_should_activate() {{
603 [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ] || return 1
604 case "${{LEAN_CTX_SHELL_ACTIVATION:-{baked_default}}}" in
605 off|none|manual) return 1 ;;
606 agents-only|agents_only|agentsonly)
607 _lc_is_agent ;;
608 *) return 0 ;;
609 esac
610}}
611
612if _lean_ctx_should_activate; then
613 command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
614fi
615"#
616 )
617}
618
619pub fn init_posix(is_zsh: bool, binary: &str) {
620 let rc_file = if is_zsh {
621 dirs::home_dir()
622 .map(|h| h.join(".zshrc"))
623 .unwrap_or_default()
624 } else {
625 dirs::home_dir()
626 .map(|h| h.join(".bashrc"))
627 .unwrap_or_default()
628 };
629
630 let shell_ext = if is_zsh { "zsh" } else { "bash" };
631 let hook_content = generate_hook_posix(binary);
632
633 if let Some(hook_path) = write_hook_file(&format!("shell-hook.{shell_ext}"), &hook_content) {
634 upsert_source_line(&rc_file, &source_line_posix(shell_ext));
635
636 if !is_zsh {
639 ensure_bash_login_sources_bashrc();
640 }
641
642 qprintln!(" Binary: {binary}");
643
644 write_env_sh_for_containers(&hook_content);
645 write_lc_path_shims(binary);
646 print_docker_env_hints(is_zsh);
647
648 let _ = hook_path;
649 }
650}
651
652fn ensure_bash_login_sources_bashrc() {
659 let Some(home) = dirs::home_dir() else {
660 return;
661 };
662
663 let target = [".bash_profile", ".bash_login", ".profile"]
666 .iter()
667 .map(|f| home.join(f))
668 .find(|p| p.exists())
669 .unwrap_or_else(|| home.join(".bash_profile"));
670
671 if let Ok(existing) = std::fs::read_to_string(&target) {
673 let sources_bashrc = existing
674 .lines()
675 .any(|l| !l.trim_start().starts_with('#') && l.contains(".bashrc"));
676 if sources_bashrc {
677 return;
678 }
679 }
680
681 let snippet = "\n# lean-ctx: load ~/.bashrc in login shells (e.g. macOS Terminal) — begin\n\
682 if [ -f \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\"; fi\n\
683 # lean-ctx: load ~/.bashrc in login shells (e.g. macOS Terminal) — end\n";
684
685 backup_shell_config(&target);
686 match std::fs::OpenOptions::new()
687 .append(true)
688 .create(true)
689 .open(&target)
690 {
691 Ok(mut f) => {
692 use std::io::Write;
693 if f.write_all(snippet.as_bytes()).is_ok() {
694 qprintln!(" Login shell: {} now sources ~/.bashrc", target.display());
695 }
696 }
697 Err(e) => {
698 tracing::warn!("could not update {}: {e}", target.display());
699 }
700 }
701}
702
703pub fn write_env_sh_for_containers(aliases: &str) {
704 let env_sh = match crate::core::paths::config_dir() {
706 Ok(d) => d.join("env.sh"),
707 Err(_) => return,
708 };
709 if let Some(parent) = env_sh.parent() {
710 let _ = std::fs::create_dir_all(parent);
711 }
712 let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
713 let mut content = String::from(
714 r#"# lean-ctx: passthrough stubs for non-interactive subshells (fixes #255).
715# These ensure _lc/_lc_compress exist so inherited aliases don't break.
716# The full hook definitions override these when the interactive shell loads.
717_lc() { command "$@"; }
718_lc_compress() { command "$@"; }
719
720"#,
721 );
722 content.push_str(&sanitized_aliases);
723 content.push_str(
724 r#"
725
726# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
727# Guards: container-only + no recursion + no re-entry via BASH_ENV + 60s cooldown + PID-lock
728if [ -f /.dockerenv ] || grep -qsE '/docker/|/lxc/' /proc/1/cgroup 2>/dev/null; then
729 if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ -z "${_LEAN_CTX_HEAL:-}" ]; then
730 # XDG-only paths (GL #623): never touch ~/.lean-ctx, which would re-collapse
731 # a committed XDG layout. heal_ts is STATE, locks live in the DATA dir
732 # (matches process_guard::lock_dir defaults).
733 _LEAN_CTX_STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/lean-ctx"
734 _LEAN_CTX_HEAL_TS="${_LEAN_CTX_STATE_DIR}/.heal_ts"
735 _LEAN_CTX_HEAL_COOLDOWN=60
736 _lean_ctx_heal_needed=1
737 if [ -f "$_LEAN_CTX_HEAL_TS" ]; then
738 _last_heal=$(cat "$_LEAN_CTX_HEAL_TS" 2>/dev/null || echo 0)
739 _now=$(date +%s 2>/dev/null || echo 0)
740 if [ $(( _now - _last_heal )) -lt $_LEAN_CTX_HEAL_COOLDOWN ]; then
741 _lean_ctx_heal_needed=0
742 fi
743 fi
744 _lean_ctx_lock_count=0
745 for _lf in "${XDG_DATA_HOME:-$HOME/.local/share}/lean-ctx/locks"/slot-*.lock; do
746 [ -f "$_lf" ] && _lean_ctx_lock_count=$(( _lean_ctx_lock_count + 1 ))
747 done
748 if [ "$_lean_ctx_heal_needed" = "1" ] && [ "$_lean_ctx_lock_count" -lt 4 ]; then
749 export _LEAN_CTX_HEAL=1
750 if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
751 if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
752 LEAN_CTX_ACTIVE=1 LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
753 mkdir -p "$_LEAN_CTX_STATE_DIR" 2>/dev/null
754 date +%s > "$_LEAN_CTX_HEAL_TS" 2>/dev/null
755 fi
756 fi
757 fi
758 fi
759fi
760"#,
761 );
762 match std::fs::write(&env_sh, content) {
763 Ok(()) => {
764 if !super::quiet_enabled() {
766 eprintln!(" env.sh: {}", env_sh.display());
767 }
768 }
769 Err(e) => tracing::warn!("could not write {}: {e}", env_sh.display()),
770 }
771}
772
773fn lc_shim_dir() -> Option<std::path::PathBuf> {
777 std::env::current_exe()
778 .ok()
779 .and_then(|p| p.parent().map(std::path::Path::to_path_buf))
780}
781
782fn shim_script(name: &str, binary: &str, flag: &str) -> String {
787 format!(
788 "#!/bin/sh\n\
789 # lean-ctx PATH fallback for the `{name}` shell function -- DO NOT EDIT.\n\
790 # Shell resolves alias -> function -> PATH, so the hook's shell function\n\
791 # shadows this whenever it is loaded (identical behavior there). This runs\n\
792 # only where the function is absent: non-interactive subshells, scripts,\n\
793 # xargs/find -exec, a pipeline's outer shell, and agent harnesses that\n\
794 # snapshot+replay the shell and drop the function but keep the aliases\n\
795 # that call it. Without it those contexts fail `{name}: command not found`.\n\
796 if [ -n \"${{LEAN_CTX_DISABLED:-}}\" ] || [ -n \"${{LEAN_CTX_NO_HOOK:-}}\" ]; then\n\
797 \texec \"$@\"\n\
798 fi\n\
799 if [ ! -t 1 ] && [ -z \"${{LEAN_CTX_AGENT:-}}\" ] && [ -z \"${{CURSOR_AGENT:-}}\" ] && [ -z \"${{CODEX_CLI_SESSION:-}}\" ] \\\n\
800 \t&& [ -z \"${{CLAUDECODE:-}}\" ] && [ -z \"${{CODEBUDDY:-}}\" ] && [ -z \"${{GEMINI_SESSION:-}}\" ]; then\n\
801 \texec \"$@\"\n\
802 fi\n\
803 '{binary}' {flag} \"$@\"\n\
804 _lc_rc=$?\n\
805 if [ \"$_lc_rc\" -eq 127 ] || [ \"$_lc_rc\" -eq 126 ]; then\n\
806 \texec \"$@\"\n\
807 fi\n\
808 exit \"$_lc_rc\"\n"
809 )
810}
811
812fn write_lc_path_shims_in(dir: &std::path::Path, binary: &str) {
814 for (name, flag) in [("_lc", "-t"), ("_lc_compress", "-c")] {
815 let path = dir.join(name);
816 if let Err(e) = std::fs::write(&path, shim_script(name, binary, flag)) {
817 tracing::warn!("could not write shim {}: {e}", path.display());
818 continue;
819 }
820 #[cfg(unix)]
821 {
822 use std::os::unix::fs::PermissionsExt;
823 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755));
824 }
825 }
826}
827
828fn write_lc_path_shims(binary: &str) {
833 if let Some(dir) = lc_shim_dir() {
834 write_lc_path_shims_in(&dir, binary);
835 }
836}
837
838fn print_docker_env_hints(is_zsh: bool) {
839 if is_zsh || !crate::shell::is_container() {
840 return;
841 }
842 let env_sh = crate::core::paths::config_dir().map_or_else(
843 |_| "/root/.config/lean-ctx/env.sh".to_string(),
844 |d| d.join("env.sh").to_string_lossy().to_string(),
845 );
846
847 let has_bash_env = std::env::var("BASH_ENV").is_ok();
848 let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
849
850 if has_bash_env && has_claude_env {
851 return;
852 }
853
854 eprintln!();
855 eprintln!(" \x1b[33m⚠ Docker detected — environment hints:\x1b[0m");
856
857 if !has_bash_env {
858 eprintln!(" For generic bash -c usage (non-interactive shells):");
859 eprintln!(" \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
860 }
861 if !has_claude_env {
862 eprintln!(" For Claude Code (sources before each command):");
863 eprintln!(" \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
864 }
865 eprintln!();
866}
867
868pub fn remove_lean_ctx_block(content: &str) -> String {
869 if content.contains("# lean-ctx shell hook — end") {
870 return remove_lean_ctx_block_by_marker(content);
871 }
872 remove_lean_ctx_block_legacy(content)
873}
874
875fn remove_lean_ctx_block_by_marker(content: &str) -> String {
876 let mut result = String::new();
877 let mut in_block = false;
878
879 for line in content.lines() {
880 if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
881 in_block = true;
882 continue;
883 }
884 if in_block {
885 if line.trim() == "# lean-ctx shell hook — end" {
886 in_block = false;
887 }
888 continue;
889 }
890 result.push_str(line);
891 result.push('\n');
892 }
893 result
894}
895
896fn remove_lean_ctx_block_legacy(content: &str) -> String {
897 let mut result = String::new();
898 let mut in_block = false;
899
900 for line in content.lines() {
901 if line.contains("lean-ctx shell hook") {
902 in_block = true;
903 continue;
904 }
905 if in_block {
906 if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
907 if line.trim() == "fi" || line.trim() == "end" {
908 in_block = false;
909 }
910 continue;
911 }
912 if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
913 in_block = false;
914 result.push_str(line);
915 result.push('\n');
916 }
917 continue;
918 }
919 result.push_str(line);
920 result.push('\n');
921 }
922 result
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928
929 #[test]
930 fn lc_shim_script_is_self_contained_fallback() {
931 let s = shim_script("_lc", "/usr/bin/lean-ctx", "-t");
932 assert!(s.starts_with("#!/bin/sh\n"), "needs a shebang: {s}");
933 assert!(s.contains("'/usr/bin/lean-ctx' -t \"$@\""), "{s}");
934 assert!(s.contains("exec \"$@\""), "{s}");
935 assert!(s.contains("CLAUDECODE"), "{s}");
936 assert!(s.contains("LEAN_CTX_DISABLED"), "{s}");
937 }
938
939 #[test]
940 fn lc_compress_shim_uses_compress_flag() {
941 let s = shim_script("_lc_compress", "/usr/bin/lean-ctx", "-c");
942 assert!(s.contains("'/usr/bin/lean-ctx' -c \"$@\""), "{s}");
943 }
944
945 #[test]
946 fn write_lc_path_shims_writes_both_executables() {
947 let tmp = tempfile::tempdir().expect("tempdir");
948 write_lc_path_shims_in(tmp.path(), "/usr/bin/lean-ctx");
949 for name in ["_lc", "_lc_compress"] {
950 assert!(tmp.path().join(name).exists(), "missing shim {name}");
951 }
952 }
953
954 #[test]
955 fn test_remove_lean_ctx_block_posix() {
956 let input = r#"# existing config
957export PATH="$HOME/bin:$PATH"
958
959# lean-ctx shell hook — transparent CLI compression (95+ patterns)
960if [ -z "$LEAN_CTX_ACTIVE" ]; then
961alias git='lean-ctx -c git'
962alias npm='lean-ctx -c npm'
963fi
964
965# other stuff
966export EDITOR=vim
967"#;
968 let result = remove_lean_ctx_block(input);
969 assert!(!result.contains("lean-ctx"), "block should be removed");
970 assert!(result.contains("export PATH"), "other content preserved");
971 assert!(
972 result.contains("export EDITOR"),
973 "trailing content preserved"
974 );
975 }
976
977 #[test]
978 fn test_remove_lean_ctx_block_fish() {
979 let input = "# other fish config\nset -x FOO bar\n\n# lean-ctx shell hook — transparent CLI compression (95+ patterns)\nif not set -q LEAN_CTX_ACTIVE\n\talias git 'lean-ctx -c git'\n\talias npm 'lean-ctx -c npm'\nend\n\n# more config\nset -x BAZ qux\n";
980 let result = remove_lean_ctx_block(input);
981 assert!(!result.contains("lean-ctx"), "block should be removed");
982 assert!(result.contains("set -x FOO"), "other content preserved");
983 assert!(result.contains("set -x BAZ"), "trailing content preserved");
984 }
985
986 #[test]
987 fn test_remove_lean_ctx_block_ps() {
988 let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (95+ patterns)\nif (-not $env:LEAN_CTX_ACTIVE) {\n $LeanCtxBin = \"C:\\\\bin\\\\lean-ctx.exe\"\n function git { & $LeanCtxBin -c \"git $($args -join ' ')\" }\n}\n\n# other stuff\n$env:EDITOR = 'vim'\n";
989 let result = remove_lean_ctx_block_ps(input);
990 assert!(
991 !result.contains("lean-ctx shell hook"),
992 "block should be removed"
993 );
994 assert!(result.contains("$env:FOO"), "other content preserved");
995 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
996 }
997
998 #[test]
999 fn test_remove_lean_ctx_block_ps_nested() {
1000 let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (95+ patterns)\nif (-not $env:LEAN_CTX_ACTIVE) {\n $LeanCtxBin = \"lean-ctx\"\n function _lc {\n & $LeanCtxBin -c \"$($args -join ' ')\"\n }\n if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {\n function git { _lc git @args }\n foreach ($c in @('npm','pnpm')) {\n if ($a) {\n Set-Variable -Name \"_lc_$c\" -Value $a.Source -Scope Script\n }\n }\n }\n}\n\n# other stuff\n$env:EDITOR = 'vim'\n";
1001 let result = remove_lean_ctx_block_ps(input);
1002 assert!(
1003 !result.contains("lean-ctx shell hook"),
1004 "block should be removed"
1005 );
1006 assert!(!result.contains("_lc"), "function should be removed");
1007 assert!(result.contains("$env:FOO"), "other content preserved");
1008 assert!(result.contains("$env:EDITOR"), "trailing content preserved");
1009 }
1010
1011 #[test]
1012 fn test_remove_block_no_lean_ctx() {
1013 let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
1014 let result = remove_lean_ctx_block(input);
1015 assert!(result.contains("export PATH"), "content unchanged");
1016 }
1017
1018 #[test]
1019 fn test_bash_hook_contains_pipe_guard_and_agent_bypass() {
1020 let output = generate_hook_posix("/usr/local/bin/lean-ctx");
1021 assert!(
1022 output.contains("! -t 1"),
1023 "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
1024 );
1025 assert!(
1026 output.contains("_lc_is_agent"),
1027 "bash/zsh hook must have agent-aware bypass"
1028 );
1029 assert!(
1030 output.contains("CODEX_CLI_SESSION"),
1031 "agent check must include CODEX_CLI_SESSION"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_lc_uses_track_mode_by_default() {
1037 let binary = "/usr/local/bin/lean-ctx";
1038 let alias_list = crate::rewrite_registry::shell_alias_list();
1039 let aliases = format!(
1040 r#"_lc() {{
1041 '{binary}' -t "$@"
1042}}
1043_lc_compress() {{
1044 '{binary}' -c "$@"
1045}}"#
1046 );
1047 assert!(
1048 aliases.contains("-t \"$@\""),
1049 "_lc must use -t (track mode) by default"
1050 );
1051 assert!(
1052 aliases.contains("-c \"$@\""),
1053 "_lc_compress must use -c (compress mode)"
1054 );
1055 let _ = alias_list;
1056 }
1057
1058 #[test]
1059 fn test_posix_shell_has_lean_ctx_mode() {
1060 let alias_list = crate::rewrite_registry::shell_alias_list();
1061 let aliases = r#"
1062lean-ctx-mode() {{
1063 case "${{1:-}}" in
1064 compress) echo compress ;;
1065 track) echo track ;;
1066 off) echo off ;;
1067 esac
1068}}
1069"#
1070 .to_string();
1071 assert!(
1072 aliases.contains("lean-ctx-mode()"),
1073 "lean-ctx-mode function must exist"
1074 );
1075 assert!(
1076 aliases.contains("compress"),
1077 "compress mode must be available"
1078 );
1079 assert!(aliases.contains("track"), "track mode must be available");
1080 let _ = alias_list;
1081 }
1082
1083 #[test]
1084 fn test_fish_hook_contains_pipe_guard_and_agent_bypass() {
1085 let output = generate_hook_fish("/usr/local/bin/lean-ctx");
1086 assert!(
1087 output.contains("isatty stdout"),
1088 "fish hook must contain pipe guard (isatty stdout)"
1089 );
1090 assert!(
1091 output.contains("_lc_is_agent"),
1092 "fish hook must have agent-aware bypass"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_powershell_hook_contains_pipe_guard() {
1098 let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
1099 assert!(
1100 hook.contains("IsOutputRedirected"),
1101 "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
1102 );
1103 }
1104
1105 #[test]
1106 fn powershell_hook_binary_is_native_not_msys() {
1107 let win = "C:/Users/Dawid/.cargo/bin/lean-ctx.exe";
1110 assert_eq!(hook_binary_for_shell("powershell", win), win);
1111 assert_eq!(hook_binary_for_shell("pwsh", win), win);
1112 assert!(!hook_binary_for_shell("powershell", win).contains("/c/"));
1113 }
1114
1115 #[test]
1116 fn posix_hook_binary_keeps_msys_form_on_windows_drive() {
1117 let win = "C:/Users/Dawid/.cargo/bin/lean-ctx.exe";
1120 let msys = "/c/Users/Dawid/.cargo/bin/lean-ctx.exe";
1121 assert_eq!(hook_binary_for_shell("bash", win), msys);
1122 assert_eq!(hook_binary_for_shell("zsh", win), msys);
1123 assert_eq!(hook_binary_for_shell("fish", win), msys);
1124 }
1125
1126 #[test]
1127 fn test_remove_lean_ctx_block_new_format_with_end_marker() {
1128 let input = r#"# existing config
1129export PATH="$HOME/bin:$PATH"
1130
1131# lean-ctx shell hook — transparent CLI compression (95+ patterns)
1132_lean_ctx_cmds=(git npm pnpm)
1133
1134lean-ctx-on() {
1135 for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
1136 alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
1137 done
1138 export LEAN_CTX_ENABLED=1
1139 [ -t 1 ] && echo "lean-ctx: ON"
1140}
1141
1142lean-ctx-off() {
1143 export LEAN_CTX_ENABLED=0
1144 [ -t 1 ] && echo "lean-ctx: OFF"
1145}
1146
1147if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
1148 lean-ctx-on
1149fi
1150# lean-ctx shell hook — end
1151
1152# other stuff
1153export EDITOR=vim
1154"#;
1155 let result = remove_lean_ctx_block(input);
1156 assert!(!result.contains("lean-ctx-on"), "block should be removed");
1157 assert!(!result.contains("lean-ctx shell hook"), "marker removed");
1158 assert!(result.contains("export PATH"), "other content preserved");
1159 assert!(
1160 result.contains("export EDITOR"),
1161 "trailing content preserved"
1162 );
1163 }
1164
1165 #[test]
1166 fn env_sh_for_containers_includes_self_heal() {
1167 let _g = crate::core::data_dir::test_env_lock();
1168 let tmp = tempfile::tempdir().expect("tempdir");
1169 let config_dir = tmp.path().join("config");
1171 std::fs::create_dir_all(&config_dir).expect("mkdir config");
1172 crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", &config_dir);
1173
1174 write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
1175 let env_sh = config_dir.join("env.sh");
1176 let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
1177 if !cfg!(windows)
1178 && let Ok(mut bash) = std::process::Command::new("bash")
1179 .arg("-n")
1180 .arg(&env_sh)
1181 .spawn()
1182 {
1183 let ok = bash.wait().is_ok_and(|s| s.success());
1184 assert!(ok, "generated env.sh must be valid bash");
1185 }
1186 assert!(
1187 content.contains(r#"_lc() { command "$@"; }"#),
1188 "env.sh must contain _lc passthrough stub for non-interactive shells"
1189 );
1190 assert!(
1191 content.contains(r#"_lc_compress() { command "$@"; }"#),
1192 "env.sh must contain _lc_compress passthrough stub"
1193 );
1194 assert!(content.contains("lean-ctx docker self-heal"));
1195 assert!(content.contains("claude mcp list"));
1196 assert!(content.contains("lean-ctx init --agent claude"));
1197 assert!(
1198 content.contains("_LEAN_CTX_HEAL"),
1199 "env.sh must guard against recursive self-heal"
1200 );
1201 assert!(
1202 content.contains("LEAN_CTX_ACTIVE"),
1203 "env.sh must check LEAN_CTX_ACTIVE to prevent re-entry"
1204 );
1205 assert!(
1206 content.contains("/.dockerenv"),
1207 "env.sh self-heal must be gated to container environments"
1208 );
1209 assert!(
1213 !content.contains("$HOME/.lean-ctx") && !content.contains("${HOME}/.lean-ctx"),
1214 "self-heal must not touch ~/.lean-ctx (GL #623)"
1215 );
1216 assert!(
1217 content.contains("${XDG_STATE_HOME:-$HOME/.local/state}/lean-ctx"),
1218 "heal_ts must live under the XDG state dir"
1219 );
1220 assert!(
1221 content.contains("${XDG_DATA_HOME:-$HOME/.local/share}/lean-ctx/locks"),
1222 "lock count must read the XDG data lock dir"
1223 );
1224
1225 crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
1226 }
1227
1228 #[cfg(unix)]
1229 #[test]
1230 fn bash_login_profile_sources_bashrc_idempotently() {
1231 let _g = crate::core::data_dir::test_env_lock();
1232 let tmp = tempfile::tempdir().expect("tempdir");
1233 let home = tmp.path();
1234 let prev = std::env::var_os("HOME");
1235 crate::test_env::set_var("HOME", home);
1236
1237 std::fs::write(home.join(".bashrc"), "# bashrc\n").expect("write .bashrc");
1238 ensure_bash_login_sources_bashrc();
1241 let profile = home.join(".bash_profile");
1242 let first = std::fs::read_to_string(&profile).expect(".bash_profile created");
1243 assert!(
1244 first.contains(". \"$HOME/.bashrc\""),
1245 "login profile must source ~/.bashrc: {first}"
1246 );
1247 let markers = first.matches("load ~/.bashrc in login shells").count();
1248
1249 ensure_bash_login_sources_bashrc();
1251 let second = std::fs::read_to_string(&profile).expect("read profile");
1252 assert_eq!(
1253 second.matches("load ~/.bashrc in login shells").count(),
1254 markers,
1255 "snippet must not be duplicated on re-run"
1256 );
1257
1258 match prev {
1259 Some(v) => crate::test_env::set_var("HOME", v),
1260 None => crate::test_env::remove_var("HOME"),
1261 }
1262 }
1263
1264 #[test]
1265 fn test_source_line_posix() {
1266 let line = source_line_posix("zsh");
1267 assert!(line.contains("shell-hook.zsh"));
1268 assert!(line.contains("[ -f"));
1269 }
1270
1271 #[test]
1272 fn test_source_line_fish() {
1273 let line = source_line_fish();
1274 assert!(line.contains("shell-hook.fish"));
1275 assert!(line.contains("source"));
1276 }
1277
1278 #[test]
1279 fn test_source_line_powershell() {
1280 let line = source_line_powershell();
1281 assert!(line.contains("shell-hook.ps1"));
1282 assert!(line.contains("Test-Path"));
1283 }
1284}