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 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
94pub fn collect_rules_files(home: &Path, project: &Path) -> Vec<RulesFileCost> {
98 let mut out = Vec::new();
99
100 push_rules_file(
102 out.as_mut(),
103 &home.join(".claude/CLAUDE.md"),
104 vec!["claude"],
105 );
106 push_rules_file(
107 out.as_mut(),
108 &home.join(".codebuddy/CODEBUDDY.md"),
109 vec!["codebuddy"],
110 );
111 push_rules_file(out.as_mut(), &home.join(".codex/AGENTS.md"), vec!["codex"]);
112 push_rules_file(
113 out.as_mut(),
114 &home.join(".gemini/GEMINI.md"),
115 vec!["gemini"],
116 );
117 scan_mdc_dir(out.as_mut(), &home.join(".cursor/rules"), &["cursor"]);
118
119 let mut dir = Some(project.to_path_buf());
121 while let Some(d) = dir {
122 push_rules_file(out.as_mut(), &d.join(".cursorrules"), vec!["cursor"]);
123 scan_mdc_dir(out.as_mut(), &d.join(".cursor/rules"), &["cursor"]);
124 push_rules_file(out.as_mut(), &d.join("AGENTS.md"), vec!["cursor", "codex"]);
127 push_rules_file(out.as_mut(), &d.join("CLAUDE.md"), vec!["claude"]);
128 push_rules_file(out.as_mut(), &d.join("CODEBUDDY.md"), vec!["codebuddy"]);
129 push_rules_file(out.as_mut(), &d.join("GEMINI.md"), vec!["gemini"]);
130
131 if d == *home {
132 break;
133 }
134 dir = d.parent().map(Path::to_path_buf);
135 }
136
137 let mut seen = std::collections::HashSet::new();
140 out.retain(|f| seen.insert(f.path.clone()));
141
142 out
143}
144
145pub fn duplicate_clients(files: &[RulesFileCost]) -> Vec<(String, usize)> {
152 let mut counts: std::collections::BTreeMap<&'static str, usize> =
153 std::collections::BTreeMap::new();
154 for f in files {
155 if f.lean_ctx_tokens == 0 || !f.carries_full {
156 continue;
157 }
158 for c in &f.clients {
159 *counts.entry(c).or_default() += 1;
160 }
161 }
162 counts
163 .into_iter()
164 .filter(|(_, n)| *n > 1)
165 .map(|(c, n)| (c.to_string(), n))
166 .collect()
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::core::rules_canonical::START_MARK;
173
174 #[test]
175 fn block_tokens_counts_only_marked_regions() {
176 let content = format!(
177 "\
178# Some user rules
179custom stuff here
180
181{}
182## lean-ctx
183Prefer ctx_read over Read.
184{}
185
186more user stuff that is not ours
187",
188 crate::core::rules_canonical::START_MARK,
189 crate::core::rules_canonical::END_MARK,
190 );
191 let ours = lean_ctx_block_tokens(&content);
192 assert!(ours > 0, "must count the marked block");
193 assert!(
194 ours < count_tokens(&content),
195 "must not count unmarked user content"
196 );
197 }
198
199 #[test]
200 fn block_tokens_zero_without_markers() {
201 assert_eq!(lean_ctx_block_tokens("just user rules\nno markers"), 0);
202 }
203
204 #[test]
205 fn block_tokens_handles_canonical_marker_without_end() {
206 let content = format!("{START_MARK}\n<!-- version: 1 -->\n\nrule body\nmore rules\n");
209 assert!(lean_ctx_block_tokens(&content) > 0);
210 }
211
212 #[test]
213 fn duplicates_flag_clients_with_multiple_lean_ctx_sources() {
214 let files = vec![
215 RulesFileCost {
216 path: "a/.cursorrules".into(),
217 file_tokens: 100,
218 lean_ctx_tokens: 50,
219 carries_full: true,
220 clients: vec!["cursor"],
221 },
222 RulesFileCost {
223 path: "a/.cursor/rules/lean-ctx.mdc".into(),
224 file_tokens: 200,
225 lean_ctx_tokens: 200,
226 carries_full: true,
227 clients: vec!["cursor"],
228 },
229 RulesFileCost {
230 path: "a/CLAUDE.md".into(),
231 file_tokens: 80,
232 lean_ctx_tokens: 40,
233 carries_full: true,
234 clients: vec!["claude"],
235 },
236 ];
237 let dups = duplicate_clients(&files);
238 assert_eq!(dups, vec![("cursor".to_string(), 2)]);
239 }
240
241 #[test]
242 fn duplicates_ignore_files_without_lean_ctx_content() {
243 let files = vec![
244 RulesFileCost {
245 path: "a/.cursorrules".into(),
246 file_tokens: 100,
247 lean_ctx_tokens: 0,
248 carries_full: false,
249 clients: vec!["cursor"],
250 },
251 RulesFileCost {
252 path: "a/.cursor/rules/user.mdc".into(),
253 file_tokens: 200,
254 lean_ctx_tokens: 0,
255 carries_full: false,
256 clients: vec!["cursor"],
257 },
258 ];
259 assert!(duplicate_clients(&files).is_empty());
260 }
261
262 #[test]
263 fn duplicates_ignore_pointer_only_files() {
264 let files = vec![
268 RulesFileCost {
269 path: "a/.cursor/rules/lean-ctx.mdc".into(),
270 file_tokens: 200,
271 lean_ctx_tokens: 200,
272 carries_full: true,
273 clients: vec!["cursor"],
274 },
275 RulesFileCost {
276 path: "a/AGENTS.md".into(),
277 file_tokens: 120,
278 lean_ctx_tokens: 60,
279 carries_full: false,
280 clients: vec!["cursor", "codex"],
281 },
282 ];
283 assert!(
284 duplicate_clients(&files).is_empty(),
285 "pointer-only AGENTS.md must not count as a duplicate source"
286 );
287 }
288
289 #[test]
290 fn collect_walks_parent_chain_and_dedups_nothing_silently() {
291 let tmp = tempfile::tempdir().unwrap();
292 let home = tmp.path();
293 let project = home.join("projects/app");
294 std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
295 std::fs::create_dir_all(home.join("projects/.cursor/rules")).unwrap();
296
297 std::fs::write(
298 project.join(".cursor/rules/lean-ctx.mdc"),
299 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
300 )
301 .unwrap();
302 std::fs::write(
303 home.join("projects/.cursor/rules/lean-ctx.mdc"),
304 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
305 )
306 .unwrap();
307 std::fs::write(
308 project.join("AGENTS.md"),
309 format!(
310 "{}\nx\n{}\n",
311 crate::core::rules_canonical::AGENTS_BLOCK_START,
312 crate::core::rules_canonical::AGENTS_BLOCK_END,
313 ),
314 )
315 .unwrap();
316
317 let files = collect_rules_files(home, &project);
318 assert_eq!(
319 files.len(),
320 3,
321 "project mdc + parent mdc + AGENTS.md: {files:?}"
322 );
323
324 let dups = duplicate_clients(&files);
328 assert!(
329 dups.iter().any(|(c, n)| c == "cursor" && *n == 2),
330 "cursor loads 2 full lean-ctx sources (pointer AGENTS.md excluded): {dups:?}"
331 );
332 }
333
334 #[test]
335 fn collect_counts_each_file_once_when_walk_overlaps_globals() {
336 let tmp = tempfile::tempdir().unwrap();
339 let home = tmp.path();
340 let project = home.join("app");
341 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
342 std::fs::create_dir_all(&project).unwrap();
343 std::fs::write(
344 home.join(".cursor/rules/lean-ctx.mdc"),
345 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
346 )
347 .unwrap();
348
349 let files = collect_rules_files(home, &project);
350 let global_count = files
351 .iter()
352 .filter(|f| f.path.ends_with("lean-ctx.mdc"))
353 .count();
354 assert_eq!(
355 global_count, 1,
356 "global mdc must be counted once: {files:?}"
357 );
358 }
359}