use std::path::Path;
pub(crate) use crate::core::rules_canonical::{COMPRESSION_BLOCK_END, COMPRESSION_BLOCK_START};
pub(crate) const AGENTS_MD_READERS: &[&str] = &["cursor", "codex", "opencode"];
pub(crate) fn carries_full_rules(content: &str) -> bool {
content.contains(crate::core::rules_canonical::START_MARK)
|| content.contains(COMPRESSION_BLOCK_START)
}
pub(crate) fn is_pointer_only(content: &str) -> bool {
content.contains("<!-- lean-ctx") && !carries_full_rules(content)
}
fn file_has_compression(path: &Path) -> bool {
std::fs::read_to_string(path).is_ok_and(|c| c.contains(COMPRESSION_BLOCK_START))
}
pub(crate) fn cursor_compression_covered(home: &Path) -> bool {
file_has_compression(&home.join(".cursor/rules/lean-ctx.mdc"))
}
pub(crate) fn cursor_hooks_cover_native_tools(home: &Path) -> bool {
cursor_hooks_json_covers(&home.join(".cursor/hooks.json"))
}
pub(crate) fn cursor_hooks_json_covers(hooks_json: &Path) -> bool {
let Ok(content) = std::fs::read_to_string(hooks_json) else {
return false;
};
let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
return false;
};
let Some(pre) = v.pointer("/hooks/preToolUse").and_then(|p| p.as_array()) else {
return false;
};
let has_lean_ctx_hook = |suffix: &str| {
pre.iter().any(|e| {
e.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
})
};
has_lean_ctx_hook("hook rewrite") && has_lean_ctx_hook("hook redirect")
}
pub(crate) fn client_hook_covered(client_name: &str, home: &Path) -> bool {
let lower = client_name.to_lowercase();
if lower.contains("cursor") {
return cursor_hooks_cover_native_tools(home);
}
if lower.contains("codex") {
return codex_hooks_cover_native_tools(home);
}
if lower.contains("windsurf") {
return windsurf_hooks_cover_native_tools(home);
}
false
}
fn codex_hooks_cover_native_tools(home: &Path) -> bool {
let hooks_json = home.join(".codex").join("hooks.json");
let Ok(content) = std::fs::read_to_string(&hooks_json) else {
return false;
};
let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
return false;
};
let Some(pre) = v.pointer("/hooks/PreToolUse").and_then(|p| p.as_array()) else {
return false;
};
let has_lean_ctx = |suffix: &str| {
pre.iter().any(|e| {
let in_command = e
.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix));
let in_hooks_array = e
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|hooks| {
hooks.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
})
});
in_command || in_hooks_array
})
};
has_lean_ctx("hook deny") && has_lean_ctx("hook codex-pretooluse")
}
fn windsurf_hooks_cover_native_tools(home: &Path) -> bool {
let hooks_json = home.join(".codeium").join("windsurf").join("hooks.json");
let Ok(content) = std::fs::read_to_string(&hooks_json) else {
return false;
};
let Ok(v) = crate::core::jsonc::parse_jsonc(&content) else {
return false;
};
let Some(pre) = v
.pointer("/hooks/pre_mcp_tool_use")
.and_then(|p| p.as_array())
else {
return false;
};
let has_lean_ctx = |suffix: &str| {
pre.iter().any(|e| {
e.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains("lean-ctx") && c.contains(suffix))
})
};
has_lean_ctx("hook rewrite") && has_lean_ctx("hook redirect")
}
fn codex_dir(home: &Path) -> std::path::PathBuf {
crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"))
}
pub(crate) fn codex_present(home: &Path) -> bool {
codex_dir(home).exists()
}
pub(crate) fn codex_compression_covered(home: &Path) -> bool {
file_has_compression(&codex_dir(home).join("AGENTS.md"))
}
pub(crate) fn agents_md_can_thin(home: &Path) -> bool {
if !cursor_compression_covered(home) {
return false;
}
if codex_present(home) && !codex_compression_covered(home) {
return false;
}
true
}
pub(crate) fn client_autoloads_compression(client_name: &str, home: &Path) -> bool {
let lower = client_name.to_lowercase();
if lower.is_empty() {
return false;
}
if lower.contains("cursor") {
return cursor_compression_covered(home);
}
if lower.contains("codex") {
return codex_present(home) && codex_compression_covered(home);
}
if lower.contains("opencode") {
let opencode_dir = home.join(".config/opencode");
let dedicated = opencode_dir.join("rules/lean-ctx.md");
let shared = opencode_dir.join("AGENTS.md");
return file_has_compression(&dedicated) || file_has_compression(&shared);
}
false
}
fn file_has_canonical_rules(path: &Path) -> bool {
std::fs::read_to_string(path)
.is_ok_and(|c| crate::core::rules_canonical::RulesFile::parse(&c).has_content())
}
pub(crate) fn client_autoloads_rules(client_name: &str, home: &Path) -> bool {
let lower = client_name.to_lowercase();
if lower.is_empty() {
return false;
}
if lower.contains("cursor") {
return file_has_canonical_rules(&home.join(".cursor/rules/lean-ctx.mdc"));
}
if lower.contains("codex") {
return codex_present(home)
&& file_has_canonical_rules(&codex_dir(home).join("instructions.md"));
}
if lower.contains("opencode") {
let opencode_dir = home.join(".config/opencode");
let dedicated = opencode_dir.join("rules/lean-ctx.md");
let shared = opencode_dir.join("AGENTS.md");
return file_has_canonical_rules(&dedicated) || file_has_canonical_rules(&shared);
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const FULL_HEADER: &str = crate::core::rules_canonical::START_MARK;
fn compression_block() -> String {
format!("{COMPRESSION_BLOCK_START}\nOUTPUT STYLE\n{COMPRESSION_BLOCK_END}\n")
}
fn pointer_block() -> String {
format!(
"{}\n## lean-ctx\nFull rules: ~/.cursor/rules/lean-ctx.mdc\n{}\n",
crate::core::rules_canonical::AGENTS_BLOCK_START,
crate::core::rules_canonical::AGENTS_BLOCK_END,
)
}
#[test]
fn full_rules_detected_for_canonical_header_and_compression() {
let comp = compression_block();
let ptr = pointer_block();
assert!(carries_full_rules(&format!("{FULL_HEADER}\nbody\n")));
assert!(carries_full_rules(&comp));
assert!(carries_full_rules(&format!("{ptr}{comp}")));
}
#[test]
fn pointer_only_block_is_not_full() {
let ptr = pointer_block();
assert!(!carries_full_rules(&ptr));
assert!(is_pointer_only(&ptr));
}
#[test]
fn plain_user_content_is_neither_full_nor_pointer() {
let user = "# My project rules\njust some notes\n";
assert!(!carries_full_rules(user));
assert!(!is_pointer_only(user));
}
#[test]
fn cursor_coverage_follows_mdc_block() {
let comp = compression_block();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
assert!(!cursor_compression_covered(home));
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::write(
home.join(".cursor/rules/lean-ctx.mdc"),
format!("{FULL_HEADER}\n{comp}"),
)
.unwrap();
assert!(cursor_compression_covered(home));
}
#[test]
fn agents_md_thins_only_when_cursor_covered_and_no_uncovered_codex() {
let comp = compression_block();
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
assert!(!agents_md_can_thin(home));
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::write(
home.join(".cursor/rules/lean-ctx.mdc"),
format!("{FULL_HEADER}\n{comp}"),
)
.unwrap();
assert!(agents_md_can_thin(home));
std::fs::create_dir_all(home.join(".codex")).unwrap();
assert!(codex_present(home));
assert!(!agents_md_can_thin(home));
std::fs::write(home.join(".codex/AGENTS.md"), &comp).unwrap();
assert!(agents_md_can_thin(home));
crate::test_env::remove_var("CODEX_HOME");
}
#[test]
fn client_autoloads_rules_requires_canonical_block_on_disk() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
crate::test_env::set_var("CODEX_HOME", home.join(".codex"));
assert!(!client_autoloads_rules("cursor", home));
assert!(!client_autoloads_rules("codex", home));
assert!(!client_autoloads_rules("", home));
assert!(!client_autoloads_rules("claude-code", home));
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::write(
home.join(".cursor/rules/lean-ctx.mdc"),
format!("{FULL_HEADER}\nbody\n"),
)
.unwrap();
assert!(client_autoloads_rules("cursor", home));
assert!(client_autoloads_rules("cursor-vscode", home));
std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), "user notes\n").unwrap();
assert!(!client_autoloads_rules("cursor", home));
std::fs::create_dir_all(home.join(".codex")).unwrap();
std::fs::write(
home.join(".codex/instructions.md"),
format!("{FULL_HEADER}\nbody\n"),
)
.unwrap();
assert!(client_autoloads_rules("codex", home));
crate::test_env::remove_var("CODEX_HOME");
}
#[test]
fn cursor_hook_coverage_requires_both_pretooluse_entries() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
assert!(!cursor_hooks_cover_native_tools(home));
assert!(!client_hook_covered("cursor", home));
std::fs::create_dir_all(home.join(".cursor")).unwrap();
let hooks = home.join(".cursor/hooks.json");
std::fs::write(
&hooks,
r#"{"version":1,"hooks":{"preToolUse":[
{"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"}
]}}"#,
)
.unwrap();
assert!(!cursor_hooks_cover_native_tools(home));
std::fs::write(
&hooks,
r#"{"version":1,"hooks":{"preToolUse":[
{"matcher":"Shell","command":"/usr/local/bin/lean-ctx hook rewrite"},
{"matcher":"Read|Grep","command":"/usr/local/bin/lean-ctx hook redirect"}
]}}"#,
)
.unwrap();
assert!(cursor_hooks_cover_native_tools(home));
assert!(client_hook_covered("cursor", home));
assert!(client_hook_covered("cursor-vscode", home));
assert!(!client_hook_covered("codex", home));
assert!(!client_hook_covered("", home));
std::fs::write(
&hooks,
r#"{"version":1,"hooks":{"preToolUse":[
{"matcher":"Shell","command":"/opt/other hook rewrite"},
{"matcher":"Read|Grep","command":"/opt/other hook redirect"}
]}}"#,
)
.unwrap();
assert!(!cursor_hooks_cover_native_tools(home));
std::fs::write(&hooks, "{ not json").unwrap();
assert!(!cursor_hooks_cover_native_tools(home));
}
#[test]
fn codex_hook_coverage_requires_deny_and_pretooluse() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
assert!(!client_hook_covered("codex-cli", home));
let codex_dir = home.join(".codex");
std::fs::create_dir_all(&codex_dir).expect("create .codex dir");
let hooks = codex_dir.join("hooks.json");
std::fs::write(
&hooks,
r#"{"hooks":{"PreToolUse":[
{"command":"/usr/local/bin/lean-ctx hook deny","matcher":"Read|Grep|Glob"}
]}}"#,
)
.expect("write deny-only hooks");
assert!(!codex_hooks_cover_native_tools(home));
std::fs::write(
&hooks,
r#"{"hooks":{"PreToolUse":[
{"command":"/usr/local/bin/lean-ctx hook deny","matcher":"Read|Grep|Glob"},
{"hooks":[{"command":"/usr/local/bin/lean-ctx hook codex-pretooluse","type":"command","timeout":15}],"matcher":"Bash"}
]}}"#,
)
.expect("write full hooks");
assert!(codex_hooks_cover_native_tools(home));
assert!(client_hook_covered("codex-cli", home));
assert!(client_hook_covered("codex", home));
assert!(!client_hook_covered("cursor", home));
}
#[test]
fn windsurf_hook_coverage_requires_rewrite_and_redirect() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
assert!(!client_hook_covered("windsurf", home));
let ws_dir = home.join(".codeium").join("windsurf");
std::fs::create_dir_all(&ws_dir).expect("create .codeium/windsurf dir");
let hooks = ws_dir.join("hooks.json");
std::fs::write(
&hooks,
r#"{"hooks":{"pre_mcp_tool_use":[
{"command":"/usr/local/bin/lean-ctx hook rewrite"}
]}}"#,
)
.expect("write rewrite-only hooks");
assert!(!windsurf_hooks_cover_native_tools(home));
std::fs::write(
&hooks,
r#"{"hooks":{"pre_mcp_tool_use":[
{"command":"/usr/local/bin/lean-ctx hook rewrite"},
{"command":"/usr/local/bin/lean-ctx hook redirect"}
]}}"#,
)
.expect("write full hooks");
assert!(windsurf_hooks_cover_native_tools(home));
assert!(client_hook_covered("windsurf", home));
assert!(!client_hook_covered("cursor", home));
assert!(!client_hook_covered("codex", home));
}
#[test]
fn opencode_autoloads_rules_from_shared_agents_md() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
assert!(!client_autoloads_rules("opencode", home));
std::fs::create_dir_all(home.join(".config/opencode")).unwrap();
std::fs::write(
home.join(".config/opencode/AGENTS.md"),
format!("{FULL_HEADER}\nbody\n"),
)
.unwrap();
assert!(client_autoloads_rules("opencode", home));
}
#[test]
fn opencode_autoloads_rules_from_dedicated_rules_file() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
std::fs::create_dir_all(home.join(".config/opencode/rules")).unwrap();
std::fs::write(
home.join(".config/opencode/rules/lean-ctx.md"),
format!("{FULL_HEADER}\nbody\n"),
)
.unwrap();
assert!(client_autoloads_rules("opencode", home));
}
#[test]
fn client_autoloads_compression_is_client_aware() {
let comp = compression_block();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::write(
home.join(".cursor/rules/lean-ctx.mdc"),
format!("{FULL_HEADER}\n{comp}"),
)
.unwrap();
assert!(client_autoloads_compression("Cursor", home));
assert!(client_autoloads_compression("cursor-vscode", home));
assert!(!client_autoloads_compression("", home));
assert!(!client_autoloads_compression("some-other-agent", home));
}
#[test]
fn render_output_is_detected_as_compression_coverage() {
use crate::core::config::CompressionLevel;
use crate::core::rules_canonical::{Wrapper, render};
use crate::core::tool_profiles::ToolProfile;
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
assert!(!cursor_compression_covered(home));
let tp = ToolProfile::Power;
let block = render(false, Wrapper::Dedicated, CompressionLevel::Standard, &tp);
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &block).unwrap();
assert!(cursor_compression_covered(home));
assert!(client_autoloads_compression("cursor", home));
let off = render(false, Wrapper::Dedicated, CompressionLevel::Off, &tp);
std::fs::write(home.join(".cursor/rules/lean-ctx.mdc"), &off).unwrap();
assert!(!cursor_compression_covered(home));
}
}