use super::content::rules_content;
use super::skills::{SKILL_TEMPLATE, build_skill_targets};
use std::sync::OnceLock;
use super::*;
use crate::core::config::CompressionLevel;
use crate::core::rules_canonical::{END_MARK, RULES_VERSION, RulesFile, START_MARK, Wrapper};
fn power() -> crate::core::tool_profiles::ToolProfile {
crate::core::tool_profiles::ToolProfile::Power
}
fn shared_content() -> String {
crate::core::rules_canonical::render(false, Wrapper::Shared, CompressionLevel::Off, &power())
}
fn dedicated_content_cached() -> &'static str {
static RULES: OnceLock<String> = OnceLock::new();
RULES.get_or_init(|| {
crate::core::rules_canonical::render(
false,
Wrapper::Dedicated,
CompressionLevel::Off,
&power(),
)
})
}
#[test]
fn shared_rules_have_markers() {
let s = shared_content();
assert!(s.contains(START_MARK));
assert!(s.contains(END_MARK));
assert!(s.contains(&format!("<!-- version: {RULES_VERSION} -->")));
assert!(s.contains("lean-ctx routing"));
assert!(s.contains("other MCP servers keep their own jobs"));
}
#[test]
fn dedicated_rules_have_markers() {
let d = dedicated_content_cached();
assert!(d.contains(START_MARK));
assert!(d.contains(END_MARK));
assert!(d.contains(&format!("<!-- version: {RULES_VERSION} -->")));
assert!(d.contains("lean-ctx routing"));
assert!(d.contains("By task:"));
}
#[test]
fn shadow_dedicated_omits_mapping() {
let rules = crate::core::rules_canonical::render(
true,
Wrapper::Dedicated,
CompressionLevel::Off,
&power(),
);
assert!(
!rules.contains("MUST USE"),
"shadow must not include tool mapping"
);
assert!(
!rules.contains("lean-ctx routing"),
"shadow must not include native tool admonition"
);
assert!(rules.contains(START_MARK), "shadow keeps markers");
assert!(rules.contains(END_MARK), "shadow keeps markers");
}
#[test]
fn shadow_shared_omits_mapping() {
let rules = crate::core::rules_canonical::render(
true,
Wrapper::Shared,
CompressionLevel::Off,
&power(),
);
assert!(
!rules.contains("lean-ctx routing"),
"shadow shared must not include mapping header"
);
assert!(rules.contains(START_MARK), "shadow shared keeps markers");
assert!(rules.contains(END_MARK), "shadow shared keeps markers");
}
#[test]
fn zed_rules_path_is_os_aware_and_matches_config_dir() {
let home = std::path::Path::new("/home/tester");
let zed = build_rules_targets(home, crate::core::config::RulesInjection::Shared)
.into_iter()
.find(|t| t.name == "Zed")
.expect("Zed rules target must exist");
let expected = crate::core::editor_registry::zed_config_dir(home).join("rules/lean-ctx.md");
assert_eq!(zed.path, expected);
}
#[test]
fn target_count() {
let home = std::path::PathBuf::from("/tmp/fake_home");
let targets = build_rules_targets(&home, crate::core::config::RulesInjection::Shared);
assert_eq!(targets.len(), 27);
assert!(
targets.iter().any(|t| t.name == "Grok"),
"Grok must have a rules target"
);
assert!(
!targets.iter().any(|t| t.name == "Claude Code"),
"Claude Code must not get a rules target"
);
assert!(
!targets.iter().any(|t| t.name == "CodeBuddy"),
"CodeBuddy must not get a rules target"
);
let dedicated = build_rules_targets(&home, crate::core::config::RulesInjection::Dedicated);
assert_eq!(dedicated.len(), 27);
}
#[test]
fn dedicated_mode_swaps_shared_agents_to_dedicated_files() {
use crate::core::config::RulesInjection;
let home = std::path::Path::new("/home/tester");
let shared = build_rules_targets(home, RulesInjection::Shared);
let gemini_shared = shared.iter().find(|t| t.name == "Gemini CLI").unwrap();
let opencode_shared = shared.iter().find(|t| t.name == "OpenCode").unwrap();
assert!(matches!(gemini_shared.format, RulesFormat::SharedMarkdown));
assert!(gemini_shared.path.ends_with("GEMINI.md"));
assert!(matches!(
opencode_shared.format,
RulesFormat::SharedMarkdown
));
assert!(opencode_shared.path.ends_with("AGENTS.md"));
let dedicated = build_rules_targets(home, RulesInjection::Dedicated);
let gemini = dedicated.iter().find(|t| t.name == "Gemini CLI").unwrap();
let opencode = dedicated.iter().find(|t| t.name == "OpenCode").unwrap();
assert!(matches!(gemini.format, RulesFormat::DedicatedMarkdown));
assert_eq!(gemini.path, gemini_dedicated_rules_path(home));
assert!(!gemini.path.ends_with("GEMINI.md"));
assert!(matches!(opencode.format, RulesFormat::DedicatedMarkdown));
assert_eq!(opencode.path, opencode_dedicated_rules_path(home));
assert!(!opencode.path.ends_with("AGENTS.md"));
}
#[test]
fn omp_rules_target_merges_into_the_native_agents_file() {
let home = std::path::PathBuf::from("/tmp/fake_home");
for injection in [
crate::core::config::RulesInjection::Shared,
crate::core::config::RulesInjection::Dedicated,
] {
let targets = build_rules_targets(&home, injection);
let omp = targets
.iter()
.find(|t| t.name == "Oh My Pi")
.expect("Oh My Pi must have a rules target");
assert_eq!(
omp.path,
crate::core::editor_registry::omp_agents_path(&home)
);
assert_eq!(omp.path.file_name().unwrap(), "AGENTS.md");
assert!(matches!(omp.format, RulesFormat::SharedMarkdown));
}
}
#[test]
fn omp_and_pi_cli_keys_do_not_cross_match() {
assert!(match_agent_name("omp", "Oh My Pi"));
assert!(!match_agent_name("omp", "Pi Coding Agent"));
assert!(!match_agent_name("pi", "Oh My Pi"));
assert!(match_agent_name("pi", "Pi Coding Agent"));
}
#[test]
fn rules_catalog_includes_previously_missing_agents_for_detection() {
let home = std::path::Path::new("/home/tester");
let names: std::collections::HashSet<&str> = [
crate::core::config::RulesInjection::Shared,
crate::core::config::RulesInjection::Dedicated,
]
.iter()
.flat_map(|inj| build_rules_targets(home, *inj))
.map(|t| t.name)
.collect();
for agent in [
"OpenCode", "Zed", "Cline", "Roo Code", "Continue", "Crush", "Oh My Pi",
] {
assert!(
names.contains(agent),
"{agent} must be in the rules catalog used for presence detection (#442)"
);
}
}
#[test]
fn cursor_mdc_has_frontmatter_and_markers() {
let mdc = rules_content(
&RulesFormat::CursorMdc,
CompressionLevel::Off,
Wrapper::Dedicated,
&power(),
);
assert!(mdc.contains("alwaysApply: true"));
assert!(mdc.contains(START_MARK));
assert!(mdc.contains(END_MARK));
assert!(mdc.contains(&format!("<!-- version: {RULES_VERSION} -->")));
}
#[test]
fn cursor_wrapper_follows_hook_coverage() {
let home = std::env::temp_dir().join("lc_test_cursor_wrapper_coverage");
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
let mdc_path = home.join(".cursor/rules/lean-ctx.mdc");
assert!(matches!(
super::content::cursor_wrapper_for_mdc(&mdc_path),
Wrapper::Dedicated
));
std::fs::write(
home.join(".cursor/hooks.json"),
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!(matches!(
super::content::cursor_wrapper_for_mdc(&mdc_path),
Wrapper::HookCovered
));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn inject_cursor_switches_profile_when_hooks_appear() {
let _guard = crate::core::data_dir::test_env_lock();
let home = std::env::temp_dir().join("lc_test_inject_cursor_hookcovered");
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(home.join(".cursor")).unwrap();
let result = inject_rules_for_agent(&home, "cursor");
assert!(result.errors.is_empty(), "{:?}", result.errors);
let mdc_path = home.join(".cursor/rules/lean-ctx.mdc");
let before = std::fs::read_to_string(&mdc_path).unwrap();
assert!(
before.contains("auto-route") || before.contains("lean-ctx routing"),
"initial rules must contain shadow nudge or full mapping"
);
std::fs::write(
home.join(".cursor/hooks.json"),
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();
let result = inject_rules_for_agent(&home, "cursor");
assert!(result.errors.is_empty(), "{:?}", result.errors);
let after = std::fs::read_to_string(&mdc_path).unwrap();
assert!(
after.contains("auto-route")
|| (after.contains("lean-ctx hooks compress native")
&& after.contains("lean-ctx tools:")),
"with hooks the mdc carries shadow block or HookCovered profile"
);
assert!(
after.contains("alwaysApply: true"),
"frontmatter survives the profile switch"
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn rules_file_merged_replaces_section_preserving_user_content() {
let path = std::env::temp_dir().join("test_rules_merged.md");
let old = format!(
"user before\n{START_MARK}\n<!-- version: 0 -->\n\nold rules\n{END_MARK}\nuser after"
);
std::fs::write(&path, &old).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let file = RulesFile::parse(&content);
assert!(file.has_content());
assert_eq!(file.version(), 0);
assert!(!file.is_current());
let merged = file.merged(false, Wrapper::Shared, CompressionLevel::Off, &power());
std::fs::write(&path, &merged).unwrap();
let result = std::fs::read_to_string(&path).unwrap();
assert!(result.contains("user before"), "prefix preserved");
assert!(result.contains("user after"), "suffix preserved");
assert!(!result.contains("old rules"), "old content replaced");
assert!(
result.contains(&format!("<!-- version: {RULES_VERSION} -->")),
"version updated"
);
std::fs::remove_file(&path).ok();
}
#[test]
fn rules_file_merged_appends_when_no_section() {
let content = "user content only";
let file = RulesFile::parse(content);
assert!(!file.has_content());
let merged = file.merged(false, Wrapper::Shared, CompressionLevel::Off, &power());
assert!(merged.contains("user content only"));
assert!(merged.contains(START_MARK));
}
#[test]
fn rules_file_without_section_strips_lean_ctx_block() {
let content = format!("header\n{START_MARK}\n<!-- version: 1 -->\n\nbody\n{END_MARK}\nfooter");
let file = RulesFile::parse(&content);
let stripped = file.without_section();
assert!(stripped.contains("header"));
assert!(stripped.contains("footer"));
assert!(!stripped.contains("body"));
assert!(!stripped.contains(START_MARK));
}
#[test]
fn inject_rules_for_agent_opencode() {
let _guard = crate::core::data_dir::test_env_lock();
let _injection_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let home = std::env::temp_dir().join("test_inject_rules_agent");
let _ = std::fs::remove_dir_all(&home);
let _ = std::fs::create_dir_all(&home);
let opencode_dir = home.join(".config/opencode");
let _ = std::fs::create_dir_all(&opencode_dir);
let result = inject_rules_for_agent(&home, "opencode");
assert!(
!result.updated.is_empty() || !result.already.is_empty(),
"should inject or find rules for OpenCode"
);
assert!(result.errors.is_empty(), "no errors expected");
let agents_md = opencode_dir.join("AGENTS.md");
if agents_md.exists() {
let content = std::fs::read_to_string(&agents_md).unwrap();
assert!(content.contains(START_MARK));
}
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn inject_rules_for_agent_cursor() {
let _guard = crate::core::data_dir::test_env_lock();
let _injection_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let home = std::env::temp_dir().join("test_inject_rules_cursor");
let _ = std::fs::remove_dir_all(&home);
let _ = std::fs::create_dir_all(&home);
let cursor_dir = home.join(".cursor");
let _ = std::fs::create_dir_all(&cursor_dir);
let result = inject_rules_for_agent(&home, "cursor");
assert!(result.errors.is_empty(), "no errors expected");
let mdc_path = home.join(".cursor/rules/lean-ctx.mdc");
if mdc_path.exists() {
let content = std::fs::read_to_string(&mdc_path).unwrap();
assert!(content.contains(START_MARK));
}
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn inject_rules_for_unknown_agent_is_empty() {
let home = std::path::PathBuf::from("/tmp/fake_home_unknown");
let result = inject_rules_for_agent(&home, "unknown_agent_xyz");
assert!(result.updated.is_empty(), "{:?}", result.updated);
assert!(result.already.is_empty(), "{:?}", result.already);
assert!(result.errors.is_empty(), "{:?}", result.errors);
}
#[test]
fn inject_rewrites_on_compression_change_without_version_bump() {
let _guard = crate::core::data_dir::test_env_lock();
let dir = std::env::temp_dir().join("lc_test_inject_compression_drift");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let target = RulesTarget {
name: "test",
path: dir.join("lean-ctx.md"),
format: RulesFormat::DedicatedMarkdown,
};
crate::test_env::set_var("LEAN_CTX_COMPRESSION", "off");
assert!(matches!(
super::write::inject_rules(&target).unwrap(),
RulesResult::Updated
));
let off = std::fs::read_to_string(&target.path).unwrap();
assert!(matches!(
super::write::inject_rules(&target).unwrap(),
RulesResult::AlreadyPresent
));
crate::test_env::set_var("LEAN_CTX_COMPRESSION", "max");
assert!(matches!(
super::write::inject_rules(&target).unwrap(),
RulesResult::Updated
));
let max = std::fs::read_to_string(&target.path).unwrap();
crate::test_env::remove_var("LEAN_CTX_COMPRESSION");
let _ = std::fs::remove_dir_all(&dir);
assert_ne!(off, max, "compression-level change must alter the body");
assert!(max.contains(&format!("<!-- version: {RULES_VERSION} -->")));
}
#[test]
fn any_rules_marker_present_detects_opencode() {
let home = std::env::temp_dir().join("lc_test_marker_opencode");
let _ = std::fs::remove_dir_all(&home);
let opencode_dir = home.join(".config/opencode");
std::fs::create_dir_all(&opencode_dir).unwrap();
std::fs::write(
opencode_dir.join("AGENTS.md"),
format!("# preamble\n\n{}\n", shared_content()),
)
.unwrap();
assert!(
any_rules_marker_present(&home),
"OpenCode AGENTS.md with the lean-ctx marker must be detected"
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn claude_md_block_counts_as_installed_guidance() {
let _guard = crate::core::data_dir::test_env_lock();
let home = tempfile::tempdir().unwrap();
with_temp_home(home.path(), || {
assert!(!host_guidance_present(home.path()));
std::fs::create_dir_all(home.path().join(".claude")).unwrap();
std::fs::write(
home.path().join(".claude/CLAUDE.md"),
"<!-- lean-ctx -->\n<!-- lean-ctx-claude-v13 -->\n## LeanCTX\n<!-- /lean-ctx -->\n",
)
.unwrap();
assert!(!any_rules_marker_present(home.path()));
assert!(host_guidance_present(home.path()));
});
}
#[test]
fn skill_template_not_empty() {
assert!(!SKILL_TEMPLATE.is_empty(), "`SKILL_TEMPLATE` is empty");
assert!(SKILL_TEMPLATE.contains("lean-ctx"));
}
#[test]
fn skill_targets_count() {
let home = std::path::PathBuf::from("/tmp/fake_home");
let targets = build_skill_targets(&home);
assert_eq!(targets.len(), 8);
}
#[test]
fn install_skill_creates_file() {
let _guard = crate::core::data_dir::test_env_lock();
let _injection_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let home = std::env::temp_dir().join("test_skill_install");
let _ = std::fs::create_dir_all(&home);
let fake_cursor = home.join(".cursor");
let _ = std::fs::create_dir_all(&fake_cursor);
let result = install_skill_for_agent(&home, "cursor");
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, SKILL_TEMPLATE);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn install_skill_idempotent() {
let _guard = crate::core::data_dir::test_env_lock();
let _injection_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let home = std::env::temp_dir().join("test_skill_idempotent");
let _ = std::fs::create_dir_all(&home);
let fake_cursor = home.join(".cursor");
let _ = std::fs::create_dir_all(&fake_cursor);
let p1 = install_skill_for_agent(&home, "cursor").unwrap();
let p2 = install_skill_for_agent(&home, "cursor").unwrap();
assert_eq!(p1, p2);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn install_skill_unknown_agent() {
let home = std::path::PathBuf::from("/tmp/fake_home");
let result = install_skill_for_agent(&home, "unknown_agent");
assert!(result.is_err());
}
#[test]
fn match_agent_name_basic() {
assert!(match_agent_name("cursor", "Cursor"));
assert!(match_agent_name("opencode", "OpenCode"));
assert!(match_agent_name("claude", "Claude Code"));
assert!(match_agent_name("vscode", "VS Code"));
assert!(match_agent_name("copilot", "Copilot CLI"));
assert!(match_agent_name("kiro", "AWS Kiro"));
assert!(match_agent_name("pi", "Pi Coding Agent"));
assert!(match_agent_name("crush", "Crush"));
assert!(match_agent_name("amp", "Amp"));
assert!(match_agent_name("cline", "Cline"));
assert!(match_agent_name("roo", "Roo Code"));
assert!(match_agent_name("trae", "Trae"));
assert!(match_agent_name("amazonq", "Amazon Q Developer"));
assert!(match_agent_name("verdent", "Verdent"));
assert!(match_agent_name("continue", "Continue"));
assert!(match_agent_name("antigravity", "Antigravity"));
assert!(match_agent_name("codebuddy", "CodeBuddy"));
assert!(match_agent_name("gemini", "Gemini CLI"));
assert!(match_agent_name("augment", "Augment"));
assert!(match_agent_name("openclaw", "OpenClaw"));
assert!(match_agent_name("qodercli", "Qoder"));
}
#[test]
fn match_agent_name_no_false_positives() {
assert!(!match_agent_name("cursor", "Claude Code"));
assert!(!match_agent_name("opencode", "Cursor"));
assert!(!match_agent_name("unknown_agent", "Cursor"));
}
#[test]
fn inject_result_tracks_backed_up_files() {
let result = InjectResult {
backed_up: vec!["/tmp/test.md.bak".to_string()],
..Default::default()
};
assert_eq!(result.backed_up.len(), 1);
assert!(
std::path::Path::new(&result.backed_up[0])
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
);
}
#[test]
fn explicit_auto_inject_false_suppresses_freshness_self_heal() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let config_dir = tmp.path().join("config");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
"[setup]\nauto_inject_rules = false\n",
)
.unwrap();
let _config_guard = crate::test_env::EnvVarGuard::set(
"LEAN_CTX_CONFIG_DIR",
config_dir.to_string_lossy().as_ref(),
);
let _injection_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let _scope_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
let target = build_rules_targets(&home, crate::core::config::RulesInjection::Shared)
.into_iter()
.find(|target| target.name == "Cursor")
.expect("Cursor rules target must exist");
std::fs::create_dir_all(target.path.parent().unwrap()).unwrap();
std::fs::write(
&target.path,
format!("{START_MARK}\n<!-- version: 0 -->\nstale rules\n{END_MARK}\n"),
)
.unwrap();
let freshness = check_rules_freshness_at_home("cursor", &home);
assert!(
freshness.is_none(),
"explicit setup.auto_inject_rules=false must suppress automatic stale-rule self-heal, got: {freshness:?}"
);
}
#[test]
fn automatic_rule_maintenance_allows_default_freshness_check() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let config_dir = tmp.path().join("config");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&config_dir).unwrap();
let _config_guard = crate::test_env::EnvVarGuard::set(
"LEAN_CTX_CONFIG_DIR",
config_dir.to_string_lossy().as_ref(),
);
let _injection_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let _scope_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
std::fs::create_dir_all(home.join(".cursor")).unwrap();
std::fs::write(
home.join(".cursor/mcp.json"),
r#"{"mcpServers":{"lean-ctx":{"command":"lean-ctx","args":["mcp"]}}}"#,
)
.unwrap();
let target = build_rules_targets(&home, crate::core::config::RulesInjection::Shared)
.into_iter()
.find(|target| target.name == "Cursor")
.expect("Cursor rules target must exist");
std::fs::create_dir_all(target.path.parent().unwrap()).unwrap();
std::fs::write(
&target.path,
format!("{START_MARK}\n<!-- version: 0 -->\nstale rules\n{END_MARK}\n"),
)
.unwrap();
let freshness = check_rules_freshness_at_home("cursor", &home);
assert!(
freshness.is_some(),
"default auto_inject_rules=None must preserve automatic freshness detection"
);
}
#[test]
fn automatic_rule_maintenance_allows_explicit_true_freshness_check() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let config_dir = tmp.path().join("config");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
"[setup]\nauto_inject_rules = true\n",
)
.unwrap();
let _config_guard = crate::test_env::EnvVarGuard::set(
"LEAN_CTX_CONFIG_DIR",
config_dir.to_string_lossy().as_ref(),
);
let _injection_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let _scope_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
std::fs::create_dir_all(home.join(".cursor")).unwrap();
std::fs::write(
home.join(".cursor/mcp.json"),
r#"{"mcpServers":{"lean-ctx":{"command":"lean-ctx","args":["mcp"]}}}"#,
)
.unwrap();
let target = build_rules_targets(&home, crate::core::config::RulesInjection::Shared)
.into_iter()
.find(|target| target.name == "Cursor")
.expect("Cursor rules target must exist");
std::fs::create_dir_all(target.path.parent().unwrap()).unwrap();
std::fs::write(
&target.path,
format!("{START_MARK}\n<!-- version: 0 -->\nstale rules\n{END_MARK}\n"),
)
.unwrap();
let freshness = check_rules_freshness_at_home("cursor", &home);
assert!(
freshness.is_some(),
"explicit auto_inject_rules=true must preserve automatic freshness detection"
);
}
#[test]
fn project_scope_suppresses_global_freshness_self_heal() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let config_dir = tmp.path().join("config");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
"rules_scope = \"project\"\n",
)
.unwrap();
let _config_guard = crate::test_env::EnvVarGuard::set(
"LEAN_CTX_CONFIG_DIR",
config_dir.to_string_lossy().as_ref(),
);
let _injection_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let _scope_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "project");
let target = build_rules_targets(&home, crate::core::config::RulesInjection::Shared)
.into_iter()
.find(|target| target.name == "Cursor")
.expect("Cursor rules target must exist");
std::fs::create_dir_all(target.path.parent().unwrap()).unwrap();
std::fs::write(
&target.path,
format!("{START_MARK}\n<!-- version: 0 -->\nstale rules\n{END_MARK}\n"),
)
.unwrap();
let freshness = check_rules_freshness_at_home("cursor", &home);
assert!(
freshness.is_none(),
"rules_scope=project must not request a global rules self-heal that inject_all_rules will refuse, got: {freshness:?}"
);
}
#[test]
fn missing_mcp_config_suppresses_freshness_self_heal() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let config_dir = tmp.path().join("config");
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::create_dir_all(&config_dir).unwrap();
let _config_guard = crate::test_env::EnvVarGuard::set(
"LEAN_CTX_CONFIG_DIR",
config_dir.to_string_lossy().as_ref(),
);
let _injection_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let _scope_guard = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
let target = build_rules_targets(&home, crate::core::config::RulesInjection::Shared)
.into_iter()
.find(|target| target.name == "Cursor")
.expect("Cursor rules target must exist");
std::fs::write(
&target.path,
format!("{START_MARK}\n<!-- version: 0 -->\nstale rules\n{END_MARK}\n"),
)
.unwrap();
assert!(
!home.join(".cursor/mcp.json").exists(),
"precondition: Cursor MCP config must be absent"
);
assert!(
!detect::is_mcp_configured(&target, &home),
"precondition: Cursor must be ineligible for rules injection when its MCP config is absent"
);
let freshness = check_rules_freshness_at_home("cursor", &home);
assert!(
freshness.is_none(),
"freshness must not request an auto-heal for a target inject_all_rules will skip because MCP is not configured, got: {freshness:?}"
);
}
#[test]
fn every_rules_target_maps_to_a_selectable_agent_key() {
use crate::core::config::RulesInjection;
let home = tempfile::tempdir().unwrap();
let known = crate::core::editor_registry::known_agent_keys(
&crate::core::editor_registry::build_targets(home.path()),
);
for injection in [RulesInjection::Shared, RulesInjection::Dedicated] {
for target in build_rules_targets(home.path(), injection) {
let key = rules_agent_key(target.name)
.unwrap_or_else(|| panic!("rules target '{}' has no agent key", target.name));
assert!(
known.contains(key),
"rules target '{}' maps to '{key}', which --agents does not accept",
target.name
);
}
}
}
#[test]
fn rules_selection_filter_skips_unselected_tools() {
let _env = crate::core::data_dir::isolated_data_dir();
crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
let home = tempfile::tempdir().unwrap();
std::fs::create_dir_all(home.path().join(".cursor")).unwrap();
std::fs::create_dir_all(home.path().join(".codeium/windsurf")).unwrap();
let only_cursor: Vec<String> = planned_rules_files(home.path(), &|k| k == "cursor")
.into_iter()
.map(|(name, _)| name)
.collect();
assert!(
only_cursor.contains(&"Cursor".to_string()),
"{only_cursor:?}"
);
assert!(
!only_cursor.contains(&"Windsurf".to_string()),
"{only_cursor:?}"
);
let result = inject_rules_where(home.path(), &|_| false);
assert!(
result.injected.is_empty() && result.updated.is_empty() && result.already.is_empty(),
"an empty selection writes no rules: {result:?}"
);
}
fn with_temp_home<T>(home: &std::path::Path, f: impl FnOnce() -> T) -> T {
let keys = [
"HOME",
"CLAUDE_CONFIG_DIR",
"CODEX_HOME",
"LEAN_CTX_MINIMAL",
];
let saved: Vec<_> = keys.iter().map(|k| (*k, std::env::var_os(k))).collect();
crate::test_env::set_var("HOME", home);
crate::test_env::remove_var("CLAUDE_CONFIG_DIR");
crate::test_env::remove_var("CODEX_HOME");
crate::test_env::set_var("LEAN_CTX_MINIMAL", "1");
let out = f();
for (k, v) in saved {
match v {
Some(v) => crate::test_env::set_var(k, v),
None => crate::test_env::remove_var(k),
}
}
out
}
const ANCHOR: &str = "your auto-loaded lean-ctx rules apply";
#[test]
fn each_host_gets_exactly_one_rules_channel() {
let _iso = crate::core::data_dir::isolated_data_dir();
let hosts: &[(&str, Option<&str>, &str)] = &[
("claude-code", None, ".claude/CLAUDE.md"),
(
"cursor-vscode",
Some("Cursor"),
".cursor/rules/lean-ctx.mdc",
),
(
"codex-mcp-client",
Some("Codex CLI"),
".codex/instructions.md",
),
(
"gemini-cli-mcp-client",
Some("Gemini CLI"),
".gemini/GEMINI.md",
),
("opencode", Some("OpenCode"), ".config/opencode/AGENTS.md"),
];
for &(client, target, rel) in hosts {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
with_temp_home(home, || {
let before = crate::instructions::build_instructions_with_client(
crate::core::protocol::CrpMode::Off,
client,
);
assert!(
!before.contains(ANCHOR),
"{client}: no carrier yet:\n{before}"
);
let path = home.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
match target {
None => {
crate::core::claude_md::sync_claude_global_rules_block(
home,
crate::core::hook_mode::recommend_hook_mode("claude"),
)
.unwrap();
}
Some(name) => {
let block = expected_blocks_by_target(home).remove(name).unwrap();
std::fs::write(&path, block).unwrap();
}
}
let after = crate::instructions::build_instructions_with_client(
crate::core::protocol::CrpMode::Off,
client,
);
assert!(
after.contains(ANCHOR),
"{client}: carrier → anchor:\n{after}"
);
assert!(
crate::core::tokens::count_tokens(&after)
< crate::core::tokens::count_tokens(&before),
"{client}: one channel must be cheaper than two"
);
let files = crate::core::rules_overhead::collect_rules_files(home, home);
let dups = crate::core::rules_overhead::duplicate_channels(home, &files, &|_| 400);
assert!(dups.is_empty(), "{client}: one channel only: {dups:?}");
});
}
}
#[test]
fn claude_duplicate_rules_channels_are_detected_and_removed_keeping_user_text() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let project = home.join("work/app");
let legacy = "# lean-ctx — Context Engineering Layer\n\
<!-- lean-ctx-rules-v9 -->\n\n\
PREFER lean-ctx MCP tools.\n\
Write, Delete, Glob → use normally.\n\
<!-- /lean-ctx -->\n\n";
let global_rules = home.join(".claude/rules/lean-ctx.md");
let project_rules = project.join(".claude/rules/lean-ctx.md");
with_temp_home(home, || {
let mode = crate::core::hook_mode::recommend_hook_mode("claude");
std::fs::create_dir_all(home.join(".claude")).unwrap();
std::fs::write(home.join(".claude/CLAUDE.md"), "# my notes\n").unwrap();
crate::core::claude_md::sync_claude_global_rules_block(home, mode).unwrap();
for (path, user) in [
(&global_rules, "## My global precedence\nkeep global\n"),
(&project_rules, "## Tool precedence\nkeep project\n"),
] {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, format!("{legacy}{user}")).unwrap();
}
let files = crate::core::rules_overhead::collect_rules_files(home, &project);
let dups = crate::core::rules_overhead::duplicate_channels(home, &files, &|_| 400);
let claude = dups
.iter()
.find(|d| d.client == "claude")
.expect("claude dup");
assert_eq!(claude.channel_count(), 3, "{claude:?}");
assert_eq!(claude.instruction_tokens, 0, "block present → anchor only");
crate::core::claude_md::sync_claude_global_rules_block(home, mode).unwrap();
crate::core::rules_dedup::auto_apply(home, &project);
let global = std::fs::read_to_string(&global_rules).unwrap();
let local = std::fs::read_to_string(&project_rules).unwrap();
assert_eq!(global, "## My global precedence\nkeep global\n");
assert_eq!(local, "## Tool precedence\nkeep project\n");
assert!(global_rules.with_extension("bak").exists(), "backup kept");
let claude_md = std::fs::read_to_string(home.join(".claude/CLAUDE.md")).unwrap();
assert!(
claude_md.starts_with("# my notes\n"),
"user text kept:\n{claude_md}"
);
let files = crate::core::rules_overhead::collect_rules_files(home, &project);
let dups = crate::core::rules_overhead::duplicate_channels(home, &files, &|_| 400);
assert!(dups.is_empty(), "one channel left: {dups:?}");
});
}
#[test]
fn dedicated_session_hook_is_the_single_rules_channel() {
let _iso = crate::core::data_dir::isolated_data_dir();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
crate::test_env::set_var("LEAN_CTX_RULES_INJECTION", "dedicated");
let (without, with) = with_temp_home(home, || {
let build = || {
crate::instructions::build_instructions_with_client(
crate::core::protocol::CrpMode::Off,
"claude-code",
)
};
let without = build();
std::fs::create_dir_all(home.join(".claude")).unwrap();
std::fs::write(
home.join(".claude/settings.json"),
r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"lean-ctx hook observe"}]}]}}"#,
)
.unwrap();
(without, build())
});
crate::test_env::remove_var("LEAN_CTX_RULES_INJECTION");
assert!(!without.contains(ANCHOR), "no hook → skeleton:\n{without}");
assert!(
with.contains(ANCHOR),
"hook carries the rules → anchor:\n{with}"
);
}