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();
142 out.retain(|f| {
143 let canonical = std::fs::canonicalize(&f.path)
144 .map_or_else(|_| f.path.clone(), |p| p.to_string_lossy().to_string());
145 seen.insert(canonical)
146 });
147
148 out
149}
150
151pub fn duplicate_clients(files: &[RulesFileCost]) -> Vec<(String, usize)> {
158 let mut counts: std::collections::BTreeMap<&'static str, usize> =
159 std::collections::BTreeMap::new();
160 for f in files {
161 if f.lean_ctx_tokens == 0 || !f.carries_full {
162 continue;
163 }
164 for c in &f.clients {
165 *counts.entry(c).or_default() += 1;
166 }
167 }
168 counts
169 .into_iter()
170 .filter(|(_, n)| *n > 1)
171 .map(|(c, n)| (c.to_string(), n))
172 .collect()
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::core::rules_canonical::START_MARK;
179
180 #[test]
181 fn block_tokens_counts_only_marked_regions() {
182 let content = format!(
183 "\
184# Some user rules
185custom stuff here
186
187{}
188## lean-ctx
189Prefer ctx_read over Read.
190{}
191
192more user stuff that is not ours
193",
194 crate::core::rules_canonical::START_MARK,
195 crate::core::rules_canonical::END_MARK,
196 );
197 let ours = lean_ctx_block_tokens(&content);
198 assert!(ours > 0, "must count the marked block");
199 assert!(
200 ours < count_tokens(&content),
201 "must not count unmarked user content"
202 );
203 }
204
205 #[test]
206 fn block_tokens_zero_without_markers() {
207 assert_eq!(lean_ctx_block_tokens("just user rules\nno markers"), 0);
208 }
209
210 #[test]
211 fn block_tokens_handles_canonical_marker_without_end() {
212 let content = format!("{START_MARK}\n<!-- version: 1 -->\n\nrule body\nmore rules\n");
215 assert!(lean_ctx_block_tokens(&content) > 0);
216 }
217
218 #[test]
219 fn duplicates_flag_clients_with_multiple_lean_ctx_sources() {
220 let files = vec![
221 RulesFileCost {
222 path: "a/.cursorrules".into(),
223 file_tokens: 100,
224 lean_ctx_tokens: 50,
225 carries_full: true,
226 clients: vec!["cursor"],
227 },
228 RulesFileCost {
229 path: "a/.cursor/rules/lean-ctx.mdc".into(),
230 file_tokens: 200,
231 lean_ctx_tokens: 200,
232 carries_full: true,
233 clients: vec!["cursor"],
234 },
235 RulesFileCost {
236 path: "a/CLAUDE.md".into(),
237 file_tokens: 80,
238 lean_ctx_tokens: 40,
239 carries_full: true,
240 clients: vec!["claude"],
241 },
242 ];
243 let dups = duplicate_clients(&files);
244 assert_eq!(dups, vec![("cursor".to_string(), 2)]);
245 }
246
247 #[test]
248 fn duplicates_ignore_files_without_lean_ctx_content() {
249 let files = vec![
250 RulesFileCost {
251 path: "a/.cursorrules".into(),
252 file_tokens: 100,
253 lean_ctx_tokens: 0,
254 carries_full: false,
255 clients: vec!["cursor"],
256 },
257 RulesFileCost {
258 path: "a/.cursor/rules/user.mdc".into(),
259 file_tokens: 200,
260 lean_ctx_tokens: 0,
261 carries_full: false,
262 clients: vec!["cursor"],
263 },
264 ];
265 assert!(duplicate_clients(&files).is_empty());
266 }
267
268 #[test]
269 fn duplicates_ignore_pointer_only_files() {
270 let files = vec![
274 RulesFileCost {
275 path: "a/.cursor/rules/lean-ctx.mdc".into(),
276 file_tokens: 200,
277 lean_ctx_tokens: 200,
278 carries_full: true,
279 clients: vec!["cursor"],
280 },
281 RulesFileCost {
282 path: "a/AGENTS.md".into(),
283 file_tokens: 120,
284 lean_ctx_tokens: 60,
285 carries_full: false,
286 clients: vec!["cursor", "codex"],
287 },
288 ];
289 assert!(
290 duplicate_clients(&files).is_empty(),
291 "pointer-only AGENTS.md must not count as a duplicate source"
292 );
293 }
294
295 #[test]
296 fn collect_walks_parent_chain_and_dedups_nothing_silently() {
297 let tmp = tempfile::tempdir().unwrap();
298 let home = tmp.path();
299 let project = home.join("projects/app");
300 std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
301 std::fs::create_dir_all(home.join("projects/.cursor/rules")).unwrap();
302
303 std::fs::write(
304 project.join(".cursor/rules/lean-ctx.mdc"),
305 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
306 )
307 .unwrap();
308 std::fs::write(
309 home.join("projects/.cursor/rules/lean-ctx.mdc"),
310 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
311 )
312 .unwrap();
313 std::fs::write(
314 project.join("AGENTS.md"),
315 format!(
316 "{}\nx\n{}\n",
317 crate::core::rules_canonical::AGENTS_BLOCK_START,
318 crate::core::rules_canonical::AGENTS_BLOCK_END,
319 ),
320 )
321 .unwrap();
322
323 let files = collect_rules_files(home, &project);
324 assert_eq!(
325 files.len(),
326 3,
327 "project mdc + parent mdc + AGENTS.md: {files:?}"
328 );
329
330 let dups = duplicate_clients(&files);
334 assert!(
335 dups.iter().any(|(c, n)| c == "cursor" && *n == 2),
336 "cursor loads 2 full lean-ctx sources (pointer AGENTS.md excluded): {dups:?}"
337 );
338 }
339
340 #[test]
341 fn collect_counts_each_file_once_when_walk_overlaps_globals() {
342 let tmp = tempfile::tempdir().unwrap();
345 let home = tmp.path();
346 let project = home.join("app");
347 std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
348 std::fs::create_dir_all(&project).unwrap();
349 std::fs::write(
350 home.join(".cursor/rules/lean-ctx.mdc"),
351 format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
352 )
353 .unwrap();
354
355 let files = collect_rules_files(home, &project);
356 let global_count = files
357 .iter()
358 .filter(|f| f.path.ends_with("lean-ctx.mdc"))
359 .count();
360 assert_eq!(
361 global_count, 1,
362 "global mdc must be counted once: {files:?}"
363 );
364 }
365}