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 (95+ 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(()) => {
496            // Keep JSON-mode stdout clean; non-quiet hints go to stderr.
497            if !super::quiet_enabled() {
498                eprintln!("  env.sh: {}", env_sh.display());
499            }
500        }
501        Err(e) => tracing::warn!("could not write {}: {e}", env_sh.display()),
502    }
503}
504
505fn print_docker_env_hints(is_zsh: bool) {
506    if is_zsh || !crate::shell::is_container() {
507        return;
508    }
509    let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
510        |_| "/root/.lean-ctx/env.sh".to_string(),
511        |d| d.join("env.sh").to_string_lossy().to_string(),
512    );
513
514    let has_bash_env = std::env::var("BASH_ENV").is_ok();
515    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
516
517    if has_bash_env && has_claude_env {
518        return;
519    }
520
521    eprintln!();
522    eprintln!("  \x1b[33m⚠  Docker detected — environment hints:\x1b[0m");
523
524    if !has_bash_env {
525        eprintln!("  For generic bash -c usage (non-interactive shells):");
526        eprintln!("    \x1b[1mENV BASH_ENV=\"{env_sh}\"\x1b[0m");
527    }
528    if !has_claude_env {
529        eprintln!("  For Claude Code (sources before each command):");
530        eprintln!("    \x1b[1mENV CLAUDE_ENV_FILE=\"{env_sh}\"\x1b[0m");
531    }
532    eprintln!();
533}
534
535pub fn remove_lean_ctx_block(content: &str) -> String {
536    if content.contains("# lean-ctx shell hook — end") {
537        return remove_lean_ctx_block_by_marker(content);
538    }
539    remove_lean_ctx_block_legacy(content)
540}
541
542fn remove_lean_ctx_block_by_marker(content: &str) -> String {
543    let mut result = String::new();
544    let mut in_block = false;
545
546    for line in content.lines() {
547        if !in_block && line.contains("lean-ctx shell hook") && !line.contains("end") {
548            in_block = true;
549            continue;
550        }
551        if in_block {
552            if line.trim() == "# lean-ctx shell hook — end" {
553                in_block = false;
554            }
555            continue;
556        }
557        result.push_str(line);
558        result.push('\n');
559    }
560    result
561}
562
563fn remove_lean_ctx_block_legacy(content: &str) -> String {
564    let mut result = String::new();
565    let mut in_block = false;
566
567    for line in content.lines() {
568        if line.contains("lean-ctx shell hook") {
569            in_block = true;
570            continue;
571        }
572        if in_block {
573            if line.trim() == "fi" || line.trim() == "end" || line.trim().is_empty() {
574                if line.trim() == "fi" || line.trim() == "end" {
575                    in_block = false;
576                }
577                continue;
578            }
579            if !line.starts_with("alias ") && !line.starts_with('\t') && !line.starts_with("if ") {
580                in_block = false;
581                result.push_str(line);
582                result.push('\n');
583            }
584            continue;
585        }
586        result.push_str(line);
587        result.push('\n');
588    }
589    result
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn test_remove_lean_ctx_block_posix() {
598        let input = r#"# existing config
599export PATH="$HOME/bin:$PATH"
600
601# lean-ctx shell hook — transparent CLI compression (95+ patterns)
602if [ -z "$LEAN_CTX_ACTIVE" ]; then
603alias git='lean-ctx -c git'
604alias npm='lean-ctx -c npm'
605fi
606
607# other stuff
608export EDITOR=vim
609"#;
610        let result = remove_lean_ctx_block(input);
611        assert!(!result.contains("lean-ctx"), "block should be removed");
612        assert!(result.contains("export PATH"), "other content preserved");
613        assert!(
614            result.contains("export EDITOR"),
615            "trailing content preserved"
616        );
617    }
618
619    #[test]
620    fn test_remove_lean_ctx_block_fish() {
621        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";
622        let result = remove_lean_ctx_block(input);
623        assert!(!result.contains("lean-ctx"), "block should be removed");
624        assert!(result.contains("set -x FOO"), "other content preserved");
625        assert!(result.contains("set -x BAZ"), "trailing content preserved");
626    }
627
628    #[test]
629    fn test_remove_lean_ctx_block_ps() {
630        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";
631        let result = remove_lean_ctx_block_ps(input);
632        assert!(
633            !result.contains("lean-ctx shell hook"),
634            "block should be removed"
635        );
636        assert!(result.contains("$env:FOO"), "other content preserved");
637        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
638    }
639
640    #[test]
641    fn test_remove_lean_ctx_block_ps_nested() {
642        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";
643        let result = remove_lean_ctx_block_ps(input);
644        assert!(
645            !result.contains("lean-ctx shell hook"),
646            "block should be removed"
647        );
648        assert!(!result.contains("_lc"), "function should be removed");
649        assert!(result.contains("$env:FOO"), "other content preserved");
650        assert!(result.contains("$env:EDITOR"), "trailing content preserved");
651    }
652
653    #[test]
654    fn test_remove_block_no_lean_ctx() {
655        let input = "# normal bashrc\nexport PATH=\"$HOME/bin:$PATH\"\n";
656        let result = remove_lean_ctx_block(input);
657        assert!(result.contains("export PATH"), "content unchanged");
658    }
659
660    #[test]
661    fn test_bash_hook_contains_pipe_guard() {
662        let binary = "/usr/local/bin/lean-ctx";
663        let hook = format!(
664            r#"_lc() {{
665    if [ -n "${{LEAN_CTX_DISABLED:-}}" ] || [ ! -t 1 ]; then
666        command "$@"
667        return
668    fi
669    '{binary}' -t "$@"
670}}"#
671        );
672        assert!(
673            hook.contains("! -t 1"),
674            "bash/zsh hook must contain pipe guard [ ! -t 1 ]"
675        );
676        assert!(
677            hook.contains("LEAN_CTX_DISABLED") && hook.contains("! -t 1"),
678            "pipe guard must be in the same conditional as LEAN_CTX_DISABLED"
679        );
680    }
681
682    #[test]
683    fn test_lc_uses_track_mode_by_default() {
684        let binary = "/usr/local/bin/lean-ctx";
685        let alias_list = crate::rewrite_registry::shell_alias_list();
686        let aliases = format!(
687            r#"_lc() {{
688    '{binary}' -t "$@"
689}}
690_lc_compress() {{
691    '{binary}' -c "$@"
692}}"#
693        );
694        assert!(
695            aliases.contains("-t \"$@\""),
696            "_lc must use -t (track mode) by default"
697        );
698        assert!(
699            aliases.contains("-c \"$@\""),
700            "_lc_compress must use -c (compress mode)"
701        );
702        let _ = alias_list;
703    }
704
705    #[test]
706    fn test_posix_shell_has_lean_ctx_mode() {
707        let alias_list = crate::rewrite_registry::shell_alias_list();
708        let aliases = r#"
709lean-ctx-mode() {{
710    case "${{1:-}}" in
711        compress) echo compress ;;
712        track) echo track ;;
713        off) echo off ;;
714    esac
715}}
716"#
717        .to_string();
718        assert!(
719            aliases.contains("lean-ctx-mode()"),
720            "lean-ctx-mode function must exist"
721        );
722        assert!(
723            aliases.contains("compress"),
724            "compress mode must be available"
725        );
726        assert!(aliases.contains("track"), "track mode must be available");
727        let _ = alias_list;
728    }
729
730    #[test]
731    fn test_fish_hook_contains_pipe_guard() {
732        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";
733        assert!(
734            hook.contains("isatty stdout"),
735            "fish hook must contain pipe guard (isatty stdout)"
736        );
737    }
738
739    #[test]
740    fn test_powershell_hook_contains_pipe_guard() {
741        let hook = "function _lc { if ($env:LEAN_CTX_DISABLED -or [Console]::IsOutputRedirected) { & @args; return } }";
742        assert!(
743            hook.contains("IsOutputRedirected"),
744            "PowerShell hook must contain pipe guard ([Console]::IsOutputRedirected)"
745        );
746    }
747
748    #[test]
749    fn test_remove_lean_ctx_block_new_format_with_end_marker() {
750        let input = r#"# existing config
751export PATH="$HOME/bin:$PATH"
752
753# lean-ctx shell hook — transparent CLI compression (95+ patterns)
754_lean_ctx_cmds=(git npm pnpm)
755
756lean-ctx-on() {
757    for _lc_cmd in "${_lean_ctx_cmds[@]}"; do
758        alias "$_lc_cmd"='lean-ctx -c '"$_lc_cmd"
759    done
760    export LEAN_CTX_ENABLED=1
761    [ -t 1 ] && echo "lean-ctx: ON"
762}
763
764lean-ctx-off() {
765    unset LEAN_CTX_ENABLED
766    [ -t 1 ] && echo "lean-ctx: OFF"
767}
768
769if [ -z "${LEAN_CTX_ACTIVE:-}" ] && [ "${LEAN_CTX_ENABLED:-1}" != "0" ]; then
770    lean-ctx-on
771fi
772# lean-ctx shell hook — end
773
774# other stuff
775export EDITOR=vim
776"#;
777        let result = remove_lean_ctx_block(input);
778        assert!(!result.contains("lean-ctx-on"), "block should be removed");
779        assert!(!result.contains("lean-ctx shell hook"), "marker removed");
780        assert!(result.contains("export PATH"), "other content preserved");
781        assert!(
782            result.contains("export EDITOR"),
783            "trailing content preserved"
784        );
785    }
786
787    #[test]
788    fn env_sh_for_containers_includes_self_heal() {
789        let _g = crate::core::data_dir::test_env_lock();
790        let tmp = tempfile::tempdir().expect("tempdir");
791        let data_dir = tmp.path().join("data");
792        std::fs::create_dir_all(&data_dir).expect("mkdir data");
793        std::env::set_var("LEAN_CTX_DATA_DIR", &data_dir);
794
795        write_env_sh_for_containers("alias git='lean-ctx -c git'\n");
796        let env_sh = data_dir.join("env.sh");
797        let content = std::fs::read_to_string(&env_sh).expect("env.sh exists");
798        assert!(content.contains("lean-ctx docker self-heal"));
799        assert!(content.contains("claude mcp list"));
800        assert!(content.contains("lean-ctx init --agent claude"));
801
802        std::env::remove_var("LEAN_CTX_DATA_DIR");
803    }
804
805    #[test]
806    fn test_source_line_posix() {
807        let line = source_line_posix("zsh");
808        assert!(line.contains("shell-hook.zsh"));
809        assert!(line.contains("[ -f"));
810    }
811
812    #[test]
813    fn test_source_line_fish() {
814        let line = source_line_fish();
815        assert!(line.contains("shell-hook.fish"));
816        assert!(line.contains("source"));
817    }
818
819    #[test]
820    fn test_source_line_powershell() {
821        let line = source_line_powershell();
822        assert!(line.contains("shell-hook.ps1"));
823        assert!(line.contains("Test-Path"));
824    }
825}