Skip to main content

lean_ctx/core/config/
loader.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Config path resolution and disk loading.
4
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use super::{Config, ConfigCacheSlot, default_shell_allowlist};
9
10const CONFIG_PROFILE_ENV: &str = "LEAN_CTX_CONFIG_PROFILE";
11
12/// Advances each time [`Config::load_arc`] builds a new value: the global or
13/// project config, the profile or the project's trust changed (#2195).
14static CONFIG_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
15
16thread_local! {
17    /// Set while the shell-security policy is merged: no environment-selected
18    /// profile applies to it (`shell_policy`).
19    static IGNORE_ENV_PROFILE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
20}
21
22/// Runs `f` with the environment's config profile ignored.
23pub(super) fn without_env_profile<T>(f: impl FnOnce() -> T) -> T {
24    let previous = IGNORE_ENV_PROFILE.with(|c| c.replace(true));
25    let out = f();
26    IGNORE_ENV_PROFILE.with(|c| c.set(previous));
27    out
28}
29
30pub(super) fn environment_config_profile() -> Option<String> {
31    if IGNORE_ENV_PROFILE.with(std::cell::Cell::get) {
32        return None;
33    }
34    std::env::var(CONFIG_PROFILE_ENV)
35        .ok()
36        .map(|name| name.trim().to_string())
37        .filter(|name| !name.is_empty())
38}
39
40/// Parses a config and recursively applies one named partial overlay. An
41/// explicit selector (normally the environment) wins over `config_profile`.
42pub(super) fn parse_config_with_profile(
43    raw: &str,
44    explicit_profile: Option<&str>,
45) -> Result<Config, String> {
46    resolve_config_table(raw, explicit_profile)?
47        .try_into()
48        .map_err(|error: toml::de::Error| error.to_string())
49}
50
51/// The TOML document `raw` describes once its selected profile overlay is
52/// applied (and legacy compression keys are migrated), before it becomes a
53/// [`Config`]: a merge can see which keys the document sets.
54pub(super) fn resolve_config_table(
55    raw: &str,
56    explicit_profile: Option<&str>,
57) -> Result<toml::Value, String> {
58    let migrated = Config::migrate_v3_compression_document(raw)?;
59    let raw = migrated.as_deref().unwrap_or(raw);
60    let mut value: toml::Value = toml::from_str(raw).map_err(|error| error.to_string())?;
61    super::renamed::apply(
62        value
63            .as_table_mut()
64            .expect("a TOML document always has a root table"),
65    );
66    let configured_profile = value.get("config_profile").and_then(toml::Value::as_str);
67    let selected = explicit_profile
68        .map(str::trim)
69        .filter(|name| !name.is_empty())
70        .or(configured_profile);
71
72    if let Some(name) = selected {
73        let profiles = value
74            .get("profiles")
75            .and_then(toml::Value::as_table)
76            .ok_or_else(|| format!("config profile '{name}' selected but [profiles] is missing"))?;
77        let mut overlay = profiles
78            .get(name)
79            .and_then(toml::Value::as_table)
80            .cloned()
81            .ok_or_else(|| format!("config profile '{name}' is not defined"))?;
82        if overlay.remove("profiles").is_some() || overlay.remove("config_profile").is_some() {
83            return Err(format!(
84                "config profile '{name}' cannot override reserved profile keys"
85            ));
86        }
87        super::renamed::apply(&mut overlay);
88        merge_toml_tables(
89            value
90                .as_table_mut()
91                .expect("a TOML document always has a root table"),
92            overlay,
93        );
94    }
95
96    Ok(value)
97}
98
99fn merge_toml_tables(base: &mut toml::Table, overlay: toml::Table) {
100    for (key, overlay_value) in overlay {
101        match (base.get_mut(&key), overlay_value) {
102            (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
103                merge_toml_tables(base_table, overlay_table);
104            }
105            (_, replacement) => {
106                base.insert(key, replacement);
107            }
108        }
109    }
110}
111
112/// Holds the most recent global `config.toml` parse error, if the file currently
113/// fails to parse. When that happens `Config::load()` silently falls back to the
114/// built-in defaults and only logs to stderr — which is invisible over an MCP/stdio
115/// transport. Recording it here lets callers (e.g. the shell-allowlist diagnostic
116/// and `lean-ctx doctor`) surface "you're on defaults because your config is broken".
117static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
118
119/// Returns the most recent global config parse error, or `None` if the current
120/// `config.toml` parsed successfully (or no config file exists).
121#[must_use]
122pub fn last_config_parse_error() -> Option<String> {
123    LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
124}
125
126fn record_parse_error(err: Option<String>) {
127    if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
128        *guard = err;
129    }
130}
131
132/// Why a loaded [`Config`] holds built-in defaults although a global
133/// `config.toml` is present. Carried on the snapshot itself
134/// ([`Config::load_failure`]), so a decision that depends on "were the
135/// operator's settings applied?" reads it from the very value it uses.
136/// [`last_config_parse_error`] is a diagnostic for humans and must not be used
137/// for that: it is process-global and a later load overwrites it.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ConfigLoadFailure {
140    /// The file exists but could not be read as text: permission denied,
141    /// invalid UTF-8, a directory in its place, or another I/O error. A file
142    /// that is simply absent (clean install) is not a failure.
143    Unreadable,
144    /// The file was read but is not a valid configuration: TOML syntax, a
145    /// field of the wrong type, or a selected profile that is not defined.
146    Invalid,
147}
148
149impl ConfigLoadFailure {
150    /// Stable reason code for refusals and diagnostics.
151    #[must_use]
152    pub fn code(self) -> &'static str {
153        match self {
154            Self::Unreadable => "config.global_unreadable",
155            Self::Invalid => "config.global_invalid",
156        }
157    }
158}
159
160/// Reset every SECURITY-sensitive field of a parsed project-local `Config` back
161/// to its default, returning the names of the ones that actually carried an
162/// override. Used by [`Config::merge_local`] for untrusted workspaces: clearing a
163/// field to its default makes the downstream "== default ⇒ no override" merge
164/// guards skip it automatically, so a single list here gates every sensitive key
165/// without touching the per-field merge arms (security audit #4).
166///
167/// Sensitive = anything that can widen lean-ctx's own boundaries or steer the
168/// agent: the shell allowlist, path-jail roots, proxy upstreams, command
169/// aliases, network passthrough, rules scope/injection, tool surface control
170/// (profile/enabled-list/categories, disabling) and permission inheritance.
171/// Comfort/perf knobs are intentionally NOT listed.
172pub(crate) fn strip_sensitive_overrides(local: &mut Config) -> Vec<&'static str> {
173    let mut withheld: Vec<&'static str> = Vec::new();
174
175    if local.shell_allowlist != default_shell_allowlist() {
176        local.shell_allowlist = default_shell_allowlist();
177        withheld.push("shell_allowlist");
178    }
179    if !local.shell_allowlist_extra.is_empty() {
180        local.shell_allowlist_extra.clear();
181        withheld.push("shell_allowlist_extra");
182    }
183    if !local.allow_paths.is_empty() {
184        local.allow_paths.clear();
185        withheld.push("allow_paths");
186    }
187    if !local.extra_roots.is_empty() {
188        local.extra_roots.clear();
189        withheld.push("extra_roots");
190    }
191    // Shell-redirect write targets widen what lean-ctx may write.
192    if !local.write_allow_paths.is_empty() {
193        local.write_allow_paths.clear();
194        withheld.push("write_allow_paths");
195    }
196    // Read-only roots also resolve reads like extra roots (#475).
197    if !local.read_only_roots.is_empty() {
198        local.read_only_roots.clear();
199        withheld.push("read_only_roots");
200    }
201    // `off` skips the native-read redirect and the redaction it applies.
202    if local.read_redirect == super::ReadRedirect::Off {
203        local.read_redirect = super::ReadRedirect::default();
204        withheld.push("read_redirect");
205    }
206    if !local.allow_symlink_roots.is_empty() {
207        local.allow_symlink_roots.clear();
208        withheld.push("allow_symlink_roots");
209    }
210    // Destinations ctx_shell redirects and downloads may write to.
211    if !local.write_allow_paths.is_empty() {
212        local.write_allow_paths.clear();
213        withheld.push("write_allow_paths");
214    }
215    if !local.custom_aliases.is_empty() {
216        local.custom_aliases.clear();
217        withheld.push("custom_aliases");
218    }
219    if !local.passthrough_urls.is_empty() {
220        local.passthrough_urls.clear();
221        withheld.push("passthrough_urls");
222    }
223    // Paths in `redirect_exclude` skip the native-read redirect, and with it
224    // the redaction that path applies; a cloned repo must not opt itself out.
225    if !local.redirect_exclude.is_empty() {
226        local.redirect_exclude.clear();
227        withheld.push("redirect_exclude");
228    }
229    if local.proxy.anthropic_upstream.is_some()
230        || local.proxy.openai_upstream.is_some()
231        || local.proxy.chatgpt_upstream.is_some()
232        || local.proxy.gemini_upstream.is_some()
233    {
234        local.proxy.anthropic_upstream = None;
235        local.proxy.openai_upstream = None;
236        local.proxy.chatgpt_upstream = None;
237        local.proxy.gemini_upstream = None;
238        withheld.push("proxy.*_upstream");
239    }
240    // `eager` lets background work start language servers, which execute
241    // project code (build scripts, proc macros). Lowering to off/auto is safe.
242    if local.semantic_mode == super::SemanticMode::Eager {
243        local.semantic_mode = super::SemanticMode::default();
244        withheld.push("semantic_mode");
245    }
246    if local.rules_scope.is_some() {
247        local.rules_scope = None;
248        withheld.push("rules_scope");
249    }
250    if local.rules_injection.is_some() {
251        local.rules_injection = None;
252        withheld.push("rules_injection");
253    }
254    if local.permission_inheritance.is_some() {
255        local.permission_inheritance = None;
256        withheld.push("permission_inheritance");
257    }
258    if !local.disabled_tools.is_empty() {
259        local.disabled_tools.clear();
260        withheld.push("disabled_tools");
261    }
262    if local.tool_profile.is_some() {
263        local.tool_profile = None;
264        withheld.push("tool_profile");
265    }
266    if !local.tools_enabled.is_empty() {
267        local.tools_enabled.clear();
268        withheld.push("tools_enabled");
269    }
270    if !local.default_tool_categories.is_empty() {
271        local.default_tool_categories.clear();
272        withheld.push("default_tool_categories");
273    }
274    if !local.index.respect_gitignore {
275        local.index.respect_gitignore = true;
276        withheld.push("index.respect_gitignore");
277    }
278    if !local.shell_allowlist_subcommand_scoping {
279        local.shell_allowlist_subcommand_scoping = true;
280        withheld.push("shell_allowlist_subcommand_scoping");
281    }
282
283    withheld
284}
285
286/// Tell the gateway evidence store the ceiling this configuration gives.
287/// The store's writers run deep inside calls and must not load a
288/// configuration themselves (`receipt_store::capacity`, G09).
289fn note_gateway_store_ceiling(cfg: &Config) {
290    crate::core::context_admission::receipt_store::capacity::note_configured_ceiling(
291        cfg.gateway_store_configured_mb(),
292    );
293}
294
295/// Names of the SECURITY-sensitive overrides a project-local `.lean-ctx.toml`
296/// carries — the keys `strip_sensitive_overrides` would withhold for an
297/// untrusted workspace. Read-only (parses a throwaway `Config`); used by
298/// `lean-ctx trust` to tell the user exactly what trusting will enable.
299#[must_use]
300pub fn local_sensitive_overrides(local_toml: &str) -> Vec<&'static str> {
301    let selected = environment_config_profile();
302    match parse_config_with_profile(local_toml, selected.as_deref()) {
303        Ok(mut parsed) => strip_sensitive_overrides(&mut parsed),
304        Err(_) => Vec::new(),
305    }
306}
307
308/// A string-array key from the raw GLOBAL config table — never the merged
309/// runtime view, so project-local or default values are never persisted
310/// globally.
311pub fn global_string_list(key: &str) -> Vec<String> {
312    let Some(path) = Config::path() else {
313        return Vec::new();
314    };
315    let Ok(raw) = std::fs::read_to_string(&path) else {
316        return Vec::new();
317    };
318    let Ok(table) = raw.parse::<toml::Table>() else {
319        return Vec::new();
320    };
321    table
322        .get(key)
323        .and_then(toml::Value::as_array)
324        .map(|arr| {
325            arr.iter()
326                .filter_map(|v| v.as_str().map(str::to_string))
327                .collect()
328        })
329        .unwrap_or_default()
330}
331
332impl Config {
333    /// Returns the path to the global config file (`$XDG_CONFIG_HOME/lean-ctx/config.toml`).
334    ///
335    /// Resolves the canonical config category without creating directories or
336    /// repairing permissions; existing single-dir installations retain their path.
337    pub fn path() -> Option<PathBuf> {
338        crate::core::paths::config_dir_read_only()
339            .ok()
340            .map(|d| d.join("config.toml"))
341    }
342
343    /// `Some(path)` when the global config the runtime *resolves* does not exist,
344    /// so lean-ctx is silently on built-in defaults. `None` when a config file is
345    /// present (or HOME is unresolvable).
346    ///
347    /// The directory is layout-dependent (XDG `~/.config/lean-ctx` vs legacy
348    /// `~/.lean-ctx` vs `$LEAN_CTX_DATA_DIR`) and an MCP client may launch the
349    /// server in a sandbox/container with a different `$HOME`. An edit made to a
350    /// *different* `config.toml` than this one is silently ignored; the block
351    /// messages use this to say so out loud over MCP, where the stderr path is
352    /// invisible (#540).
353    #[must_use]
354    pub fn missing_config_path() -> Option<PathBuf> {
355        match Self::path() {
356            Some(p) if !p.exists() => Some(p),
357            _ => None,
358        }
359    }
360
361    /// Returns the path to the project-local config override file.
362    pub fn local_path(project_root: &str) -> PathBuf {
363        PathBuf::from(project_root).join(".lean-ctx.toml")
364    }
365
366    /// Resolves the active project root (env override → session → git toplevel →
367    /// cwd), cached for the process. Exposed crate-wide so workspace-trust and the
368    /// CLI agree with config loading on *which* directory a `.lean-ctx.toml`
369    /// belongs to (GH security audit, finding 4).
370    ///
371    /// Inside a request the request's bound project is the root, uncached: a
372    /// server serving several projects must apply each one's own
373    /// `.lean-ctx.toml` and trust, never the first one it happened to resolve
374    /// (AP04-G3).
375    pub fn find_project_root() -> Option<String> {
376        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
377        if let Ok(project) =
378            crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone())
379        {
380            return project.map(|root| root.to_string_lossy().into_owned());
381        }
382        ROOT_CACHE
383            .get_or_init(Self::find_project_root_inner)
384            .clone()
385    }
386
387    fn find_project_root_inner() -> Option<String> {
388        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
389            && !env_root.is_empty()
390        {
391            return Some(env_root);
392        }
393
394        let cwd = std::env::current_dir().ok();
395
396        if let Some(root) =
397            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
398        {
399            let root_path = std::path::Path::new(&root);
400            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
401            // Route the marker probe through the TCC-guarded helper and never
402            // adopt a ~/Documents project root from a launchd-standalone process
403            // (#356): doing so would later stat its `.lean-ctx.toml`/markers and
404            // pop the macOS privacy prompt in lean-ctx's own name.
405            let has_marker = crate::core::pathutil::has_project_marker(root_path);
406
407            if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
408            {
409                return Some(root);
410            }
411        }
412
413        if let Some(ref cwd) = cwd {
414            // A launchd-standalone process must not shell out to `git` (which
415            // stats the working tree) or adopt cwd as the project root when cwd
416            // is under a TCC-protected dir (#356).
417            let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
418            let git_root = if may_probe_cwd {
419                std::process::Command::new("git")
420                    .args(["rev-parse", "--show-toplevel"])
421                    .current_dir(cwd)
422                    .stdout(std::process::Stdio::piped())
423                    .stderr(std::process::Stdio::null())
424                    .output()
425                    .ok()
426                    .and_then(|o| {
427                        if o.status.success() {
428                            String::from_utf8(o.stdout)
429                                .ok()
430                                .map(|s| s.trim().to_string())
431                        } else {
432                            None
433                        }
434                    })
435            } else {
436                None
437            };
438            if let Some(root) = git_root {
439                return Some(root);
440            }
441            if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
442                return Some(cwd.to_string_lossy().to_string());
443            }
444        }
445        None
446    }
447
448    /// Loads config from disk with caching, merging global + project-local overrides.
449    ///
450    /// The cache is keyed on a **content hash** of the global + project-local
451    /// files, not their mtime. mtime-only invalidation silently served a stale
452    /// `Config` whenever a content edit preserved the mtime (coarse filesystem
453    /// mtime resolution, `cp -p`, atomic save-then-rename, two edits within the
454    /// same second). A long-lived MCP server then kept the old value (e.g.
455    /// `path_jail`) while a fresh `lean-ctx doctor` process — with an empty
456    /// cache — saw the new one (#406). Config files are tiny, so reading +
457    /// hashing them on every load is negligible and guarantees liveness.
458    pub fn load() -> Self {
459        (*Self::load_arc()).clone()
460    }
461
462    /// Shared-ownership variant of [`load`](Self::load): returns the cached
463    /// `Arc<Config>` so the per-dispatch hot path bumps a refcount instead of
464    /// deep-cloning the whole struct. Liveness is identical to `load` — the
465    /// global and project-local files are still read and content-hashed on
466    /// every call (#406); only the cache payload became an `Arc`, so a cache
467    /// hit is a cheap `Arc::clone`.
468    /// The config generation: it advances whenever a load sees a changed
469    /// config (content, project, profile or trust), so a cache derived from
470    /// the config can key on it instead of re-reading the config (#2195).
471    pub fn generation() -> u64 {
472        CONFIG_GENERATION.load(std::sync::atomic::Ordering::Acquire)
473    }
474
475    pub fn load_arc() -> Arc<Self> {
476        static CACHE: Mutex<ConfigCacheSlot> = Mutex::new(None);
477        Self::load_arc_with(Self::find_project_root().as_deref(), &CACHE)
478    }
479
480    pub(super) fn load_arc_with(
481        project_root: Option<&str>,
482        cache: &Mutex<ConfigCacheSlot>,
483    ) -> Arc<Self> {
484        let Some(path) = Self::path() else {
485            return Arc::new(Self::default());
486        };
487
488        let local_path = project_root.map(Self::local_path);
489
490        // Read raw content up front so the cache key is a content hash. Only a
491        // file that is absent counts as "no config"; any other read error
492        // means settings exist that could not be applied.
493        let (global_content, unreadable) = match std::fs::read_to_string(&path) {
494            Ok(content) => (Some(content), None),
495            Err(error) if error.kind() == std::io::ErrorKind::NotFound => (None, None),
496            Err(error) => (None, Some(error.kind())),
497        };
498        // TCC (#356): never read a project-local `.lean-ctx.toml` under
499        // ~/Documents from a launchd-standalone process — the read pops the
500        // macOS privacy prompt. `find_project_root` already avoids returning
501        // such roots; this also guards the explicit `LEAN_CTX_PROJECT_ROOT` path.
502        let local_content = local_path
503            .as_ref()
504            .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
505            .and_then(|p| std::fs::read_to_string(p).ok());
506
507        // An unreadable file gets its own cache key, distinct from "absent".
508        let global_hash = global_content
509            .as_deref()
510            .map(crate::core::hasher::hash_str)
511            .or_else(|| unreadable.map(|kind| format!("unreadable:{kind:?}")));
512        let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
513        // Trust is per project: equal local files of two projects are not one
514        // cache entry.
515        let local_key = local_hash
516            .as_deref()
517            .map(|hash| format!("{}\0{hash}", project_root.unwrap_or_default()));
518        let selected_profile = environment_config_profile();
519
520        // Finding 4: a project-local `.lean-ctx.toml`'s SECURITY-sensitive
521        // overrides (shell allowlist, path-jail widening, proxy upstream, …)
522        // are honoured only for a workspace the user has explicitly trusted.
523        // `local_hash` is exactly the content hash workspace-trust pins, so
524        // editing the file after trust re-gates it (see `workspace_trust`).
525        // Decided before the cache lookup and part of the key, so `trust` /
526        // `untrust` reach a running server without a file change (#2144).
527        let trusted = local_content.is_some()
528            && project_root.is_some_and(|r| {
529                crate::core::workspace_trust::is_trusted_for(
530                    std::path::Path::new(r),
531                    local_hash.as_deref().unwrap_or_default(),
532                )
533            });
534
535        // The machine's managed configuration overlays `[hive]` (below).
536        let managed = crate::core::config::managed::current();
537        let managed_key = crate::core::config::managed::cache_key();
538
539        if let Ok(guard) = cache.lock()
540            && let Some((
541                ref cfg,
542                ref cached_global,
543                ref cached_local,
544                ref cached_profile,
545                cached_trusted,
546                ref cached_managed,
547            )) = *guard
548            && *cached_global == global_hash
549            && *cached_local == local_key
550            && *cached_profile == selected_profile
551            && cached_trusted == trusted
552            && *cached_managed == managed_key
553        {
554            note_gateway_store_ceiling(cfg);
555            return Arc::clone(cfg);
556        }
557
558        let mut failure = unreadable.map(|_| ConfigLoadFailure::Unreadable);
559        let mut cfg: Config = if let Some(ref content) = global_content {
560            match parse_config_with_profile(content, selected_profile.as_deref()) {
561                Ok(c) => {
562                    record_parse_error(None);
563                    c
564                }
565                Err(e) => {
566                    failure = Some(ConfigLoadFailure::Invalid);
567                    record_parse_error(Some(e.clone()));
568                    tracing::warn!("config parse error in {}: {e}", path.display());
569                    eprintln!(
570                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
571                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
572                        path.display()
573                    );
574                    Self::default()
575                }
576            }
577        } else if let Some(kind) = unreadable {
578            let message = format!("cannot read config: {kind}");
579            record_parse_error(Some(message.clone()));
580            tracing::warn!("{message} ({})", path.display());
581            eprintln!(
582                "\x1b[33m[lean-ctx] WARNING: {message} ({})\n  \
583                 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
584                path.display()
585            );
586            Self::default()
587        } else {
588            record_parse_error(None);
589            Self::default()
590        };
591
592        if let Some(ref local) = local_content {
593            cfg.merge_local(local, trusted);
594        }
595
596        // A snapshot of defaults standing in for an unusable file must not be
597        // migrated back to disk.
598        if failure.is_none() {
599            cfg.migrate_v4_config_on_disk();
600        }
601        // Managed > Hive floor > user config > defaults. After the on-disk
602        // migration, so the overlay never reaches the user's file.
603        if let Some(managed) = &managed {
604            managed.apply_to_hive(&mut cfg.hive);
605        }
606        // Last, so no merge step can drop it: the marker belongs to this value.
607        cfg.load_failure = failure;
608
609        let cfg = Arc::new(cfg);
610        if let Ok(mut guard) = cache.lock() {
611            *guard = Some((
612                Arc::clone(&cfg),
613                global_hash,
614                local_key,
615                selected_profile,
616                trusted,
617                managed_key,
618            ));
619        }
620        CONFIG_GENERATION.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
621
622        note_gateway_store_ceiling(&cfg);
623        cfg
624    }
625
626    /// Global config merged with the `.lean-ctx.toml` of `project_root` — not
627    /// of the process's own project. A daemon serving several repositories
628    /// must evaluate per-project settings (and their workspace trust) against
629    /// the project being processed. Uncached; same merge + trust rules as
630    /// [`load_arc`](Self::load_arc).
631    pub fn load_for_project_root(project_root: &str) -> Self {
632        let selected_profile = environment_config_profile();
633        let mut cfg = Self::path()
634            .and_then(|p| std::fs::read_to_string(p).ok())
635            .and_then(|c| parse_config_with_profile(&c, selected_profile.as_deref()).ok())
636            .unwrap_or_default();
637        let local_path = Self::local_path(project_root);
638        if crate::core::pathutil::may_probe_path(local_path.as_path())
639            && let Ok(local) = std::fs::read_to_string(&local_path)
640        {
641            let trusted = crate::core::workspace_trust::is_trusted_for(
642                std::path::Path::new(project_root),
643                &crate::core::hasher::hash_str(&local),
644            );
645            cfg.merge_local(&local, trusted);
646        }
647        cfg
648    }
649
650    // `merge_local` is in `merge.rs` (extracted for #660 LOC gate).
651
652    /// Consolidate legacy changes into one recoverable config transaction.
653    /// Preserve explicit telemetry opt-outs and the integrated preference model.
654    pub(crate) fn migrate_v4_config_on_disk(&mut self) {
655        // Telemetry was opt-in before v2, so a persisted `enabled = false`
656        // without an explicit preference is not a choice against v2. Re-enable
657        // it once, in a writable config only: a read-only (declaratively
658        // managed) file keeps exactly what its owner wrote.
659        let path = Self::path();
660        let writable = path
661            .as_deref()
662            .is_some_and(allows_automatic_config_migration);
663        let raw = path
664            .as_deref()
665            .filter(|_| writable)
666            .and_then(|path| std::fs::read_to_string(path).ok());
667        let reset_pending = crate::core::telemetry_consent::legacy_opt_out_reset_pending();
668        let reset = raw
669            .as_deref()
670            .filter(|_| reset_pending)
671            .and_then(reset_legacy_telemetry_opt_out);
672        let reenables =
673            reset.is_some() && self.telemetry.preference == super::TelemetryPreference::DefaultOn;
674        // The opt-out the migrated document will carry: with the reset applied
675        // only an explicit preference keeps telemetry off.
676        let preserve_opt_out = if reenables {
677            self.telemetry.preference == super::TelemetryPreference::ExplicitlyDisabled
678        } else {
679            self.telemetry.explicitly_disabled()
680        };
681        // The reset flips consent, so it takes effect in memory only once it is
682        // on disk. While it cannot be persisted (#2175: a refused journal), the
683        // user's saved `enabled = false` stays authoritative and the reset
684        // stays pending.
685        let persisted = self.persist_v4_migration(
686            path.as_deref(),
687            writable,
688            raw.as_deref(),
689            reset.as_deref(),
690            reset_pending,
691            preserve_opt_out,
692        );
693        if reenables && persisted {
694            self.telemetry.enabled = true;
695        }
696        let preserve_opt_out = self.telemetry.explicitly_disabled();
697        if self.cloud.contribute_enabled {
698            self.cloud.contribute_enabled = false;
699            if !preserve_opt_out {
700                self.telemetry.enabled = true;
701                self.telemetry.preference = super::TelemetryPreference::ExplicitlyEnabled;
702            }
703        }
704    }
705
706    /// Writes the migrated document through the config migration journal.
707    /// Returns whether the on-disk config now carries the migration (also
708    /// `true` when nothing had to change).
709    fn persist_v4_migration(
710        &self,
711        path: Option<&Path>,
712        writable: bool,
713        raw: Option<&str>,
714        reset: Option<&str>,
715        reset_pending: bool,
716        preserve_opt_out: bool,
717    ) -> bool {
718        let Some(path) = path else {
719            return false;
720        };
721        let Some(raw) = raw else {
722            if !writable {
723                tracing::debug!(
724                    "config migration remains in memory: source or directory is read-only"
725                );
726            }
727            // No config yet: there is no legacy opt-out to reset, now or later.
728            if reset_pending && !path.exists() {
729                crate::core::telemetry_consent::mark_legacy_opt_out_reset();
730            }
731            return false;
732        };
733        let source = reset.unwrap_or(raw);
734        let migrated = match Self::migrate_v4_config_document(
735            source,
736            environment_config_profile().as_deref(),
737            preserve_opt_out,
738        ) {
739            Ok(Some(updated)) => Some(updated),
740            Ok(None) => (source != raw).then(|| source.to_string()),
741            Err(error) => {
742                tracing::warn!("config migration failed: {error}");
743                return false;
744            }
745        };
746        let persisted = match migrated {
747            Some(updated) => {
748                match crate::config_io::write_atomic_config_migration_checked(
749                    path,
750                    &updated,
751                    Some(raw.as_bytes()),
752                ) {
753                    Ok(()) => true,
754                    Err(error) => {
755                        warn_config_migration_paused_once(path, raw, &updated, &error);
756                        false
757                    }
758                }
759            }
760            None => true,
761        };
762        if persisted {
763            clear_config_migration_pause_marker(path);
764        }
765        if persisted && reset_pending {
766            crate::core::telemetry_consent::mark_legacy_opt_out_reset();
767        }
768        persisted
769    }
770
771    pub(super) fn migrate_v4_config_document(
772        raw: &str,
773        selected_profile: Option<&str>,
774        preserve_opt_out: bool,
775    ) -> Result<Option<String>, String> {
776        let document = raw
777            .parse::<toml_edit::DocumentMut>()
778            .map_err(|error| error.to_string())?;
779        let legacy_telemetry = document
780            .get("cloud")
781            .and_then(|table| table.get("contribute_enabled"))
782            .and_then(toml_edit::Item::as_bool)
783            == Some(true);
784        let persisted_opt_out = document
785            .get("telemetry")
786            .and_then(|table| table.get("enabled"))
787            .and_then(toml_edit::Item::as_bool)
788            == Some(false);
789        let mut updated = if legacy_telemetry {
790            migrate_legacy_contribute_document(raw, preserve_opt_out || persisted_opt_out)
791                .ok_or_else(|| "invalid legacy telemetry configuration".to_string())?
792        } else {
793            raw.to_string()
794        };
795        if let Some(next) = Self::validated_v3_compression_document(&updated, selected_profile)? {
796            updated = next;
797        }
798        if let Some(next) = super::retired::migrate_retired_document(&updated)? {
799            updated = next;
800        }
801        parse_config_with_profile(&updated, selected_profile)?;
802        Ok((updated != raw).then_some(updated))
803    }
804
805    pub(crate) fn validated_v3_compression_document(
806        raw: &str,
807        selected_profile: Option<&str>,
808    ) -> Result<Option<String>, String> {
809        let migrated = Self::migrate_v3_compression_document(raw)?;
810        if let Some(ref updated) = migrated {
811            parse_config_with_profile(updated, selected_profile)?;
812        }
813        Ok(migrated)
814    }
815
816    pub(crate) fn migrate_v3_compression_document(raw: &str) -> Result<Option<String>, String> {
817        fn migrate_table(table: &mut toml_edit::Table) -> Result<bool, String> {
818            let has_legacy = ["terse_agent", "output_density", "ultra_compact"]
819                .iter()
820                .any(|key| table.get(key).is_some());
821            if !has_legacy {
822                return Ok(false);
823            }
824
825            let terse = match table.get("terse_agent") {
826                Some(item) => Some(
827                    item.as_str()
828                        .ok_or_else(|| "terse_agent must be a string".to_string())?,
829                ),
830                None => None,
831            };
832            if terse.is_some_and(|value| !matches!(value, "off" | "lite" | "full" | "ultra")) {
833                return Err("invalid terse_agent".to_string());
834            }
835            let density = match table.get("output_density") {
836                Some(item) => Some(
837                    item.as_str()
838                        .ok_or_else(|| "output_density must be a string".to_string())?,
839                ),
840                None => None,
841            };
842            if density.is_some_and(|value| !matches!(value, "normal" | "terse" | "ultra")) {
843                return Err("invalid output_density".to_string());
844            }
845            let ultra = match table.get("ultra_compact") {
846                Some(item) => Some(
847                    item.as_bool()
848                        .ok_or_else(|| "ultra_compact must be a boolean".to_string())?,
849                ),
850                None => None,
851            };
852
853            let level = if let Some(item) = table.get("compression_level") {
854                let value = item
855                    .as_str()
856                    .ok_or_else(|| "compression_level must be a string".to_string())?;
857                if !matches!(value, "off" | "lite" | "standard" | "max" | "raw") {
858                    return Err(format!("invalid compression_level: {value}"));
859                }
860                value
861            } else if ultra == Some(true) || terse == Some("ultra") || density == Some("ultra") {
862                "max"
863            } else if terse == Some("full") {
864                "standard"
865            } else if terse == Some("lite") || density == Some("terse") {
866                "lite"
867            } else {
868                "off"
869            };
870
871            table["compression_level"] = toml_edit::value(level);
872            for key in ["terse_agent", "output_density", "ultra_compact"] {
873                table.remove(key);
874            }
875            Ok(true)
876        }
877
878        let mut document = raw
879            .parse::<toml_edit::DocumentMut>()
880            .map_err(|error| error.to_string())?;
881        let mut changed = migrate_table(document.as_table_mut())?;
882        if let Some(profiles) = document
883            .get_mut("profiles")
884            .and_then(toml_edit::Item::as_table_mut)
885        {
886            for (_, profile) in profiles.iter_mut() {
887                if let Some(table) = profile.as_table_mut() {
888                    changed |= migrate_table(table)?;
889                }
890            }
891        }
892        Ok(changed.then(|| document.to_string()))
893    }
894
895    /// Loads ONLY the global config file — never merging project-local
896    /// `.lean-ctx.toml` overrides, and bypassing the in-memory cache. Every
897    /// PERSIST path must use this (or [`Config::update_global`]): [`Config::load`]
898    /// folds per-project overrides into the struct, and [`Config::save`] writes
899    /// the whole struct back to the GLOBAL file — so a `load → mutate → save`
900    /// round-trip silently leaks per-project values (and, historically, reset
901    /// customized keys) into the global config (#443). Reading global-only makes
902    /// the save leak-free by construction.
903    pub fn load_global() -> Self {
904        Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
905    }
906
907    /// Strict variant of [`Config::load_global`]: an unreadable or unparseable
908    /// global config is an error instead of silently becoming the defaults.
909    /// Consent-gated paths (telemetry send) must use this so a corrupt file
910    /// never turns into default-on behaviour.
911    pub fn try_load_global() -> Result<Self, super::error::LeanCtxError> {
912        let path = Self::path().ok_or_else(|| {
913            super::error::LeanCtxError::Config("cannot determine home directory".into())
914        })?;
915        Self::try_load_global_from(&path)
916    }
917
918    pub(super) fn try_load_global_from(path: &Path) -> Result<Self, super::error::LeanCtxError> {
919        match std::fs::read_to_string(path) {
920            Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).map_err(|error| {
921                super::error::LeanCtxError::Config(
922                    format!("refusing invalid global config.toml ({error})").into(),
923                )
924            }),
925            Ok(_) => Ok(Self::default()),
926            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
927            Err(error) => Err(error.into()),
928        }
929    }
930
931    /// Path-parameterized core of [`Config::load_global`] (unit-testable without
932    /// the real config dir). Missing, empty, or unparseable files yield
933    /// defaults; persisting callers that must not clobber a corrupt file use
934    /// [`Config::update_global`], which refuses instead.
935    pub(super) fn load_global_from(path: &Path) -> Self {
936        Self::try_load_global_from(path).unwrap_or_default()
937    }
938}
939
940/// Prefix of the per-config markers in the state dir that hold the
941/// fingerprint of the last reported paused migration. Every command loads the
942/// config, so without them one refused journal warns on every invocation
943/// (#2175).
944const CONFIG_MIGRATION_PAUSE_MARKER: &str = "config_migration_paused";
945
946/// Marker for one config file: keyed by the blake3 of its canonical write
947/// target, so two config paths (profiles, overrides) never share or clear
948/// each other's state. Content-addressed name, no time or counter.
949pub(super) fn config_migration_pause_marker(path: &Path) -> Option<std::path::PathBuf> {
950    let target =
951        crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
952    let key = blake3::hash(target.to_string_lossy().as_bytes()).to_hex();
953    crate::core::paths::state_dir()
954        .ok()
955        .map(|dir| dir.join(format!("{CONFIG_MIGRATION_PAUSE_MARKER}.{}", &key[..16])))
956}
957
958/// Why a config migration is paused and what the user can do about it.
959/// Every field is a deterministic function of the config path and the
960/// journal state on disk.
961#[derive(Debug, Clone, PartialEq, Eq)]
962pub struct ConfigMigrationPause {
963    /// Stable class, e.g. `write-refused` or `backup-mismatch`.
964    pub label: &'static str,
965    pub config: std::path::PathBuf,
966    pub backup: std::path::PathBuf,
967    pub receipt: std::path::PathBuf,
968    /// What is wrong, in one sentence.
969    pub reason: String,
970    /// The action that works in this state.
971    pub action: String,
972    /// Archive that keeps the config from before the first recorded migration.
973    pub original: Option<std::path::PathBuf>,
974}
975
976/// Describes the paused migration of `path` from the journal state on disk.
977/// A journal that verifies (or none) means the writer itself was refused.
978pub(super) fn describe_config_migration_pause(path: &Path) -> ConfigMigrationPause {
979    use crate::config_io::{ConfigMigrationJournalRefusal as Refusal, ConfigMigrationJournalState};
980    let config =
981        crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
982    let (backup, receipt) =
983        crate::config_io::config_migration_journal_files(path).unwrap_or_else(|| {
984            (
985                config.with_file_name("config.toml.v4-migration.bak"),
986                config.with_file_name("config.toml.v4-migration.json"),
987            )
988        });
989    let next = crate::config_io::config_migration_next_receipt_file(path)
990        .unwrap_or_else(|| config.with_file_name("config.toml.v4-migration.next.json"));
991    let dir = config
992        .parent()
993        .map_or_else(|| ".".to_string(), |dir| dir.display().to_string());
994    let (b, r, n, c) = (
995        backup.display(),
996        receipt.display(),
997        next.display(),
998        config.display(),
999    );
1000    let fresh = "; the migration starts a fresh journal on the next command.";
1001    let state = crate::config_io::config_migration_journal_state(path);
1002    let (label, reason, action) = if let ConfigMigrationJournalState::Refused(refusal) = state {
1003        let (reason, action) = match refusal {
1004            Refusal::Symlink => (
1005                "a migration journal file is a symbolic link, which lean-ctx does not follow."
1006                    .to_string(),
1007                format!(
1008                    "Replace {b}, {r} and {n} with regular files or move them out of {dir}{fresh}"
1009                ),
1010            ),
1011            Refusal::Unreadable => (
1012                "a migration journal file cannot be read.".to_string(),
1013                format!(
1014                    "Make {b} and {r} readable for lean-ctx; the migration retries on the next command."
1015                ),
1016            ),
1017            Refusal::ReceiptUnverifiable => (
1018                format!(
1019                    "the journal receipt {r} cannot be verified, so neither the migration nor \
1020                         `lean-ctx migrate config --rollback` can use it."
1021                ),
1022                format!(
1023                    "Check {b} (the config before the recorded migration) and {r}, then move \
1024                         both out of {dir}{fresh}"
1025                ),
1026            ),
1027            Refusal::BackupMismatch => (
1028                format!(
1029                    "the journal backup {b} does not match its receipt {r}, so neither the \
1030                         migration nor `lean-ctx migrate config --rollback` can use it."
1031                ),
1032                format!("Check {b} and {r}, then move both out of {dir}{fresh}"),
1033            ),
1034            Refusal::OrphanBackup => (
1035                format!(
1036                    "{b} has no receipt and matches neither the config nor an archived journal."
1037                ),
1038                format!("Check {b}, then move it out of {dir}{fresh}"),
1039            ),
1040            Refusal::OrphanReceipt => (
1041                format!("{r} has no backup and the config is not the source it records."),
1042                format!("Check {r}, then move it out of {dir}{fresh}"),
1043            ),
1044            Refusal::NextReceiptUnverifiable => (
1045                format!(
1046                    "the unfinished journal update {n} cannot be verified and the config \
1047                         matches neither recorded state."
1048                ),
1049                format!(
1050                    "Check {n}, then move it out of {dir}; the migration retries on the next \
1051                         command."
1052                ),
1053            ),
1054        };
1055        (refusal.label(), reason, action)
1056    } else {
1057        let mut action = format!(
1058            "Make {dir} and {c} writable for lean-ctx; the migration retries on the next command."
1059        );
1060        if state.rollback_available() {
1061            action.push_str(&format!(
1062                " `lean-ctx migrate config --rollback` restores the config recorded in {b}."
1063            ));
1064        }
1065        (
1066            "write-refused",
1067            "lean-ctx could not write the migrated config or its migration journal.".to_string(),
1068            action,
1069        )
1070    };
1071    ConfigMigrationPause {
1072        label,
1073        original: crate::config_io::original_config_migration_backup(path),
1074        config,
1075        backup,
1076        receipt,
1077        reason,
1078        action,
1079    }
1080}
1081
1082impl ConfigMigrationPause {
1083    /// The one-time warning text; stable for a given state.
1084    pub fn message(&self) -> String {
1085        let original = self
1086            .original
1087            .as_deref()
1088            .map(crate::config_io::original_config_sentence)
1089            .unwrap_or_default();
1090        format!(
1091            "config migration paused for {} ({}): {} {}{original} Reported once until the config \
1092             or the journal changes.",
1093            self.config.display(),
1094            self.label,
1095            self.reason,
1096            self.action
1097        )
1098    }
1099}
1100
1101/// The paused migration of `path`, if the last load had to pause it (for
1102/// `lean-ctx doctor`, which reports it on every run, independent of the
1103/// once-per-state warning).
1104pub fn config_migration_pause_status(path: &Path) -> Option<ConfigMigrationPause> {
1105    config_migration_pause_marker(path)
1106        .is_some_and(|marker| marker.is_file())
1107        .then(|| describe_config_migration_pause(path))
1108}
1109
1110/// Deterministic identity of one paused-migration state: the stable class,
1111/// the config path and bytes, the proposed output and every journal file.
1112/// Never the error text, which can carry random temporary file names.
1113pub(super) fn config_migration_pause_fingerprint(
1114    path: &Path,
1115    raw: &str,
1116    updated: &str,
1117    label: &str,
1118) -> String {
1119    let mut hasher = blake3::Hasher::new();
1120    let target =
1121        crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
1122    let target = target.to_string_lossy();
1123    for part in [
1124        label.as_bytes(),
1125        target.as_bytes(),
1126        raw.as_bytes(),
1127        updated.as_bytes(),
1128    ] {
1129        hasher.update(&(part.len() as u64).to_le_bytes());
1130        hasher.update(part);
1131    }
1132    let journal = crate::config_io::config_migration_journal_files(path)
1133        .map(|(backup, receipt)| vec![backup, receipt])
1134        .unwrap_or_default();
1135    let next = crate::config_io::config_migration_next_receipt_file(path);
1136    for file in journal.into_iter().chain(next) {
1137        let bytes = std::fs::read(file).unwrap_or_default();
1138        hasher.update(&(bytes.len() as u64).to_le_bytes());
1139        hasher.update(&bytes);
1140    }
1141    hasher.finalize().to_hex().to_string()
1142}
1143
1144/// Warns about a refused config migration once per distinct state; repeats
1145/// of the same state go to debug level only. Returns whether it warned.
1146pub(super) fn warn_config_migration_paused_once(
1147    path: &Path,
1148    raw: &str,
1149    updated: &str,
1150    error: &str,
1151) -> bool {
1152    let pause = describe_config_migration_pause(path);
1153    let fingerprint = config_migration_pause_fingerprint(path, raw, updated, pause.label);
1154    let marker = config_migration_pause_marker(path);
1155    let reported = marker
1156        .as_deref()
1157        .and_then(|marker| std::fs::read_to_string(marker).ok())
1158        .is_some_and(|seen| seen.lines().next() == Some(fingerprint.as_str()));
1159    // The raw error can name random temporary files: debug only.
1160    tracing::debug!("config migration paused ({}): {error}", pause.label);
1161    if reported {
1162        return false;
1163    }
1164    tracing::warn!("{}", pause.message());
1165    if let Some(marker) = marker {
1166        if let Some(parent) = marker.parent() {
1167            let _ = std::fs::create_dir_all(parent);
1168        }
1169        let _ = std::fs::write(marker, format!("{fingerprint}\n{}\n", pause.label));
1170    }
1171    true
1172}
1173
1174fn clear_config_migration_pause_marker(path: &Path) {
1175    if let Some(marker) = config_migration_pause_marker(path)
1176        && marker.exists()
1177    {
1178        let _ = std::fs::remove_file(marker);
1179    }
1180}
1181
1182// Respect declared read-only permissions even when a privileged process could
1183// bypass them. Other access restrictions still fail at the transactional writer.
1184fn allows_automatic_config_migration(path: &Path) -> bool {
1185    path.parent().is_some_and(|parent| {
1186        [path, parent].iter().all(|entry| {
1187            std::fs::metadata(entry).is_ok_and(|metadata| !metadata.permissions().readonly())
1188        })
1189    })
1190}
1191
1192/// Re-enables a pre-v2 `telemetry.enabled = false` that carries no explicit
1193/// preference. Returns `None` when there is nothing to reset, including every
1194/// explicit choice (`explicitly_disabled` stays off).
1195pub(super) fn reset_legacy_telemetry_opt_out(raw: &str) -> Option<String> {
1196    let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
1197    let telemetry = document.get("telemetry")?;
1198    let disabled = telemetry.get("enabled").and_then(toml_edit::Item::as_bool) == Some(false);
1199    let implicit = matches!(
1200        telemetry.get("preference").map(toml_edit::Item::as_str),
1201        None | Some(Some("default_on"))
1202    );
1203    if !(disabled && implicit) {
1204        return None;
1205    }
1206    document["telemetry"]["enabled"] = toml_edit::value(true);
1207    Some(document.to_string())
1208}
1209
1210fn migrate_legacy_contribute_document(raw: &str, preserve_opt_out: bool) -> Option<String> {
1211    let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
1212    document["cloud"]["contribute_enabled"] = toml_edit::value(false);
1213    if !preserve_opt_out {
1214        document["telemetry"]["enabled"] = toml_edit::value(true);
1215        document["telemetry"]["preference"] = toml_edit::value("explicitly_enabled");
1216    }
1217    Some(document.to_string())
1218}
1219
1220#[cfg(test)]
1221mod telemetry_migration_tests {
1222    use super::*;
1223
1224    #[test]
1225    #[cfg(unix)]
1226    fn automatic_migration_respects_readonly_file_and_directory() {
1227        use std::os::unix::fs::PermissionsExt;
1228        let directory = tempfile::tempdir().expect("migration directory");
1229        let path = directory.path().join("config.toml");
1230        std::fs::write(&path, "ultra_compact = true\n").expect("legacy config");
1231        assert!(allows_automatic_config_migration(&path));
1232        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
1233        let readonly_file = allows_automatic_config_migration(&path);
1234        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1235        std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
1236        let readonly_directory = allows_automatic_config_migration(&path);
1237        std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1238        assert!(!readonly_file);
1239        assert!(!readonly_directory);
1240        assert!(!allows_automatic_config_migration(
1241            &directory.path().join("missing.toml")
1242        ));
1243    }
1244
1245    #[test]
1246    fn legacy_opt_in_becomes_explicit_and_send_eligible() {
1247        let migrated = migrate_legacy_contribute_document(
1248            "# keep me\n[cloud]\ncontribute_enabled = true\n",
1249            false,
1250        )
1251        .expect("valid TOML");
1252        assert!(migrated.contains("# keep me"));
1253        let cfg: Config = toml::from_str(&migrated).expect("migrated config");
1254        assert!(!cfg.cloud.contribute_enabled);
1255        assert!(cfg.telemetry.enabled);
1256        assert_eq!(
1257            cfg.telemetry.preference,
1258            super::super::TelemetryPreference::ExplicitlyEnabled
1259        );
1260        assert!(cfg.telemetry.send_eligible(None, None));
1261    }
1262
1263    #[test]
1264    fn legacy_contribute_never_overrides_explicit_opt_out() {
1265        let migrated = migrate_legacy_contribute_document(
1266            "[cloud]\ncontribute_enabled = true\n[telemetry]\nenabled = false\n",
1267            true,
1268        )
1269        .expect("valid TOML");
1270        let cfg: Config = toml::from_str(&migrated).expect("migrated config");
1271        assert!(!cfg.cloud.contribute_enabled);
1272        assert!(cfg.telemetry.explicitly_disabled());
1273    }
1274}