Skip to main content

lean_ctx/
hooks.rs

1use std::path::PathBuf;
2
3fn resolve_binary_path() -> String {
4    std::env::current_exe()
5        .map(|p| p.to_string_lossy().to_string())
6        .unwrap_or_else(|_| "lean-ctx".to_string())
7}
8
9fn resolve_binary_path_for_bash() -> String {
10    let path = resolve_binary_path();
11    to_bash_compatible_path(&path)
12}
13
14pub fn to_bash_compatible_path(path: &str) -> String {
15    let path = path.replace('\\', "/");
16    if path.len() >= 2 && path.as_bytes()[1] == b':' {
17        let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
18        format!("/{drive}{}", &path[2..])
19    } else {
20        path
21    }
22}
23
24pub fn install_agent_hook(agent: &str, global: bool) {
25    match agent {
26        "claude" | "claude-code" => install_claude_hook(global),
27        "cursor" => install_cursor_hook(global),
28        "gemini" => install_gemini_hook(),
29        "codex" => install_codex_hook(),
30        "windsurf" => install_windsurf_rules(global),
31        "cline" | "roo" => install_cline_rules(global),
32        "copilot" => install_copilot_hook(global),
33        "pi" => install_pi_hook(global),
34        "qwen" => install_mcp_json_agent(
35            "Qwen Code",
36            "~/.qwen/mcp.json",
37            &dirs::home_dir().unwrap_or_default().join(".qwen/mcp.json"),
38        ),
39        "trae" => install_mcp_json_agent(
40            "Trae",
41            "~/.trae/mcp.json",
42            &dirs::home_dir().unwrap_or_default().join(".trae/mcp.json"),
43        ),
44        "amazonq" => install_mcp_json_agent(
45            "Amazon Q Developer",
46            "~/.aws/amazonq/mcp.json",
47            &dirs::home_dir()
48                .unwrap_or_default()
49                .join(".aws/amazonq/mcp.json"),
50        ),
51        "jetbrains" => install_mcp_json_agent(
52            "JetBrains IDEs",
53            "~/.jb-mcp.json",
54            &dirs::home_dir().unwrap_or_default().join(".jb-mcp.json"),
55        ),
56        _ => {
57            eprintln!("Unknown agent: {agent}");
58            eprintln!("  Supported: claude, cursor, gemini, codex, windsurf, cline, roo, copilot, pi, qwen, trae, amazonq, jetbrains");
59            std::process::exit(1);
60        }
61    }
62}
63
64fn install_claude_hook(global: bool) {
65    let home = match dirs::home_dir() {
66        Some(h) => h,
67        None => {
68            eprintln!("Cannot resolve home directory");
69            return;
70        }
71    };
72
73    let hooks_dir = home.join(".claude").join("hooks");
74    let _ = std::fs::create_dir_all(&hooks_dir);
75
76    let script_path = hooks_dir.join("lean-ctx-rewrite.sh");
77    let binary = resolve_binary_path_for_bash();
78    let script = format!(
79        r#"#!/usr/bin/env bash
80# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
81set -euo pipefail
82
83LEAN_CTX_BIN="{binary}"
84
85INPUT=$(cat)
86TOOL=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
87
88if [ "$TOOL" != "Bash" ] && [ "$TOOL" != "bash" ]; then
89  exit 0
90fi
91
92CMD=$(echo "$INPUT" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4)
93
94if echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
95  exit 0
96fi
97
98REWRITE=""
99case "$CMD" in
100  git\ *)       REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
101  gh\ *)        REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
102  cargo\ *)     REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
103  npm\ *)       REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
104  pnpm\ *)      REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
105  yarn\ *)      REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
106  docker\ *)    REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
107  kubectl\ *)   REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
108  pip\ *|pip3\ *)  REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
109  ruff\ *)      REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
110  go\ *)        REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
111  curl\ *)      REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
112  grep\ *|rg\ *)  REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
113  find\ *)      REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
114  cat\ *|head\ *|tail\ *)  REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
115  ls\ *|ls)     REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
116  eslint*|prettier*|tsc*)  REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
117  pytest*|ruff\ *|mypy*)   REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
118  aws\ *)       REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
119  helm\ *)      REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
120  *)            exit 0 ;;
121esac
122
123if [ -n "$REWRITE" ]; then
124  echo "{{\"command\":\"$REWRITE\"}}"
125fi
126"#
127    );
128
129    write_file(&script_path, &script);
130    make_executable(&script_path);
131
132    let settings_path = home.join(".claude").join("settings.json");
133    let settings_content = if settings_path.exists() {
134        std::fs::read_to_string(&settings_path).unwrap_or_default()
135    } else {
136        String::new()
137    };
138
139    if settings_content.contains("lean-ctx-rewrite") {
140        println!("Claude Code hook already configured.");
141    } else {
142        let hook_entry = serde_json::json!({
143            "hooks": {
144                "PreToolUse": [{
145                    "matcher": "Bash|bash",
146                    "hooks": [{
147                        "type": "command",
148                        "command": script_path.to_string_lossy()
149                    }]
150                }]
151            }
152        });
153
154        if settings_content.is_empty() {
155            write_file(
156                &settings_path,
157                &serde_json::to_string_pretty(&hook_entry).unwrap(),
158            );
159        } else if let Ok(mut existing) =
160            serde_json::from_str::<serde_json::Value>(&settings_content)
161        {
162            if let Some(obj) = existing.as_object_mut() {
163                obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
164                write_file(
165                    &settings_path,
166                    &serde_json::to_string_pretty(&existing).unwrap(),
167                );
168            }
169        }
170        println!(
171            "Installed Claude Code PreToolUse hook at {}",
172            script_path.display()
173        );
174    }
175
176    if !global {
177        let claude_md = PathBuf::from("CLAUDE.md");
178        if !claude_md.exists()
179            || !std::fs::read_to_string(&claude_md)
180                .unwrap_or_default()
181                .contains("lean-ctx")
182        {
183            let content = include_str!("templates/CLAUDE.md");
184            write_file(&claude_md, content);
185            println!("Created CLAUDE.md in current project directory.");
186        } else {
187            println!("CLAUDE.md already configured.");
188        }
189    } else {
190        println!(
191            "Global mode: skipping project-local CLAUDE.md (use without --global in a project)."
192        );
193    }
194}
195
196fn install_cursor_hook(global: bool) {
197    let home = match dirs::home_dir() {
198        Some(h) => h,
199        None => {
200            eprintln!("Cannot resolve home directory");
201            return;
202        }
203    };
204
205    let hooks_dir = home.join(".cursor").join("hooks");
206    let _ = std::fs::create_dir_all(&hooks_dir);
207
208    let script_path = hooks_dir.join("lean-ctx-rewrite.sh");
209    let binary = resolve_binary_path_for_bash();
210    let script = format!(
211        r#"#!/usr/bin/env bash
212# lean-ctx Cursor hook — rewrites shell commands
213set -euo pipefail
214LEAN_CTX_BIN="{binary}"
215INPUT=$(cat)
216CMD=$(echo "$INPUT" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
217if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
218case "$CMD" in
219  git\ *|gh\ *|cargo\ *|npm\ *|pnpm\ *|docker\ *|kubectl\ *|pip\ *|ruff\ *|go\ *|curl\ *|grep\ *|rg\ *|find\ *|ls\ *|ls|cat\ *|aws\ *|helm\ *)
220    echo "{{\"command\":\"$LEAN_CTX_BIN -c $CMD\"}}" ;;
221  *) exit 0 ;;
222esac
223"#
224    );
225
226    write_file(&script_path, &script);
227    make_executable(&script_path);
228
229    let hooks_json = home.join(".cursor").join("hooks.json");
230    let hook_config = serde_json::json!({
231        "hooks": [{
232            "event": "preToolUse",
233            "matcher": {
234                "tool": "terminal_command"
235            },
236            "command": script_path.to_string_lossy()
237        }]
238    });
239
240    let content = if hooks_json.exists() {
241        std::fs::read_to_string(&hooks_json).unwrap_or_default()
242    } else {
243        String::new()
244    };
245
246    if content.contains("lean-ctx-rewrite") {
247        println!("Cursor hook already configured.");
248    } else {
249        write_file(
250            &hooks_json,
251            &serde_json::to_string_pretty(&hook_config).unwrap(),
252        );
253        println!("Installed Cursor hook at {}", hooks_json.display());
254    }
255
256    if !global {
257        let rules_dir = PathBuf::from(".cursor").join("rules");
258        let _ = std::fs::create_dir_all(&rules_dir);
259        let rule_path = rules_dir.join("lean-ctx.mdc");
260        if !rule_path.exists() {
261            let rule_content = include_str!("templates/lean-ctx.mdc");
262            write_file(&rule_path, rule_content);
263            println!("Created .cursor/rules/lean-ctx.mdc in current project.");
264        } else {
265            println!("Cursor rule already exists.");
266        }
267    } else {
268        println!("Global mode: skipping project-local .cursor/rules/ (use without --global in a project).");
269    }
270
271    println!("Restart Cursor to activate.");
272}
273
274fn install_gemini_hook() {
275    let home = match dirs::home_dir() {
276        Some(h) => h,
277        None => {
278            eprintln!("Cannot resolve home directory");
279            return;
280        }
281    };
282
283    let hooks_dir = home.join(".gemini").join("hooks");
284    let _ = std::fs::create_dir_all(&hooks_dir);
285
286    let script_path = hooks_dir.join("lean-ctx-hook-gemini.sh");
287    let binary = resolve_binary_path_for_bash();
288    let script = format!(
289        r#"#!/usr/bin/env bash
290# lean-ctx Gemini CLI BeforeTool hook
291set -euo pipefail
292LEAN_CTX_BIN="{binary}"
293INPUT=$(cat)
294CMD=$(echo "$INPUT" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
295if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
296case "$CMD" in
297  git\ *|gh\ *|cargo\ *|npm\ *|pnpm\ *|docker\ *|kubectl\ *|pip\ *|ruff\ *|go\ *|curl\ *|grep\ *|rg\ *|find\ *|ls\ *|ls|cat\ *|aws\ *|helm\ *)
298    echo "{{\"command\":\"$LEAN_CTX_BIN -c $CMD\"}}" ;;
299  *) exit 0 ;;
300esac
301"#
302    );
303
304    write_file(&script_path, &script);
305    make_executable(&script_path);
306
307    let settings_path = home.join(".gemini").join("settings.json");
308    let settings_content = if settings_path.exists() {
309        std::fs::read_to_string(&settings_path).unwrap_or_default()
310    } else {
311        String::new()
312    };
313
314    if settings_content.contains("lean-ctx") {
315        println!("Gemini CLI hook already configured.");
316    } else {
317        let hook_config = serde_json::json!({
318            "hooks": {
319                "BeforeTool": [{
320                    "command": script_path.to_string_lossy()
321                }]
322            }
323        });
324
325        if settings_content.is_empty() {
326            write_file(
327                &settings_path,
328                &serde_json::to_string_pretty(&hook_config).unwrap(),
329            );
330        } else if let Ok(mut existing) =
331            serde_json::from_str::<serde_json::Value>(&settings_content)
332        {
333            if let Some(obj) = existing.as_object_mut() {
334                obj.insert("hooks".to_string(), hook_config["hooks"].clone());
335                write_file(
336                    &settings_path,
337                    &serde_json::to_string_pretty(&existing).unwrap(),
338                );
339            }
340        }
341        println!("Installed Gemini CLI hook at {}", script_path.display());
342    }
343}
344
345fn install_codex_hook() {
346    let home = match dirs::home_dir() {
347        Some(h) => h,
348        None => {
349            eprintln!("Cannot resolve home directory");
350            return;
351        }
352    };
353
354    let codex_dir = home.join(".codex");
355    let _ = std::fs::create_dir_all(&codex_dir);
356
357    let agents_path = codex_dir.join("AGENTS.md");
358    let agents_content = "# Global Agent Instructions\n\n@LEAN-CTX.md\n";
359
360    let lean_ctx_md = codex_dir.join("LEAN-CTX.md");
361    let binary = resolve_binary_path();
362    let lean_ctx_content = format!(
363        r#"# lean-ctx — Token Optimization
364
365Prefix all shell commands with `{binary} -c` for compressed output:
366
367```bash
368{binary} -c git status    # instead of: git status
369{binary} -c cargo test    # instead of: cargo test
370{binary} -c ls src/       # instead of: ls src/
371```
372
373This saves 60-90% tokens per command. Works with: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more commands.
374"#
375    );
376
377    if agents_path.exists() {
378        let content = std::fs::read_to_string(&agents_path).unwrap_or_default();
379        if content.contains("lean-ctx") || content.contains("LEAN-CTX") {
380            println!("Codex AGENTS.md already configured.");
381            return;
382        }
383    }
384
385    write_file(&agents_path, agents_content);
386    write_file(&lean_ctx_md, &lean_ctx_content);
387    println!("Installed Codex instructions at {}", codex_dir.display());
388}
389
390fn install_windsurf_rules(global: bool) {
391    if global {
392        println!("Global mode: skipping project-local .windsurfrules (use without --global in a project).");
393        return;
394    }
395
396    let rules_path = PathBuf::from(".windsurfrules");
397    if rules_path.exists() {
398        let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
399        if content.contains("lean-ctx") {
400            println!(".windsurfrules already configured.");
401            return;
402        }
403    }
404
405    let rules = include_str!("templates/windsurfrules.txt");
406    write_file(&rules_path, rules);
407    println!("Installed .windsurfrules in current project.");
408}
409
410fn install_cline_rules(global: bool) {
411    if global {
412        println!(
413            "Global mode: skipping project-local .clinerules (use without --global in a project)."
414        );
415        return;
416    }
417
418    let rules_path = PathBuf::from(".clinerules");
419    if rules_path.exists() {
420        let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
421        if content.contains("lean-ctx") {
422            println!(".clinerules already configured.");
423            return;
424        }
425    }
426
427    let binary = resolve_binary_path();
428    let rules = format!(
429        r#"# lean-ctx Shell Optimization
430# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
431
432When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
433- `{binary} -c git status` instead of `git status`
434- `{binary} -c cargo test` instead of `cargo test`
435- `{binary} -c ls src/` instead of `ls src/`
436
437Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
438"#
439    );
440
441    write_file(&rules_path, &rules);
442    println!("Installed .clinerules in current project.");
443}
444
445fn install_pi_hook(global: bool) {
446    let has_pi = std::process::Command::new("pi")
447        .arg("--version")
448        .output()
449        .is_ok();
450
451    if !has_pi {
452        println!("Pi Coding Agent not found in PATH.");
453        println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
454        println!();
455    }
456
457    println!("Installing pi-lean-ctx Pi Package...");
458    println!();
459
460    let install_result = std::process::Command::new("pi")
461        .args(["install", "npm:pi-lean-ctx"])
462        .status();
463
464    match install_result {
465        Ok(status) if status.success() => {
466            println!("Installed pi-lean-ctx Pi Package.");
467        }
468        _ => {
469            println!("Could not auto-install pi-lean-ctx. Install manually:");
470            println!("  pi install npm:pi-lean-ctx");
471            println!();
472        }
473    }
474
475    if !global {
476        let agents_md = PathBuf::from("AGENTS.md");
477        if !agents_md.exists()
478            || !std::fs::read_to_string(&agents_md)
479                .unwrap_or_default()
480                .contains("lean-ctx")
481        {
482            let content = include_str!("templates/PI_AGENTS.md");
483            write_file(&agents_md, content);
484            println!("Created AGENTS.md in current project directory.");
485        } else {
486            println!("AGENTS.md already contains lean-ctx configuration.");
487        }
488    } else {
489        println!(
490            "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
491        );
492    }
493
494    println!();
495    println!(
496        "Setup complete. All Pi tools (bash, read, grep, find, ls) now route through lean-ctx."
497    );
498    println!("Use /lean-ctx in Pi to verify the binary path.");
499}
500
501fn install_copilot_hook(global: bool) {
502    let binary = resolve_binary_path();
503
504    if global {
505        let mcp_path = copilot_global_mcp_path();
506        if mcp_path.as_os_str() == "/nonexistent" {
507            println!("  \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
508            return;
509        }
510        write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
511    } else {
512        let vscode_dir = PathBuf::from(".vscode");
513        let _ = std::fs::create_dir_all(&vscode_dir);
514        let mcp_path = vscode_dir.join("mcp.json");
515        write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
516    }
517}
518
519fn copilot_global_mcp_path() -> PathBuf {
520    if let Some(home) = dirs::home_dir() {
521        #[cfg(target_os = "macos")]
522        {
523            return home.join("Library/Application Support/Code/User/mcp.json");
524        }
525        #[cfg(target_os = "linux")]
526        {
527            return home.join(".config/Code/User/mcp.json");
528        }
529        #[cfg(target_os = "windows")]
530        {
531            if let Ok(appdata) = std::env::var("APPDATA") {
532                return PathBuf::from(appdata).join("Code/User/mcp.json");
533            }
534        }
535        #[allow(unreachable_code)]
536        home.join(".config/Code/User/mcp.json")
537    } else {
538        PathBuf::from("/nonexistent")
539    }
540}
541
542fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
543    if mcp_path.exists() {
544        let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
545        if content.contains("lean-ctx") {
546            println!("  \x1b[32m✓\x1b[0m Copilot already configured in {label}");
547            return;
548        }
549
550        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
551            if let Some(obj) = json.as_object_mut() {
552                let servers = obj
553                    .entry("servers")
554                    .or_insert_with(|| serde_json::json!({}));
555                if let Some(servers_obj) = servers.as_object_mut() {
556                    servers_obj.insert(
557                        "lean-ctx".to_string(),
558                        serde_json::json!({ "command": binary, "args": [] }),
559                    );
560                }
561                write_file(
562                    mcp_path,
563                    &serde_json::to_string_pretty(&json).unwrap_or_default(),
564                );
565                println!("  \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
566                return;
567            }
568        }
569    }
570
571    if let Some(parent) = mcp_path.parent() {
572        let _ = std::fs::create_dir_all(parent);
573    }
574
575    let config = serde_json::json!({
576        "servers": {
577            "lean-ctx": {
578                "command": binary,
579                "args": []
580            }
581        }
582    });
583
584    write_file(
585        mcp_path,
586        &serde_json::to_string_pretty(&config).unwrap_or_default(),
587    );
588    println!("  \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
589}
590
591fn write_file(path: &PathBuf, content: &str) {
592    if let Err(e) = std::fs::write(path, content) {
593        eprintln!("Error writing {}: {e}", path.display());
594    }
595}
596
597#[cfg(unix)]
598fn make_executable(path: &PathBuf) {
599    use std::os::unix::fs::PermissionsExt;
600    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
601}
602
603#[cfg(not(unix))]
604fn make_executable(_path: &PathBuf) {}
605
606fn install_mcp_json_agent(name: &str, display_path: &str, config_path: &std::path::Path) {
607    let binary = resolve_binary_path();
608
609    if let Some(parent) = config_path.parent() {
610        let _ = std::fs::create_dir_all(parent);
611    }
612
613    if config_path.exists() {
614        let content = std::fs::read_to_string(config_path).unwrap_or_default();
615        if content.contains("lean-ctx") {
616            println!("{name} MCP already configured at {display_path}");
617            return;
618        }
619
620        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
621            if let Some(obj) = json.as_object_mut() {
622                let servers = obj
623                    .entry("mcpServers")
624                    .or_insert_with(|| serde_json::json!({}));
625                if let Some(servers_obj) = servers.as_object_mut() {
626                    servers_obj.insert(
627                        "lean-ctx".to_string(),
628                        serde_json::json!({ "command": binary }),
629                    );
630                }
631                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
632                    let _ = std::fs::write(config_path, formatted);
633                    println!("  \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
634                    return;
635                }
636            }
637        }
638    }
639
640    let content = serde_json::to_string_pretty(&serde_json::json!({
641        "mcpServers": {
642            "lean-ctx": {
643                "command": binary
644            }
645        }
646    }));
647
648    if let Ok(json_str) = content {
649        let _ = std::fs::write(config_path, json_str);
650        println!("  \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
651    } else {
652        eprintln!("  \x1b[31m✗\x1b[0m Failed to configure {name}");
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn bash_path_unix_unchanged() {
662        assert_eq!(
663            to_bash_compatible_path("/usr/local/bin/lean-ctx"),
664            "/usr/local/bin/lean-ctx"
665        );
666    }
667
668    #[test]
669    fn bash_path_home_unchanged() {
670        assert_eq!(
671            to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
672            "/home/user/.cargo/bin/lean-ctx"
673        );
674    }
675
676    #[test]
677    fn bash_path_windows_drive_converted() {
678        assert_eq!(
679            to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
680            "/c/Users/Fraser/bin/lean-ctx.exe"
681        );
682    }
683
684    #[test]
685    fn bash_path_windows_lowercase_drive() {
686        assert_eq!(
687            to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
688            "/d/tools/lean-ctx.exe"
689        );
690    }
691
692    #[test]
693    fn bash_path_windows_forward_slashes() {
694        assert_eq!(
695            to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
696            "/c/Users/Fraser/bin/lean-ctx.exe"
697        );
698    }
699
700    #[test]
701    fn bash_path_bare_name_unchanged() {
702        assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
703    }
704}