Skip to main content

lean_ctx/core/config/
provenance.rs

1//! Config provenance (GH #450) — *where does each effective setting come from?*
2//!
3//! The "quick settings keep resetting themselves" reports were impossible to
4//! diagnose because nothing surfaced *which* source actually backed an effective
5//! value. A value typed into the dashboard lands in the global `config.toml`, but
6//! the effective value can be silently shadowed by:
7//!
8//!   - an environment variable (`LEAN_CTX_COMPRESSION`, …) — wins in `effective()`;
9//!   - a project-local `.lean-ctx.toml` — overrides `compression_level`,
10//!     `terse_agent` and `tool_profile` in `Config::merge_local`;
11//!   - a divergent *resolved config dir* (launchd vs. terminal env) — the
12//!     dashboard writes path X while the runtime reads path Y, so the global file
13//!     "does not exist" from the reader's view;
14//!   - a parse error — `load()` falls back to defaults (only a stderr warning).
15//!
16//! [`Config::provenance`] captures all four mechanisms in one snapshot, consumed
17//! by both `lean-ctx config validate` and the dashboard `/api/settings` endpoint
18//! so each becomes visible — and therefore fixable.
19
20use std::path::PathBuf;
21
22use serde::{Deserialize, Serialize};
23
24use super::Config;
25
26/// Controls capture and retention of edit provenance records.
27#[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
57/// Editable quick-settings a project-local `.lean-ctx.toml` can override via
58/// `Config::merge_local`. `structure_first` is intentionally absent: the local
59/// merge never touches it.
60const LOCAL_OVERRIDABLE_KEYS: &[&str] = &["compression_level", "terse_agent", "tool_profile"];
61
62/// Editable quick-settings paired with the environment variable that overrides
63/// each in `effective()`. Keep in sync with the dashboard allow-list and the
64/// per-field `*_effective()` readers.
65const 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/// An active environment variable shadowing a persisted setting.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct EnvOverride {
75    /// The setting key (e.g. `"compression_level"`).
76    pub setting: &'static str,
77    /// The environment variable currently pinning it (e.g. `"LEAN_CTX_COMPRESSION"`).
78    pub var: &'static str,
79}
80
81/// Where the effective config comes from — the four shadowing mechanisms behind
82/// GH #450, captured in one snapshot.
83#[derive(Debug, Clone)]
84pub struct ConfigProvenance {
85    /// Resolved global `config.toml` path (`None` only when no config base
86    /// resolves, e.g. no `HOME`).
87    pub config_path: Option<PathBuf>,
88    /// Whether that global file currently exists on disk.
89    pub config_exists: bool,
90    /// Whether this install is committed to the XDG four-dir layout.
91    pub xdg_pinned: bool,
92    /// The global-config parse error, if `config.toml` exists but is unparseable
93    /// (mirrors the fallback-to-defaults `Config::load` takes).
94    pub parse_error: Option<String>,
95    /// Resolved project-local `.lean-ctx.toml` path, if a project root resolves.
96    pub local_path: Option<PathBuf>,
97    /// Whether that project-local file exists and is readable.
98    pub local_exists: bool,
99    /// Editable keys the project-local file overrides (subset of
100    /// `compression_level` / `terse_agent` / `tool_profile`).
101    pub local_keys: Vec<&'static str>,
102    /// Active environment overrides among the editable settings.
103    pub env_overrides: Vec<EnvOverride>,
104}
105
106impl ConfigProvenance {
107    /// `true` when at least one shadowing source (env override, project-local
108    /// override, or parse error) could make a saved global value appear to reset.
109    #[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    /// `true` when `setting` is overridden by a project-local `.lean-ctx.toml`.
115    // `contains` would require a `&&'static str` argument; accepting a plain
116    // `&str` keeps the call site lifetime-agnostic, so the manual compare stays.
117    #[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    /// Snapshot the provenance of the editable settings (GH #450).
126    ///
127    /// Reads the same sources as [`Config::load`] (honoring the `#356` TCC guard
128    /// for the project-local file) plus the live environment, so the result
129    /// matches what a fresh `load()` would resolve. Pure: it never mutates the
130    /// config cache or writes to disk.
131    #[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        // Mirror `load()`: a present-but-unparseable global file means the runtime
137        // silently runs on defaults. Parse into `Config` (not a bare `Table`) to
138        // match exactly what `load()` rejects.
139        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
174/// Editable keys explicitly set in a project-local `.lean-ctx.toml`. Mirrors the
175/// keys `Config::merge_local` honors, detected via parsed top-level table keys
176/// (a comment that merely mentions the key does not count).
177fn 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
188/// `true` when `var` is set to a non-empty value. Matches the dashboard's
189/// `env_present` so the two surfaces never disagree about an override.
190fn 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        // structure_first is not merged from local config, so it must not appear.
211        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        // A bare comment mentioning the key must not be reported as an override.
221        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/// Cross-agent memory sharing configuration (Hive adaptation).
291#[derive(Clone, Debug, Serialize, Deserialize)]
292pub struct CrossAgentConfig {
293    /// Share knowledge facts across agent sessions.
294    #[serde(default = "default_true")]
295    pub cross_agent_sync: bool,
296    /// Automatically extract decisions/patterns from completed sessions.
297    #[serde(default = "default_true")]
298    pub auto_extract: bool,
299    /// Enable semantic (embedding-based) knowledge retrieval.
300    /// Off by default; requires embedding model download.
301    #[serde(default)]
302    pub semantic_search: bool,
303    /// On-device embedding model for semantic retrieval.
304    #[serde(default = "default_embedding_model")]
305    pub embedding_model: String,
306    /// Maximum knowledge facts extracted per session.
307    #[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}