lean_ctx/core/config/
read_dedup.rs1use serde::{Deserialize, Serialize};
12
13use super::Config;
14
15#[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 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 pub fn effective(config: &Config) -> Self {
48 Self::from_env().unwrap_or(config.read_dedup)
49 }
50
51 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 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(); 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}