Skip to main content

lean_ctx/core/
rules_overhead.rs

1//! Shared accounting for the rules files an agent auto-loads each session.
2//!
3//! A "rules file" (CLAUDE.md, AGENTS.md, `.cursor/rules/*.mdc`, …) is injected
4//! into every session as fixed context — it costs tokens before lean-ctx saves
5//! anything. This module enumerates those files, attributes the lean-ctx-owned
6//! share of each, and flags clients that load the same guidance more than once.
7//!
8//! Used by `lean-ctx doctor overhead` (fixed-cost board) and `lean-ctx tools
9//! health` (token-budget / rot report, #848). Lives in `core` so neither caller
10//! has to depend on the other.
11
12use std::path::Path;
13
14use crate::core::tokens::count_tokens;
15
16/// One rules file a client auto-loads into context.
17#[derive(Debug, Clone, serde::Serialize)]
18pub struct RulesFileCost {
19    pub path: String,
20    /// Tokens of the whole file (what the client actually injects).
21    pub file_tokens: usize,
22    /// Tokens inside lean-ctx marker blocks (our share of the file).
23    pub lean_ctx_tokens: usize,
24    /// True when the file carries a *full* lean-ctx payload (canonical rules or
25    /// the compression block), as opposed to only the lightweight
26    /// `<!-- lean-ctx -->` pointer. Pointer-only files cross-reference the
27    /// canonical source and do not duplicate guidance (#684).
28    pub carries_full: bool,
29    /// Clients that auto-load this file.
30    pub clients: Vec<&'static str>,
31}
32
33/// Tokens of the lean-ctx-owned portions of a rules file: every block that
34/// starts at a line containing `<!-- lean-ctx` or the canonical rules marker
35/// and ends at `<!-- /lean-ctx... -->` (inclusive). Files without markers
36/// contribute 0 lean-ctx tokens (they still cost their full size).
37pub 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        // Unterminated block (e.g. whole-file rules like .mdc without an end
60        // marker) — count what we collected.
61        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
94/// Collects every rules file that an agent auto-loads for work in `project`:
95/// global per-client files, the project root, and the parent chain up to
96/// `home` (Cursor merges parent `.cursor/rules/` and AGENTS.md in monorepos).
97pub fn collect_rules_files(home: &Path, project: &Path) -> Vec<RulesFileCost> {
98    let mut out = Vec::new();
99
100    // Global, per-client.
101    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    // Project root + parent chain (stop at home or filesystem root).
120    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        // AGENTS.md is the shared instruction file: Cursor, Codex and several
125        // other agents auto-load it.
126        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    // The parent walk can reach directories the global scan already covered
138    // (e.g. ~/.cursor/rules when the walk ends at home) — count each file once.
139    let mut seen = std::collections::HashSet::new();
140    out.retain(|f| seen.insert(f.path.clone()));
141
142    out
143}
144
145/// Clients that auto-load more than one file carrying a *full* lean-ctx
146/// payload — the same guidance billed multiple times per session (#578/#684).
147///
148/// Pointer-only files (a thinned `AGENTS.md` / `.cursorrules` that merely
149/// cross-references the canonical source) are not counted: they exist precisely
150/// to avoid duplication and cost only a handful of tokens (#684).
151pub 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        // Dedicated files start with the canonical header and the whole
207        // remainder counts as lean-ctx content.
208        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        // #684: a thinned AGENTS.md keeps the `<!-- lean-ctx -->` pointer (so
265        // lean_ctx_tokens > 0) but is not a second full source — Cursor's only
266        // full carrier is the global mdc, so there is no duplication.
267        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        // The two mdc files are full carriers; the AGENTS.md here holds only the
325        // `<!-- lean-ctx -->` pointer, so it is NOT counted as a third source
326        // (#684 — pointers cross-reference, they do not duplicate).
327        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        // Project directly under home: the parent walk ends AT home, whose
337        // .cursor/rules the global scan already covered.
338        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}