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