lean_ctx/core/config/
provenance.rs1use std::path::PathBuf;
21
22use serde::{Deserialize, Serialize};
23
24use super::Config;
25
26#[derive(Serialize, Deserialize, Clone, Debug)]
28#[serde(default)]
29pub struct ProvenanceConfig {
30 #[serde(default = "default_true")]
31 pub enabled: bool,
32 pub retention_days: u64,
33 #[serde(default = "default_true")]
34 pub capture_native_edits: bool,
35 #[serde(default = "default_true")]
36 pub capture_mcp_edits: bool,
37 #[serde(default = "default_true")]
38 pub checkpoint_on_commit: bool,
39}
40
41const fn default_true() -> bool {
42 true
43}
44
45impl Default for ProvenanceConfig {
46 fn default() -> Self {
47 Self {
48 enabled: true,
49 retention_days: 90,
50 capture_native_edits: true,
51 capture_mcp_edits: true,
52 checkpoint_on_commit: true,
53 }
54 }
55}
56
57const LOCAL_OVERRIDABLE_KEYS: &[&str] = &["compression_level", "terse_agent", "tool_profile"];
61
62const ENV_OVERRIDABLE: &[(&str, &str)] = &[
66 ("compression_level", "LEAN_CTX_COMPRESSION"),
67 ("terse_agent", "LEAN_CTX_TERSE_AGENT"),
68 ("tool_profile", "LEAN_CTX_TOOL_PROFILE"),
69 ("structure_first", "LEAN_CTX_STRUCTURE_FIRST"),
70];
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct EnvOverride {
75 pub setting: &'static str,
77 pub var: &'static str,
79}
80
81#[derive(Debug, Clone)]
84pub struct ConfigProvenance {
85 pub config_path: Option<PathBuf>,
88 pub config_exists: bool,
90 pub xdg_pinned: bool,
92 pub parse_error: Option<String>,
95 pub local_path: Option<PathBuf>,
97 pub local_exists: bool,
99 pub local_keys: Vec<&'static str>,
102 pub env_overrides: Vec<EnvOverride>,
104}
105
106impl ConfigProvenance {
107 #[must_use]
110 pub fn has_shadow(&self) -> bool {
111 !self.env_overrides.is_empty() || !self.local_keys.is_empty() || self.parse_error.is_some()
112 }
113
114 #[must_use]
118 #[allow(clippy::manual_contains)]
119 pub fn local_overrides(&self, setting: &str) -> bool {
120 self.local_keys.iter().any(|k| *k == setting)
121 }
122}
123
124impl Config {
125 #[must_use]
132 pub fn provenance() -> ConfigProvenance {
133 let config_path = Self::path();
134 let config_exists = config_path.as_ref().is_some_and(|p| p.exists());
135
136 let parse_error = config_path.as_ref().and_then(|p| {
140 let raw = std::fs::read_to_string(p).ok()?;
141 toml::from_str::<Config>(&raw).err().map(|e| e.to_string())
142 });
143
144 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
145 let local_content = local_path
146 .as_ref()
147 .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
148 .and_then(|p| std::fs::read_to_string(p).ok());
149 let local_exists = local_content.is_some();
150 let local_keys = local_content
151 .as_deref()
152 .map(local_override_keys)
153 .unwrap_or_default();
154
155 let env_overrides = ENV_OVERRIDABLE
156 .iter()
157 .filter(|(_, var)| env_is_set(var))
158 .map(|&(setting, var)| EnvOverride { setting, var })
159 .collect();
160
161 ConfigProvenance {
162 config_path,
163 config_exists,
164 xdg_pinned: crate::core::layout_pin::is_xdg_pinned(),
165 parse_error,
166 local_path,
167 local_exists,
168 local_keys,
169 env_overrides,
170 }
171 }
172}
173
174fn local_override_keys(local_toml: &str) -> Vec<&'static str> {
178 let Ok(table) = local_toml.parse::<toml::Table>() else {
179 return Vec::new();
180 };
181 LOCAL_OVERRIDABLE_KEYS
182 .iter()
183 .filter(|k| table.contains_key(**k))
184 .copied()
185 .collect()
186}
187
188fn env_is_set(var: &str) -> bool {
191 std::env::var_os(var).is_some_and(|v| !v.is_empty())
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn local_override_keys_detects_editable_keys() {
200 let toml =
201 "compression_level = \"max\"\nterse_agent = \"ultra\"\ntool_profile = \"power\"\n";
202 let keys = local_override_keys(toml);
203 assert!(keys.contains(&"compression_level"));
204 assert!(keys.contains(&"terse_agent"));
205 assert!(keys.contains(&"tool_profile"));
206 }
207
208 #[test]
209 fn local_override_keys_ignores_structure_first_and_unrelated() {
210 let toml = "structure_first = true\nultra_compact = true\n";
212 {
213 let found = &local_override_keys(toml);
214 assert!(found.is_empty(), "{found:?}");
215 }
216 }
217
218 #[test]
219 fn local_override_keys_ignores_comment_mentions() {
220 let toml = "# compression_level = \"max\" was considered\nultra_compact = false\n";
222 {
223 let found = &local_override_keys(toml);
224 assert!(found.is_empty(), "{found:?}");
225 }
226 }
227
228 #[test]
229 fn local_override_keys_empty_on_parse_error() {
230 {
231 let found = &local_override_keys("this is = = not toml");
232 assert!(found.is_empty(), "{found:?}");
233 }
234 }
235
236 #[test]
237 fn has_shadow_reflects_each_source() {
238 let clean = ConfigProvenance {
239 config_path: None,
240 config_exists: false,
241 xdg_pinned: true,
242 parse_error: None,
243 local_path: None,
244 local_exists: false,
245 local_keys: vec![],
246 env_overrides: vec![],
247 };
248 assert!(!clean.has_shadow());
249
250 let with_local = ConfigProvenance {
251 local_keys: vec!["compression_level"],
252 ..clean.clone()
253 };
254 assert!(with_local.has_shadow());
255 assert!(with_local.local_overrides("compression_level"));
256 assert!(!with_local.local_overrides("terse_agent"));
257
258 let with_env = ConfigProvenance {
259 env_overrides: vec![EnvOverride {
260 setting: "compression_level",
261 var: "LEAN_CTX_COMPRESSION",
262 }],
263 ..clean.clone()
264 };
265 assert!(with_env.has_shadow());
266
267 let with_parse_err = ConfigProvenance {
268 parse_error: Some("bad".into()),
269 ..clean
270 };
271 assert!(with_parse_err.has_shadow());
272 }
273
274 #[test]
275 fn provenance_reports_active_env_override() {
276 let _guard = crate::core::data_dir::test_env_lock();
277 crate::test_env::set_var("LEAN_CTX_COMPRESSION", "lite");
278 let prov = Config::provenance();
279 crate::test_env::remove_var("LEAN_CTX_COMPRESSION");
280
281 assert!(
282 prov.env_overrides
283 .iter()
284 .any(|e| e.setting == "compression_level" && e.var == "LEAN_CTX_COMPRESSION"),
285 "expected LEAN_CTX_COMPRESSION to be reported as an env override"
286 );
287 }
288}
289
290#[derive(Clone, Debug, Serialize, Deserialize)]
292pub struct CrossAgentConfig {
293 #[serde(default = "default_true")]
295 pub cross_agent_sync: bool,
296 #[serde(default = "default_true")]
298 pub auto_extract: bool,
299 #[serde(default)]
302 pub semantic_search: bool,
303 #[serde(default = "default_embedding_model")]
305 pub embedding_model: String,
306 #[serde(default = "default_max_facts")]
308 pub max_facts_per_session: usize,
309}
310
311impl Default for CrossAgentConfig {
312 fn default() -> Self {
313 Self {
314 cross_agent_sync: true,
315 auto_extract: true,
316 semantic_search: false,
317 embedding_model: default_embedding_model(),
318 max_facts_per_session: default_max_facts(),
319 }
320 }
321}
322
323fn default_embedding_model() -> String {
324 "minilm-l6".to_owned()
325}
326fn default_max_facts() -> usize {
327 50
328}