1use std::path::Path;
13
14use crate::core::tokens::count_tokens;
15
16#[derive(Debug, Clone, serde::Serialize)]
18pub struct RulesFileCost {
19 pub path: String,
20 pub file_tokens: usize,
22 pub lean_ctx_tokens: usize,
24 pub carries_full: bool,
29 pub clients: Vec<&'static str>,
31}
32
33pub(crate) fn lean_ctx_block_tokens(content: &str) -> usize {
38 let mut tokens = 0;
39 let mut in_block = false;
40 let mut block = String::new();
41 for line in content.lines() {
42 if !in_block
43 && (line.contains("<!-- lean-ctx")
44 || line.contains(crate::core::rules_canonical::START_MARK))
45 {
46 in_block = true;
47 }
48 if in_block {
49 block.push_str(line);
50 block.push('\n');
51 if line.contains("<!-- /lean-ctx") {
52 in_block = false;
53 tokens += count_tokens(&block);
54 block.clear();
55 }
56 }
57 }
58 if !block.is_empty() {
59 tokens += count_tokens(&block);
62 }
63 tokens
64}
65
66fn push_rules_file(out: &mut Vec<RulesFileCost>, path: &Path, clients: Vec<&'static str>) {
67 let Ok(content) = std::fs::read_to_string(path) else {
68 return;
69 };
70 if content.trim().is_empty() {
71 return;
72 }
73 out.push(RulesFileCost {
74 path: path.to_string_lossy().to_string(),
75 file_tokens: count_tokens(&content),
76 lean_ctx_tokens: lean_ctx_block_tokens(&content),
77 carries_full: crate::core::rules_channel::carries_full_rules(&content),
78 clients,
79 });
80}
81
82fn scan_mdc_dir(out: &mut Vec<RulesFileCost>, dir: &Path, clients: &[&'static str]) {
83 let Ok(entries) = std::fs::read_dir(dir) else {
84 return;
85 };
86 for entry in entries.flatten() {
87 let path = entry.path();
88 if path.extension().and_then(|e| e.to_str()) == Some("mdc") {
89 push_rules_file(out, &path, clients.to_vec());
90 }
91 }
92}
93
94fn scan_claude_rules_dir(out: &mut Vec<RulesFileCost>, dir: &Path) {
97 let Ok(entries) = std::fs::read_dir(dir) else {
98 return;
99 };
100 for entry in entries.flatten() {
101 let path = entry.path();
102 if path.extension().and_then(|e| e.to_str()) == Some("md") {
103 push_rules_file(out, &path, vec!["claude"]);
104 }
105 }
106}
107
108pub fn collect_rules_files(home: &Path, project: &Path) -> Vec<RulesFileCost> {
112 let mut out = Vec::new();
113
114 let claude_dir = crate::core::editor_registry::claude_state_dir(home);
117 push_rules_file(out.as_mut(), &claude_dir.join("CLAUDE.md"), vec!["claude"]);
118 scan_claude_rules_dir(out.as_mut(), &claude_dir.join("rules"));
119 push_rules_file(
120 out.as_mut(),
121 &home.join(".codebuddy/CODEBUDDY.md"),
122 vec!["codebuddy"],
123 );
124 push_rules_file(out.as_mut(), &home.join(".codex/AGENTS.md"), vec!["codex"]);
125 push_rules_file(
126 out.as_mut(),
127 &home.join(".gemini/GEMINI.md"),
128 vec!["gemini"],
129 );
130 scan_mdc_dir(out.as_mut(), &home.join(".cursor/rules"), &["cursor"]);
131 push_rules_file(
132 out.as_mut(),
133 &home.join(".config/opencode/AGENTS.md"),
134 vec!["opencode"],
135 );
136 push_rules_file(
137 out.as_mut(),
138 &home.join(".config/opencode/rules/lean-ctx.md"),
139 vec!["opencode"],
140 );
141
142 let mut dir = Some(project.to_path_buf());
144 while let Some(d) = dir {
145 push_rules_file(out.as_mut(), &d.join(".cursorrules"), vec!["cursor"]);
146 scan_mdc_dir(out.as_mut(), &d.join(".cursor/rules"), &["cursor"]);
147 scan_claude_rules_dir(out.as_mut(), &d.join(".claude/rules"));
148 let agents_readers = if claude_md_imports_agents_md(&d) {
153 vec!["cursor", "codex", "claude"]
154 } else {
155 vec!["cursor", "codex"]
156 };
157 push_rules_file(out.as_mut(), &d.join("AGENTS.md"), agents_readers);
158 push_rules_file(out.as_mut(), &d.join("CLAUDE.md"), vec!["claude"]);
159 push_rules_file(out.as_mut(), &d.join("CODEBUDDY.md"), vec!["codebuddy"]);
160 push_rules_file(out.as_mut(), &d.join("GEMINI.md"), vec!["gemini"]);
161
162 if d == *home {
163 break;
164 }
165 dir = d.parent().map(Path::to_path_buf);
166 }
167
168 let mut seen = std::collections::HashSet::new();
173 out.retain(|f| {
174 let canonical = std::fs::canonicalize(&f.path)
175 .map_or_else(|_| f.path.clone(), |p| p.to_string_lossy().to_string());
176 seen.insert(canonical)
177 });
178
179 out
180}
181
182fn claude_md_imports_agents_md(dir: &Path) -> bool {
185 std::fs::read_to_string(dir.join("CLAUDE.md")).is_ok_and(|c| {
186 c.lines()
187 .any(|l| matches!(l.trim(), "@AGENTS.md" | "@./AGENTS.md"))
188 })
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
194pub struct DuplicateChannel {
195 pub client: String,
197 pub files: Vec<(String, usize)>,
199 pub instruction_tokens: usize,
202}
203
204impl DuplicateChannel {
205 #[must_use]
207 pub fn channel_count(&self) -> usize {
208 self.files.len() + usize::from(self.instruction_tokens > 0)
209 }
210
211 #[must_use]
213 pub fn excess_tokens(&self) -> usize {
214 let mut costs: Vec<usize> = self.files.iter().map(|(_, t)| *t).collect();
215 if self.instruction_tokens > 0 {
216 costs.push(self.instruction_tokens);
217 }
218 let total: usize = costs.iter().sum();
219 total.saturating_sub(costs.into_iter().max().unwrap_or(0))
220 }
221}
222
223fn mcp_client_name(client: &str) -> Option<&'static str> {
226 Some(match client {
227 "claude" => "claude-code",
228 "cursor" => "cursor",
229 "codex" => "codex",
230 "gemini" => "gemini-cli",
231 "opencode" => "opencode",
232 "codebuddy" => "codebuddy",
233 _ => return None,
234 })
235}
236
237pub fn duplicate_channels(
244 home: &Path,
245 files: &[RulesFileCost],
246 instruction_tokens_for: &dyn Fn(&str) -> usize,
247) -> Vec<DuplicateChannel> {
248 let mut by_client: std::collections::BTreeMap<&'static str, Vec<(String, usize)>> =
249 std::collections::BTreeMap::new();
250 for f in files {
251 let carries_rules = std::fs::read_to_string(&f.path)
252 .is_ok_and(|c| crate::core::rules_channel::carries_rules_block(&c));
253 if !carries_rules {
254 continue;
255 }
256 for c in &f.clients {
257 by_client
258 .entry(c)
259 .or_default()
260 .push((f.path.clone(), f.lean_ctx_tokens));
261 }
262 }
263 by_client
264 .into_iter()
265 .filter_map(|(client, files)| {
266 let instruction_tokens = match mcp_client_name(client) {
267 Some(name) if !crate::core::rules_channel::client_autoloads_rules(name, home) => {
268 instruction_tokens_for(name)
269 }
270 _ => 0,
271 };
272 let dup = DuplicateChannel {
273 client: client.to_string(),
274 files,
275 instruction_tokens,
276 };
277 (dup.channel_count() > 1).then_some(dup)
278 })
279 .collect()
280}
281
282pub fn duplicate_clients(files: &[RulesFileCost]) -> Vec<(String, usize)> {
289 let mut counts: std::collections::BTreeMap<&'static str, usize> =
290 std::collections::BTreeMap::new();
291 for f in files {
292 if f.lean_ctx_tokens == 0 || !f.carries_full {
293 continue;
294 }
295 for c in &f.clients {
296 *counts.entry(c).or_default() += 1;
297 }
298 }
299 counts
300 .into_iter()
301 .filter(|(_, n)| *n > 1)
302 .map(|(c, n)| (c.to_string(), n))
303 .collect()
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::core::rules_canonical::START_MARK;
310
311 #[test]
312 fn block_tokens_counts_only_marked_regions() {
313 let content = format!(
314 "\
315# Some user rules
316custom stuff here
317
318{}
319## lean-ctx
320Prefer ctx_read over Read.
321{}
322
323more user stuff that is not ours
324",
325 crate::core::rules_canonical::START_MARK,
326 crate::core::rules_canonical::END_MARK,
327 );
328 let ours = lean_ctx_block_tokens(&content);
329 assert!(ours > 0, "must count the marked block");
330 assert!(
331 ours < count_tokens(&content),
332 "must not count unmarked user content"
333 );
334 }
335
336 #[test]
337 fn block_tokens_zero_without_markers() {
338 assert_eq!(lean_ctx_block_tokens("just user rules\nno markers"), 0);
339 }
340
341 #[test]
342 fn block_tokens_handles_canonical_marker_without_end() {
343 let content = format!("{START_MARK}\n<!-- version: 1 -->\n\nrule body\nmore rules\n");
346 assert!(lean_ctx_block_tokens(&content) > 0);
347 }
348
349 #[test]
350 fn duplicates_flag_clients_with_multiple_lean_ctx_sources() {
351 let files = vec![
352 RulesFileCost {
353 path: "a/.cursorrules".into(),
354 file_tokens: 100,
355 lean_ctx_tokens: 50,
356 carries_full: true,
357 clients: vec!["cursor"],
358 },
359 RulesFileCost {
360 path: "a/.cursor/rules/lean-ctx.mdc".into(),
361 file_tokens: 200,
362 lean_ctx_tokens: 200,
363 carries_full: true,
364 clients: vec!["cursor"],
365 },
366 RulesFileCost {
367 path: "a/CLAUDE.md".into(),
368 file_tokens: 80,
369 lean_ctx_tokens: 40,
370 carries_full: true,
371 clients: vec!["claude"],
372 },
373 ];
374 let dups = duplicate_clients(&files);
375 assert_eq!(dups, vec![("cursor".to_string(), 2)]);
376 }
377
378 #[test]
379 fn duplicates_ignore_files_without_lean_ctx_content() {
380 let files = vec![
381 RulesFileCost {
382 path: "a/.cursorrules".into(),
383 file_tokens: 100,
384 lean_ctx_tokens: 0,
385 carries_full: false,
386 clients: vec!["cursor"],
387 },
388 RulesFileCost {
389 path: "a/.cursor/rules/user.mdc".into(),
390 file_tokens: 200,
391 lean_ctx_tokens: 0,
392 carries_full: false,
393 clients: vec!["cursor"],
394 },
395 ];
396 {
397 let found = &duplicate_clients(&files);
398 assert!(found.is_empty(), "{found:?}");
399 }
400 }
401
402 #[test]
403 fn duplicates_ignore_pointer_only_files() {
404 let files = vec![
408 RulesFileCost {
409 path: "a/.cursor/rules/lean-ctx.mdc".into(),
410 file_tokens: 200,
411 lean_ctx_tokens: 200,
412 carries_full: true,
413 clients: vec!["cursor"],
414 },
415 RulesFileCost {
416 path: "a/AGENTS.md".into(),
417 file_tokens: 120,
418 lean_ctx_tokens: 60,
419 carries_full: false,
420 clients: vec!["cursor", "codex"],
421 },
422 ];
423 assert!(
424 duplicate_clients(&files).is_empty(),
425 "pointer-only AGENTS.md must not count as a duplicate source"
426 );
427 }
428
429 #[test]
430 fn collect_walks_parent_chain_and_dedups_nothing_silently() {
431 let tmp = tempfile::tempdir().unwrap();
432 let home = tmp.path();
433 let project = home.join("projects/app");
434 std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
435 std::fs::create_dir_all(home.join("projects/.cursor/rules")).unwrap();
436
437 std::fs::write(
438 project.join(".cursor/rules/lean-ctx.mdc"),
439 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
440 )
441 .unwrap();
442 std::fs::write(
443 home.join("projects/.cursor/rules/lean-ctx.mdc"),
444 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
445 )
446 .unwrap();
447 std::fs::write(
448 project.join("AGENTS.md"),
449 format!(
450 "{}\nx\n{}\n",
451 crate::core::rules_canonical::AGENTS_BLOCK_START,
452 crate::core::rules_canonical::AGENTS_BLOCK_END,
453 ),
454 )
455 .unwrap();
456
457 let files = collect_rules_files(home, &project);
458 assert_eq!(
459 files.len(),
460 3,
461 "project mdc + parent mdc + AGENTS.md: {files:?}"
462 );
463
464 let dups = duplicate_clients(&files);
468 assert!(
469 dups.iter().any(|(c, n)| c == "cursor" && *n == 2),
470 "cursor loads 2 full lean-ctx sources (pointer AGENTS.md excluded): {dups:?}"
471 );
472 }
473
474 #[test]
477 fn collect_includes_project_claude_rules_dir() {
478 let _env = crate::core::data_dir::test_env_lock();
482 let tmp = tempfile::tempdir().unwrap();
483 let home = tmp.path();
484 let _claude_dir = crate::test_env::VarGuard::set("CLAUDE_CONFIG_DIR", home.join(".claude"));
485 let project = home.join("projects/app");
486 std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
487 std::fs::write(
488 project.join(".claude/rules/lean-ctx.md"),
489 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
490 )
491 .unwrap();
492 std::fs::write(project.join(".claude/rules/notes.txt"), "not a rules file").unwrap();
493
494 let files = collect_rules_files(home, &project);
495 assert_eq!(files.len(), 1, "only the .md rules file counts: {files:?}");
496 assert!(files[0].path.ends_with("lean-ctx.md"));
497 assert_eq!(files[0].clients, vec!["claude"]);
498 assert!(files[0].lean_ctx_tokens > 0);
499 }
500
501 #[test]
502 fn collect_counts_each_file_once_when_walk_overlaps_globals() {
503 let tmp = tempfile::tempdir().unwrap();
506 let home = tmp.path();
507 let project = home.join("app");
508 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
509 std::fs::create_dir_all(&project).unwrap();
510 std::fs::write(
511 home.join(".cursor/rules/lean-ctx.mdc"),
512 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
513 )
514 .unwrap();
515
516 let files = collect_rules_files(home, &project);
517 let global_count = files
518 .iter()
519 .filter(|f| f.path.ends_with("lean-ctx.mdc"))
520 .count();
521 assert_eq!(
522 global_count, 1,
523 "global mdc must be counted once: {files:?}"
524 );
525 }
526}