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) {
154 vec!["cursor", "codex", "muse", "claude"]
155 } else {
156 vec!["cursor", "codex", "muse"]
157 };
158 push_rules_file(out.as_mut(), &d.join("AGENTS.md"), agents_readers);
159 push_rules_file(out.as_mut(), &d.join("CLAUDE.md"), vec!["claude"]);
160 push_rules_file(out.as_mut(), &d.join("CODEBUDDY.md"), vec!["codebuddy"]);
161 push_rules_file(out.as_mut(), &d.join("GEMINI.md"), vec!["gemini"]);
162
163 if d == *home {
164 break;
165 }
166 dir = d.parent().map(Path::to_path_buf);
167 }
168
169 let mut seen = std::collections::HashSet::new();
174 out.retain(|f| {
175 let canonical = std::fs::canonicalize(&f.path)
176 .map_or_else(|_| f.path.clone(), |p| p.to_string_lossy().to_string());
177 seen.insert(canonical)
178 });
179
180 out
181}
182
183fn claude_md_imports_agents_md(dir: &Path) -> bool {
186 std::fs::read_to_string(dir.join("CLAUDE.md")).is_ok_and(|c| {
187 c.lines()
188 .any(|l| matches!(l.trim(), "@AGENTS.md" | "@./AGENTS.md"))
189 })
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
195pub struct DuplicateChannel {
196 pub client: String,
198 pub files: Vec<(String, usize)>,
200 pub instruction_tokens: usize,
203}
204
205impl DuplicateChannel {
206 #[must_use]
208 pub fn channel_count(&self) -> usize {
209 self.files.len() + usize::from(self.instruction_tokens > 0)
210 }
211
212 #[must_use]
214 pub fn excess_tokens(&self) -> usize {
215 let mut costs: Vec<usize> = self.files.iter().map(|(_, t)| *t).collect();
216 if self.instruction_tokens > 0 {
217 costs.push(self.instruction_tokens);
218 }
219 let total: usize = costs.iter().sum();
220 total.saturating_sub(costs.into_iter().max().unwrap_or(0))
221 }
222}
223
224fn mcp_client_name(client: &str) -> Option<&'static str> {
227 Some(match client {
228 "claude" => "claude-code",
229 "cursor" => "cursor",
230 "codex" => "codex",
231 "gemini" => "gemini-cli",
232 "opencode" => "opencode",
233 "codebuddy" => "codebuddy",
234 _ => return None,
235 })
236}
237
238pub fn duplicate_channels(
245 home: &Path,
246 files: &[RulesFileCost],
247 instruction_tokens_for: &dyn Fn(&str) -> usize,
248) -> Vec<DuplicateChannel> {
249 let mut by_client: std::collections::BTreeMap<&'static str, Vec<(String, usize)>> =
250 std::collections::BTreeMap::new();
251 for f in files {
252 let carries_rules = std::fs::read_to_string(&f.path)
253 .is_ok_and(|c| crate::core::rules_channel::carries_rules_block(&c));
254 if !carries_rules {
255 continue;
256 }
257 for c in &f.clients {
258 by_client
259 .entry(c)
260 .or_default()
261 .push((f.path.clone(), f.lean_ctx_tokens));
262 }
263 }
264 by_client
265 .into_iter()
266 .filter_map(|(client, files)| {
267 let instruction_tokens = match mcp_client_name(client) {
268 Some(name) if !crate::core::rules_channel::client_autoloads_rules(name, home) => {
269 instruction_tokens_for(name)
270 }
271 _ => 0,
272 };
273 let dup = DuplicateChannel {
274 client: client.to_string(),
275 files,
276 instruction_tokens,
277 };
278 (dup.channel_count() > 1).then_some(dup)
279 })
280 .collect()
281}
282
283pub fn duplicate_clients(files: &[RulesFileCost]) -> Vec<(String, usize)> {
290 let mut counts: std::collections::BTreeMap<&'static str, usize> =
291 std::collections::BTreeMap::new();
292 for f in files {
293 if f.lean_ctx_tokens == 0 || !f.carries_full {
294 continue;
295 }
296 for c in &f.clients {
297 *counts.entry(c).or_default() += 1;
298 }
299 }
300 counts
301 .into_iter()
302 .filter(|(_, n)| *n > 1)
303 .map(|(c, n)| (c.to_string(), n))
304 .collect()
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::core::rules_canonical::START_MARK;
311
312 #[test]
313 fn block_tokens_counts_only_marked_regions() {
314 let content = format!(
315 "\
316# Some user rules
317custom stuff here
318
319{}
320## lean-ctx
321Prefer ctx_read over Read.
322{}
323
324more user stuff that is not ours
325",
326 crate::core::rules_canonical::START_MARK,
327 crate::core::rules_canonical::END_MARK,
328 );
329 let ours = lean_ctx_block_tokens(&content);
330 assert!(ours > 0, "must count the marked block");
331 assert!(
332 ours < count_tokens(&content),
333 "must not count unmarked user content"
334 );
335 }
336
337 #[test]
338 fn block_tokens_zero_without_markers() {
339 assert_eq!(lean_ctx_block_tokens("just user rules\nno markers"), 0);
340 }
341
342 #[test]
343 fn block_tokens_handles_canonical_marker_without_end() {
344 let content = format!("{START_MARK}\n<!-- version: 1 -->\n\nrule body\nmore rules\n");
347 assert!(lean_ctx_block_tokens(&content) > 0);
348 }
349
350 #[test]
351 fn duplicates_flag_clients_with_multiple_lean_ctx_sources() {
352 let files = vec![
353 RulesFileCost {
354 path: "a/.cursorrules".into(),
355 file_tokens: 100,
356 lean_ctx_tokens: 50,
357 carries_full: true,
358 clients: vec!["cursor"],
359 },
360 RulesFileCost {
361 path: "a/.cursor/rules/lean-ctx.mdc".into(),
362 file_tokens: 200,
363 lean_ctx_tokens: 200,
364 carries_full: true,
365 clients: vec!["cursor"],
366 },
367 RulesFileCost {
368 path: "a/CLAUDE.md".into(),
369 file_tokens: 80,
370 lean_ctx_tokens: 40,
371 carries_full: true,
372 clients: vec!["claude"],
373 },
374 ];
375 let dups = duplicate_clients(&files);
376 assert_eq!(dups, vec![("cursor".to_string(), 2)]);
377 }
378
379 #[test]
380 fn duplicates_ignore_files_without_lean_ctx_content() {
381 let files = vec![
382 RulesFileCost {
383 path: "a/.cursorrules".into(),
384 file_tokens: 100,
385 lean_ctx_tokens: 0,
386 carries_full: false,
387 clients: vec!["cursor"],
388 },
389 RulesFileCost {
390 path: "a/.cursor/rules/user.mdc".into(),
391 file_tokens: 200,
392 lean_ctx_tokens: 0,
393 carries_full: false,
394 clients: vec!["cursor"],
395 },
396 ];
397 {
398 let found = &duplicate_clients(&files);
399 assert!(found.is_empty(), "{found:?}");
400 }
401 }
402
403 #[test]
404 fn duplicates_ignore_pointer_only_files() {
405 let files = vec![
409 RulesFileCost {
410 path: "a/.cursor/rules/lean-ctx.mdc".into(),
411 file_tokens: 200,
412 lean_ctx_tokens: 200,
413 carries_full: true,
414 clients: vec!["cursor"],
415 },
416 RulesFileCost {
417 path: "a/AGENTS.md".into(),
418 file_tokens: 120,
419 lean_ctx_tokens: 60,
420 carries_full: false,
421 clients: vec!["cursor", "codex"],
422 },
423 ];
424 assert!(
425 duplicate_clients(&files).is_empty(),
426 "pointer-only AGENTS.md must not count as a duplicate source"
427 );
428 }
429
430 #[test]
431 fn collect_walks_parent_chain_and_dedups_nothing_silently() {
432 let tmp = tempfile::tempdir().unwrap();
433 let home = tmp.path();
434 let project = home.join("projects/app");
435 std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
436 std::fs::create_dir_all(home.join("projects/.cursor/rules")).unwrap();
437
438 std::fs::write(
439 project.join(".cursor/rules/lean-ctx.mdc"),
440 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
441 )
442 .unwrap();
443 std::fs::write(
444 home.join("projects/.cursor/rules/lean-ctx.mdc"),
445 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
446 )
447 .unwrap();
448 std::fs::write(
449 project.join("AGENTS.md"),
450 format!(
451 "{}\nx\n{}\n",
452 crate::core::rules_canonical::AGENTS_BLOCK_START,
453 crate::core::rules_canonical::AGENTS_BLOCK_END,
454 ),
455 )
456 .unwrap();
457
458 let files = collect_rules_files(home, &project);
459 assert_eq!(
460 files.len(),
461 3,
462 "project mdc + parent mdc + AGENTS.md: {files:?}"
463 );
464
465 let dups = duplicate_clients(&files);
469 assert!(
470 dups.iter().any(|(c, n)| c == "cursor" && *n == 2),
471 "cursor loads 2 full lean-ctx sources (pointer AGENTS.md excluded): {dups:?}"
472 );
473 }
474
475 #[test]
478 fn collect_includes_project_claude_rules_dir() {
479 let _env = crate::core::data_dir::test_env_lock();
483 let tmp = tempfile::tempdir().unwrap();
484 let home = tmp.path();
485 let _claude_dir = crate::test_env::VarGuard::set("CLAUDE_CONFIG_DIR", home.join(".claude"));
486 let project = home.join("projects/app");
487 std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
488 std::fs::write(
489 project.join(".claude/rules/lean-ctx.md"),
490 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
491 )
492 .unwrap();
493 std::fs::write(project.join(".claude/rules/notes.txt"), "not a rules file").unwrap();
494
495 let files = collect_rules_files(home, &project);
496 assert_eq!(files.len(), 1, "only the .md rules file counts: {files:?}");
497 assert!(files[0].path.ends_with("lean-ctx.md"));
498 assert_eq!(files[0].clients, vec!["claude"]);
499 assert!(files[0].lean_ctx_tokens > 0);
500 }
501
502 #[test]
503 fn collect_counts_each_file_once_when_walk_overlaps_globals() {
504 let tmp = tempfile::tempdir().unwrap();
507 let home = tmp.path();
508 let project = home.join("app");
509 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
510 std::fs::create_dir_all(&project).unwrap();
511 std::fs::write(
512 home.join(".cursor/rules/lean-ctx.mdc"),
513 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
514 )
515 .unwrap();
516
517 let files = collect_rules_files(home, &project);
518 let global_count = files
519 .iter()
520 .filter(|f| f.path.ends_with("lean-ctx.mdc"))
521 .count();
522 assert_eq!(
523 global_count, 1,
524 "global mdc must be counted once: {files:?}"
525 );
526 }
527}