use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
#[derive(Debug, Clone)]
pub(crate) struct DiscoveredRule {
pub source: String,
pub content: String,
}
static INJECTED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
fn already_injected(key: &str) -> bool {
let mut guard = INJECTED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let set = guard.get_or_insert_with(HashSet::new);
!set.insert(key.to_string())
}
fn is_injected(key: &str) -> bool {
INJECTED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.is_some_and(|set| set.contains(key))
}
fn admitted_rule(project_root: &str, source: &str) -> Option<String> {
let path = Path::new(project_root).join(source);
let mut remaining = crate::core::limits::max_read_bytes();
crate::core::rooted_read::read_file_for_tool_rooted_admitted(
path.to_str()?,
project_root,
"ctx_read",
&mut remaining,
)
.ok()
.filter(|read| read.complete && !read.content.trim().is_empty())
.map(|read| read.content)
}
#[cfg(test)]
pub(crate) fn reset_injection_cache() {
let mut guard = INJECTED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = None;
}
const MAX_RULES_SUFFIX_TOKENS: usize = 800;
pub fn rules_suffix_for_read(file_path: &str, project_root: &str, client_id: &str) -> String {
let cfg = crate::core::config::Config::load();
if cfg.declines_rule_steering() || client_natively_injects_rules(client_id) {
return String::new();
}
let rules = discover_rules(file_path, project_root, client_id);
if rules.is_empty() {
return String::new();
}
let mut parts = Vec::new();
let mut token_count = 0;
for rule in &rules {
let key = format!("{}:{}", rule.source, blake3_short(&rule.content));
if is_injected(&key) {
continue;
}
let Some(content) = admitted_rule(project_root, &rule.source) else {
continue;
};
already_injected(&key);
let part = format!("[From: {}]\n{}", rule.source, content.trim());
let part_tokens = crate::core::tokens::count_tokens(&part);
if token_count + part_tokens > MAX_RULES_SUFFIX_TOKENS && !parts.is_empty() {
parts.push(format!(
"[… {} more rule(s) omitted — use auto_inject_rules=false to disable]",
rules.len() - parts.len()
));
break;
}
token_count += part_tokens;
parts.push(part);
}
if parts.is_empty() {
return String::new();
}
format!(
"\n\n--- Rules in scope for this file ---\n{}",
parts.join("\n\n")
)
}
fn client_natively_injects_rules(client_id: &str) -> bool {
let lower = client_id.to_lowercase();
lower.contains("claude") || lower.contains("cursor")
}
static DIR_CACHE: Mutex<Option<HashMap<String, Vec<DiscoveredRule>>>> = Mutex::new(None);
fn discover_rules(file_path: &str, project_root: &str, client_id: &str) -> Vec<DiscoveredRule> {
let file = Path::new(file_path);
let root = Path::new(project_root);
let dir = file.parent().unwrap_or(root);
let cache_key = format!("{client_id}:{}", dir.display());
{
let guard = DIR_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(cache) = guard.as_ref()
&& let Some(cached) = cache.get(&cache_key)
{
return filter_by_path(cached, file_path, project_root);
}
}
let mut rules = Vec::new();
collect_hierarchy_rules(dir, root, &mut rules);
collect_glob_rules(project_root, client_id, &mut rules);
{
let mut guard = DIR_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cache = guard.get_or_insert_with(HashMap::new);
cache.insert(cache_key, rules.clone());
}
filter_by_path(&rules, file_path, project_root)
}
fn collect_hierarchy_rules(dir: &Path, root: &Path, rules: &mut Vec<DiscoveredRule>) {
let mut current = Some(dir);
while let Some(d) = current {
for name in &["CLAUDE.md", "AGENTS.md"] {
let candidate = d.join(name);
if candidate.is_file()
&& let Ok(content) = std::fs::read_to_string(&candidate)
&& !content.trim().is_empty()
{
let relative = candidate.strip_prefix(root).map_or_else(
|_| candidate.display().to_string(),
|p| p.display().to_string(),
);
rules.push(DiscoveredRule {
source: relative,
content,
});
}
}
if d == root {
break;
}
current = d.parent();
}
}
fn collect_glob_rules(project_root: &str, client_id: &str, rules: &mut Vec<DiscoveredRule>) {
let root = Path::new(project_root);
let rule_dirs: Vec<PathBuf> = if client_id.contains("cursor") {
vec![root.join(".cursor/rules")]
} else if client_id.contains("claude") {
vec![root.join(".claude/rules")]
} else {
vec![root.join(".cursor/rules"), root.join(".claude/rules")]
};
for rule_dir in rule_dirs {
if !rule_dir.is_dir() {
continue;
}
let Ok(entries) = std::fs::read_dir(&rule_dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext, "md" | "mdc") {
continue;
}
if let Ok(content) = std::fs::read_to_string(&path) {
if content.trim().is_empty() {
continue;
}
let relative = path
.strip_prefix(root)
.map_or_else(|_| path.display().to_string(), |p| p.display().to_string());
rules.push(DiscoveredRule {
source: relative,
content,
});
}
}
}
}
fn filter_by_path(
rules: &[DiscoveredRule],
file_path: &str,
project_root: &str,
) -> Vec<DiscoveredRule> {
let relative_file = Path::new(file_path)
.strip_prefix(project_root)
.map_or_else(|_| file_path.to_string(), |p| p.display().to_string());
rules
.iter()
.filter(|r| {
let src = &r.source;
if src.ends_with("CLAUDE.md") || src.ends_with("AGENTS.md") {
return true;
}
if is_always_apply(&r.content) {
return false;
}
match extract_globs(&r.content) {
Some(patterns) => patterns.iter().any(|p| glob_matches(p, &relative_file)),
None => false,
}
})
.cloned()
.collect()
}
fn is_always_apply(content: &str) -> bool {
if !content.starts_with("---") {
return false;
}
let Some(end) = content[3..].find("---") else {
return false;
};
let frontmatter = &content[3..3 + end];
frontmatter
.lines()
.any(|l| l.trim().starts_with("alwaysApply:") && l.contains("true"))
}
fn extract_globs(content: &str) -> Option<Vec<String>> {
if !content.starts_with("---") {
return None;
}
let end = content[3..].find("---")?;
let frontmatter = &content[3..3 + end];
let mut patterns = Vec::new();
for line in frontmatter.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed
.strip_prefix("globs:")
.or_else(|| trimmed.strip_prefix("path:"))
{
let pattern = rest.trim().trim_matches('"').trim_matches('\'');
if !pattern.is_empty() {
for p in pattern.split(',') {
let p = p.trim();
if !p.is_empty() {
patterns.push(p.to_string());
}
}
}
}
}
if patterns.is_empty() {
None
} else {
Some(patterns)
}
}
fn glob_matches(pattern: &str, path: &str) -> bool {
if pattern == "*" || pattern == "**/*" || pattern == "**" {
return true;
}
let pattern = pattern.replace('\\', "/");
let path = path.replace('\\', "/");
if let Some(suffix) = pattern.strip_prefix("**/") {
if !suffix.contains('/') {
let filename = path.rsplit('/').next().unwrap_or(&path);
return glob_matches(suffix, filename);
}
let mut remaining = &path[..];
loop {
if glob_matches(suffix, remaining) {
return true;
}
match remaining.find('/') {
Some(pos) => remaining = &remaining[pos + 1..],
None => return false,
}
}
}
if pattern.starts_with("*.") {
let ext = &pattern[1..];
return path.ends_with(ext);
}
if !pattern.contains('*') {
return path == pattern || path.ends_with(&format!("/{pattern}"));
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
let (prefix, suffix) = (parts[0], parts[1]);
return (prefix.is_empty() || path.starts_with(prefix))
&& (suffix.is_empty() || path.ends_with(suffix));
}
path.contains(pattern.trim_matches('*'))
}
fn blake3_short(content: &str) -> String {
let hash = blake3::hash(content.as_bytes());
hash.to_hex()[..8].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
const BANNERED_RULE: &str = "*** TOP SECRET ***\nAlways deploy through the launch window.\n";
const INJECTING: &str = "[setup]\nauto_inject_rules = true\n";
fn isolated(config: &str) -> crate::core::data_dir::IsolatedDataDir {
let data = crate::core::data_dir::isolated_data_dir();
let dir = crate::core::paths::config_dir_read_only().expect("isolated config dir");
std::fs::create_dir_all(&dir).expect("config dir");
std::fs::write(dir.join("config.toml"), config).expect("config");
data
}
fn rule_project(rules: &[(&str, &str)]) -> (tempfile::TempDir, String, String) {
let directory = tempfile::tempdir().expect("project");
let root = directory.path().canonicalize().expect("canonical root");
std::fs::create_dir_all(root.join("src")).expect("source dir");
let file = root.join("src").join("main.rs");
std::fs::write(&file, "fn main() {}\n").expect("source");
for (name, content) in rules {
std::fs::write(root.join(name), content).expect("rule file");
}
let root = root.to_str().expect("utf-8 root").to_owned();
let file = file.to_str().expect("utf-8 file").to_owned();
(directory, root, file)
}
#[test]
fn a_withheld_rule_file_is_absent_from_the_reply() {
let _data = isolated(&format!(
"{INJECTING}[context_gateway]\nclassification = \"block\"\n"
));
let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
let _injection = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let (_directory, root, file) = rule_project(&[("AGENTS.md", BANNERED_RULE)]);
reset_injection_cache();
let suffix = rules_suffix_for_read(&file, &root, "opencode");
assert!(
suffix.is_empty(),
"a withheld rule file must add nothing, got: {suffix:?}"
);
std::fs::write(
Path::new(&root).join("AGENTS.md"),
"Always write regression tests.\n",
)
.expect("declassified rule file");
let suffix = rules_suffix_for_read(&file, &root, "opencode");
assert!(
suffix.contains("Always write regression tests."),
"{suffix:?}"
);
assert!(!suffix.contains("launch window"), "{suffix:?}");
}
#[tokio::test]
async fn an_admitted_rule_file_is_published_as_a_source_of_the_reply() {
let _data = isolated(&format!(
"{INJECTING}[context_gateway]\nmode = \"governed\"\nclassification = \"block\"\n"
));
let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
let _injection = crate::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
let (_directory, root, file) = rule_project(&[
("CLAUDE.md", "Prefer small modules.\n"),
("AGENTS.md", BANNERED_RULE),
]);
reset_injection_cache();
let (suffix, authority) = crate::core::archive::authority::capture(async {
rules_suffix_for_read(&file, &root, "opencode")
})
.await;
assert!(suffix.contains("[From: CLAUDE.md]"), "{suffix:?}");
assert!(suffix.contains("Prefer small modules."), "{suffix:?}");
for withheld in ["launch window", "TOP SECRET", "AGENTS.md"] {
assert!(!suffix.contains(withheld), "{withheld}: {suffix:?}");
}
let authority = authority.expect("the admitted reader published the rule file");
assert!(!authority.is_incomplete(), "the withheld rule is no source");
let admitted = || {
crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
std::cell::RefCell::new(Some(std::path::PathBuf::from(&root))),
|| {
crate::core::policy::runtime::with_source_view(|| authority.admitted())
.unwrap_or(false)
},
)
};
assert!(admitted(), "unchanged rule file: the reply may be reused");
std::fs::write(Path::new(&root).join("CLAUDE.md"), BANNERED_RULE).expect("reclassify");
assert!(!admitted(), "reclassified rule file: no reuse");
}
#[test]
fn explicit_auto_inject_false_suppresses_ctx_read_rule_discovery() {
let _guard = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().expect("tempdir");
let config_dir = tmp.path().join("config");
let project = tmp.path().join("project");
let source_dir = project.join("src");
let file = source_dir.join("main.rs");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::create_dir_all(&source_dir).unwrap();
std::fs::write(&file, "fn main() {}\n").unwrap();
std::fs::write(
project.join("AGENTS.md"),
"# Project rules\nAlways write regression tests.\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");
std::fs::write(
config_dir.join("config.toml"),
"[setup]\nauto_inject_rules = true\n",
)
.unwrap();
reset_injection_cache();
let enabled = rules_suffix_for_read(
file.to_string_lossy().as_ref(),
project.to_string_lossy().as_ref(),
"opencode",
);
assert!(
enabled.contains("Always write regression tests."),
"test precondition failed: enabled rule discovery produced {enabled:?}"
);
std::fs::write(
config_dir.join("config.toml"),
"[setup]\nauto_inject_rules = false\n",
)
.unwrap();
reset_injection_cache();
let disabled = rules_suffix_for_read(
file.to_string_lossy().as_ref(),
project.to_string_lossy().as_ref(),
"opencode",
);
assert!(
disabled.is_empty(),
"explicit auto_inject_rules=false must suppress ctx_read rule discovery, got: {disabled:?}"
);
}
#[test]
fn glob_matches_extension() {
assert!(glob_matches("*.rs", "src/main.rs"));
assert!(glob_matches("*.rs", "main.rs"));
assert!(!glob_matches("*.rs", "src/main.py"));
}
#[test]
fn glob_matches_double_star() {
assert!(glob_matches("**/test_*.rs", "src/tests/test_foo.rs"));
assert!(glob_matches("**/test_*.rs", "test_foo.rs"));
assert!(!glob_matches("**/test_*.rs", "src/foo.rs"));
}
#[test]
fn glob_matches_exact() {
assert!(glob_matches("src/main.rs", "src/main.rs"));
assert!(!glob_matches("src/main.rs", "src/lib.rs"));
}
#[test]
fn extract_globs_cursor_mdc() {
let content = "---\nglobs: \"*.rs, *.toml\"\n---\nSome rule content";
let patterns = extract_globs(content).unwrap();
assert_eq!(patterns, vec!["*.rs", "*.toml"]);
}
#[test]
fn extract_globs_claude_path() {
let content = "---\npath: src/**/*.ts\n---\nRule for TS files";
let patterns = extract_globs(content).unwrap();
assert_eq!(patterns, vec!["src/**/*.ts"]);
}
#[test]
fn extract_globs_no_frontmatter() {
let content = "Just a plain markdown rule file";
assert!(extract_globs(content).is_none());
}
#[test]
fn format_suffix_empty_when_no_rules() {
reset_injection_cache();
let suffix = rules_suffix_for_read("/nonexistent/file.rs", "/nonexistent", "cursor");
assert!(suffix.is_empty(), "{suffix:?}");
}
#[test]
fn dedup_prevents_reinjection() {
reset_injection_cache();
let key = "test:abc12345";
assert!(!already_injected(key));
assert!(already_injected(key));
}
#[test]
fn always_apply_detected() {
let content = "---\ndescription: test\nalwaysApply: true\n---\nRule body";
assert!(is_always_apply(content));
}
#[test]
fn always_apply_false_not_detected() {
let content = "---\ndescription: test\nalwaysApply: false\n---\nRule body";
assert!(!is_always_apply(content));
}
#[test]
fn no_frontmatter_not_always_apply() {
let content = "Just a plain rule without frontmatter";
assert!(!is_always_apply(content));
}
#[test]
fn filter_skips_always_apply_rules() {
let rules = vec![
DiscoveredRule {
source: ".cursor/rules/always.mdc".into(),
content: "---\nalwaysApply: true\n---\nAlready in system prompt".into(),
},
DiscoveredRule {
source: ".cursor/rules/scoped.mdc".into(),
content: "---\nglobs: \"*.rs\"\n---\nRust-scoped rule".into(),
},
DiscoveredRule {
source: ".cursor/rules/no-frontmatter.mdc".into(),
content: "Rule without frontmatter".into(),
},
DiscoveredRule {
source: "AGENTS.md".into(),
content: "Agent instructions".into(),
},
];
let filtered = filter_by_path(&rules, "/project/src/main.rs", "/project");
let sources: Vec<&str> = filtered.iter().map(|r| r.source.as_str()).collect();
assert!(
sources.contains(&"AGENTS.md"),
"hierarchy rules always pass"
);
assert!(
sources.contains(&".cursor/rules/scoped.mdc"),
"glob-matched rules pass"
);
assert!(
!sources.contains(&".cursor/rules/always.mdc"),
"alwaysApply skipped"
);
assert!(
!sources.contains(&".cursor/rules/no-frontmatter.mdc"),
"no-frontmatter skipped"
);
}
#[test]
fn rules_suffix_completes_within_budget() {
reset_injection_cache();
let start = std::time::Instant::now();
for _ in 0..100 {
let _ =
rules_suffix_for_read("/tmp/nonexistent/src/main.rs", "/tmp/nonexistent", "cursor");
}
let elapsed = start.elapsed();
let per_call_us = elapsed.as_micros() / 100;
assert!(
per_call_us < 5_000,
"rule discovery too slow: {per_call_us}μs per call (budget: 5ms)"
);
}
#[test]
fn real_project_overhead_acceptable() {
reset_injection_cache();
let manifest = env!("CARGO_MANIFEST_DIR");
let project_root = std::path::Path::new(manifest)
.parent()
.unwrap()
.to_str()
.unwrap();
let file = format!("{manifest}/src/core/rule_discovery.rs");
let start = std::time::Instant::now();
let suffix = rules_suffix_for_read(&file, project_root, "cursor");
let cold_us = start.elapsed().as_micros();
let start = std::time::Instant::now();
for _ in 0..100 {
reset_injection_cache();
let _ = rules_suffix_for_read(&file, project_root, "cursor");
}
let warm_us = start.elapsed().as_micros() / 100;
eprintln!("\n=== Rule Discovery Performance ===");
eprintln!(
"Cold: {cold_us}μs | Warm: {warm_us}μs | Rules: {} bytes",
suffix.len()
);
assert!(
cold_us < 50_000,
"cold call too slow: {cold_us}μs (budget: 50ms)"
);
assert!(
warm_us < 5_000,
"warm call too slow: {warm_us}μs (budget: 5ms)"
);
}
}