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 and several
149        // other agents auto-load it.
150        // Claude Code reads AGENTS.md only through an `@AGENTS.md` import in
151        // the sibling CLAUDE.md — then the block rides every Claude session too.
152        let agents_readers = if claude_md_imports_agents_md(&d) {
153            vec!["cursor", "codex", "claude"]
154        } else {
155            vec!["cursor", "codex"]
156        };
157        push_rules_file(out.as_mut(), &d.join("AGENTS.md"), agents_readers);
158        push_rules_file(out.as_mut(), &d.join("CLAUDE.md"), vec!["claude"]);
159        push_rules_file(out.as_mut(), &d.join("CODEBUDDY.md"), vec!["codebuddy"]);
160        push_rules_file(out.as_mut(), &d.join("GEMINI.md"), vec!["gemini"]);
161
162        if d == *home {
163            break;
164        }
165        dir = d.parent().map(Path::to_path_buf);
166    }
167
168    // The parent walk can reach directories the global scan already covered
169    // (e.g. ~/.cursor/rules when the walk ends at home) — count each file once.
170    // Canonicalize paths so symlink aliases (e.g. CLAUDE.md → AGENTS.md) are
171    // recognized as the same physical file (#759).
172    let mut seen = std::collections::HashSet::new();
173    out.retain(|f| {
174        let canonical = std::fs::canonicalize(&f.path)
175            .map_or_else(|_| f.path.clone(), |p| p.to_string_lossy().to_string());
176        seen.insert(canonical)
177    });
178
179    out
180}
181
182/// True when `dir/CLAUDE.md` imports the sibling AGENTS.md (`@AGENTS.md`);
183/// Claude Code expands such imports inline at session start.
184fn claude_md_imports_agents_md(dir: &Path) -> bool {
185    std::fs::read_to_string(dir.join("CLAUDE.md")).is_ok_and(|c| {
186        c.lines()
187            .any(|l| matches!(l.trim(), "@AGENTS.md" | "@./AGENTS.md"))
188    })
189}
190
191/// One host whose session receives lean-ctx *rules* (the tool mapping)
192/// through more than one channel (A3: exactly one channel per host).
193#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
194pub struct DuplicateChannel {
195    /// Client key as in [`RulesFileCost::clients`] (`claude`, `cursor`, …).
196    pub client: String,
197    /// Auto-loaded files carrying a lean-ctx rules block: (path, lean-ctx tokens).
198    pub files: Vec<(String, usize)>,
199    /// Tokens of the full MCP instructions skeleton the host still receives
200    /// next to those files; 0 when it gets the one-line anchor.
201    pub instruction_tokens: usize,
202}
203
204impl DuplicateChannel {
205    /// Channels this host pays for every session.
206    #[must_use]
207    pub fn channel_count(&self) -> usize {
208        self.files.len() + usize::from(self.instruction_tokens > 0)
209    }
210
211    /// Tokens billed beyond the cheapest single channel.
212    #[must_use]
213    pub fn excess_tokens(&self) -> usize {
214        let mut costs: Vec<usize> = self.files.iter().map(|(_, t)| *t).collect();
215        if self.instruction_tokens > 0 {
216            costs.push(self.instruction_tokens);
217        }
218        let total: usize = costs.iter().sum();
219        total.saturating_sub(costs.into_iter().max().unwrap_or(0))
220    }
221}
222
223/// The MCP `clientInfo.name` a client key stands for, so the instructions
224/// channel can be priced with the same anchor decision the server makes.
225fn mcp_client_name(client: &str) -> Option<&'static str> {
226    Some(match client {
227        "claude" => "claude-code",
228        "cursor" => "cursor",
229        "codex" => "codex",
230        "gemini" => "gemini-cli",
231        "opencode" => "opencode",
232        "codebuddy" => "codebuddy",
233        _ => return None,
234    })
235}
236
237/// Hosts that load the lean-ctx rules block more than once per session:
238/// several auto-loaded files carrying it, or one such file *plus* the full
239/// MCP instructions skeleton (the server only collapses it to the anchor
240/// when the host's canonical carrier holds the block,
241/// `rules_channel::client_autoloads_rules`). `instruction_tokens_for` prices
242/// the skeleton a client receives (doctor passes the live builder).
243pub fn duplicate_channels(
244    home: &Path,
245    files: &[RulesFileCost],
246    instruction_tokens_for: &dyn Fn(&str) -> usize,
247) -> Vec<DuplicateChannel> {
248    let mut by_client: std::collections::BTreeMap<&'static str, Vec<(String, usize)>> =
249        std::collections::BTreeMap::new();
250    for f in files {
251        let carries_rules = std::fs::read_to_string(&f.path)
252            .is_ok_and(|c| crate::core::rules_channel::carries_rules_block(&c));
253        if !carries_rules {
254            continue;
255        }
256        for c in &f.clients {
257            by_client
258                .entry(c)
259                .or_default()
260                .push((f.path.clone(), f.lean_ctx_tokens));
261        }
262    }
263    by_client
264        .into_iter()
265        .filter_map(|(client, files)| {
266            let instruction_tokens = match mcp_client_name(client) {
267                Some(name) if !crate::core::rules_channel::client_autoloads_rules(name, home) => {
268                    instruction_tokens_for(name)
269                }
270                _ => 0,
271            };
272            let dup = DuplicateChannel {
273                client: client.to_string(),
274                files,
275                instruction_tokens,
276            };
277            (dup.channel_count() > 1).then_some(dup)
278        })
279        .collect()
280}
281
282/// Clients that auto-load more than one file carrying a *full* lean-ctx
283/// payload — the same guidance billed multiple times per session (#578/#684).
284///
285/// Pointer-only files (a thinned `AGENTS.md` / `.cursorrules` that merely
286/// cross-references the canonical source) are not counted: they exist precisely
287/// to avoid duplication and cost only a handful of tokens (#684).
288pub fn duplicate_clients(files: &[RulesFileCost]) -> Vec<(String, usize)> {
289    let mut counts: std::collections::BTreeMap<&'static str, usize> =
290        std::collections::BTreeMap::new();
291    for f in files {
292        if f.lean_ctx_tokens == 0 || !f.carries_full {
293            continue;
294        }
295        for c in &f.clients {
296            *counts.entry(c).or_default() += 1;
297        }
298    }
299    counts
300        .into_iter()
301        .filter(|(_, n)| *n > 1)
302        .map(|(c, n)| (c.to_string(), n))
303        .collect()
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::core::rules_canonical::START_MARK;
310
311    #[test]
312    fn block_tokens_counts_only_marked_regions() {
313        let content = format!(
314            "\
315# Some user rules
316custom stuff here
317
318{}
319## lean-ctx
320Prefer ctx_read over Read.
321{}
322
323more user stuff that is not ours
324",
325            crate::core::rules_canonical::START_MARK,
326            crate::core::rules_canonical::END_MARK,
327        );
328        let ours = lean_ctx_block_tokens(&content);
329        assert!(ours > 0, "must count the marked block");
330        assert!(
331            ours < count_tokens(&content),
332            "must not count unmarked user content"
333        );
334    }
335
336    #[test]
337    fn block_tokens_zero_without_markers() {
338        assert_eq!(lean_ctx_block_tokens("just user rules\nno markers"), 0);
339    }
340
341    #[test]
342    fn block_tokens_handles_canonical_marker_without_end() {
343        // Dedicated files start with the canonical header and the whole
344        // remainder counts as lean-ctx content.
345        let content = format!("{START_MARK}\n<!-- version: 1 -->\n\nrule body\nmore rules\n");
346        assert!(lean_ctx_block_tokens(&content) > 0);
347    }
348
349    #[test]
350    fn duplicates_flag_clients_with_multiple_lean_ctx_sources() {
351        let files = vec![
352            RulesFileCost {
353                path: "a/.cursorrules".into(),
354                file_tokens: 100,
355                lean_ctx_tokens: 50,
356                carries_full: true,
357                clients: vec!["cursor"],
358            },
359            RulesFileCost {
360                path: "a/.cursor/rules/lean-ctx.mdc".into(),
361                file_tokens: 200,
362                lean_ctx_tokens: 200,
363                carries_full: true,
364                clients: vec!["cursor"],
365            },
366            RulesFileCost {
367                path: "a/CLAUDE.md".into(),
368                file_tokens: 80,
369                lean_ctx_tokens: 40,
370                carries_full: true,
371                clients: vec!["claude"],
372            },
373        ];
374        let dups = duplicate_clients(&files);
375        assert_eq!(dups, vec![("cursor".to_string(), 2)]);
376    }
377
378    #[test]
379    fn duplicates_ignore_files_without_lean_ctx_content() {
380        let files = vec![
381            RulesFileCost {
382                path: "a/.cursorrules".into(),
383                file_tokens: 100,
384                lean_ctx_tokens: 0,
385                carries_full: false,
386                clients: vec!["cursor"],
387            },
388            RulesFileCost {
389                path: "a/.cursor/rules/user.mdc".into(),
390                file_tokens: 200,
391                lean_ctx_tokens: 0,
392                carries_full: false,
393                clients: vec!["cursor"],
394            },
395        ];
396        {
397            let found = &duplicate_clients(&files);
398            assert!(found.is_empty(), "{found:?}");
399        }
400    }
401
402    #[test]
403    fn duplicates_ignore_pointer_only_files() {
404        // #684: a thinned AGENTS.md keeps the `<!-- lean-ctx -->` pointer (so
405        // lean_ctx_tokens > 0) but is not a second full source — Cursor's only
406        // full carrier is the global mdc, so there is no duplication.
407        let files = vec![
408            RulesFileCost {
409                path: "a/.cursor/rules/lean-ctx.mdc".into(),
410                file_tokens: 200,
411                lean_ctx_tokens: 200,
412                carries_full: true,
413                clients: vec!["cursor"],
414            },
415            RulesFileCost {
416                path: "a/AGENTS.md".into(),
417                file_tokens: 120,
418                lean_ctx_tokens: 60,
419                carries_full: false,
420                clients: vec!["cursor", "codex"],
421            },
422        ];
423        assert!(
424            duplicate_clients(&files).is_empty(),
425            "pointer-only AGENTS.md must not count as a duplicate source"
426        );
427    }
428
429    #[test]
430    fn collect_walks_parent_chain_and_dedups_nothing_silently() {
431        let tmp = tempfile::tempdir().unwrap();
432        let home = tmp.path();
433        let project = home.join("projects/app");
434        std::fs::create_dir_all(project.join(".cursor/rules")).unwrap();
435        std::fs::create_dir_all(home.join("projects/.cursor/rules")).unwrap();
436
437        std::fs::write(
438            project.join(".cursor/rules/lean-ctx.mdc"),
439            format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
440        )
441        .unwrap();
442        std::fs::write(
443            home.join("projects/.cursor/rules/lean-ctx.mdc"),
444            format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
445        )
446        .unwrap();
447        std::fs::write(
448            project.join("AGENTS.md"),
449            format!(
450                "{}\nx\n{}\n",
451                crate::core::rules_canonical::AGENTS_BLOCK_START,
452                crate::core::rules_canonical::AGENTS_BLOCK_END,
453            ),
454        )
455        .unwrap();
456
457        let files = collect_rules_files(home, &project);
458        assert_eq!(
459            files.len(),
460            3,
461            "project mdc + parent mdc + AGENTS.md: {files:?}"
462        );
463
464        // The two mdc files are full carriers; the AGENTS.md here holds only the
465        // `<!-- lean-ctx -->` pointer, so it is NOT counted as a third source
466        // (#684 — pointers cross-reference, they do not duplicate).
467        let dups = duplicate_clients(&files);
468        assert!(
469            dups.iter().any(|(c, n)| c == "cursor" && *n == 2),
470            "cursor loads 2 full lean-ctx sources (pointer AGENTS.md excluded): {dups:?}"
471        );
472    }
473
474    /// Claude Code auto-loads `.claude/rules/*.md` — the scan must attribute
475    /// those files to the claude client (fixed cost billed every session).
476    #[test]
477    fn collect_includes_project_claude_rules_dir() {
478        // claude_state_dir honours CLAUDE_CONFIG_DIR, which other tests (and a
479        // host such as Claude Code) set: hold the env lock and pin it to the
480        // default location under this test's home while scanning.
481        let _env = crate::core::data_dir::test_env_lock();
482        let tmp = tempfile::tempdir().unwrap();
483        let home = tmp.path();
484        let _claude_dir = crate::test_env::VarGuard::set("CLAUDE_CONFIG_DIR", home.join(".claude"));
485        let project = home.join("projects/app");
486        std::fs::create_dir_all(project.join(".claude/rules")).unwrap();
487        std::fs::write(
488            project.join(".claude/rules/lean-ctx.md"),
489            format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
490        )
491        .unwrap();
492        std::fs::write(project.join(".claude/rules/notes.txt"), "not a rules file").unwrap();
493
494        let files = collect_rules_files(home, &project);
495        assert_eq!(files.len(), 1, "only the .md rules file counts: {files:?}");
496        assert!(files[0].path.ends_with("lean-ctx.md"));
497        assert_eq!(files[0].clients, vec!["claude"]);
498        assert!(files[0].lean_ctx_tokens > 0);
499    }
500
501    #[test]
502    fn collect_counts_each_file_once_when_walk_overlaps_globals() {
503        // Project directly under home: the parent walk ends AT home, whose
504        // .cursor/rules the global scan already covered.
505        let tmp = tempfile::tempdir().unwrap();
506        let home = tmp.path();
507        let project = home.join("app");
508        std::fs::create_dir_all(home.join(".cursor/rules")).unwrap();
509        std::fs::create_dir_all(&project).unwrap();
510        std::fs::write(
511            home.join(".cursor/rules/lean-ctx.mdc"),
512            format!("{START_MARK}\n<!-- version: 1 -->\n\nbody\n"),
513        )
514        .unwrap();
515
516        let files = collect_rules_files(home, &project);
517        let global_count = files
518            .iter()
519            .filter(|f| f.path.ends_with("lean-ctx.mdc"))
520            .count();
521        assert_eq!(
522            global_count, 1,
523            "global mdc must be counted once: {files:?}"
524        );
525    }
526}