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(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        // 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/// Claude Code auto-loads every `.md` under a project's `.claude/rules/`
95/// directory — same fixed-cost semantics as `.cursor/rules/*.mdc` (#684).
96fn 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
108/// Collects every rules file that an agent auto-loads for work in `project`:
109/// global per-client files, the project root, and the parent chain up to
110/// `home` (Cursor merges parent `.cursor/rules/` and AGENTS.md in monorepos).
111pub fn collect_rules_files(home: &Path, project: &Path) -> Vec<RulesFileCost> {
112    let mut out = Vec::new();
113
114    // Global, per-client. Claude honours `$CLAUDE_CONFIG_DIR` and auto-loads
115    // its global `rules/*.md` exactly like the project ones.
116    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    // Project root + parent chain (stop at home or filesystem root).
143    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        // AGENTS.md is the shared instruction file: Cursor, Codex, Muse Code
149        // (#2198, walks up to the `.git` boundary) and several other agents
150        // auto-load it.
151        // Claude Code reads AGENTS.md only through an `@AGENTS.md` import in
152        // the sibling CLAUDE.md — then the block rides every Claude session too.
153        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    // The parent walk can reach directories the global scan already covered
170    // (e.g. ~/.cursor/rules when the walk ends at home) — count each file once.
171    // Canonicalize paths so symlink aliases (e.g. CLAUDE.md → AGENTS.md) are
172    // recognized as the same physical file (#759).
173    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
183/// True when `dir/CLAUDE.md` imports the sibling AGENTS.md (`@AGENTS.md`);
184/// Claude Code expands such imports inline at session start.
185fn 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/// One host whose session receives lean-ctx *rules* (the tool mapping)
193/// through more than one channel (A3: exactly one channel per host).
194#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
195pub struct DuplicateChannel {
196    /// Client key as in [`RulesFileCost::clients`] (`claude`, `cursor`, …).
197    pub client: String,
198    /// Auto-loaded files carrying a lean-ctx rules block: (path, lean-ctx tokens).
199    pub files: Vec<(String, usize)>,
200    /// Tokens of the full MCP instructions skeleton the host still receives
201    /// next to those files; 0 when it gets the one-line anchor.
202    pub instruction_tokens: usize,
203}
204
205impl DuplicateChannel {
206    /// Channels this host pays for every session.
207    #[must_use]
208    pub fn channel_count(&self) -> usize {
209        self.files.len() + usize::from(self.instruction_tokens > 0)
210    }
211
212    /// Tokens billed beyond the cheapest single channel.
213    #[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
224/// The MCP `clientInfo.name` a client key stands for, so the instructions
225/// channel can be priced with the same anchor decision the server makes.
226fn 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
238/// Hosts that load the lean-ctx rules block more than once per session:
239/// several auto-loaded files carrying it, or one such file *plus* the full
240/// MCP instructions skeleton (the server only collapses it to the anchor
241/// when the host's canonical carrier holds the block,
242/// `rules_channel::client_autoloads_rules`). `instruction_tokens_for` prices
243/// the skeleton a client receives (doctor passes the live builder).
244pub 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
283/// Clients that auto-load more than one file carrying a *full* lean-ctx
284/// payload — the same guidance billed multiple times per session (#578/#684).
285///
286/// Pointer-only files (a thinned `AGENTS.md` / `.cursorrules` that merely
287/// cross-references the canonical source) are not counted: they exist precisely
288/// to avoid duplication and cost only a handful of tokens (#684).
289pub 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        // Dedicated files start with the canonical header and the whole
345        // remainder counts as lean-ctx content.
346        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        // #684: a thinned AGENTS.md keeps the `<!-- lean-ctx -->` pointer (so
406        // lean_ctx_tokens > 0) but is not a second full source — Cursor's only
407        // full carrier is the global mdc, so there is no duplication.
408        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        // The two mdc files are full carriers; the AGENTS.md here holds only the
466        // `<!-- lean-ctx -->` pointer, so it is NOT counted as a third source
467        // (#684 — pointers cross-reference, they do not duplicate).
468        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    /// Claude Code auto-loads `.claude/rules/*.md` — the scan must attribute
476    /// those files to the claude client (fixed cost billed every session).
477    #[test]
478    fn collect_includes_project_claude_rules_dir() {
479        // claude_state_dir honours CLAUDE_CONFIG_DIR, which other tests (and a
480        // host such as Claude Code) set: hold the env lock and pin it to the
481        // default location under this test's home while scanning.
482        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        // Project directly under home: the parent walk ends AT home, whose
505        // .cursor/rules the global scan already covered.
506        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}