use std::path::Path;
use crate::core::tokens::count_tokens;
#[derive(Debug, Clone, serde::Serialize)]
pub struct RulesFileCost {
pub path: String,
pub file_tokens: usize,
pub lean_ctx_tokens: usize,
pub carries_full: bool,
pub clients: Vec<&'static str>,
}
pub(crate) fn lean_ctx_block_tokens(content: &str) -> usize {
let mut tokens = 0;
let mut in_block = false;
let mut block = String::new();
for line in content.lines() {
if !in_block
&& (line.contains("<!-- lean-ctx")
|| line.contains(crate::core::rules_canonical::START_MARK))
{
in_block = true;
}
if in_block {
block.push_str(line);
block.push('\n');
if line.contains("<!-- /lean-ctx") {
in_block = false;
tokens += count_tokens(&block);
block.clear();
}
}
}
if !block.is_empty() {
tokens += count_tokens(&block);
}
tokens
}
fn push_rules_file(out: &mut Vec<RulesFileCost>, path: &Path, clients: Vec<&'static str>) {
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
if content.trim().is_empty() {
return;
}
out.push(RulesFileCost {
path: path.to_string_lossy().to_string(),
file_tokens: count_tokens(&content),
lean_ctx_tokens: lean_ctx_block_tokens(&content),
carries_full: crate::core::rules_channel::carries_full_rules(&content),
clients,
});
}
fn scan_mdc_dir(out: &mut Vec<RulesFileCost>, dir: &Path, clients: &[&'static str]) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("mdc") {
push_rules_file(out, &path, clients.to_vec());
}
}
}
fn scan_claude_rules_dir(out: &mut Vec<RulesFileCost>, dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
push_rules_file(out, &path, vec!["claude"]);
}
}
}
pub fn collect_rules_files(home: &Path, project: &Path) -> Vec<RulesFileCost> {
let mut out = Vec::new();
let claude_dir = crate::core::editor_registry::claude_state_dir(home);
push_rules_file(out.as_mut(), &claude_dir.join("CLAUDE.md"), vec!["claude"]);
scan_claude_rules_dir(out.as_mut(), &claude_dir.join("rules"));
push_rules_file(
out.as_mut(),
&home.join(".codebuddy/CODEBUDDY.md"),
vec!["codebuddy"],
);
push_rules_file(out.as_mut(), &home.join(".codex/AGENTS.md"), vec!["codex"]);
push_rules_file(
out.as_mut(),
&home.join(".gemini/GEMINI.md"),
vec!["gemini"],
);
scan_mdc_dir(out.as_mut(), &home.join(".cursor/rules"), &["cursor"]);
push_rules_file(
out.as_mut(),
&home.join(".config/opencode/AGENTS.md"),
vec!["opencode"],
);
push_rules_file(
out.as_mut(),
&home.join(".config/opencode/rules/lean-ctx.md"),
vec!["opencode"],
);
let mut dir = Some(project.to_path_buf());
while let Some(d) = dir {
push_rules_file(out.as_mut(), &d.join(".cursorrules"), vec!["cursor"]);
scan_mdc_dir(out.as_mut(), &d.join(".cursor/rules"), &["cursor"]);
scan_claude_rules_dir(out.as_mut(), &d.join(".claude/rules"));
let agents_readers = if claude_md_imports_agents_md(&d) {
vec!["cursor", "codex", "muse", "claude"]
} else {
vec!["cursor", "codex", "muse"]
};
push_rules_file(out.as_mut(), &d.join("AGENTS.md"), agents_readers);
push_rules_file(out.as_mut(), &d.join("CLAUDE.md"), vec!["claude"]);
push_rules_file(out.as_mut(), &d.join("CODEBUDDY.md"), vec!["codebuddy"]);
push_rules_file(out.as_mut(), &d.join("GEMINI.md"), vec!["gemini"]);
if d == *home {
break;
}
dir = d.parent().map(Path::to_path_buf);
}
let mut seen = std::collections::HashSet::new();
out.retain(|f| {
let canonical = std::fs::canonicalize(&f.path)
.map_or_else(|_| f.path.clone(), |p| p.to_string_lossy().to_string());
seen.insert(canonical)
});
out
}
fn claude_md_imports_agents_md(dir: &Path) -> bool {
std::fs::read_to_string(dir.join("CLAUDE.md")).is_ok_and(|c| {
c.lines()
.any(|l| matches!(l.trim(), "@AGENTS.md" | "@./AGENTS.md"))
})
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DuplicateChannel {
pub client: String,
pub files: Vec<(String, usize)>,
pub instruction_tokens: usize,
}
impl DuplicateChannel {
#[must_use]
pub fn channel_count(&self) -> usize {
self.files.len() + usize::from(self.instruction_tokens > 0)
}
#[must_use]
pub fn excess_tokens(&self) -> usize {
let mut costs: Vec<usize> = self.files.iter().map(|(_, t)| *t).collect();
if self.instruction_tokens > 0 {
costs.push(self.instruction_tokens);
}
let total: usize = costs.iter().sum();
total.saturating_sub(costs.into_iter().max().unwrap_or(0))
}
}
fn mcp_client_name(client: &str) -> Option<&'static str> {
Some(match client {
"claude" => "claude-code",
"cursor" => "cursor",
"codex" => "codex",
"gemini" => "gemini-cli",
"opencode" => "opencode",
"codebuddy" => "codebuddy",
_ => return None,
})
}
pub fn duplicate_channels(
home: &Path,
files: &[RulesFileCost],
instruction_tokens_for: &dyn Fn(&str) -> usize,
) -> Vec<DuplicateChannel> {
let mut by_client: std::collections::BTreeMap<&'static str, Vec<(String, usize)>> =
std::collections::BTreeMap::new();
for f in files {
let carries_rules = std::fs::read_to_string(&f.path)
.is_ok_and(|c| crate::core::rules_channel::carries_rules_block(&c));
if !carries_rules {
continue;
}
for c in &f.clients {
by_client
.entry(c)
.or_default()
.push((f.path.clone(), f.lean_ctx_tokens));
}
}
by_client
.into_iter()
.filter_map(|(client, files)| {
let instruction_tokens = match mcp_client_name(client) {
Some(name) if !crate::core::rules_channel::client_autoloads_rules(name, home) => {
instruction_tokens_for(name)
}
_ => 0,
};
let dup = DuplicateChannel {
client: client.to_string(),
files,
instruction_tokens,
};
(dup.channel_count() > 1).then_some(dup)
})
.collect()
}
pub fn duplicate_clients(files: &[RulesFileCost]) -> Vec<(String, usize)> {
let mut counts: std::collections::BTreeMap<&'static str, usize> =
std::collections::BTreeMap::new();
for f in files {
if f.lean_ctx_tokens == 0 || !f.carries_full {
continue;
}
for c in &f.clients {
*counts.entry(c).or_default() += 1;
}
}
counts
.into_iter()
.filter(|(_, n)| *n > 1)
.map(|(c, n)| (c.to_string(), n))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::rules_canonical::START_MARK;
#[test]
fn block_tokens_counts_only_marked_regions() {
let content = format!(
"\
# Some user rules
custom stuff here
{}
## lean-ctx
Prefer ctx_read over Read.
{}
more user stuff that is not ours
",
crate::core::rules_canonical::START_MARK,
crate::core::rules_canonical::END_MARK,
);
let ours = lean_ctx_block_tokens(&content);
assert!(ours > 0, "must count the marked block");
assert!(
ours < count_tokens(&content),
"must not count unmarked user content"
);
}
#[test]
fn block_tokens_zero_without_markers() {
assert_eq!(lean_ctx_block_tokens("just user rules\nno markers"), 0);
}
#[test]
fn block_tokens_handles_canonical_marker_without_end() {
let content = format!("{START_MARK}\n<!-- version: 1 -->\n\nrule body\nmore rules\n");
assert!(lean_ctx_block_tokens(&content) > 0);
}
#[test]
fn duplicates_flag_clients_with_multiple_lean_ctx_sources() {
let files = vec![
RulesFileCost {
path: "a/.cursorrules".into(),
file_tokens: 100,
lean_ctx_tokens: 50,
carries_full: true,
clients: vec!["cursor"],
},
RulesFileCost {
path: "a/.cursor/rules/lean-ctx.mdc".into(),
file_tokens: 200,
lean_ctx_tokens: 200,
carries_full: true,
clients: vec!["cursor"],
},
RulesFileCost {
path: "a/CLAUDE.md".into(),
file_tokens: 80,
lean_ctx_tokens: 40,
carries_full: true,
clients: vec!["claude"],
},
];
let dups = duplicate_clients(&files);
assert_eq!(dups, vec![("cursor".to_string(), 2)]);
}
#[test]
fn duplicates_ignore_files_without_lean_ctx_content() {
let files = vec![
RulesFileCost {
path: "a/.cursorrules".into(),
file_tokens: 100,
lean_ctx_tokens: 0,
carries_full: false,
clients: vec!["cursor"],
},
RulesFileCost {
path: "a/.cursor/rules/user.mdc".into(),
file_tokens: 200,
lean_ctx_tokens: 0,
carries_full: false,
clients: vec!["cursor"],
},
];
{
let found = &duplicate_clients(&files);
assert!(found.is_empty(), "{found:?}");
}
}
#[test]
fn duplicates_ignore_pointer_only_files() {
let files = vec![
RulesFileCost {
path: "a/.cursor/rules/lean-ctx.mdc".into(),
file_tokens: 200,
lean_ctx_tokens: 200,
carries_full: true,
clients: vec!["cursor"],
},
RulesFileCost {
path: "a/AGENTS.md".into(),
file_tokens: 120,
lean_ctx_tokens: 60,
carries_full: false,
clients: vec!["cursor", "codex"],
},
];
assert!(
duplicate_clients(&files).is_empty(),
"pointer-only AGENTS.md must not count as a duplicate source"
);
}
#[test]
fn collect_walks_parent_chain_and_dedups_nothing_silently() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let project = home.join("projects/app");
std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
std::fs::create_dir_all(home.join("projects/.cursor/rules")).unwrap();
std::fs::write(
project.join(".cursor/rules/lean-ctx.mdc"),
format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
)
.unwrap();
std::fs::write(
home.join("projects/.cursor/rules/lean-ctx.mdc"),
format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
)
.unwrap();
std::fs::write(
project.join("AGENTS.md"),
format!(
"{}\nx\n{}\n",
crate::core::rules_canonical::AGENTS_BLOCK_START,
crate::core::rules_canonical::AGENTS_BLOCK_END,
),
)
.unwrap();
let files = collect_rules_files(home, &project);
assert_eq!(
files.len(),
3,
"project mdc + parent mdc + AGENTS.md: {files:?}"
);
let dups = duplicate_clients(&files);
assert!(
dups.iter().any(|(c, n)| c == "cursor" && *n == 2),
"cursor loads 2 full lean-ctx sources (pointer AGENTS.md excluded): {dups:?}"
);
}
#[test]
fn collect_includes_project_claude_rules_dir() {
let _env = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let _claude_dir = crate::test_env::VarGuard::set("CLAUDE_CONFIG_DIR", home.join(".claude"));
let project = home.join("projects/app");
std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
std::fs::write(
project.join(".claude/rules/lean-ctx.md"),
format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
)
.unwrap();
std::fs::write(project.join(".claude/rules/notes.txt"), "not a rules file").unwrap();
let files = collect_rules_files(home, &project);
assert_eq!(files.len(), 1, "only the .md rules file counts: {files:?}");
assert!(files[0].path.ends_with("lean-ctx.md"));
assert_eq!(files[0].clients, vec!["claude"]);
assert!(files[0].lean_ctx_tokens > 0);
}
#[test]
fn collect_counts_each_file_once_when_walk_overlaps_globals() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let project = home.join("app");
std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::write(
home.join(".cursor/rules/lean-ctx.mdc"),
format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
)
.unwrap();
let files = collect_rules_files(home, &project);
let global_count = files
.iter()
.filter(|f| f.path.ends_with("lean-ctx.mdc"))
.count();
assert_eq!(
global_count, 1,
"global mdc must be counted once: {files:?}"
);
}
}