use super::*;
#[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"))));
let absent = serde_json::json!({ "pattern": "x" });
let absent_result = grep_content_mode(Some(&absent));
let on_cursor = crate::core::config::read_redirect::hook_host_is_cursor();
assert_eq!(absent_result, on_cursor);
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_smart_mode_selection() {
let windowed = redirect_read_args("/repo/src/main.rs", true);
assert_eq!(
windowed,
["read", "/repo/src/main.rs", "-m", "full-compact"]
);
let full = redirect_read_args("/repo/src/main.rs", false);
assert_eq!(full, ["read", "/repo/src/main.rs", "-m", "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"
);
}
#[test]
fn gh760_non_rewritable_command_not_wrapped() {
assert_eq!(
rewrite_candidate("mvnw clean package", "lean-ctx"),
None,
"mvnw is not in REWRITE_COMMANDS — hook must not wrap it"
);
assert_eq!(
rewrite_candidate("md5sum file.txt", "lean-ctx"),
None,
"md5sum is not rewritable — must pass through raw"
);
assert_eq!(
rewrite_candidate("update-alternatives --list java", "lean-ctx"),
None,
"update-alternatives is not rewritable — must pass through raw"
);
}
#[test]
fn gh760_pipeline_with_path_segments_wraps_when_gate_clean() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "find,tr");
let cmd = "find target/quarkus-app/lib -name \"*.jar\" | tr '\\n' ':'";
let result = rewrite_candidate(cmd, "lean-ctx");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
assert_eq!(
result,
Some(expect_wrapped(cmd, "lean-ctx")),
"gate-clean pipeline must be wrapped whole; path segment 'lib' must not interfere"
);
}
#[test]
fn gh760_pipeline_with_non_allowed_sink_wrapped_for_enforcement() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::set_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE", "find");
let cmd = "find . -name '*.jar' | custom-tool";
let result = rewrite_candidate(cmd, "lean-ctx");
crate::test_env::remove_var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE");
assert_eq!(
result,
Some(expect_wrapped(cmd, "lean-ctx")),
"non-allowlisted sink must be wrapped for enforcement (#1408)"
);
}