use super::*;
#[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 codex_allow_output_is_empty() {
let output = codex_allow_output();
assert!(output.is_empty());
}
#[test]
fn codex_deny_output_with_nested_quotes_is_valid_json() {
let complex_cmd = r#"lean-ctx raw 'cat file; printf "
---
"; sed -n "55,145p" file2'"#;
let output = codex_deny_output(complex_cmd);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("codex_deny_output must be valid JSON");
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(
parsed["hookSpecificOutput"].get("reason").is_none(),
"Codex rejects unknown fields in hookSpecificOutput"
);
let reason = parsed["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap_or("");
assert!(
reason.contains("lean-ctx raw"),
"deny reason must contain the command: {reason}"
);
}
#[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!(
p.get("decision").is_none(),
"PreToolUse output must not emit the deprecated top-level decision"
);
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 claude_allow_output_omits_deprecated_top_level_decision() {
let parsed: serde_json::Value =
serde_json::from_str(&build_dual_allow_output()).expect("valid hook JSON");
assert!(parsed.get("decision").is_none());
assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[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!(p.get("decision").is_none());
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);
}