Skip to main content

lean_ctx/cli/
shell_init.rs

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 = crate::hooks::to_bash_compatible_path(&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 backup_shell_config(path: &std::path::Path) {
27    if !path.exists() {
28        return;
29    }
30    let bak = path.with_extension("lean-ctx.bak");
31    if std::fs::copy(path, &bak).is_ok() {
32        qprintln!(
33            "  Backup: {}",
34            bak.file_name().map_or_else(
35                || bak.display().to_string(),
36                |n| format!("~/{}", n.to_string_lossy())
37            )
38        );
39    }
40}
41
42fn lean_ctx_dir() -> Option<std::path::PathBuf> {
43    crate::core::data_dir::lean_ctx_data_dir().ok()
44}
45
46fn write_hook_file(filename: &str, content: &str) -> Option<std::path::PathBuf> {
47    let dir = lean_ctx_dir()?;
48    let _ = std::fs::create_dir_all(&dir);
49    let path = dir.join(filename);
50    match std::fs::write(&path, content) {
51        Ok(()) => Some(path),
52        Err(e) => {
53            tracing::error!("Error writing {}: {e}", path.display());
54            None
55        }
56    }
57}
58
59fn resolved_hook_dir_display() -> String {
60    lean_ctx_dir().map_or_else(
61        || "$HOME/.lean-ctx".to_string(),
62        |p| p.to_string_lossy().to_string(),
63    )
64}
65
66fn source_line_posix(shell_ext: &str) -> String {
67    let mut dir = resolved_hook_dir_display();
68    // Git Bash / MSYS expects /c/... style paths in bashrc/zshrc.
69    if cfg!(windows) {
70        dir = crate::hooks::to_bash_compatible_path(&dir);
71    }
72    format!(
73        "# lean-ctx shell hook — begin\n\
74         if [ -f \"{dir}/shell-hook.{shell_ext}\" ]; then\n\
75           . \"{dir}/shell-hook.{shell_ext}\"\n\
76         fi\n\
77         # lean-ctx shell hook — end\n"
78    )
79}
80
81fn source_line_fish() -> String {
82    let mut dir = resolved_hook_dir_display();
83    // Fish on Windows (MSYS) also expects /c/... style paths.
84    if cfg!(windows) {
85        dir = crate::hooks::to_bash_compatible_path(&dir);
86    }
87    format!(
88        "# lean-ctx shell hook — begin\n\
89         if test -f \"{dir}/shell-hook.fish\"\n\
90           source \"{dir}/shell-hook.fish\"\n\
91         end\n\
92         # lean-ctx shell hook — end\n"
93    )
94}
95
96fn source_line_powershell() -> String {
97    let dir = resolved_hook_dir_display();
98    let dir_ps = dir.replace('/', "\\");
99    format!(
100        "# lean-ctx shell hook — begin\n\
101         $leanCtxHook = \"{dir_ps}\\shell-hook.ps1\"\n\
102         if ((Test-Path $leanCtxHook) -and -not [Console]::IsOutputRedirected) {{ . $leanCtxHook }}\n"
103    )
104}
105
106fn upsert_source_line(rc_path: &std::path::Path, source_line: &str) {
107    backup_shell_config(rc_path);
108
109    if let Ok(existing) = std::fs::read_to_string(rc_path) {
110        if existing.contains(source_line.trim()) {
111            return;
112        }
113
114        // Remove any legacy blocks and one-liner source lines, then append our canonical block.
115        let cleaned = remove_lean_ctx_block(&existing);
116        let cleaned = cleaned
117            .lines()
118            .filter(|line| {
119                !line.contains("lean-ctx/shell-hook.")
120                    && !line.contains("lean-ctx\\shell-hook.")
121                    && line.trim() != "lean-ctx shell hook"
122            })
123            .collect::<Vec<_>>()
124            .join("\n");
125        let cleaned = if cleaned.ends_with('\n') {
126            cleaned
127        } else {
128            format!("{cleaned}\n")
129        };
130
131        match std::fs::write(rc_path, format!("{cleaned}{source_line}")) {
132            Ok(()) => {
133                qprintln!("Updated lean-ctx hook in {}", rc_path.display());
134            }
135            Err(e) => {
136                tracing::error!("Error updating {}: {e}", rc_path.display());
137            }
138        }
139        return;
140    }
141
142    match std::fs::OpenOptions::new()
143        .append(true)
144        .create(true)
145        .open(rc_path)
146    {
147        Ok(mut f) => {
148            use std::io::Write;
149            let _ = f.write_all(source_line.as_bytes());
150            qprintln!("Added lean-ctx hook to {}", rc_path.display());
151        }
152        Err(e) => tracing::error!("Error writing {}: {e}", rc_path.display()),
153    }
154}
155
156pub fn generate_hook_powershell(binary: &str) -> String {
157    let binary_escaped = binary.replace('\\', "\\\\");
158    format!(
159        r#"# lean-ctx shell hook — transparent CLI compression (90+ patterns)
160if (-not $env:LEAN_CTX_ACTIVE -and -not $env:LEAN_CTX_DISABLED -and -not $env:LEAN_CTX_NO_HOOK) {{
161  $LeanCtxBin = "{binary_escaped}"
162  function _lc {{
163    if ($env:LEAN_CTX_DISABLED -or $env:LEAN_CTX_NO_HOOK -or [Console]::IsOutputRedirected) {{ & @args; return }}
164    & $LeanCtxBin -c @args
165    if ($LASTEXITCODE -eq 127 -or $LASTEXITCODE -eq 126) {{
166      & @args
167    }}
168  }}
169  function lean-ctx-raw {{ $env:LEAN_CTX_RAW = '1'; & @args; Remove-Item Env:LEAN_CTX_RAW -ErrorAction SilentlyContinue }}
170  if (Get-Command lean-ctx -ErrorAction SilentlyContinue) {{
171    function git {{ _lc git @args }}
172    function cargo {{ _lc cargo @args }}
173    function docker {{ _lc docker @args }}
174    function kubectl {{ _lc kubectl @args }}
175    function gh {{ _lc gh @args }}
176    function pip {{ _lc pip @args }}
177    function pip3 {{ _lc pip3 @args }}
178    function ruff {{ _lc ruff @args }}
179    function go {{ _lc go @args }}
180    function curl {{ _lc curl @args }}
181    function wget {{ _lc wget @args }}
182    foreach ($c in @('npm','pnpm','yarn','eslint','prettier','tsc')) {{
183      if (Get-Command $c -CommandType Application -ErrorAction SilentlyContinue) {{
184        New-Item -Path "function:$c" -Value ([scriptblock]::Create("_lc $c @args")) -Force | Out-Null
185      }}
186    }}
187  }}
188}}
189"#
190    )
191}
192
193pub fn init_powershell(binary: &str) {
194    let profile_dir = dirs::home_dir().map(|h| h.join("Documents").join("PowerShell"));
195    let profile_path = if let Some(dir) = profile_dir {
196        let _ = std::fs::create_dir_all(&dir);
197        dir.join("Microsoft.PowerShell_profile.ps1")
198    } else {
199        tracing::error!("Could not resolve PowerShell profile directory");
200        return;
201    };
202
203    let hook_content = generate_hook_powershell(binary);
204
205    if write_hook_file("shell-hook.ps1", &hook_content).is_some() {
206        upsert_source_line(&profile_path, &source_line_powershell());
207        qprintln!("  Binary: {binary}");
208    }
209}
210
211pub fn remove_lean_ctx_block_ps(content: &str) -> String {
212    let mut result = String::new();
213    let mut in_block = false;
214    let mut brace_depth = 0i32;
215
216    for line in content.lines() {
217        if line.contains("lean-ctx shell hook") {
218            in_block = true;
219            continue;
220        }
221        if in_block {
222            brace_depth += line.matches('{').count() as i32;
223            brace_depth -= line.matches('}').count() as i32;
224            if brace_depth <= 0 && (line.trim() == "}" || line.trim().is_empty()) {
225                if line.trim() == "}" {
226                    in_block = false;
227                    brace_depth = 0;
228                }
229                continue;
230            }
231            continue;
232        }
233        result.push_str(line);
234        result.push('\n');
235    }
236    result
237}
238
239pub fn generate_hook_fish(binary: &str) -> String {
240    let alias_list = crate::rewrite_registry::shell_alias_list();
241    format!(
242        "# lean-ctx shell hook — smart shell mode (track-by-default)\n\
243        set -g _lean_ctx_cmds {alias_list}\n\
244        \n\
245        function _lc\n\
246        \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK; or not isatty stdout\n\
247        \t\tcommand $argv\n\
248        \t\treturn\n\
249        \tend\n\
250        \t'{binary}' -t $argv\n\
251        \tset -l _lc_rc $status\n\
252        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
253        \t\tcommand $argv\n\
254        \telse\n\
255        \t\treturn $_lc_rc\n\
256        \tend\n\
257        end\n\
258        \n\
259        function _lc_compress\n\
260        \tif set -q LEAN_CTX_DISABLED; or set -q LEAN_CTX_NO_HOOK; or not isatty stdout\n\
261        \t\tcommand $argv\n\
262        \t\treturn\n\
263        \tend\n\
264        \t'{binary}' -c $argv\n\
265        \tset -l _lc_rc $status\n\
266        \tif test $_lc_rc -eq 127 -o $_lc_rc -eq 126\n\
267        \t\tcommand $argv\n\
268        \telse\n\
269        \t\treturn $_lc_rc\n\
270        \tend\n\
271        end\n\
272        \n\
273        function lean-ctx-on\n\
274        \tfor _lc_cmd in $_lean_ctx_cmds\n\
275        \t\talias $_lc_cmd '_lc '$_lc_cmd\n\
276        \tend\n\
277        \talias k '_lc kubectl'\n\
278        \tset -gx LEAN_CTX_ENABLED 1\n\
279        \tisatty stdout; and echo 'lean-ctx: ON (track mode — full output, stats recorded)'\n\
280        end\n\
281        \n\
282        function lean-ctx-off\n\
283        \tfor _lc_cmd in $_lean_ctx_cmds\n\
284        \t\tfunctions --erase $_lc_cmd 2>/dev/null; true\n\
285        \tend\n\
286        \tfunctions --erase k 2>/dev/null; true\n\
287        \tset -e LEAN_CTX_ENABLED\n\
288        \tisatty stdout; and echo 'lean-ctx: OFF'\n\
289        end\n\
290        \n\
291        function lean-ctx-mode\n\
292        \tswitch $argv[1]\n\
293        \t\tcase compress\n\
294        \t\t\tfor _lc_cmd in $_lean_ctx_cmds\n\
295        \t\t\t\talias $_lc_cmd '_lc_compress '$_lc_cmd\n\
296        \t\t\t\tend\n\
297        \t\t\talias k '_lc_compress kubectl'\n\
298        \t\t\tset -gx LEAN_CTX_ENABLED 1\n\
299        \t\t\tisatty stdout; and echo 'lean-ctx: COMPRESS mode (all output compressed)'\n\
300        \t\tcase track\n\
301        \t\t\tlean-ctx-on\n\
302        \t\tcase off\n\
303        \t\t\tlean-ctx-off\n\
304        \t\tcase '*'\n\
305        \t\t\techo 'Usage: lean-ctx-mode <track|compress|off>'\n\
306        \t\t\techo '  track    — Full output, stats recorded (default)'\n\
307        \t\t\techo '  compress — Compressed output for all commands'\n\
308        \t\t\techo '  off      — No aliases, raw shell'\n\
309        \tend\n\
310        end\n\
311        \n\
312        function lean-ctx-raw\n\
313        \tset -lx LEAN_CTX_RAW 1\n\
314        \tcommand $argv\n\
315        end\n\
316        \n\
317        function lean-ctx-status\n\
318        \tif set -q LEAN_CTX_DISABLED\n\
319        \t\tisatty stdout; and echo 'lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)'\n\
320        \telse if set -q LEAN_CTX_ENABLED\n\
321        \t\tisatty stdout; and echo 'lean-ctx: ON'\n\
322        \telse\n\
323        \t\tisatty stdout; and echo 'lean-ctx: OFF'\n\
324        \tend\n\
325        end\n\
326        \n\
327        if not set -q LEAN_CTX_ACTIVE; and not set -q LEAN_CTX_DISABLED; and test (set -q LEAN_CTX_ENABLED; and echo $LEAN_CTX_ENABLED; or echo 1) != '0'\n\
328        \tif command -q lean-ctx\n\
329        \t\tlean-ctx-on\n\
330        \tend\n\
331        end\n"
332    )
333}
334
335pub fn init_fish(binary: &str) {
336    let config = dirs::home_dir()
337        .map(|h| h.join(".config/fish/config.fish"))
338        .unwrap_or_default();
339
340    let hook_content = generate_hook_fish(binary);
341
342    if write_hook_file("shell-hook.fish", &hook_content).is_some() {
343        upsert_source_line(&config, &source_line_fish());
344        qprintln!("  Binary: {binary}");
345    }
346}
347
348pub fn generate_hook_posix(binary: &str) -> String {
349    let alias_list = crate::rewrite_registry::shell_alias_list();
350    format!(
351        r#"# lean-ctx shell hook — smart shell mode (track-by-default)
352_lean_ctx_cmds=({alias_list})
353
354_lc() {{
355    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ] || [ ! -t 1 ]; then
356        command "$@"
357        return
358    fi
359    '{binary}' -t "$@"
360    local _lc_rc=$?
361    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
362        command "$@"
363    else
364        return "$_lc_rc"
365    fi
366}}
367
368_lc_compress() {{
369    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ -n "${{LEAN_CTX_NO_HOOK:-}}" ] || [ ! -t 1 ]; then
370        command "$@"
371        return
372    fi
373    '{binary}' -c "$@"
374    local _lc_rc=$?
375    if [ "$_lc_rc" -eq 127 ] || [ "$_lc_rc" -eq 126 ]; then
376        command "$@"
377    else
378        return "$_lc_rc"
379    fi
380}}
381
382lean-ctx-on() {{
383    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
384        # shellcheck disable=SC2139
385        alias "$_lc_cmd"='_lc '"$_lc_cmd"
386    done
387    alias k='_lc kubectl'
388    export LEAN_CTX_ENABLED=1
389    [ -t 1 ] && echo "lean-ctx: ON (track mode — full output, stats recorded)"
390}}
391
392lean-ctx-off() {{
393    for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
394        unalias "$_lc_cmd" 2>/dev/null || true
395    done
396    unalias k 2>/dev/null || true
397    unset LEAN_CTX_ENABLED
398    [ -t 1 ] && echo "lean-ctx: OFF"
399}}
400
401lean-ctx-mode() {{
402    case "${{1:-}}" in
403        compress)
404            for _lc_cmd in "${{_lean_ctx_cmds[@]}}"; do
405                # shellcheck disable=SC2139
406                alias "$_lc_cmd"='_lc_compress '"$_lc_cmd"
407            done
408            alias k='_lc_compress kubectl'
409            export LEAN_CTX_ENABLED=1
410            [ -t 1 ] && echo "lean-ctx: COMPRESS mode (all output compressed)"
411            ;;
412        track)
413            lean-ctx-on
414            ;;
415        off)
416            lean-ctx-off
417            ;;
418        *)
419            echo "Usage: lean-ctx-mode <track|compress|off>"
420            echo "  track    — Full output, stats recorded (default)"
421            echo "  compress — Compressed output for all commands"
422            echo "  off      — No aliases, raw shell"
423            ;;
424    esac
425}}
426
427lean-ctx-raw() {{
428    LEAN_CTX_RAW=1 command "$@"
429}}
430
431lean-ctx-status() {{
432    if [ -n "${{LEAN_CTX_DISABLED:-}}" ]; then
433        [ -t 1 ] && echo "lean-ctx: DISABLED (LEAN_CTX_DISABLED is set)"
434    elif [ -n "${{LEAN_CTX_ENABLED:-}}" ]; then
435        [ -t 1 ] && echo "lean-ctx: ON"
436    else
437        [ -t 1 ] && echo "lean-ctx: OFF"
438    fi
439}}
440
441if [ -z "${{LEAN_CTX_ACTIVE:-}}" ] && [ -z "${{LEAN_CTX_DISABLED:-}}" ] && [ "${{LEAN_CTX_ENABLED:-1}}" != "0" ]; then
442    command -v lean-ctx >/dev/null 2>&1 && lean-ctx-on
443fi
444"#
445    )
446}
447
448pub fn init_posix(is_zsh: bool, binary: &str) {
449    let rc_file = if is_zsh {
450        dirs::home_dir()
451            .map(|h| h.join(".zshrc"))
452            .unwrap_or_default()
453    } else {
454        dirs::home_dir()
455            .map(|h| h.join(".bashrc"))
456            .unwrap_or_default()
457    };
458
459    let shell_ext = if is_zsh { "zsh" } else { "bash" };
460    let hook_content = generate_hook_posix(binary);
461
462    if let Some(hook_path) = write_hook_file(&format!("shell-hook.{shell_ext}"), &hook_content) {
463        upsert_source_line(&rc_file, &source_line_posix(shell_ext));
464        qprintln!("  Binary: {binary}");
465
466        write_env_sh_for_containers(&hook_content);
467        print_docker_env_hints(is_zsh);
468
469        let _ = hook_path;
470    }
471}
472
473pub fn write_env_sh_for_containers(aliases: &str) {
474    let env_sh = match crate::core::data_dir::lean_ctx_data_dir() {
475        Ok(d) => d.join("env.sh"),
476        Err(_) => return,
477    };
478    if let Some(parent) = env_sh.parent() {
479        let _ = std::fs::create_dir_all(parent);
480    }
481    let sanitized_aliases = crate::core::sanitize::neutralize_shell_content(aliases);
482    let mut content = sanitized_aliases;
483    content.push_str(
484        r#"
485
486# lean-ctx docker self-heal: re-inject Claude MCP config if Claude overwrote ~/.claude.json
487if command -v claude >/dev/null 2>&1 && command -v lean-ctx >/dev/null 2>&1; then
488  if ! claude mcp list 2>/dev/null | grep -q "lean-ctx"; then
489    LEAN_CTX_QUIET=1 lean-ctx init --agent claude >/dev/null 2>&1
490  fi
491fi
492"#,
493    );
494    match std::fs::write(&env_sh, content) {
495        Ok(()) => qprintln!("  env.sh: {}", env_sh.display()),
496        Err(e) => tracing::warn!("could not write {}: {e}", env_sh.display()),
497    }
498}
499
500fn print_docker_env_hints(is_zsh: bool) {
501    if is_zsh || !crate::shell::is_container() {
502        return;
503    }
504    let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
505        |_| "/root/.lean-ctx/env.sh".to_string(),
506        |d| d.join("env.sh").to_string_lossy().to_string(),
507    );
508
509    let has_bash_env = std::env::var("BASH_ENV").is_ok();
510    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
511
512    if has_bash_env && has_claude_env {
513        return;
514    }
515
516    eprintln!();
517    eprintln!("  \x1b[33m⚠  Docker detected — environment hints:\x1b[0m");
518
519    if !has_bash_env {
520        eprintln!("  For generic bash -c usage (non-interactive shells):");
521        eprintln!("    \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
522    }
523    if !has_claude_env {
524        eprintln!("  For Claude Code (sources before each command):");
525        eprintln!("    \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
526    }
527    eprintln!();
528}
529
530pub fn remove_lean_ctx_block(content: &str) -> String {
531    if content.contains("# lean-ctx shell hook — end") {
532        return remove_lean_ctx_block_by_marker(content);
533    }
534    remove_lean_ctx_block_legacy(content)
535}
536
537fn remove_lean_ctx_block_by_marker(content: &str) -> String {
538    let mut result = String::new();
539    let mut in_block = false;
540
541    for line in content.lines() {
542        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
543            in_block = true;
544            continue;
545        }
546        if in_block {
547            if line.trim() == "# lean-ctx shell hook — end" {
548                in_block = false;
549            }
550            continue;
551        }
552        result.push_str(line);
553        result.push('\n');
554    }
555    result
556}
557
558fn remove_lean_ctx_block_legacy(content: &str) -> String {
559    let mut result = String::new();
560    let mut in_block = false;
561
562    for line in content.lines() {
563        if line.contains("lean-ctx shell hook") {
564            in_block = true;
565            continue;
566        }
567        if in_block {
568            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
569                if line.trim() == "fi" || line.trim() == "end" {
570                    in_block = false;
571                }
572                continue;
573            }
574            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
575                in_block = false;
576                result.push_str(line);
577                result.push('\n');
578            }
579            continue;
580        }
581        result.push_str(line);
582        result.push('\n');
583    }
584    result
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_remove_lean_ctx_block_posix() {
593        let input = r#"# existing config
594export PATH="$HOME/bin:$PATH"
595
596# lean-ctx shell hook — transparent CLI compression (90+ patterns)
597if [ -z "$LEAN_CTX_ACTIVE" ]; then
598alias git='lean-ctx -c git'
599alias npm='lean-ctx -c npm'
600fi
601
602# other stuff
603export EDITOR=vim
604"#;
605        let result = remove_lean_ctx_block(input);
606        assert!(!result.contains("lean-ctx"), "block should be removed");
607        assert!(result.contains("export PATH"), "other content preserved");
608        assert!(
609            result.contains("export EDITOR"),
610            "trailing content preserved"
611        );
612    }
613
614    #[test]
615    fn test_remove_lean_ctx_block_fish() {
616        let input = "# other fish config\nset -x FOO bar\n\n# lean-ctx shell hook — transparent CLI compression (90+ 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";
617        let result = remove_lean_ctx_block(input);
618        assert!(!result.contains("lean-ctx"), "block should be removed");
619        assert!(result.contains("set -x FOO"), "other content preserved");
620        assert!(result.contains("set -x BAZ"), "trailing content preserved");
621    }
622
623    #[test]
624    fn test_remove_lean_ctx_block_ps() {
625        let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (90+ 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";
626        let result = remove_lean_ctx_block_ps(input);
627        assert!(
628            !result.contains("lean-ctx shell hook"),
629            "block should be removed"
630        );
631        assert!(result.contains("$env:FOO"), "other content preserved");
632        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
633    }
634
635    #[test]
636    fn test_remove_lean_ctx_block_ps_nested() {
637        let input = "# PowerShell profile\n$env:FOO = 'bar'\n\n# lean-ctx shell hook — transparent CLI compression (90+ 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";
638        let result = remove_lean_ctx_block_ps(input);
639        assert!(
640            !result.contains("lean-ctx shell hook"),
641            "block should be removed"
642        );
643        assert!(!result.contains("_lc"), "function should be removed");
644        assert!(result.contains("$env:FOO"), "other content preserved");
645        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
646    }
647
648    #[test]
649    fn test_remove_block_no_lean_ctx() {
650        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
651        let result = remove_lean_ctx_block(input);
652        assert!(result.contains("export PATH"), "content unchanged");
653    }
654
655    #[test]
656    fn test_bash_hook_contains_pipe_guard() {
657        let binary = "/usr/local/bin/lean-ctx";
658        let hook = format!(
659            r#"_lc() {{
660    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
661        command "$@"
662        return
663    fi
664    '{binary}' -t "$@"
665}}"#
666        );
667        assert!(
668            hook.contains("! -t 1"),
669            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
670        );
671        assert!(
672            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
673            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
674        );
675    }
676
677    #[test]
678    fn test_lc_uses_track_mode_by_default() {
679        let binary = "/usr/local/bin/lean-ctx";
680        let alias_list = crate::rewrite_registry::shell_alias_list();
681        let aliases = format!(
682            r#"_lc() {{
683    '{binary}' -t "$@"
684}}
685_lc_compress() {{
686    '{binary}' -c "$@"
687}}"#
688        );
689        assert!(
690            aliases.contains("-t \"$@\""),
691            "_lc must use -t (track mode) by default"
692        );
693        assert!(
694            aliases.contains("-c \"$@\""),
695            "_lc_compress must use -c (compress mode)"
696        );
697        let _ = alias_list;
698    }
699
700    #[test]
701    fn test_posix_shell_has_lean_ctx_mode() {
702        let alias_list = crate::rewrite_registry::shell_alias_list();
703        let aliases = r#"
704lean-ctx-mode() {{
705    case "${{1:-}}" in
706        compress) echo compress ;;
707        track) echo track ;;
708        off) echo off ;;
709    esac
710}}
711"#
712        .to_string();
713        assert!(
714            aliases.contains("lean-ctx-mode()"),
715            "lean-ctx-mode function must exist"
716        );
717        assert!(
718            aliases.contains("compress"),
719            "compress mode must be available"
720        );
721        assert!(aliases.contains("track"), "track mode must be available");
722        let _ = alias_list;
723    }
724
725    #[test]
726    fn test_fish_hook_contains_pipe_guard() {
727        let hook = "function _lc\n\tif set -q LEAN_CTX_DISABLED; or not isatty stdout\n\t\tcommand $argv\n\t\treturn\n\tend\nend";
728        assert!(
729            hook.contains("isatty stdout"),
730            "fish hook must contain pipe guard (isatty stdout)"
731        );
732    }
733
734    #[test]
735    fn test_powershell_hook_contains_pipe_guard() {
736        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
737        assert!(
738            hook.contains("IsOutputRedirected"),
739            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
740        );
741    }
742
743    #[test]
744    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
745        let input = r#"# existing config
746export PATH="$HOME/bin:$PATH"
747
748# lean-ctx shell hook — transparent CLI compression (90+ patterns)
749_lean_ctx_cmds=(git npm pnpm)
750
751lean-ctx-on() {
752    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
753        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
754    done
755    export LEAN_CTX_ENABLED=1
756    [ -t 1 ] && echo "lean-ctx: ON"
757}
758
759lean-ctx-off() {
760    unset LEAN_CTX_ENABLED
761    [ -t 1 ] && echo "lean-ctx: OFF"
762}
763
764if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
765    lean-ctx-on
766fi
767# lean-ctx shell hook — end
768
769# other stuff
770export EDITOR=vim
771"#;
772        let result = remove_lean_ctx_block(input);
773        assert!(!result.contains("lean-ctx-on"), "block should be removed");
774        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
775        assert!(result.contains("export PATH"), "other content preserved");
776        assert!(
777            result.contains("export EDITOR"),
778            "trailing content preserved"
779        );
780    }
781
782    #[test]
783    fn env_sh_for_containers_includes_self_heal() {
784        let _g = crate::core::data_dir::test_env_lock();
785        let tmp = tempfile::tempdir().expect("tempdir");
786        let data_dir = tmp.path().join("data");
787        std::fs::create_dir_all(&data_dir).expect("mkdir data");
788        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
789
790        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
791        let env_sh = data_dir.join("env.sh");
792        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
793        assert!(content.contains("lean-ctx docker self-heal"));
794        assert!(content.contains("claude mcp list"));
795        assert!(content.contains("lean-ctx init --agent claude"));
796
797        std::env::remove_var("LEAN_CTX_DATA_DIR");
798    }
799
800    #[test]
801    fn test_source_line_posix() {
802        let line = source_line_posix("zsh");
803        assert!(line.contains("shell-hook.zsh"));
804        assert!(line.contains("[ -f"));
805    }
806
807    #[test]
808    fn test_source_line_fish() {
809        let line = source_line_fish();
810        assert!(line.contains("shell-hook.fish"));
811        assert!(line.contains("source"));
812    }
813
814    #[test]
815    fn test_source_line_powershell() {
816        let line = source_line_powershell();
817        assert!(line.contains("shell-hook.ps1"));
818        assert!(line.contains("Test-Path"));
819    }
820}