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    // Canonicalize paths so symlink aliases (e.g. CLAUDE.md → AGENTS.md) are
140    // recognized as the same physical file (#759).
141    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
151/// Clients that auto-load more than one file carrying a *full* lean-ctx
152/// payload — the same guidance billed multiple times per session (#578/#684).
153///
154/// Pointer-only files (a thinned `AGENTS.md` / `.cursorrules` that merely
155/// cross-references the canonical source) are not counted: they exist precisely
156/// to avoid duplication and cost only a handful of tokens (#684).
157pub 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        // Dedicated files start with the canonical header and the whole
213        // remainder counts as lean-ctx content.
214        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        // #684: a thinned AGENTS.md keeps the `<!-- lean-ctx -->` pointer (so
271        // lean_ctx_tokens > 0) but is not a second full source — Cursor's only
272        // full carrier is the global mdc, so there is no duplication.
273        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        // The two mdc files are full carriers; the AGENTS.md here holds only the
331        // `<!-- lean-ctx -->` pointer, so it is NOT counted as a third source
332        // (#684 — pointers cross-reference, they do not duplicate).
333        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        // Project directly under home: the parent walk ends AT home, whose
343        // .cursor/rules the global scan already covered.
344        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}