Skip to main content

lean_ctx/core/config/
read_dedup.rs

1//! Read-dedup control — whether the PostToolUse hook replaces a native Read's
2//! *result* with the compact re-read stub (GL #1140, follow-up to GH #637).
3//!
4//! On guard hosts (Claude Code / CodeBuddy) `read_redirect = auto` keeps the
5//! PreToolUse path-swap off so the native read-before-write guard stays intact —
6//! at the cost of the Read dedup savings. `PostToolUse.updatedToolOutput` restores
7//! them guard-safely: the native Read has already run on the *real* path (guard
8//! satisfied, first read byte-identical), and only the model-visible result of a
9//! **re-read of an unchanged file** is replaced by the stub.
10
11use serde::{Deserialize, Serialize};
12
13use super::Config;
14
15/// Controls the PostToolUse native-Read re-read dedup.
16///
17/// - `Auto`: (Default) dedup only on hosts with a read-before-write guard
18///   (Claude Code / CodeBuddy) — exactly where the PreToolUse redirect is off and
19///   the savings would otherwise be lost. Elsewhere the PreToolUse redirect
20///   already dedups re-reads, so the PostToolUse hook stays passive.
21/// - `On`: dedup wherever the hook fires.
22/// - `Off`: never replace a Read result.
23#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "kebab-case")]
25pub enum ReadDedup {
26    #[default]
27    Auto,
28    On,
29    Off,
30}
31
32impl ReadDedup {
33    /// Parse `LEAN_CTX_READ_DEDUP`. Accepts the canonical `auto|on|off` plus the
34    /// usual boolean spellings, mirroring `LEAN_CTX_READ_REDIRECT`.
35    pub fn from_env() -> Option<Self> {
36        std::env::var("LEAN_CTX_READ_DEDUP").ok().and_then(|v| {
37            match v.trim().to_lowercase().as_str() {
38                "auto" => Some(Self::Auto),
39                "on" | "1" | "true" | "yes" => Some(Self::On),
40                "off" | "0" | "false" | "no" => Some(Self::Off),
41                _ => None,
42            }
43        })
44    }
45
46    /// Env override (`LEAN_CTX_READ_DEDUP`) wins over the on-disk config value.
47    pub fn effective(config: &Config) -> Self {
48        Self::from_env().unwrap_or(config.read_dedup)
49    }
50
51    /// Whether the PostToolUse read-dedup may replace a re-read result in the
52    /// current process/host. `Auto` restricts it to guard hosts, where the
53    /// PreToolUse redirect is disabled and re-reads would otherwise flow at
54    /// full size (#637 / GL #1140).
55    pub fn read_dedup_enabled(config: &Config) -> bool {
56        match Self::effective(config) {
57            Self::On => true,
58            Self::Off => false,
59            Self::Auto => super::read_redirect::host_has_read_before_write_guard(),
60        }
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn default_is_auto() {
70        assert_eq!(ReadDedup::default(), ReadDedup::Auto);
71    }
72
73    #[test]
74    fn serde_roundtrip_kebab() {
75        #[derive(Deserialize)]
76        struct Wrapper {
77            read_dedup: ReadDedup,
78        }
79        for (raw, want) in [
80            ("auto", ReadDedup::Auto),
81            ("on", ReadDedup::On),
82            ("off", ReadDedup::Off),
83        ] {
84            let w: Wrapper = toml::from_str(&format!("read_dedup = \"{raw}\"")).expect("parse");
85            assert_eq!(w.read_dedup, want, "{raw}");
86        }
87    }
88
89    #[test]
90    fn from_env_parses_canonical_and_boolean_spellings() {
91        let _lock = crate::core::data_dir::test_env_lock();
92
93        crate::test_env::set_var("LEAN_CTX_READ_DEDUP", "auto");
94        assert_eq!(ReadDedup::from_env(), Some(ReadDedup::Auto));
95        crate::test_env::set_var("LEAN_CTX_READ_DEDUP", "ON");
96        assert_eq!(ReadDedup::from_env(), Some(ReadDedup::On));
97        crate::test_env::set_var("LEAN_CTX_READ_DEDUP", " off ");
98        assert_eq!(ReadDedup::from_env(), Some(ReadDedup::Off));
99        crate::test_env::set_var("LEAN_CTX_READ_DEDUP", "0");
100        assert_eq!(ReadDedup::from_env(), Some(ReadDedup::Off));
101        crate::test_env::set_var("LEAN_CTX_READ_DEDUP", "nonsense");
102        assert_eq!(ReadDedup::from_env(), None);
103        crate::test_env::remove_var("LEAN_CTX_READ_DEDUP");
104        assert_eq!(ReadDedup::from_env(), None);
105    }
106
107    /// Clear every env var the guard detection reads — including the Cursor
108    /// markers a Cursor agent shell exports itself (GH #720/#722) — so these
109    /// tests are deterministic on any host running `cargo test`.
110    fn clear_host_markers() {
111        for var in [
112            "LEAN_CTX_READ_DEDUP",
113            "CLAUDE_PROJECT_DIR",
114            "CLAUDECODE",
115            "CODEBUDDY",
116            "CURSOR_VERSION",
117            "CURSOR_PROJECT_DIR",
118            "CURSOR_TRANSCRIPT_PATH",
119            "CURSOR_EXTENSION_HOST_ROLE",
120            "CURSOR_AGENT",
121            "CURSOR_TRACE_ID",
122        ] {
123            crate::test_env::remove_var(var);
124        }
125    }
126
127    #[test]
128    fn auto_enables_only_on_guard_hosts() {
129        // Inverse of read_redirect's auto: dedup where the redirect is off.
130        let _lock = crate::core::data_dir::test_env_lock();
131        clear_host_markers();
132
133        let cfg = Config::default(); // Auto
134        assert!(
135            !ReadDedup::read_dedup_enabled(&cfg),
136            "auto must stay passive off guard hosts (PreToolUse redirect dedups there)"
137        );
138
139        crate::test_env::set_var("CLAUDE_PROJECT_DIR", "/repo");
140        assert!(
141            ReadDedup::read_dedup_enabled(&cfg),
142            "auto must dedup under Claude Code hooks (CLAUDE_PROJECT_DIR)"
143        );
144        crate::test_env::remove_var("CLAUDE_PROJECT_DIR");
145
146        crate::test_env::set_var("CODEBUDDY", "1");
147        assert!(
148            ReadDedup::read_dedup_enabled(&cfg),
149            "auto must dedup under CodeBuddy (shared guard contract)"
150        );
151        crate::test_env::remove_var("CODEBUDDY");
152
153        // GH #722: Cursor exports CLAUDE_PROJECT_DIR for Claude-compat, but is
154        // NOT a guard host — the PreToolUse redirect runs there, so the
155        // PostToolUse dedup must stay passive.
156        crate::test_env::set_var("CLAUDE_PROJECT_DIR", "/repo");
157        crate::test_env::set_var("CURSOR_VERSION", "3.7.36");
158        assert!(
159            !ReadDedup::read_dedup_enabled(&cfg),
160            "Cursor must not be treated as a guard host despite CLAUDE_PROJECT_DIR"
161        );
162        clear_host_markers();
163    }
164
165    #[test]
166    fn on_and_off_are_absolute() {
167        let _lock = crate::core::data_dir::test_env_lock();
168        clear_host_markers();
169
170        let cfg_on = Config {
171            read_dedup: ReadDedup::On,
172            ..Config::default()
173        };
174        assert!(ReadDedup::read_dedup_enabled(&cfg_on));
175
176        let cfg_off = Config {
177            read_dedup: ReadDedup::Off,
178            ..Config::default()
179        };
180        crate::test_env::set_var("CLAUDE_PROJECT_DIR", "/repo");
181        assert!(!ReadDedup::read_dedup_enabled(&cfg_off));
182        crate::test_env::remove_var("CLAUDE_PROJECT_DIR");
183    }
184}