use super::*;
fn expect_wrapped(cmd: &str, binary: &str) -> String {
if cfg!(windows) {
let escaped = cmd.replace('"', "\\\"");
format!("{binary} -c \"{escaped}\"")
} else {
let shell_escaped = cmd.replace('\'', "'\\''");
format!("{binary} -c '{shell_escaped}'")
}
}
fn with_test_allowlist<T>(body: impl FnOnce() -> T) -> T {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::set_var(
"LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE",
"git,cargo,npm,head,grep,wc,cat,rg,echo,cd,ls",
);
let out = body();
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
out
}
#[test]
fn is_rewritable_basic() {
assert!(is_rewritable("git status"));
assert!(is_rewritable("cargo test --lib"));
assert!(is_rewritable("npm run build"));
assert!(!is_rewritable("echo hello"));
assert!(!is_rewritable("cd src"));
assert!(!is_rewritable("cat file.rs"));
}
#[test]
fn file_read_rewrite_cat() {
let r = rewrite_file_read_command("cat src/main.rs", "lean-ctx");
assert_eq!(r, Some("lean-ctx read src/main.rs".to_string()));
}
#[test]
fn rewrite_skip_reason_tracks_candidate_none_branches() {
let binary = "lean-ctx";
let already = "lean-ctx read x";
assert!(rewrite_candidate(already, binary).is_none());
assert_eq!(rewrite_skip_reason(already), "already a lean-ctx command");
let heredoc = "cat <<EOF\nhi\nEOF";
assert!(rewrite_candidate(heredoc, binary).is_none());
assert_eq!(
rewrite_skip_reason(heredoc),
"heredoc cannot be rewritten safely"
);
let unknown = "echo hello";
assert!(rewrite_candidate(unknown, binary).is_none());
assert_eq!(
rewrite_skip_reason(unknown),
"not a known read/search/list command"
);
let tricky = "git log | python3 -c 'print(1)'";
let (declined, reason) = with_test_allowlist(|| {
(
rewrite_candidate(tricky, binary).is_none(),
rewrite_skip_reason(tricky),
)
});
assert!(declined, "tricky compound sink must not be rewritten");
assert_eq!(
reason,
"compound pipes/chains into a non-allowlisted or interpreter sink — left raw for the agent shell"
);
}
#[test]
fn file_read_rewrite_head_with_n() {
let r = rewrite_file_read_command("head -n 20 src/main.rs", "lean-ctx");
assert_eq!(
r,
Some("lean-ctx read src/main.rs -m lines:1-20".to_string())
);
}
#[test]
fn file_read_rewrite_head_short() {
let r = rewrite_file_read_command("head -50 src/main.rs", "lean-ctx");
assert_eq!(
r,
Some("lean-ctx read src/main.rs -m lines:1-50".to_string())
);
}
#[test]
fn file_read_rewrite_tail() {
let r = rewrite_file_read_command("tail -n 10 src/main.rs", "lean-ctx");
assert_eq!(
r,
Some("lean-ctx read src/main.rs -m lines:-10".to_string())
);
}
#[test]
fn file_read_rewrite_not_git() {
assert_eq!(rewrite_file_read_command("git status", "lean-ctx"), None);
}
#[test]
fn file_read_skips_home_relative_paths() {
assert_eq!(
rewrite_file_read_command("cat ~/Library/Logs/proxy.log", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("head -20 ~/.lean-ctx/logs/proxy.stderr.log", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("tail -50 ~/some/file.txt", "lean-ctx"),
None
);
}
#[test]
fn file_read_skips_system_paths() {
assert_eq!(
rewrite_file_read_command("cat /tmp/test.log", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("cat /var/log/syslog", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("cat /proc/cpuinfo", "lean-ctx"),
None
);
}
#[test]
fn file_read_skips_env_var_paths() {
assert_eq!(
rewrite_file_read_command("cat $HOME/.bashrc", "lean-ctx"),
None
);
}
#[test]
fn file_read_skips_library_and_config_paths() {
assert_eq!(
rewrite_file_read_command(
"cat /Users/user/Library/LaunchAgents/com.leanctx.proxy.plist",
"lean-ctx"
),
None
);
assert_eq!(
rewrite_file_read_command("cat /home/user/.config/lean-ctx/config.toml", "lean-ctx"),
None
);
}
#[test]
fn file_read_skips_pipes_and_redirects() {
assert_eq!(
rewrite_file_read_command("cat file.rs | grep fn", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("cat file.rs 2>&1", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("cat file.rs >> output.log", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("cat a.rs && cat b.rs", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("cat a.rs; echo done", "lean-ctx"),
None
);
}
#[test]
fn file_read_still_rewrites_project_relative_paths() {
assert_eq!(
rewrite_file_read_command("cat src/main.rs", "lean-ctx"),
Some("lean-ctx read src/main.rs".to_string())
);
assert_eq!(
rewrite_file_read_command("cat ./Cargo.toml", "lean-ctx"),
Some("lean-ctx read ./Cargo.toml".to_string())
);
assert_eq!(
rewrite_file_read_command("head -20 src/lib.rs", "lean-ctx"),
Some("lean-ctx read src/lib.rs -m lines:1-20".to_string())
);
}
#[test]
fn ps_get_content_basic_and_alias() {
assert_eq!(
rewrite_file_read_command("Get-Content src/main.rs", "lean-ctx"),
Some("lean-ctx read src/main.rs".to_string())
);
assert_eq!(
rewrite_file_read_command("gc src/main.rs", "lean-ctx"),
Some("lean-ctx read src/main.rs".to_string())
);
assert_eq!(
rewrite_file_read_command("Get-Content -Path src/lib.rs", "lean-ctx"),
Some("lean-ctx read src/lib.rs".to_string())
);
}
#[test]
fn ps_get_content_head_and_tail() {
assert_eq!(
rewrite_file_read_command("Get-Content -TotalCount 20 src/main.rs", "lean-ctx"),
Some("lean-ctx read src/main.rs -m lines:1-20".to_string())
);
assert_eq!(
rewrite_file_read_command("Get-Content src/main.rs -head 5", "lean-ctx"),
Some("lean-ctx read src/main.rs -m lines:1-5".to_string())
);
assert_eq!(
rewrite_file_read_command("gc -Tail 10 src/main.rs", "lean-ctx"),
Some("lean-ctx read src/main.rs -m lines:-10".to_string())
);
}
#[test]
fn ps_get_content_passthrough() {
assert_eq!(
rewrite_file_read_command("Get-Content -Raw src/main.rs", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("Get-Content -TotalCount 5 -Tail 5 src/main.rs", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("Get-Content ~/secret.txt", "lean-ctx"),
None
);
assert_eq!(
rewrite_file_read_command("Get-Content a.txt | Select-String x", "lean-ctx"),
None
);
}
#[test]
fn ps_select_string_forms() {
assert_eq!(
rewrite_search_command("Select-String TODO src/main.rs", "lean-ctx"),
Some("lean-ctx grep TODO src/main.rs".to_string())
);
assert_eq!(
rewrite_search_command("sls TODO", "lean-ctx"),
Some("lean-ctx grep TODO".to_string())
);
assert_eq!(
rewrite_search_command("Select-String -Pattern TODO -Path src/lib.rs", "lean-ctx"),
Some("lean-ctx grep TODO src/lib.rs".to_string())
);
assert_eq!(
rewrite_search_command("Select-String -CaseSensitive TODO", "lean-ctx"),
None
);
}
#[test]
fn ps_get_childitem_forms() {
assert_eq!(
rewrite_dir_list_command("Get-ChildItem", "lean-ctx"),
Some("lean-ctx ls".to_string())
);
assert_eq!(
rewrite_dir_list_command("gci src", "lean-ctx"),
Some("lean-ctx ls src".to_string())
);
assert_eq!(
rewrite_dir_list_command("Get-ChildItem -Path src", "lean-ctx"),
Some("lean-ctx ls src".to_string())
);
assert_eq!(
rewrite_dir_list_command("Get-ChildItem -Recurse", "lean-ctx"),
None
);
}
#[test]
fn ps_cmdlets_route_through_rewrite_candidate() {
assert_eq!(
rewrite_candidate("Get-Content src/main.rs", "lean-ctx"),
Some("lean-ctx read src/main.rs".to_string())
);
assert_eq!(
rewrite_candidate("Select-String TODO src/main.rs", "lean-ctx"),
Some("lean-ctx grep TODO src/main.rs".to_string())
);
assert_eq!(
rewrite_candidate("gci src", "lean-ctx"),
Some("lean-ctx ls src".to_string())
);
}
#[test]
fn is_outside_project_path_tests() {
assert!(is_outside_project_path("~/foo"));
assert!(is_outside_project_path("~/.lean-ctx/config.toml"));
assert!(is_outside_project_path("$HOME/.bashrc"));
assert!(is_outside_project_path("/tmp/test"));
assert!(is_outside_project_path("/var/log/syslog"));
assert!(is_outside_project_path("/proc/cpuinfo"));
assert!(is_outside_project_path("/Users/x/Library/Logs/foo.log"));
assert!(is_outside_project_path("/home/x/.config/app/conf"));
assert!(is_outside_project_path("/root/.lean-ctx/logs/proxy.log"));
assert!(!is_outside_project_path("src/main.rs"));
assert!(!is_outside_project_path("./Cargo.toml"));
assert!(!is_outside_project_path("../sibling/file.rs"));
assert!(!is_outside_project_path("file.txt"));
}
#[test]
fn parse_head_tail_args_basic() {
let (n, path) = parse_head_tail_args(&["-n", "20", "file.rs"]);
assert_eq!(n, Some(20));
assert_eq!(path, Some("file.rs"));
}
#[test]
fn parse_head_tail_args_combined() {
let (n, path) = parse_head_tail_args(&["-n20", "file.rs"]);
assert_eq!(n, Some(20));
assert_eq!(path, Some("file.rs"));
}
#[test]
fn parse_head_tail_args_short_flag() {
let (n, path) = parse_head_tail_args(&["-50", "file.rs"]);
assert_eq!(n, Some(50));
assert_eq!(path, Some("file.rs"));
}
#[test]
fn should_passthrough_rules_files() {
assert!(should_passthrough("/home/user/.cursorrules"));
assert!(should_passthrough("/project/.cursor/rules/test.mdc"));
assert!(should_passthrough("/home/.cursor/hooks/hooks.json"));
assert!(should_passthrough("/project/SKILL.md"));
assert!(should_passthrough("/project/AGENTS.md"));
assert!(should_passthrough("/project/icon.png"));
assert!(!should_passthrough("/project/src/main.rs"));
assert!(!should_passthrough("/project/src/lib.ts"));
}
#[test]
fn wrap_single() {
let r = wrap_single_command("git status", "lean-ctx");
assert_eq!(r, expect_wrapped("git status", "lean-ctx"));
}
#[test]
fn wrap_with_quotes() {
let r = wrap_single_command(r#"curl -H "Auth" https://api.com"#, "lean-ctx");
assert_eq!(
r,
expect_wrapped(r#"curl -H "Auth" https://api.com"#, "lean-ctx")
);
}
#[test]
fn rewrite_candidate_returns_none_for_existing_lean_ctx_command() {
assert_eq!(
rewrite_candidate("lean-ctx -c git status", "lean-ctx"),
None
);
}
#[test]
fn rewrite_candidate_leaves_raw_escape_hatch_untouched() {
assert_eq!(
rewrite_candidate("lean-ctx raw \"git diff\"", "lean-ctx"),
None
);
assert_eq!(
rewrite_candidate("lean-ctx -c --raw \"git diff\"", "lean-ctx"),
None
);
}
#[test]
fn rewrite_candidate_wraps_single_command() {
assert_eq!(
rewrite_candidate("git status", "lean-ctx"),
Some(expect_wrapped("git status", "lean-ctx"))
);
}
#[test]
fn rewrite_candidate_passes_through_heredoc() {
assert_eq!(
rewrite_candidate(
"git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
"lean-ctx"
),
None
);
}
#[test]
fn rewrite_candidate_passes_through_heredoc_compound() {
assert_eq!(
rewrite_candidate(
"git add . && git commit -m \"$(cat <<EOF\nfeat: add\nEOF\n)\"",
"lean-ctx"
),
None
);
}
#[test]
fn codex_rewrite_output_uses_native_updated_input_contract() {
let output = codex_rewrite_output("lean-ctx -c 'git status'");
let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid hook JSON");
assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow");
assert_eq!(
parsed["hookSpecificOutput"]["updatedInput"]["command"],
"lean-ctx -c 'git status'"
);
}
#[test]
fn dual_rewrite_output_carries_claude_cursor_and_copilot_fields() {
let tool_input = serde_json::json!({ "command": "cat foo.txt", "cwd": "/repo" });
let out = build_dual_rewrite_output(Some(&tool_input), "lean-ctx read foo.txt");
let p: serde_json::Value = serde_json::from_str(&out).expect("valid hook JSON");
assert_eq!(p["permissionDecision"], "allow");
assert_eq!(p["modifiedArgs"]["command"], "lean-ctx read foo.txt");
assert_eq!(
p["modifiedArgs"]["cwd"], "/repo",
"modifiedArgs must preserve the other original args"
);
assert_eq!(p["hookSpecificOutput"]["permissionDecision"], "allow");
assert_eq!(
p["hookSpecificOutput"]["updatedInput"]["command"],
"lean-ctx read foo.txt"
);
assert_eq!(p["updated_input"]["command"], "lean-ctx read foo.txt");
}
#[test]
fn redirect_output_carries_copilot_modified_args() {
let tool_input = serde_json::json!({ "path": "src/main.rs" });
let out = build_redirect_output(Some(&tool_input), "path", "/tmp/x.lctx", None);
let p: serde_json::Value = serde_json::from_str(&out).expect("valid hook JSON");
assert_eq!(p["permissionDecision"], "allow");
assert_eq!(p["modifiedArgs"]["path"], "/tmp/x.lctx");
assert_eq!(
p["hookSpecificOutput"]["updatedInput"]["path"],
"/tmp/x.lctx"
);
assert_eq!(p["updated_input"]["path"], "/tmp/x.lctx");
}
#[test]
fn read_redirect_resolves_and_rewrites_cursor_file_path() {
let tool_input = serde_json::json!({ "file_path": "/repo/src/main.rs" });
let (field, path) = payload::resolve_path_field(Some(&tool_input), payload::READ_PATH_FIELDS)
.expect("Cursor file_path must resolve");
assert_eq!(field, "file_path");
assert_eq!(path, "/repo/src/main.rs");
let out = build_redirect_output(Some(&tool_input), field, "/tmp/x.lctx", None);
let p: serde_json::Value = serde_json::from_str(&out).expect("valid hook JSON");
assert_eq!(p["updated_input"]["file_path"], "/tmp/x.lctx");
assert_eq!(
p["hookSpecificOutput"]["updatedInput"]["file_path"],
"/tmp/x.lctx"
);
assert_eq!(p["modifiedArgs"]["file_path"], "/tmp/x.lctx");
assert!(
p["updated_input"].get("path").is_none(),
"must not invent a `path` field Cursor never sent"
);
}
#[test]
fn compound_rewrite_and_chain() {
let cmd = "cd src && git status && echo done";
let result = with_test_allowlist(|| build_rewrite_compound(cmd, "lean-ctx"));
assert_eq!(result, Some(expect_wrapped(cmd, "lean-ctx")));
}
#[test]
fn compound_rewrite_pipe() {
let cmd = "git log --oneline | head -5";
let result = with_test_allowlist(|| build_rewrite_compound(cmd, "lean-ctx"));
assert_eq!(result, Some(expect_wrapped(cmd, "lean-ctx")));
}
#[test]
fn compound_rewrite_multi_pipe() {
let cmd = "git log | grep fix | wc -l";
let result = with_test_allowlist(|| build_rewrite_compound(cmd, "lean-ctx"));
assert_eq!(result, Some(expect_wrapped(cmd, "lean-ctx")));
}
#[test]
fn compound_rewrite_right_only_rewritable() {
let cmd = "cat notes.txt | rg TODO";
let result = with_test_allowlist(|| build_rewrite_compound(cmd, "lean-ctx"));
assert_eq!(result, Some(expect_wrapped(cmd, "lean-ctx")));
}
#[test]
fn compound_rewrite_no_rewritable_segment() {
let result = with_test_allowlist(|| build_rewrite_compound("cd src && echo done", "lean-ctx"));
assert_eq!(result, None);
}
#[test]
fn compound_rewrite_multiple_rewritable() {
let cmd = "git add . && cargo test && npm run lint";
let result = with_test_allowlist(|| build_rewrite_compound(cmd, "lean-ctx"));
assert_eq!(result, Some(expect_wrapped(cmd, "lean-ctx")));
}
#[test]
fn compound_rewrite_semicolons() {
let cmd = "git add .; git commit -m 'fix'";
let result = with_test_allowlist(|| build_rewrite_compound(cmd, "lean-ctx"));
assert_eq!(result, Some(expect_wrapped(cmd, "lean-ctx")));
}
#[test]
fn compound_rewrite_or_chain() {
let cmd = "git pull || echo failed";
let result = with_test_allowlist(|| build_rewrite_compound(cmd, "lean-ctx"));
assert_eq!(result, Some(expect_wrapped(cmd, "lean-ctx")));
}
#[test]
fn compound_skips_already_rewritten() {
let result = with_test_allowlist(|| {
build_rewrite_compound("lean-ctx -c git status && git diff", "lean-ctx")
});
assert_eq!(result, None);
}
#[test]
fn compound_tricky_interpreter_sink_left_raw() {
let result = with_test_allowlist(|| {
build_rewrite_compound("git log | python3 -c 'print(1)'", "lean-ctx")
});
assert_eq!(result, None);
}
#[test]
fn compound_tricky_non_allowlisted_sink_left_raw() {
let result =
with_test_allowlist(|| build_rewrite_compound("git log | kubectl apply -f -", "lean-ctx"));
assert_eq!(result, None);
}
#[test]
fn compound_tricky_chain_sink_left_raw() {
let result = with_test_allowlist(|| {
build_rewrite_compound("cargo test && python3 -c 'print(1)'", "lean-ctx")
});
assert_eq!(result, None);
}
#[test]
fn single_command_not_compound() {
let result = with_test_allowlist(|| build_rewrite_compound("git status", "lean-ctx"));
assert_eq!(result, None);
}
#[test]
fn rewrite_candidate_wraps_clean_compound() {
let cmd = "git log | head -5";
let result = with_test_allowlist(|| rewrite_candidate(cmd, "lean-ctx"));
assert_eq!(result, Some(expect_wrapped(cmd, "lean-ctx")));
}
#[test]
fn rewrite_candidate_leaves_tricky_compound_untouched() {
let result =
with_test_allowlist(|| rewrite_candidate("git log | python3 -c 'print(1)'", "lean-ctx"));
assert_eq!(result, None);
}
#[test]
fn extract_field_works() {
let input = r#"{"tool_name":"Bash","command":"git status"}"#;
assert_eq!(
extract_json_field(input, "tool_name"),
Some("Bash".to_string())
);
assert_eq!(
extract_json_field(input, "command"),
Some("git status".to_string())
);
}
#[test]
fn extract_field_with_spaces_after_colon() {
let input = r#"{"tool_name": "Bash", "tool_input": {"command": "git status"}}"#;
assert_eq!(
extract_json_field(input, "tool_name"),
Some("Bash".to_string())
);
assert_eq!(
extract_json_field(input, "command"),
Some("git status".to_string())
);
}
#[test]
fn extract_field_pretty_printed() {
let input =
"{\n \"tool_name\": \"Bash\",\n \"tool_input\": {\n \"command\": \"npm test\"\n }\n}";
assert_eq!(
extract_json_field(input, "tool_name"),
Some("Bash".to_string())
);
assert_eq!(
extract_json_field(input, "command"),
Some("npm test".to_string())
);
}
#[test]
fn extract_field_handles_escaped_quotes() {
let input = r#"{"tool_name":"Bash","command":"grep -r \"TODO\" src/"}"#;
assert_eq!(
extract_json_field(input, "command"),
Some(r#"grep -r "TODO" src/"#.to_string())
);
}
#[test]
fn extract_field_handles_escaped_backslash() {
let input = r#"{"tool_name":"Bash","command":"echo \\\"hello\\\""}"#;
assert_eq!(
extract_json_field(input, "command"),
Some(r#"echo \"hello\""#.to_string())
);
}
#[test]
fn extract_field_handles_complex_curl() {
let input = r#"{"tool_name":"Bash","command":"curl -H \"Authorization: Bearer token\" https://api.com"}"#;
assert_eq!(
extract_json_field(input, "command"),
Some(r#"curl -H "Authorization: Bearer token" https://api.com"#.to_string())
);
}
#[test]
fn to_bash_compatible_path_windows_drive() {
let p = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
assert_eq!(p, "/e/packages/lean-ctx.exe");
}
#[test]
fn to_bash_compatible_path_backslashes() {
let p = crate::hooks::to_bash_compatible_path(r"C:\Users\test\bin\lean-ctx.exe");
assert_eq!(p, "/c/Users/test/bin/lean-ctx.exe");
}
#[test]
fn to_bash_compatible_path_unix_unchanged() {
let p = crate::hooks::to_bash_compatible_path("/usr/local/bin/lean-ctx");
assert_eq!(p, "/usr/local/bin/lean-ctx");
}
#[test]
fn to_bash_compatible_path_msys2_unchanged() {
let p = crate::hooks::to_bash_compatible_path("/e/packages/lean-ctx.exe");
assert_eq!(p, "/e/packages/lean-ctx.exe");
}
#[test]
fn resolve_binary_is_native_not_msys() {
assert_eq!(
resolve_binary(),
crate::core::portable_binary::resolve_portable_binary()
);
}
#[test]
fn rewrite_preserves_native_windows_binary_path() {
let win_binary = "C:/Users/Dawid/.cargo/bin/lean-ctx.exe";
let rewritten =
rewrite_candidate("git status", win_binary).expect("git status is a rewrite candidate");
assert!(rewritten.contains(win_binary), "rewritten: {rewritten}");
assert!(
!rewritten.contains("/c/"),
"must not emit MSYS path: {rewritten}"
);
}
#[test]
fn wrap_command_with_bash_path() {
let binary = crate::hooks::to_bash_compatible_path(r"E:\packages\lean-ctx.exe");
let result = wrap_single_command("git status", &binary);
assert!(
!result.contains('\\'),
"wrapped command must not contain backslashes, got: {result}"
);
assert!(
result.starts_with("/e/packages/lean-ctx.exe"),
"must use bash-compatible path, got: {result}"
);
}
#[test]
fn wrap_single_command_em_dash() {
let r = wrap_single_command("gh --comment \"closing — see #407\"", "lean-ctx");
assert_eq!(
r,
expect_wrapped("gh --comment \"closing — see #407\"", "lean-ctx")
);
}
#[test]
fn wrap_single_command_dollar_sign() {
let r = wrap_single_command("echo $HOME", "lean-ctx");
assert_eq!(r, expect_wrapped("echo $HOME", "lean-ctx"));
}
#[test]
fn wrap_single_command_backticks() {
let r = wrap_single_command("echo `date`", "lean-ctx");
assert_eq!(r, expect_wrapped("echo `date`", "lean-ctx"));
}
#[test]
fn wrap_single_command_nested_single_quotes() {
let r = wrap_single_command("echo 'hello world'", "lean-ctx");
assert_eq!(r, expect_wrapped("echo 'hello world'", "lean-ctx"));
}
#[test]
fn wrap_single_command_exclamation_mark() {
let r = wrap_single_command("echo hello!", "lean-ctx");
assert_eq!(r, expect_wrapped("echo hello!", "lean-ctx"));
}
#[test]
fn wrap_single_command_find_with_many_excludes() {
let cmd = "find . -not -path ./node_modules -not -path ./.git -not -path ./dist";
let r = wrap_single_command(cmd, "lean-ctx");
assert_eq!(r, expect_wrapped(cmd, "lean-ctx"));
}
#[test]
fn session_start_uses_codex_additional_context_channel() {
let json = session_start_additional_context_json("prefer lean-ctx -c");
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON on stdout");
assert_eq!(
v["hookSpecificOutput"]["hookEventName"]
.as_str()
.unwrap_or_default(),
"SessionStart"
);
assert_eq!(
v["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default(),
"prefer lean-ctx -c"
);
}
#[test]
fn codex_session_start_hint_teaches_the_raw_escape_hatch() {
let hint = CODEX_SHELL_RECOVERY_HINT;
assert!(
hint.contains("lean-ctx raw \"<exact command>\""),
"names the raw CLI: {hint}"
);
assert!(
hint.contains("is not exact evidence"),
"states compressed output is not exact evidence: {hint}"
);
assert!(
hint.contains("chunked reads"),
"forbids chunk-based reconstruction: {hint}"
);
assert!(
!hint.contains("prefer `lean-ctx -c`"),
"drops the redundant prefer-c coaching (auto-rewrite handles it): {hint}"
);
let json = session_start_additional_context_json(hint);
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(
v["hookSpecificOutput"]["additionalContext"]
.as_str()
.unwrap_or_default(),
hint
);
}
#[test]
fn is_shell_tool_matches_powershell_variants() {
assert!(is_shell_tool("powershell"));
assert!(is_shell_tool("PowerShell"));
assert!(is_shell_tool("pwsh"));
}
#[test]
fn is_shell_tool_matches_existing_shell_names() {
for name in [
"Bash",
"bash",
"Shell",
"shell",
"runInTerminal",
"run_in_terminal",
"terminal",
] {
assert!(is_shell_tool(name), "{name} should be a shell tool");
}
}
#[test]
fn is_shell_tool_rejects_non_shell_tools() {
for name in ["Read", "read", "Grep", "Glob", "glob", "view", "edit", ""] {
assert!(!is_shell_tool(name), "{name} must not be a shell tool");
}
}
#[test]
fn classify_redirect_covers_copilot_view_and_rg() {
assert_eq!(classify_redirect("view"), RedirectKind::Read);
assert_eq!(classify_redirect("rg"), RedirectKind::Grep);
}
#[test]
fn grep_content_mode_only_redirects_explicit_content() {
let mode = |m: &str| serde_json::json!({ "pattern": "x", "output_mode": m });
assert!(grep_content_mode(Some(&mode("content"))));
assert!(!grep_content_mode(Some(&mode("files_with_matches"))));
assert!(!grep_content_mode(Some(&mode("count"))));
assert!(!grep_content_mode(Some(
&serde_json::json!({ "pattern": "x" })
)));
assert!(!grep_content_mode(None));
}
#[test]
fn classify_redirect_covers_existing_tool_names() {
for n in ["Read", "read", "read_file"] {
assert_eq!(classify_redirect(n), RedirectKind::Read, "{n}");
}
for n in ["Grep", "grep", "search", "ripgrep"] {
assert_eq!(classify_redirect(n), RedirectKind::Grep, "{n}");
}
for n in ["Glob", "glob"] {
assert_eq!(classify_redirect(n), RedirectKind::Glob, "{n}");
}
}
#[test]
fn classify_redirect_passes_through_shell_and_unknown() {
for n in [
"Bash",
"bash",
"powershell",
"pwsh",
"edit",
"Write",
"Unknown",
"",
] {
assert_eq!(classify_redirect(n), RedirectKind::None, "{n}");
}
}
#[test]
fn redirect_read_args_pin_full_mode_never_auto() {
let args = redirect_read_args("/repo/src/main.rs");
assert_eq!(args, ["read", "/repo/src/main.rs", "-m", "full"]);
assert!(args.contains(&"full"));
assert!(!args.contains(&"auto"));
}
#[test]
fn redirect_output_routes_shadow_note_to_additional_context() {
let tool_input = serde_json::json!({ "file_path": "/repo/src/main.rs" });
let note = "lean-ctx shadow mode: served by ctx_read.";
let out = build_redirect_output(Some(&tool_input), "file_path", "/tmp/x.lctx", Some(note));
let p: serde_json::Value = serde_json::from_str(&out).expect("valid hook JSON");
assert_eq!(p["hookSpecificOutput"]["additionalContext"], note);
assert_eq!(p["updated_input"]["file_path"], "/tmp/x.lctx");
assert_eq!(p["modifiedArgs"]["file_path"], "/tmp/x.lctx");
assert!(
!out.contains("shadow-mode:"),
"the legacy in-content banner must never reappear in redirect output"
);
}
#[test]
fn redirect_output_omits_additional_context_without_shadow() {
let tool_input = serde_json::json!({ "path": "src/main.rs" });
let out = build_redirect_output(Some(&tool_input), "path", "/tmp/x.lctx", None);
let p: serde_json::Value = serde_json::from_str(&out).expect("valid hook JSON");
assert!(
p["hookSpecificOutput"].get("additionalContext").is_none(),
"no shadow note => no additionalContext key"
);
}
#[test]
fn redirect_read_passes_through_when_disabled_by_config() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::remove_var("CLAUDE_PROJECT_DIR");
crate::test_env::remove_var("CLAUDECODE");
crate::test_env::remove_var("CODEBUDDY");
crate::test_env::set_var("LEAN_CTX_READ_REDIRECT", "off");
let tool_input = serde_json::json!({ "file_path": "/repo/src/main.rs" });
let out = redirect_read(Some(&tool_input));
crate::test_env::remove_var("LEAN_CTX_READ_REDIRECT");
assert_eq!(
out,
build_dual_allow_output(),
"disabled Read redirect must emit the plain dual-allow passthrough"
);
assert!(
!out.contains(".lctx") && !out.contains("updatedInput") && !out.contains("modifiedArgs"),
"disabled Read redirect must not rewrite the path to a temp copy: {out}"
);
}
#[test]
fn redirect_read_auto_passes_through_under_claude_code() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::set_var("LEAN_CTX_READ_REDIRECT", "auto");
crate::test_env::remove_var("CLAUDECODE");
crate::test_env::remove_var("CODEBUDDY");
crate::test_env::set_var("CLAUDE_PROJECT_DIR", "/repo");
let tool_input = serde_json::json!({ "file_path": "/repo/src/main.rs" });
let out = redirect_read(Some(&tool_input));
crate::test_env::remove_var("CLAUDE_PROJECT_DIR");
crate::test_env::remove_var("LEAN_CTX_READ_REDIRECT");
assert_eq!(
out,
build_dual_allow_output(),
"auto must disable the Read redirect under Claude Code hooks (#637)"
);
assert!(
!out.contains(".lctx"),
"no temp path-swap under Claude Code: {out}"
);
}
#[test]
fn gating_decision_returns_work_result_when_fast() {
let out = decide_with_timeout(
std::time::Duration::from_secs(5),
"FALLBACK".to_string(),
|| "WORK".to_string(),
);
assert_eq!(out, "WORK");
}
#[test]
fn gating_decision_fails_open_on_timeout() {
let start = std::time::Instant::now();
let out = decide_with_timeout(
std::time::Duration::from_millis(50),
"FALLBACK".to_string(),
|| {
std::thread::sleep(std::time::Duration::from_secs(3));
"WORK".to_string()
},
);
assert_eq!(out, "FALLBACK", "a hung hook must fail open to passthrough");
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"fail-open must not wait for the hung work"
);
}