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    #[test]
108    fn auto_enables_only_on_guard_hosts() {
109        // Inverse of read_redirect's auto: dedup where the redirect is off.
110        let _lock = crate::core::data_dir::test_env_lock();
111        crate::test_env::remove_var("LEAN_CTX_READ_DEDUP");
112        crate::test_env::remove_var("CLAUDE_PROJECT_DIR");
113        crate::test_env::remove_var("CLAUDECODE");
114        crate::test_env::remove_var("CODEBUDDY");
115
116        let cfg = Config::default(); // Auto
117        assert!(
118            !ReadDedup::read_dedup_enabled(&cfg),
119            "auto must stay passive off guard hosts (PreToolUse redirect dedups there)"
120        );
121
122        crate::test_env::set_var("CLAUDE_PROJECT_DIR", "/repo");
123        assert!(
124            ReadDedup::read_dedup_enabled(&cfg),
125            "auto must dedup under Claude Code hooks (CLAUDE_PROJECT_DIR)"
126        );
127        crate::test_env::remove_var("CLAUDE_PROJECT_DIR");
128
129        crate::test_env::set_var("CODEBUDDY", "1");
130        assert!(
131            ReadDedup::read_dedup_enabled(&cfg),
132            "auto must dedup under CodeBuddy (shared guard contract)"
133        );
134        crate::test_env::remove_var("CODEBUDDY");
135    }
136
137    #[test]
138    fn on_and_off_are_absolute() {
139        let _lock = crate::core::data_dir::test_env_lock();
140        crate::test_env::remove_var("LEAN_CTX_READ_DEDUP");
141        crate::test_env::remove_var("CLAUDE_PROJECT_DIR");
142        crate::test_env::remove_var("CLAUDECODE");
143        crate::test_env::remove_var("CODEBUDDY");
144
145        let cfg_on = Config {
146            read_dedup: ReadDedup::On,
147            ..Config::default()
148        };
149        assert!(ReadDedup::read_dedup_enabled(&cfg_on));
150
151        let cfg_off = Config {
152            read_dedup: ReadDedup::Off,
153            ..Config::default()
154        };
155        crate::test_env::set_var("CLAUDE_PROJECT_DIR", "/repo");
156        assert!(!ReadDedup::read_dedup_enabled(&cfg_off));
157        crate::test_env::remove_var("CLAUDE_PROJECT_DIR");
158    }
159}