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 super::Config;
23
24/// Editable quick-settings a project-local `.lean-ctx.toml` can override via
25/// [`Config::merge_local`]. `structure_first` is intentionally absent: the local
26/// merge never touches it.
27const LOCAL_OVERRIDABLE_KEYS: &[&str] = &["compression_level", "terse_agent", "tool_profile"];
28
29/// Editable quick-settings paired with the environment variable that overrides
30/// each in `effective()`. Keep in sync with the dashboard allow-list and the
31/// per-field `*_effective()` readers.
32const ENV_OVERRIDABLE: &[(&str, &str)] = &[
33    ("compression_level", "LEAN_CTX_COMPRESSION"),
34    ("terse_agent", "LEAN_CTX_TERSE_AGENT"),
35    ("tool_profile", "LEAN_CTX_TOOL_PROFILE"),
36    ("structure_first", "LEAN_CTX_STRUCTURE_FIRST"),
37];
38
39/// An active environment variable shadowing a persisted setting.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct EnvOverride {
42    /// The setting key (e.g. `"compression_level"`).
43    pub setting: &'static str,
44    /// The environment variable currently pinning it (e.g. `"LEAN_CTX_COMPRESSION"`).
45    pub var: &'static str,
46}
47
48/// Where the effective config comes from — the four shadowing mechanisms behind
49/// GH #450, captured in one snapshot.
50#[derive(Debug, Clone)]
51pub struct ConfigProvenance {
52    /// Resolved global `config.toml` path (`None` only when no config base
53    /// resolves, e.g. no `HOME`).
54    pub config_path: Option<PathBuf>,
55    /// Whether that global file currently exists on disk.
56    pub config_exists: bool,
57    /// Whether this install is committed to the XDG four-dir layout.
58    pub xdg_pinned: bool,
59    /// The global-config parse error, if `config.toml` exists but is unparseable
60    /// (mirrors the fallback-to-defaults `Config::load` takes).
61    pub parse_error: Option<String>,
62    /// Resolved project-local `.lean-ctx.toml` path, if a project root resolves.
63    pub local_path: Option<PathBuf>,
64    /// Whether that project-local file exists and is readable.
65    pub local_exists: bool,
66    /// Editable keys the project-local file overrides (subset of
67    /// `compression_level` / `terse_agent` / `tool_profile`).
68    pub local_keys: Vec<&'static str>,
69    /// Active environment overrides among the editable settings.
70    pub env_overrides: Vec<EnvOverride>,
71}
72
73impl ConfigProvenance {
74    /// `true` when at least one shadowing source (env override, project-local
75    /// override, or parse error) could make a saved global value appear to reset.
76    #[must_use]
77    pub fn has_shadow(&self) -> bool {
78        !self.env_overrides.is_empty() || !self.local_keys.is_empty() || self.parse_error.is_some()
79    }
80
81    /// `true` when `setting` is overridden by a project-local `.lean-ctx.toml`.
82    // `contains` would require a `&&'static str` argument; accepting a plain
83    // `&str` keeps the call site lifetime-agnostic, so the manual compare stays.
84    #[must_use]
85    #[allow(clippy::manual_contains)]
86    pub fn local_overrides(&self, setting: &str) -> bool {
87        self.local_keys.iter().any(|k| *k == setting)
88    }
89}
90
91impl Config {
92    /// Snapshot the provenance of the editable settings (GH #450).
93    ///
94    /// Reads the same sources as [`Config::load`] (honoring the `#356` TCC guard
95    /// for the project-local file) plus the live environment, so the result
96    /// matches what a fresh `load()` would resolve. Pure: it never mutates the
97    /// config cache or writes to disk.
98    #[must_use]
99    pub fn provenance() -> ConfigProvenance {
100        let config_path = Self::path();
101        let config_exists = config_path.as_ref().is_some_and(|p| p.exists());
102
103        // Mirror `load()`: a present-but-unparseable global file means the runtime
104        // silently runs on defaults. Parse into `Config` (not a bare `Table`) to
105        // match exactly what `load()` rejects.
106        let parse_error = config_path.as_ref().and_then(|p| {
107            let raw = std::fs::read_to_string(p).ok()?;
108            toml::from_str::<Config>(&raw).err().map(|e| e.to_string())
109        });
110
111        let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
112        let local_content = local_path
113            .as_ref()
114            .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
115            .and_then(|p| std::fs::read_to_string(p).ok());
116        let local_exists = local_content.is_some();
117        let local_keys = local_content
118            .as_deref()
119            .map(local_override_keys)
120            .unwrap_or_default();
121
122        let env_overrides = ENV_OVERRIDABLE
123            .iter()
124            .filter(|(_, var)| env_is_set(var))
125            .map(|&(setting, var)| EnvOverride { setting, var })
126            .collect();
127
128        ConfigProvenance {
129            config_path,
130            config_exists,
131            xdg_pinned: crate::core::layout_pin::is_xdg_pinned(),
132            parse_error,
133            local_path,
134            local_exists,
135            local_keys,
136            env_overrides,
137        }
138    }
139}
140
141/// Editable keys explicitly set in a project-local `.lean-ctx.toml`. Mirrors the
142/// keys [`Config::merge_local`] honors, detected via parsed top-level table keys
143/// (a comment that merely mentions the key does not count).
144fn local_override_keys(local_toml: &str) -> Vec<&'static str> {
145    let Ok(table) = local_toml.parse::<toml::Table>() else {
146        return Vec::new();
147    };
148    LOCAL_OVERRIDABLE_KEYS
149        .iter()
150        .filter(|k| table.contains_key(**k))
151        .copied()
152        .collect()
153}
154
155/// `true` when `var` is set to a non-empty value. Matches the dashboard's
156/// `env_present` so the two surfaces never disagree about an override.
157fn env_is_set(var: &str) -> bool {
158    std::env::var_os(var).is_some_and(|v| !v.is_empty())
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn local_override_keys_detects_editable_keys() {
167        let toml =
168            "compression_level = \"max\"\nterse_agent = \"ultra\"\ntool_profile = \"power\"\n";
169        let keys = local_override_keys(toml);
170        assert!(keys.contains(&"compression_level"));
171        assert!(keys.contains(&"terse_agent"));
172        assert!(keys.contains(&"tool_profile"));
173    }
174
175    #[test]
176    fn local_override_keys_ignores_structure_first_and_unrelated() {
177        // structure_first is not merged from local config, so it must not appear.
178        let toml = "structure_first = true\nultra_compact = true\n";
179        assert!(local_override_keys(toml).is_empty());
180    }
181
182    #[test]
183    fn local_override_keys_ignores_comment_mentions() {
184        // A bare comment mentioning the key must not be reported as an override.
185        let toml = "# compression_level = \"max\" was considered\nultra_compact = false\n";
186        assert!(local_override_keys(toml).is_empty());
187    }
188
189    #[test]
190    fn local_override_keys_empty_on_parse_error() {
191        assert!(local_override_keys("this is = = not toml").is_empty());
192    }
193
194    #[test]
195    fn has_shadow_reflects_each_source() {
196        let clean = ConfigProvenance {
197            config_path: None,
198            config_exists: false,
199            xdg_pinned: true,
200            parse_error: None,
201            local_path: None,
202            local_exists: false,
203            local_keys: vec![],
204            env_overrides: vec![],
205        };
206        assert!(!clean.has_shadow());
207
208        let with_local = ConfigProvenance {
209            local_keys: vec!["compression_level"],
210            ..clean.clone()
211        };
212        assert!(with_local.has_shadow());
213        assert!(with_local.local_overrides("compression_level"));
214        assert!(!with_local.local_overrides("terse_agent"));
215
216        let with_env = ConfigProvenance {
217            env_overrides: vec![EnvOverride {
218                setting: "compression_level",
219                var: "LEAN_CTX_COMPRESSION",
220            }],
221            ..clean.clone()
222        };
223        assert!(with_env.has_shadow());
224
225        let with_parse_err = ConfigProvenance {
226            parse_error: Some("bad".into()),
227            ..clean
228        };
229        assert!(with_parse_err.has_shadow());
230    }
231
232    #[test]
233    fn provenance_reports_active_env_override() {
234        let _guard = crate::core::data_dir::test_env_lock();
235        crate::test_env::set_var("LEAN_CTX_COMPRESSION", "lite");
236        let prov = Config::provenance();
237        crate::test_env::remove_var("LEAN_CTX_COMPRESSION");
238
239        assert!(
240            prov.env_overrides
241                .iter()
242                .any(|e| e.setting == "compression_level" && e.var == "LEAN_CTX_COMPRESSION"),
243            "expected LEAN_CTX_COMPRESSION to be reported as an env override"
244        );
245    }
246}