lean-ctx-engine 3.12.1

LeanCTX | OSS library: the open-source runtime for the Context Gateway for AI agents behind the lean-ctx CLI, MCP server and proxy integrations.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
// SPDX-License-Identifier: Apache-2.0

//! Config path resolution and disk loading.

use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use super::{Config, ConfigCacheSlot, default_shell_allowlist};

const CONFIG_PROFILE_ENV: &str = "LEAN_CTX_CONFIG_PROFILE";

thread_local! {
    /// Set while the shell-security policy is merged: no environment-selected
    /// profile applies to it (`shell_policy`).
    static IGNORE_ENV_PROFILE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}

/// Runs `f` with the environment's config profile ignored.
pub(super) fn without_env_profile<T>(f: impl FnOnce() -> T) -> T {
    let previous = IGNORE_ENV_PROFILE.with(|c| c.replace(true));
    let out = f();
    IGNORE_ENV_PROFILE.with(|c| c.set(previous));
    out
}

pub(super) fn environment_config_profile() -> Option<String> {
    if IGNORE_ENV_PROFILE.with(std::cell::Cell::get) {
        return None;
    }
    std::env::var(CONFIG_PROFILE_ENV)
        .ok()
        .map(|name| name.trim().to_string())
        .filter(|name| !name.is_empty())
}

/// Parses a config and recursively applies one named partial overlay. An
/// explicit selector (normally the environment) wins over `config_profile`.
pub(super) fn parse_config_with_profile(
    raw: &str,
    explicit_profile: Option<&str>,
) -> Result<Config, String> {
    resolve_config_table(raw, explicit_profile)?
        .try_into()
        .map_err(|error: toml::de::Error| error.to_string())
}

/// The TOML document `raw` describes once its selected profile overlay is
/// applied (and legacy compression keys are migrated), before it becomes a
/// [`Config`]: a merge can see which keys the document sets.
pub(super) fn resolve_config_table(
    raw: &str,
    explicit_profile: Option<&str>,
) -> Result<toml::Value, String> {
    let migrated = Config::migrate_v3_compression_document(raw)?;
    let raw = migrated.as_deref().unwrap_or(raw);
    let mut value: toml::Value = toml::from_str(raw).map_err(|error| error.to_string())?;
    super::renamed::apply(
        value
            .as_table_mut()
            .expect("a TOML document always has a root table"),
    );
    let configured_profile = value.get("config_profile").and_then(toml::Value::as_str);
    let selected = explicit_profile
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .or(configured_profile);

    if let Some(name) = selected {
        let profiles = value
            .get("profiles")
            .and_then(toml::Value::as_table)
            .ok_or_else(|| format!("config profile '{name}' selected but [profiles] is missing"))?;
        let mut overlay = profiles
            .get(name)
            .and_then(toml::Value::as_table)
            .cloned()
            .ok_or_else(|| format!("config profile '{name}' is not defined"))?;
        if overlay.remove("profiles").is_some() || overlay.remove("config_profile").is_some() {
            return Err(format!(
                "config profile '{name}' cannot override reserved profile keys"
            ));
        }
        super::renamed::apply(&mut overlay);
        merge_toml_tables(
            value
                .as_table_mut()
                .expect("a TOML document always has a root table"),
            overlay,
        );
    }

    Ok(value)
}

fn merge_toml_tables(base: &mut toml::Table, overlay: toml::Table) {
    for (key, overlay_value) in overlay {
        match (base.get_mut(&key), overlay_value) {
            (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
                merge_toml_tables(base_table, overlay_table);
            }
            (_, replacement) => {
                base.insert(key, replacement);
            }
        }
    }
}

/// Holds the most recent global `config.toml` parse error, if the file currently
/// fails to parse. When that happens `Config::load()` silently falls back to the
/// built-in defaults and only logs to stderr — which is invisible over an MCP/stdio
/// transport. Recording it here lets callers (e.g. the shell-allowlist diagnostic
/// and `lean-ctx doctor`) surface "you're on defaults because your config is broken".
static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);

/// Returns the most recent global config parse error, or `None` if the current
/// `config.toml` parsed successfully (or no config file exists).
#[must_use]
pub fn last_config_parse_error() -> Option<String> {
    LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
}

fn record_parse_error(err: Option<String>) {
    if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
        *guard = err;
    }
}

/// Why a loaded [`Config`] holds built-in defaults although a global
/// `config.toml` is present. Carried on the snapshot itself
/// ([`Config::load_failure`]), so a decision that depends on "were the
/// operator's settings applied?" reads it from the very value it uses.
/// [`last_config_parse_error`] is a diagnostic for humans and must not be used
/// for that: it is process-global and a later load overwrites it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigLoadFailure {
    /// The file exists but could not be read as text: permission denied,
    /// invalid UTF-8, a directory in its place, or another I/O error. A file
    /// that is simply absent (clean install) is not a failure.
    Unreadable,
    /// The file was read but is not a valid configuration: TOML syntax, a
    /// field of the wrong type, or a selected profile that is not defined.
    Invalid,
}

impl ConfigLoadFailure {
    /// Stable reason code for refusals and diagnostics.
    #[must_use]
    pub fn code(self) -> &'static str {
        match self {
            Self::Unreadable => "config.global_unreadable",
            Self::Invalid => "config.global_invalid",
        }
    }
}

/// Reset every SECURITY-sensitive field of a parsed project-local `Config` back
/// to its default, returning the names of the ones that actually carried an
/// override. Used by [`Config::merge_local`] for untrusted workspaces: clearing a
/// field to its default makes the downstream "== default ⇒ no override" merge
/// guards skip it automatically, so a single list here gates every sensitive key
/// without touching the per-field merge arms (security audit #4).
///
/// Sensitive = anything that can widen lean-ctx's own boundaries or steer the
/// agent: the shell allowlist, path-jail roots, proxy upstreams, command
/// aliases, network passthrough, rules scope/injection, tool surface control
/// (profile/enabled-list/categories, disabling) and permission inheritance.
/// Comfort/perf knobs are intentionally NOT listed.
pub(crate) fn strip_sensitive_overrides(local: &mut Config) -> Vec<&'static str> {
    let mut withheld: Vec<&'static str> = Vec::new();

    if local.shell_allowlist != default_shell_allowlist() {
        local.shell_allowlist = default_shell_allowlist();
        withheld.push("shell_allowlist");
    }
    if !local.shell_allowlist_extra.is_empty() {
        local.shell_allowlist_extra.clear();
        withheld.push("shell_allowlist_extra");
    }
    if !local.allow_paths.is_empty() {
        local.allow_paths.clear();
        withheld.push("allow_paths");
    }
    if !local.extra_roots.is_empty() {
        local.extra_roots.clear();
        withheld.push("extra_roots");
    }
    // Shell-redirect write targets widen what lean-ctx may write.
    if !local.write_allow_paths.is_empty() {
        local.write_allow_paths.clear();
        withheld.push("write_allow_paths");
    }
    // Read-only roots also resolve reads like extra roots (#475).
    if !local.read_only_roots.is_empty() {
        local.read_only_roots.clear();
        withheld.push("read_only_roots");
    }
    // `off` skips the native-read redirect and the redaction it applies.
    if local.read_redirect == super::ReadRedirect::Off {
        local.read_redirect = super::ReadRedirect::default();
        withheld.push("read_redirect");
    }
    if !local.allow_symlink_roots.is_empty() {
        local.allow_symlink_roots.clear();
        withheld.push("allow_symlink_roots");
    }
    // Destinations ctx_shell redirects and downloads may write to.
    if !local.write_allow_paths.is_empty() {
        local.write_allow_paths.clear();
        withheld.push("write_allow_paths");
    }
    if !local.custom_aliases.is_empty() {
        local.custom_aliases.clear();
        withheld.push("custom_aliases");
    }
    if !local.passthrough_urls.is_empty() {
        local.passthrough_urls.clear();
        withheld.push("passthrough_urls");
    }
    // Paths in `redirect_exclude` skip the native-read redirect, and with it
    // the redaction that path applies; a cloned repo must not opt itself out.
    if !local.redirect_exclude.is_empty() {
        local.redirect_exclude.clear();
        withheld.push("redirect_exclude");
    }
    if local.proxy.anthropic_upstream.is_some()
        || local.proxy.openai_upstream.is_some()
        || local.proxy.chatgpt_upstream.is_some()
        || local.proxy.gemini_upstream.is_some()
    {
        local.proxy.anthropic_upstream = None;
        local.proxy.openai_upstream = None;
        local.proxy.chatgpt_upstream = None;
        local.proxy.gemini_upstream = None;
        withheld.push("proxy.*_upstream");
    }
    // `eager` lets background work start language servers, which execute
    // project code (build scripts, proc macros). Lowering to off/auto is safe.
    if local.semantic_mode == super::SemanticMode::Eager {
        local.semantic_mode = super::SemanticMode::default();
        withheld.push("semantic_mode");
    }
    if local.rules_scope.is_some() {
        local.rules_scope = None;
        withheld.push("rules_scope");
    }
    if local.rules_injection.is_some() {
        local.rules_injection = None;
        withheld.push("rules_injection");
    }
    if local.permission_inheritance.is_some() {
        local.permission_inheritance = None;
        withheld.push("permission_inheritance");
    }
    if !local.disabled_tools.is_empty() {
        local.disabled_tools.clear();
        withheld.push("disabled_tools");
    }
    if local.tool_profile.is_some() {
        local.tool_profile = None;
        withheld.push("tool_profile");
    }
    if !local.tools_enabled.is_empty() {
        local.tools_enabled.clear();
        withheld.push("tools_enabled");
    }
    if !local.default_tool_categories.is_empty() {
        local.default_tool_categories.clear();
        withheld.push("default_tool_categories");
    }
    if !local.index.respect_gitignore {
        local.index.respect_gitignore = true;
        withheld.push("index.respect_gitignore");
    }
    if !local.shell_allowlist_subcommand_scoping {
        local.shell_allowlist_subcommand_scoping = true;
        withheld.push("shell_allowlist_subcommand_scoping");
    }

    withheld
}

/// Tell the gateway evidence store the ceiling this configuration gives.
/// The store's writers run deep inside calls and must not load a
/// configuration themselves (`receipt_store::capacity`, G09).
fn note_gateway_store_ceiling(cfg: &Config) {
    crate::core::context_admission::receipt_store::capacity::note_configured_ceiling(
        cfg.gateway_store_configured_mb(),
    );
}

/// Names of the SECURITY-sensitive overrides a project-local `.lean-ctx.toml`
/// carries — the keys `strip_sensitive_overrides` would withhold for an
/// untrusted workspace. Read-only (parses a throwaway `Config`); used by
/// `lean-ctx trust` to tell the user exactly what trusting will enable.
#[must_use]
pub fn local_sensitive_overrides(local_toml: &str) -> Vec<&'static str> {
    let selected = environment_config_profile();
    match parse_config_with_profile(local_toml, selected.as_deref()) {
        Ok(mut parsed) => strip_sensitive_overrides(&mut parsed),
        Err(_) => Vec::new(),
    }
}

/// A string-array key from the raw GLOBAL config table — never the merged
/// runtime view, so project-local or default values are never persisted
/// globally.
pub fn global_string_list(key: &str) -> Vec<String> {
    let Some(path) = Config::path() else {
        return Vec::new();
    };
    let Ok(raw) = std::fs::read_to_string(&path) else {
        return Vec::new();
    };
    let Ok(table) = raw.parse::<toml::Table>() else {
        return Vec::new();
    };
    table
        .get(key)
        .and_then(toml::Value::as_array)
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default()
}

impl Config {
    /// Returns the path to the global config file (`$XDG_CONFIG_HOME/lean-ctx/config.toml`).
    ///
    /// Resolves the canonical config category without creating directories or
    /// repairing permissions; existing single-dir installations retain their path.
    pub fn path() -> Option<PathBuf> {
        crate::core::paths::config_dir_read_only()
            .ok()
            .map(|d| d.join("config.toml"))
    }

    /// `Some(path)` when the global config the runtime *resolves* does not exist,
    /// so lean-ctx is silently on built-in defaults. `None` when a config file is
    /// present (or HOME is unresolvable).
    ///
    /// The directory is layout-dependent (XDG `~/.config/lean-ctx` vs legacy
    /// `~/.lean-ctx` vs `$LEAN_CTX_DATA_DIR`) and an MCP client may launch the
    /// server in a sandbox/container with a different `$HOME`. An edit made to a
    /// *different* `config.toml` than this one is silently ignored; the block
    /// messages use this to say so out loud over MCP, where the stderr path is
    /// invisible (#540).
    #[must_use]
    pub fn missing_config_path() -> Option<PathBuf> {
        match Self::path() {
            Some(p) if !p.exists() => Some(p),
            _ => None,
        }
    }

    /// Returns the path to the project-local config override file.
    pub fn local_path(project_root: &str) -> PathBuf {
        PathBuf::from(project_root).join(".lean-ctx.toml")
    }

    /// Resolves the active project root (env override → session → git toplevel →
    /// cwd), cached for the process. Exposed crate-wide so workspace-trust and the
    /// CLI agree with config loading on *which* directory a `.lean-ctx.toml`
    /// belongs to (GH security audit, finding 4).
    ///
    /// Inside a request the request's bound project is the root, uncached: a
    /// server serving several projects must apply each one's own
    /// `.lean-ctx.toml` and trust, never the first one it happened to resolve
    /// (AP04-G3).
    pub fn find_project_root() -> Option<String> {
        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
        if let Ok(project) =
            crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone())
        {
            return project.map(|root| root.to_string_lossy().into_owned());
        }
        ROOT_CACHE
            .get_or_init(Self::find_project_root_inner)
            .clone()
    }

    fn find_project_root_inner() -> Option<String> {
        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
            && !env_root.is_empty()
        {
            return Some(env_root);
        }

        let cwd = std::env::current_dir().ok();

        if let Some(root) =
            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
        {
            let root_path = std::path::Path::new(&root);
            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
            // Route the marker probe through the TCC-guarded helper and never
            // adopt a ~/Documents project root from a launchd-standalone process
            // (#356): doing so would later stat its `.lean-ctx.toml`/markers and
            // pop the macOS privacy prompt in lean-ctx's own name.
            let has_marker = crate::core::pathutil::has_project_marker(root_path);

            if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
            {
                return Some(root);
            }
        }

        if let Some(ref cwd) = cwd {
            // A launchd-standalone process must not shell out to `git` (which
            // stats the working tree) or adopt cwd as the project root when cwd
            // is under a TCC-protected dir (#356).
            let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
            let git_root = if may_probe_cwd {
                std::process::Command::new("git")
                    .args(["rev-parse", "--show-toplevel"])
                    .current_dir(cwd)
                    .stdout(std::process::Stdio::piped())
                    .stderr(std::process::Stdio::null())
                    .output()
                    .ok()
                    .and_then(|o| {
                        if o.status.success() {
                            String::from_utf8(o.stdout)
                                .ok()
                                .map(|s| s.trim().to_string())
                        } else {
                            None
                        }
                    })
            } else {
                None
            };
            if let Some(root) = git_root {
                return Some(root);
            }
            if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
                return Some(cwd.to_string_lossy().to_string());
            }
        }
        None
    }

    /// Loads config from disk with caching, merging global + project-local overrides.
    ///
    /// The cache is keyed on a **content hash** of the global + project-local
    /// files, not their mtime. mtime-only invalidation silently served a stale
    /// `Config` whenever a content edit preserved the mtime (coarse filesystem
    /// mtime resolution, `cp -p`, atomic save-then-rename, two edits within the
    /// same second). A long-lived MCP server then kept the old value (e.g.
    /// `path_jail`) while a fresh `lean-ctx doctor` process — with an empty
    /// cache — saw the new one (#406). Config files are tiny, so reading +
    /// hashing them on every load is negligible and guarantees liveness.
    pub fn load() -> Self {
        (*Self::load_arc()).clone()
    }

    /// Shared-ownership variant of [`load`](Self::load): returns the cached
    /// `Arc<Config>` so the per-dispatch hot path bumps a refcount instead of
    /// deep-cloning the whole struct. Liveness is identical to `load` — the
    /// global and project-local files are still read and content-hashed on
    /// every call (#406); only the cache payload became an `Arc`, so a cache
    /// hit is a cheap `Arc::clone`.
    pub fn load_arc() -> Arc<Self> {
        static CACHE: Mutex<ConfigCacheSlot> = Mutex::new(None);
        Self::load_arc_with(Self::find_project_root().as_deref(), &CACHE)
    }

    pub(super) fn load_arc_with(
        project_root: Option<&str>,
        cache: &Mutex<ConfigCacheSlot>,
    ) -> Arc<Self> {
        let Some(path) = Self::path() else {
            return Arc::new(Self::default());
        };

        let local_path = project_root.map(Self::local_path);

        // Read raw content up front so the cache key is a content hash. Only a
        // file that is absent counts as "no config"; any other read error
        // means settings exist that could not be applied.
        let (global_content, unreadable) = match std::fs::read_to_string(&path) {
            Ok(content) => (Some(content), None),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => (None, None),
            Err(error) => (None, Some(error.kind())),
        };
        // TCC (#356): never read a project-local `.lean-ctx.toml` under
        // ~/Documents from a launchd-standalone process — the read pops the
        // macOS privacy prompt. `find_project_root` already avoids returning
        // such roots; this also guards the explicit `LEAN_CTX_PROJECT_ROOT` path.
        let local_content = local_path
            .as_ref()
            .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
            .and_then(|p| std::fs::read_to_string(p).ok());

        // An unreadable file gets its own cache key, distinct from "absent".
        let global_hash = global_content
            .as_deref()
            .map(crate::core::hasher::hash_str)
            .or_else(|| unreadable.map(|kind| format!("unreadable:{kind:?}")));
        let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
        // Trust is per project: equal local files of two projects are not one
        // cache entry.
        let local_key = local_hash
            .as_deref()
            .map(|hash| format!("{}\0{hash}", project_root.unwrap_or_default()));
        let selected_profile = environment_config_profile();

        // Finding 4: a project-local `.lean-ctx.toml`'s SECURITY-sensitive
        // overrides (shell allowlist, path-jail widening, proxy upstream, …)
        // are honoured only for a workspace the user has explicitly trusted.
        // `local_hash` is exactly the content hash workspace-trust pins, so
        // editing the file after trust re-gates it (see `workspace_trust`).
        // Decided before the cache lookup and part of the key, so `trust` /
        // `untrust` reach a running server without a file change (#2144).
        let trusted = local_content.is_some()
            && project_root.is_some_and(|r| {
                crate::core::workspace_trust::is_trusted_for(
                    std::path::Path::new(r),
                    local_hash.as_deref().unwrap_or_default(),
                )
            });

        if let Ok(guard) = cache.lock()
            && let Some((
                ref cfg,
                ref cached_global,
                ref cached_local,
                ref cached_profile,
                cached_trusted,
            )) = *guard
            && *cached_global == global_hash
            && *cached_local == local_key
            && *cached_profile == selected_profile
            && cached_trusted == trusted
        {
            note_gateway_store_ceiling(cfg);
            return Arc::clone(cfg);
        }

        let mut failure = unreadable.map(|_| ConfigLoadFailure::Unreadable);
        let mut cfg: Config = if let Some(ref content) = global_content {
            match parse_config_with_profile(content, selected_profile.as_deref()) {
                Ok(c) => {
                    record_parse_error(None);
                    c
                }
                Err(e) => {
                    failure = Some(ConfigLoadFailure::Invalid);
                    record_parse_error(Some(e.clone()));
                    tracing::warn!("config parse error in {}: {e}", path.display());
                    eprintln!(
                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
                        path.display()
                    );
                    Self::default()
                }
            }
        } else if let Some(kind) = unreadable {
            let message = format!("cannot read config: {kind}");
            record_parse_error(Some(message.clone()));
            tracing::warn!("{message} ({})", path.display());
            eprintln!(
                "\x1b[33m[lean-ctx] WARNING: {message} ({})\n  \
                 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
                path.display()
            );
            Self::default()
        } else {
            record_parse_error(None);
            Self::default()
        };

        if let Some(ref local) = local_content {
            cfg.merge_local(local, trusted);
        }

        // A snapshot of defaults standing in for an unusable file must not be
        // migrated back to disk.
        if failure.is_none() {
            cfg.migrate_v4_config_on_disk();
        }
        // Last, so no merge step can drop it: the marker belongs to this value.
        cfg.load_failure = failure;

        let cfg = Arc::new(cfg);
        if let Ok(mut guard) = cache.lock() {
            *guard = Some((
                Arc::clone(&cfg),
                global_hash,
                local_key,
                selected_profile,
                trusted,
            ));
        }

        note_gateway_store_ceiling(&cfg);
        cfg
    }

    /// Global config merged with the `.lean-ctx.toml` of `project_root` — not
    /// of the process's own project. A daemon serving several repositories
    /// must evaluate per-project settings (and their workspace trust) against
    /// the project being processed. Uncached; same merge + trust rules as
    /// [`load_arc`](Self::load_arc).
    pub fn load_for_project_root(project_root: &str) -> Self {
        let selected_profile = environment_config_profile();
        let mut cfg = Self::path()
            .and_then(|p| std::fs::read_to_string(p).ok())
            .and_then(|c| parse_config_with_profile(&c, selected_profile.as_deref()).ok())
            .unwrap_or_default();
        let local_path = Self::local_path(project_root);
        if crate::core::pathutil::may_probe_path(local_path.as_path())
            && let Ok(local) = std::fs::read_to_string(&local_path)
        {
            let trusted = crate::core::workspace_trust::is_trusted_for(
                std::path::Path::new(project_root),
                &crate::core::hasher::hash_str(&local),
            );
            cfg.merge_local(&local, trusted);
        }
        cfg
    }

    // `merge_local` is in `merge.rs` (extracted for #660 LOC gate).

    /// Consolidate legacy changes into one recoverable config transaction.
    /// Preserve explicit telemetry opt-outs and the integrated preference model.
    pub(crate) fn migrate_v4_config_on_disk(&mut self) {
        // Telemetry was opt-in before v2, so a persisted `enabled = false`
        // without an explicit preference is not a choice against v2. Re-enable
        // it once, in a writable config only: a read-only (declaratively
        // managed) file keeps exactly what its owner wrote.
        let path = Self::path();
        let writable = path
            .as_deref()
            .is_some_and(allows_automatic_config_migration);
        let raw = path
            .as_deref()
            .filter(|_| writable)
            .and_then(|path| std::fs::read_to_string(path).ok());
        let reset_pending = crate::core::telemetry_consent::legacy_opt_out_reset_pending();
        let reset = raw
            .as_deref()
            .filter(|_| reset_pending)
            .and_then(reset_legacy_telemetry_opt_out);
        let reenables =
            reset.is_some() && self.telemetry.preference == super::TelemetryPreference::DefaultOn;
        // The opt-out the migrated document will carry: with the reset applied
        // only an explicit preference keeps telemetry off.
        let preserve_opt_out = if reenables {
            self.telemetry.preference == super::TelemetryPreference::ExplicitlyDisabled
        } else {
            self.telemetry.explicitly_disabled()
        };
        // The reset flips consent, so it takes effect in memory only once it is
        // on disk. While it cannot be persisted (#2175: a refused journal), the
        // user's saved `enabled = false` stays authoritative and the reset
        // stays pending.
        let persisted = self.persist_v4_migration(
            path.as_deref(),
            writable,
            raw.as_deref(),
            reset.as_deref(),
            reset_pending,
            preserve_opt_out,
        );
        if reenables && persisted {
            self.telemetry.enabled = true;
        }
        let preserve_opt_out = self.telemetry.explicitly_disabled();
        if self.cloud.contribute_enabled {
            self.cloud.contribute_enabled = false;
            if !preserve_opt_out {
                self.telemetry.enabled = true;
                self.telemetry.preference = super::TelemetryPreference::ExplicitlyEnabled;
            }
        }
    }

    /// Writes the migrated document through the config migration journal.
    /// Returns whether the on-disk config now carries the migration (also
    /// `true` when nothing had to change).
    fn persist_v4_migration(
        &self,
        path: Option<&Path>,
        writable: bool,
        raw: Option<&str>,
        reset: Option<&str>,
        reset_pending: bool,
        preserve_opt_out: bool,
    ) -> bool {
        let Some(path) = path else {
            return false;
        };
        let Some(raw) = raw else {
            if !writable {
                tracing::debug!(
                    "config migration remains in memory: source or directory is read-only"
                );
            }
            // No config yet: there is no legacy opt-out to reset, now or later.
            if reset_pending && !path.exists() {
                crate::core::telemetry_consent::mark_legacy_opt_out_reset();
            }
            return false;
        };
        let source = reset.unwrap_or(raw);
        let migrated = match Self::migrate_v4_config_document(
            source,
            environment_config_profile().as_deref(),
            preserve_opt_out,
        ) {
            Ok(Some(updated)) => Some(updated),
            Ok(None) => (source != raw).then(|| source.to_string()),
            Err(error) => {
                tracing::warn!("config migration failed: {error}");
                return false;
            }
        };
        let persisted = match migrated {
            Some(updated) => {
                match crate::config_io::write_atomic_config_migration_checked(
                    path,
                    &updated,
                    Some(raw.as_bytes()),
                ) {
                    Ok(()) => true,
                    Err(error) => {
                        warn_config_migration_paused_once(path, raw, &updated, &error);
                        false
                    }
                }
            }
            None => true,
        };
        if persisted {
            clear_config_migration_pause_marker(path);
        }
        if persisted && reset_pending {
            crate::core::telemetry_consent::mark_legacy_opt_out_reset();
        }
        persisted
    }

    pub(super) fn migrate_v4_config_document(
        raw: &str,
        selected_profile: Option<&str>,
        preserve_opt_out: bool,
    ) -> Result<Option<String>, String> {
        let document = raw
            .parse::<toml_edit::DocumentMut>()
            .map_err(|error| error.to_string())?;
        let legacy_telemetry = document
            .get("cloud")
            .and_then(|table| table.get("contribute_enabled"))
            .and_then(toml_edit::Item::as_bool)
            == Some(true);
        let persisted_opt_out = document
            .get("telemetry")
            .and_then(|table| table.get("enabled"))
            .and_then(toml_edit::Item::as_bool)
            == Some(false);
        let mut updated = if legacy_telemetry {
            migrate_legacy_contribute_document(raw, preserve_opt_out || persisted_opt_out)
                .ok_or_else(|| "invalid legacy telemetry configuration".to_string())?
        } else {
            raw.to_string()
        };
        if let Some(next) = Self::validated_v3_compression_document(&updated, selected_profile)? {
            updated = next;
        }
        if let Some(next) = super::retired::migrate_retired_document(&updated)? {
            updated = next;
        }
        parse_config_with_profile(&updated, selected_profile)?;
        Ok((updated != raw).then_some(updated))
    }

    pub(crate) fn validated_v3_compression_document(
        raw: &str,
        selected_profile: Option<&str>,
    ) -> Result<Option<String>, String> {
        let migrated = Self::migrate_v3_compression_document(raw)?;
        if let Some(ref updated) = migrated {
            parse_config_with_profile(updated, selected_profile)?;
        }
        Ok(migrated)
    }

    pub(crate) fn migrate_v3_compression_document(raw: &str) -> Result<Option<String>, String> {
        fn migrate_table(table: &mut toml_edit::Table) -> Result<bool, String> {
            let has_legacy = ["terse_agent", "output_density", "ultra_compact"]
                .iter()
                .any(|key| table.get(key).is_some());
            if !has_legacy {
                return Ok(false);
            }

            let terse = match table.get("terse_agent") {
                Some(item) => Some(
                    item.as_str()
                        .ok_or_else(|| "terse_agent must be a string".to_string())?,
                ),
                None => None,
            };
            if terse.is_some_and(|value| !matches!(value, "off" | "lite" | "full" | "ultra")) {
                return Err("invalid terse_agent".to_string());
            }
            let density = match table.get("output_density") {
                Some(item) => Some(
                    item.as_str()
                        .ok_or_else(|| "output_density must be a string".to_string())?,
                ),
                None => None,
            };
            if density.is_some_and(|value| !matches!(value, "normal" | "terse" | "ultra")) {
                return Err("invalid output_density".to_string());
            }
            let ultra = match table.get("ultra_compact") {
                Some(item) => Some(
                    item.as_bool()
                        .ok_or_else(|| "ultra_compact must be a boolean".to_string())?,
                ),
                None => None,
            };

            let level = if let Some(item) = table.get("compression_level") {
                let value = item
                    .as_str()
                    .ok_or_else(|| "compression_level must be a string".to_string())?;
                if !matches!(value, "off" | "lite" | "standard" | "max" | "raw") {
                    return Err(format!("invalid compression_level: {value}"));
                }
                value
            } else if ultra == Some(true) || terse == Some("ultra") || density == Some("ultra") {
                "max"
            } else if terse == Some("full") {
                "standard"
            } else if terse == Some("lite") || density == Some("terse") {
                "lite"
            } else {
                "off"
            };

            table["compression_level"] = toml_edit::value(level);
            for key in ["terse_agent", "output_density", "ultra_compact"] {
                table.remove(key);
            }
            Ok(true)
        }

        let mut document = raw
            .parse::<toml_edit::DocumentMut>()
            .map_err(|error| error.to_string())?;
        let mut changed = migrate_table(document.as_table_mut())?;
        if let Some(profiles) = document
            .get_mut("profiles")
            .and_then(toml_edit::Item::as_table_mut)
        {
            for (_, profile) in profiles.iter_mut() {
                if let Some(table) = profile.as_table_mut() {
                    changed |= migrate_table(table)?;
                }
            }
        }
        Ok(changed.then(|| document.to_string()))
    }

    /// Loads ONLY the global config file — never merging project-local
    /// `.lean-ctx.toml` overrides, and bypassing the in-memory cache. Every
    /// PERSIST path must use this (or [`Config::update_global`]): [`Config::load`]
    /// folds per-project overrides into the struct, and [`Config::save`] writes
    /// the whole struct back to the GLOBAL file — so a `load → mutate → save`
    /// round-trip silently leaks per-project values (and, historically, reset
    /// customized keys) into the global config (#443). Reading global-only makes
    /// the save leak-free by construction.
    pub fn load_global() -> Self {
        Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
    }

    /// Strict variant of [`Config::load_global`]: an unreadable or unparseable
    /// global config is an error instead of silently becoming the defaults.
    /// Consent-gated paths (telemetry send) must use this so a corrupt file
    /// never turns into default-on behaviour.
    pub fn try_load_global() -> Result<Self, super::error::LeanCtxError> {
        let path = Self::path().ok_or_else(|| {
            super::error::LeanCtxError::Config("cannot determine home directory".into())
        })?;
        Self::try_load_global_from(&path)
    }

    pub(super) fn try_load_global_from(path: &Path) -> Result<Self, super::error::LeanCtxError> {
        match std::fs::read_to_string(path) {
            Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).map_err(|error| {
                super::error::LeanCtxError::Config(
                    format!("refusing invalid global config.toml ({error})").into(),
                )
            }),
            Ok(_) => Ok(Self::default()),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
            Err(error) => Err(error.into()),
        }
    }

    /// Path-parameterized core of [`Config::load_global`] (unit-testable without
    /// the real config dir). Missing, empty, or unparseable files yield
    /// defaults; persisting callers that must not clobber a corrupt file use
    /// [`Config::update_global`], which refuses instead.
    pub(super) fn load_global_from(path: &Path) -> Self {
        Self::try_load_global_from(path).unwrap_or_default()
    }
}

/// Prefix of the per-config markers in the state dir that hold the
/// fingerprint of the last reported paused migration. Every command loads the
/// config, so without them one refused journal warns on every invocation
/// (#2175).
const CONFIG_MIGRATION_PAUSE_MARKER: &str = "config_migration_paused";

/// Marker for one config file: keyed by the blake3 of its canonical write
/// target, so two config paths (profiles, overrides) never share or clear
/// each other's state. Content-addressed name, no time or counter.
pub(super) fn config_migration_pause_marker(path: &Path) -> Option<std::path::PathBuf> {
    let target =
        crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
    let key = blake3::hash(target.to_string_lossy().as_bytes()).to_hex();
    crate::core::paths::state_dir()
        .ok()
        .map(|dir| dir.join(format!("{CONFIG_MIGRATION_PAUSE_MARKER}.{}", &key[..16])))
}

/// Why a config migration is paused and what the user can do about it.
/// Every field is a deterministic function of the config path and the
/// journal state on disk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigMigrationPause {
    /// Stable class, e.g. `write-refused` or `backup-mismatch`.
    pub label: &'static str,
    pub config: std::path::PathBuf,
    pub backup: std::path::PathBuf,
    pub receipt: std::path::PathBuf,
    /// What is wrong, in one sentence.
    pub reason: String,
    /// The action that works in this state.
    pub action: String,
    /// Archive that keeps the config from before the first recorded migration.
    pub original: Option<std::path::PathBuf>,
}

/// Describes the paused migration of `path` from the journal state on disk.
/// A journal that verifies (or none) means the writer itself was refused.
pub(super) fn describe_config_migration_pause(path: &Path) -> ConfigMigrationPause {
    use crate::config_io::{ConfigMigrationJournalRefusal as Refusal, ConfigMigrationJournalState};
    let config =
        crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
    let (backup, receipt) =
        crate::config_io::config_migration_journal_files(path).unwrap_or_else(|| {
            (
                config.with_file_name("config.toml.v4-migration.bak"),
                config.with_file_name("config.toml.v4-migration.json"),
            )
        });
    let next = crate::config_io::config_migration_next_receipt_file(path)
        .unwrap_or_else(|| config.with_file_name("config.toml.v4-migration.next.json"));
    let dir = config
        .parent()
        .map_or_else(|| ".".to_string(), |dir| dir.display().to_string());
    let (b, r, n, c) = (
        backup.display(),
        receipt.display(),
        next.display(),
        config.display(),
    );
    let fresh = "; the migration starts a fresh journal on the next command.";
    let state = crate::config_io::config_migration_journal_state(path);
    let (label, reason, action) = if let ConfigMigrationJournalState::Refused(refusal) = state {
        let (reason, action) = match refusal {
            Refusal::Symlink => (
                "a migration journal file is a symbolic link, which lean-ctx does not follow."
                    .to_string(),
                format!(
                    "Replace {b}, {r} and {n} with regular files or move them out of {dir}{fresh}"
                ),
            ),
            Refusal::Unreadable => (
                "a migration journal file cannot be read.".to_string(),
                format!(
                    "Make {b} and {r} readable for lean-ctx; the migration retries on the next command."
                ),
            ),
            Refusal::ReceiptUnverifiable => (
                format!(
                    "the journal receipt {r} cannot be verified, so neither the migration nor \
                         `lean-ctx migrate config --rollback` can use it."
                ),
                format!(
                    "Check {b} (the config before the recorded migration) and {r}, then move \
                         both out of {dir}{fresh}"
                ),
            ),
            Refusal::BackupMismatch => (
                format!(
                    "the journal backup {b} does not match its receipt {r}, so neither the \
                         migration nor `lean-ctx migrate config --rollback` can use it."
                ),
                format!("Check {b} and {r}, then move both out of {dir}{fresh}"),
            ),
            Refusal::OrphanBackup => (
                format!(
                    "{b} has no receipt and matches neither the config nor an archived journal."
                ),
                format!("Check {b}, then move it out of {dir}{fresh}"),
            ),
            Refusal::OrphanReceipt => (
                format!("{r} has no backup and the config is not the source it records."),
                format!("Check {r}, then move it out of {dir}{fresh}"),
            ),
            Refusal::NextReceiptUnverifiable => (
                format!(
                    "the unfinished journal update {n} cannot be verified and the config \
                         matches neither recorded state."
                ),
                format!(
                    "Check {n}, then move it out of {dir}; the migration retries on the next \
                         command."
                ),
            ),
        };
        (refusal.label(), reason, action)
    } else {
        let mut action = format!(
            "Make {dir} and {c} writable for lean-ctx; the migration retries on the next command."
        );
        if state.rollback_available() {
            action.push_str(&format!(
                " `lean-ctx migrate config --rollback` restores the config recorded in {b}."
            ));
        }
        (
            "write-refused",
            "lean-ctx could not write the migrated config or its migration journal.".to_string(),
            action,
        )
    };
    ConfigMigrationPause {
        label,
        original: crate::config_io::original_config_migration_backup(path),
        config,
        backup,
        receipt,
        reason,
        action,
    }
}

impl ConfigMigrationPause {
    /// The one-time warning text; stable for a given state.
    pub fn message(&self) -> String {
        let original = self
            .original
            .as_deref()
            .map(crate::config_io::original_config_sentence)
            .unwrap_or_default();
        format!(
            "config migration paused for {} ({}): {} {}{original} Reported once until the config \
             or the journal changes.",
            self.config.display(),
            self.label,
            self.reason,
            self.action
        )
    }
}

/// The paused migration of `path`, if the last load had to pause it (for
/// `lean-ctx doctor`, which reports it on every run, independent of the
/// once-per-state warning).
pub fn config_migration_pause_status(path: &Path) -> Option<ConfigMigrationPause> {
    config_migration_pause_marker(path)
        .is_some_and(|marker| marker.is_file())
        .then(|| describe_config_migration_pause(path))
}

/// Deterministic identity of one paused-migration state: the stable class,
/// the config path and bytes, the proposed output and every journal file.
/// Never the error text, which can carry random temporary file names.
pub(super) fn config_migration_pause_fingerprint(
    path: &Path,
    raw: &str,
    updated: &str,
    label: &str,
) -> String {
    let mut hasher = blake3::Hasher::new();
    let target =
        crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
    let target = target.to_string_lossy();
    for part in [
        label.as_bytes(),
        target.as_bytes(),
        raw.as_bytes(),
        updated.as_bytes(),
    ] {
        hasher.update(&(part.len() as u64).to_le_bytes());
        hasher.update(part);
    }
    let journal = crate::config_io::config_migration_journal_files(path)
        .map(|(backup, receipt)| vec![backup, receipt])
        .unwrap_or_default();
    let next = crate::config_io::config_migration_next_receipt_file(path);
    for file in journal.into_iter().chain(next) {
        let bytes = std::fs::read(file).unwrap_or_default();
        hasher.update(&(bytes.len() as u64).to_le_bytes());
        hasher.update(&bytes);
    }
    hasher.finalize().to_hex().to_string()
}

/// Warns about a refused config migration once per distinct state; repeats
/// of the same state go to debug level only. Returns whether it warned.
pub(super) fn warn_config_migration_paused_once(
    path: &Path,
    raw: &str,
    updated: &str,
    error: &str,
) -> bool {
    let pause = describe_config_migration_pause(path);
    let fingerprint = config_migration_pause_fingerprint(path, raw, updated, pause.label);
    let marker = config_migration_pause_marker(path);
    let reported = marker
        .as_deref()
        .and_then(|marker| std::fs::read_to_string(marker).ok())
        .is_some_and(|seen| seen.lines().next() == Some(fingerprint.as_str()));
    // The raw error can name random temporary files: debug only.
    tracing::debug!("config migration paused ({}): {error}", pause.label);
    if reported {
        return false;
    }
    tracing::warn!("{}", pause.message());
    if let Some(marker) = marker {
        if let Some(parent) = marker.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let _ = std::fs::write(marker, format!("{fingerprint}\n{}\n", pause.label));
    }
    true
}

fn clear_config_migration_pause_marker(path: &Path) {
    if let Some(marker) = config_migration_pause_marker(path)
        && marker.exists()
    {
        let _ = std::fs::remove_file(marker);
    }
}

// Respect declared read-only permissions even when a privileged process could
// bypass them. Other access restrictions still fail at the transactional writer.
fn allows_automatic_config_migration(path: &Path) -> bool {
    path.parent().is_some_and(|parent| {
        [path, parent].iter().all(|entry| {
            std::fs::metadata(entry).is_ok_and(|metadata| !metadata.permissions().readonly())
        })
    })
}

/// Re-enables a pre-v2 `telemetry.enabled = false` that carries no explicit
/// preference. Returns `None` when there is nothing to reset, including every
/// explicit choice (`explicitly_disabled` stays off).
pub(super) fn reset_legacy_telemetry_opt_out(raw: &str) -> Option<String> {
    let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
    let telemetry = document.get("telemetry")?;
    let disabled = telemetry.get("enabled").and_then(toml_edit::Item::as_bool) == Some(false);
    let implicit = matches!(
        telemetry.get("preference").map(toml_edit::Item::as_str),
        None | Some(Some("default_on"))
    );
    if !(disabled && implicit) {
        return None;
    }
    document["telemetry"]["enabled"] = toml_edit::value(true);
    Some(document.to_string())
}

fn migrate_legacy_contribute_document(raw: &str, preserve_opt_out: bool) -> Option<String> {
    let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
    document["cloud"]["contribute_enabled"] = toml_edit::value(false);
    if !preserve_opt_out {
        document["telemetry"]["enabled"] = toml_edit::value(true);
        document["telemetry"]["preference"] = toml_edit::value("explicitly_enabled");
    }
    Some(document.to_string())
}

#[cfg(test)]
mod telemetry_migration_tests {
    use super::*;

    #[test]
    #[cfg(unix)]
    fn automatic_migration_respects_readonly_file_and_directory() {
        use std::os::unix::fs::PermissionsExt;
        let directory = tempfile::tempdir().expect("migration directory");
        let path = directory.path().join("config.toml");
        std::fs::write(&path, "ultra_compact = true\n").expect("legacy config");
        assert!(allows_automatic_config_migration(&path));
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
        let readonly_file = allows_automatic_config_migration(&path);
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
        std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
        let readonly_directory = allows_automatic_config_migration(&path);
        std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
        assert!(!readonly_file);
        assert!(!readonly_directory);
        assert!(!allows_automatic_config_migration(
            &directory.path().join("missing.toml")
        ));
    }

    #[test]
    fn legacy_opt_in_becomes_explicit_and_send_eligible() {
        let migrated = migrate_legacy_contribute_document(
            "# keep me\n[cloud]\ncontribute_enabled = true\n",
            false,
        )
        .expect("valid TOML");
        assert!(migrated.contains("# keep me"));
        let cfg: Config = toml::from_str(&migrated).expect("migrated config");
        assert!(!cfg.cloud.contribute_enabled);
        assert!(cfg.telemetry.enabled);
        assert_eq!(
            cfg.telemetry.preference,
            super::super::TelemetryPreference::ExplicitlyEnabled
        );
        assert!(cfg.telemetry.send_eligible(None, None));
    }

    #[test]
    fn legacy_contribute_never_overrides_explicit_opt_out() {
        let migrated = migrate_legacy_contribute_document(
            "[cloud]\ncontribute_enabled = true\n[telemetry]\nenabled = false\n",
            true,
        )
        .expect("valid TOML");
        let cfg: Config = toml::from_str(&migrated).expect("migrated config");
        assert!(!cfg.cloud.contribute_enabled);
        assert!(cfg.telemetry.explicitly_disabled());
    }
}