1use 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
12static CONFIG_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
15
16thread_local! {
17 static IGNORE_ENV_PROFILE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
20}
21
22pub(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
40pub(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
51pub(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
112static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
118
119#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ConfigLoadFailure {
140 Unreadable,
144 Invalid,
147}
148
149impl ConfigLoadFailure {
150 #[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
160pub(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 if !local.write_allow_paths.is_empty() {
193 local.write_allow_paths.clear();
194 withheld.push("write_allow_paths");
195 }
196 if !local.read_only_roots.is_empty() {
198 local.read_only_roots.clear();
199 withheld.push("read_only_roots");
200 }
201 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 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 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 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
286fn 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#[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
308pub 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 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 #[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 pub fn local_path(project_root: &str) -> PathBuf {
363 PathBuf::from(project_root).join(".lean-ctx.toml")
364 }
365
366 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 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 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 pub fn load() -> Self {
459 (*Self::load_arc()).clone()
460 }
461
462 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 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 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 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 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 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 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 if failure.is_none() {
599 cfg.migrate_v4_config_on_disk();
600 }
601 if let Some(managed) = &managed {
604 managed.apply_to_hive(&mut cfg.hive);
605 }
606 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 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 pub(crate) fn migrate_v4_config_on_disk(&mut self) {
655 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 let preserve_opt_out = if reenables {
677 self.telemetry.preference == super::TelemetryPreference::ExplicitlyDisabled
678 } else {
679 self.telemetry.explicitly_disabled()
680 };
681 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 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 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 pub fn load_global() -> Self {
904 Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
905 }
906
907 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 pub(super) fn load_global_from(path: &Path) -> Self {
936 Self::try_load_global_from(path).unwrap_or_default()
937 }
938}
939
940const CONFIG_MIGRATION_PAUSE_MARKER: &str = "config_migration_paused";
945
946pub(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#[derive(Debug, Clone, PartialEq, Eq)]
962pub struct ConfigMigrationPause {
963 pub label: &'static str,
965 pub config: std::path::PathBuf,
966 pub backup: std::path::PathBuf,
967 pub receipt: std::path::PathBuf,
968 pub reason: String,
970 pub action: String,
972 pub original: Option<std::path::PathBuf>,
974}
975
976pub(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 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
1101pub 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
1110pub(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
1144pub(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 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
1182fn 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
1192pub(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}