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
12thread_local! {
13 static IGNORE_ENV_PROFILE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
16}
17
18pub(super) fn without_env_profile<T>(f: impl FnOnce() -> T) -> T {
20 let previous = IGNORE_ENV_PROFILE.with(|c| c.replace(true));
21 let out = f();
22 IGNORE_ENV_PROFILE.with(|c| c.set(previous));
23 out
24}
25
26pub(super) fn environment_config_profile() -> Option<String> {
27 if IGNORE_ENV_PROFILE.with(std::cell::Cell::get) {
28 return None;
29 }
30 std::env::var(CONFIG_PROFILE_ENV)
31 .ok()
32 .map(|name| name.trim().to_string())
33 .filter(|name| !name.is_empty())
34}
35
36pub(super) fn parse_config_with_profile(
39 raw: &str,
40 explicit_profile: Option<&str>,
41) -> Result<Config, String> {
42 resolve_config_table(raw, explicit_profile)?
43 .try_into()
44 .map_err(|error: toml::de::Error| error.to_string())
45}
46
47pub(super) fn resolve_config_table(
51 raw: &str,
52 explicit_profile: Option<&str>,
53) -> Result<toml::Value, String> {
54 let migrated = Config::migrate_v3_compression_document(raw)?;
55 let raw = migrated.as_deref().unwrap_or(raw);
56 let mut value: toml::Value = toml::from_str(raw).map_err(|error| error.to_string())?;
57 super::renamed::apply(
58 value
59 .as_table_mut()
60 .expect("a TOML document always has a root table"),
61 );
62 let configured_profile = value.get("config_profile").and_then(toml::Value::as_str);
63 let selected = explicit_profile
64 .map(str::trim)
65 .filter(|name| !name.is_empty())
66 .or(configured_profile);
67
68 if let Some(name) = selected {
69 let profiles = value
70 .get("profiles")
71 .and_then(toml::Value::as_table)
72 .ok_or_else(|| format!("config profile '{name}' selected but [profiles] is missing"))?;
73 let mut overlay = profiles
74 .get(name)
75 .and_then(toml::Value::as_table)
76 .cloned()
77 .ok_or_else(|| format!("config profile '{name}' is not defined"))?;
78 if overlay.remove("profiles").is_some() || overlay.remove("config_profile").is_some() {
79 return Err(format!(
80 "config profile '{name}' cannot override reserved profile keys"
81 ));
82 }
83 super::renamed::apply(&mut overlay);
84 merge_toml_tables(
85 value
86 .as_table_mut()
87 .expect("a TOML document always has a root table"),
88 overlay,
89 );
90 }
91
92 Ok(value)
93}
94
95fn merge_toml_tables(base: &mut toml::Table, overlay: toml::Table) {
96 for (key, overlay_value) in overlay {
97 match (base.get_mut(&key), overlay_value) {
98 (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
99 merge_toml_tables(base_table, overlay_table);
100 }
101 (_, replacement) => {
102 base.insert(key, replacement);
103 }
104 }
105 }
106}
107
108static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
114
115#[must_use]
118pub fn last_config_parse_error() -> Option<String> {
119 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
120}
121
122fn record_parse_error(err: Option<String>) {
123 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
124 *guard = err;
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum ConfigLoadFailure {
136 Unreadable,
140 Invalid,
143}
144
145impl ConfigLoadFailure {
146 #[must_use]
148 pub fn code(self) -> &'static str {
149 match self {
150 Self::Unreadable => "config.global_unreadable",
151 Self::Invalid => "config.global_invalid",
152 }
153 }
154}
155
156pub(crate) fn strip_sensitive_overrides(local: &mut Config) -> Vec<&'static str> {
169 let mut withheld: Vec<&'static str> = Vec::new();
170
171 if local.shell_allowlist != default_shell_allowlist() {
172 local.shell_allowlist = default_shell_allowlist();
173 withheld.push("shell_allowlist");
174 }
175 if !local.shell_allowlist_extra.is_empty() {
176 local.shell_allowlist_extra.clear();
177 withheld.push("shell_allowlist_extra");
178 }
179 if !local.allow_paths.is_empty() {
180 local.allow_paths.clear();
181 withheld.push("allow_paths");
182 }
183 if !local.extra_roots.is_empty() {
184 local.extra_roots.clear();
185 withheld.push("extra_roots");
186 }
187 if !local.write_allow_paths.is_empty() {
189 local.write_allow_paths.clear();
190 withheld.push("write_allow_paths");
191 }
192 if !local.read_only_roots.is_empty() {
194 local.read_only_roots.clear();
195 withheld.push("read_only_roots");
196 }
197 if local.read_redirect == super::ReadRedirect::Off {
199 local.read_redirect = super::ReadRedirect::default();
200 withheld.push("read_redirect");
201 }
202 if !local.allow_symlink_roots.is_empty() {
203 local.allow_symlink_roots.clear();
204 withheld.push("allow_symlink_roots");
205 }
206 if !local.write_allow_paths.is_empty() {
208 local.write_allow_paths.clear();
209 withheld.push("write_allow_paths");
210 }
211 if !local.custom_aliases.is_empty() {
212 local.custom_aliases.clear();
213 withheld.push("custom_aliases");
214 }
215 if !local.passthrough_urls.is_empty() {
216 local.passthrough_urls.clear();
217 withheld.push("passthrough_urls");
218 }
219 if !local.redirect_exclude.is_empty() {
222 local.redirect_exclude.clear();
223 withheld.push("redirect_exclude");
224 }
225 if local.proxy.anthropic_upstream.is_some()
226 || local.proxy.openai_upstream.is_some()
227 || local.proxy.chatgpt_upstream.is_some()
228 || local.proxy.gemini_upstream.is_some()
229 {
230 local.proxy.anthropic_upstream = None;
231 local.proxy.openai_upstream = None;
232 local.proxy.chatgpt_upstream = None;
233 local.proxy.gemini_upstream = None;
234 withheld.push("proxy.*_upstream");
235 }
236 if local.semantic_mode == super::SemanticMode::Eager {
239 local.semantic_mode = super::SemanticMode::default();
240 withheld.push("semantic_mode");
241 }
242 if local.rules_scope.is_some() {
243 local.rules_scope = None;
244 withheld.push("rules_scope");
245 }
246 if local.rules_injection.is_some() {
247 local.rules_injection = None;
248 withheld.push("rules_injection");
249 }
250 if local.permission_inheritance.is_some() {
251 local.permission_inheritance = None;
252 withheld.push("permission_inheritance");
253 }
254 if !local.disabled_tools.is_empty() {
255 local.disabled_tools.clear();
256 withheld.push("disabled_tools");
257 }
258 if local.tool_profile.is_some() {
259 local.tool_profile = None;
260 withheld.push("tool_profile");
261 }
262 if !local.tools_enabled.is_empty() {
263 local.tools_enabled.clear();
264 withheld.push("tools_enabled");
265 }
266 if !local.default_tool_categories.is_empty() {
267 local.default_tool_categories.clear();
268 withheld.push("default_tool_categories");
269 }
270 if !local.index.respect_gitignore {
271 local.index.respect_gitignore = true;
272 withheld.push("index.respect_gitignore");
273 }
274 if !local.shell_allowlist_subcommand_scoping {
275 local.shell_allowlist_subcommand_scoping = true;
276 withheld.push("shell_allowlist_subcommand_scoping");
277 }
278
279 withheld
280}
281
282fn note_gateway_store_ceiling(cfg: &Config) {
286 crate::core::context_admission::receipt_store::capacity::note_configured_ceiling(
287 cfg.gateway_store_configured_mb(),
288 );
289}
290
291#[must_use]
296pub fn local_sensitive_overrides(local_toml: &str) -> Vec<&'static str> {
297 let selected = environment_config_profile();
298 match parse_config_with_profile(local_toml, selected.as_deref()) {
299 Ok(mut parsed) => strip_sensitive_overrides(&mut parsed),
300 Err(_) => Vec::new(),
301 }
302}
303
304pub fn global_string_list(key: &str) -> Vec<String> {
308 let Some(path) = Config::path() else {
309 return Vec::new();
310 };
311 let Ok(raw) = std::fs::read_to_string(&path) else {
312 return Vec::new();
313 };
314 let Ok(table) = raw.parse::<toml::Table>() else {
315 return Vec::new();
316 };
317 table
318 .get(key)
319 .and_then(toml::Value::as_array)
320 .map(|arr| {
321 arr.iter()
322 .filter_map(|v| v.as_str().map(str::to_string))
323 .collect()
324 })
325 .unwrap_or_default()
326}
327
328impl Config {
329 pub fn path() -> Option<PathBuf> {
334 crate::core::paths::config_dir_read_only()
335 .ok()
336 .map(|d| d.join("config.toml"))
337 }
338
339 #[must_use]
350 pub fn missing_config_path() -> Option<PathBuf> {
351 match Self::path() {
352 Some(p) if !p.exists() => Some(p),
353 _ => None,
354 }
355 }
356
357 pub fn local_path(project_root: &str) -> PathBuf {
359 PathBuf::from(project_root).join(".lean-ctx.toml")
360 }
361
362 pub fn find_project_root() -> Option<String> {
372 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
373 if let Ok(project) =
374 crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone())
375 {
376 return project.map(|root| root.to_string_lossy().into_owned());
377 }
378 ROOT_CACHE
379 .get_or_init(Self::find_project_root_inner)
380 .clone()
381 }
382
383 fn find_project_root_inner() -> Option<String> {
384 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
385 && !env_root.is_empty()
386 {
387 return Some(env_root);
388 }
389
390 let cwd = std::env::current_dir().ok();
391
392 if let Some(root) =
393 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
394 {
395 let root_path = std::path::Path::new(&root);
396 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
397 let has_marker = crate::core::pathutil::has_project_marker(root_path);
402
403 if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
404 {
405 return Some(root);
406 }
407 }
408
409 if let Some(ref cwd) = cwd {
410 let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
414 let git_root = if may_probe_cwd {
415 std::process::Command::new("git")
416 .args(["rev-parse", "--show-toplevel"])
417 .current_dir(cwd)
418 .stdout(std::process::Stdio::piped())
419 .stderr(std::process::Stdio::null())
420 .output()
421 .ok()
422 .and_then(|o| {
423 if o.status.success() {
424 String::from_utf8(o.stdout)
425 .ok()
426 .map(|s| s.trim().to_string())
427 } else {
428 None
429 }
430 })
431 } else {
432 None
433 };
434 if let Some(root) = git_root {
435 return Some(root);
436 }
437 if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
438 return Some(cwd.to_string_lossy().to_string());
439 }
440 }
441 None
442 }
443
444 pub fn load() -> Self {
455 (*Self::load_arc()).clone()
456 }
457
458 pub fn load_arc() -> Arc<Self> {
465 static CACHE: Mutex<ConfigCacheSlot> = Mutex::new(None);
466 Self::load_arc_with(Self::find_project_root().as_deref(), &CACHE)
467 }
468
469 pub(super) fn load_arc_with(
470 project_root: Option<&str>,
471 cache: &Mutex<ConfigCacheSlot>,
472 ) -> Arc<Self> {
473 let Some(path) = Self::path() else {
474 return Arc::new(Self::default());
475 };
476
477 let local_path = project_root.map(Self::local_path);
478
479 let (global_content, unreadable) = match std::fs::read_to_string(&path) {
483 Ok(content) => (Some(content), None),
484 Err(error) if error.kind() == std::io::ErrorKind::NotFound => (None, None),
485 Err(error) => (None, Some(error.kind())),
486 };
487 let local_content = local_path
492 .as_ref()
493 .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
494 .and_then(|p| std::fs::read_to_string(p).ok());
495
496 let global_hash = global_content
498 .as_deref()
499 .map(crate::core::hasher::hash_str)
500 .or_else(|| unreadable.map(|kind| format!("unreadable:{kind:?}")));
501 let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
502 let local_key = local_hash
505 .as_deref()
506 .map(|hash| format!("{}\0{hash}", project_root.unwrap_or_default()));
507 let selected_profile = environment_config_profile();
508
509 let trusted = local_content.is_some()
517 && project_root.is_some_and(|r| {
518 crate::core::workspace_trust::is_trusted_for(
519 std::path::Path::new(r),
520 local_hash.as_deref().unwrap_or_default(),
521 )
522 });
523
524 if let Ok(guard) = cache.lock()
525 && let Some((
526 ref cfg,
527 ref cached_global,
528 ref cached_local,
529 ref cached_profile,
530 cached_trusted,
531 )) = *guard
532 && *cached_global == global_hash
533 && *cached_local == local_key
534 && *cached_profile == selected_profile
535 && cached_trusted == trusted
536 {
537 note_gateway_store_ceiling(cfg);
538 return Arc::clone(cfg);
539 }
540
541 let mut failure = unreadable.map(|_| ConfigLoadFailure::Unreadable);
542 let mut cfg: Config = if let Some(ref content) = global_content {
543 match parse_config_with_profile(content, selected_profile.as_deref()) {
544 Ok(c) => {
545 record_parse_error(None);
546 c
547 }
548 Err(e) => {
549 failure = Some(ConfigLoadFailure::Invalid);
550 record_parse_error(Some(e.clone()));
551 tracing::warn!("config parse error in {}: {e}", path.display());
552 eprintln!(
553 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
554 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
555 path.display()
556 );
557 Self::default()
558 }
559 }
560 } else if let Some(kind) = unreadable {
561 let message = format!("cannot read config: {kind}");
562 record_parse_error(Some(message.clone()));
563 tracing::warn!("{message} ({})", path.display());
564 eprintln!(
565 "\x1b[33m[lean-ctx] WARNING: {message} ({})\n \
566 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
567 path.display()
568 );
569 Self::default()
570 } else {
571 record_parse_error(None);
572 Self::default()
573 };
574
575 if let Some(ref local) = local_content {
576 cfg.merge_local(local, trusted);
577 }
578
579 if failure.is_none() {
582 cfg.migrate_v4_config_on_disk();
583 }
584 cfg.load_failure = failure;
586
587 let cfg = Arc::new(cfg);
588 if let Ok(mut guard) = cache.lock() {
589 *guard = Some((
590 Arc::clone(&cfg),
591 global_hash,
592 local_key,
593 selected_profile,
594 trusted,
595 ));
596 }
597
598 note_gateway_store_ceiling(&cfg);
599 cfg
600 }
601
602 pub fn load_for_project_root(project_root: &str) -> Self {
608 let selected_profile = environment_config_profile();
609 let mut cfg = Self::path()
610 .and_then(|p| std::fs::read_to_string(p).ok())
611 .and_then(|c| parse_config_with_profile(&c, selected_profile.as_deref()).ok())
612 .unwrap_or_default();
613 let local_path = Self::local_path(project_root);
614 if crate::core::pathutil::may_probe_path(local_path.as_path())
615 && let Ok(local) = std::fs::read_to_string(&local_path)
616 {
617 let trusted = crate::core::workspace_trust::is_trusted_for(
618 std::path::Path::new(project_root),
619 &crate::core::hasher::hash_str(&local),
620 );
621 cfg.merge_local(&local, trusted);
622 }
623 cfg
624 }
625
626 pub(crate) fn migrate_v4_config_on_disk(&mut self) {
631 let path = Self::path();
636 let writable = path
637 .as_deref()
638 .is_some_and(allows_automatic_config_migration);
639 let raw = path
640 .as_deref()
641 .filter(|_| writable)
642 .and_then(|path| std::fs::read_to_string(path).ok());
643 let reset_pending = crate::core::telemetry_consent::legacy_opt_out_reset_pending();
644 let reset = raw
645 .as_deref()
646 .filter(|_| reset_pending)
647 .and_then(reset_legacy_telemetry_opt_out);
648 let reenables =
649 reset.is_some() && self.telemetry.preference == super::TelemetryPreference::DefaultOn;
650 let preserve_opt_out = if reenables {
653 self.telemetry.preference == super::TelemetryPreference::ExplicitlyDisabled
654 } else {
655 self.telemetry.explicitly_disabled()
656 };
657 let persisted = self.persist_v4_migration(
662 path.as_deref(),
663 writable,
664 raw.as_deref(),
665 reset.as_deref(),
666 reset_pending,
667 preserve_opt_out,
668 );
669 if reenables && persisted {
670 self.telemetry.enabled = true;
671 }
672 let preserve_opt_out = self.telemetry.explicitly_disabled();
673 if self.cloud.contribute_enabled {
674 self.cloud.contribute_enabled = false;
675 if !preserve_opt_out {
676 self.telemetry.enabled = true;
677 self.telemetry.preference = super::TelemetryPreference::ExplicitlyEnabled;
678 }
679 }
680 }
681
682 fn persist_v4_migration(
686 &self,
687 path: Option<&Path>,
688 writable: bool,
689 raw: Option<&str>,
690 reset: Option<&str>,
691 reset_pending: bool,
692 preserve_opt_out: bool,
693 ) -> bool {
694 let Some(path) = path else {
695 return false;
696 };
697 let Some(raw) = raw else {
698 if !writable {
699 tracing::debug!(
700 "config migration remains in memory: source or directory is read-only"
701 );
702 }
703 if reset_pending && !path.exists() {
705 crate::core::telemetry_consent::mark_legacy_opt_out_reset();
706 }
707 return false;
708 };
709 let source = reset.unwrap_or(raw);
710 let migrated = match Self::migrate_v4_config_document(
711 source,
712 environment_config_profile().as_deref(),
713 preserve_opt_out,
714 ) {
715 Ok(Some(updated)) => Some(updated),
716 Ok(None) => (source != raw).then(|| source.to_string()),
717 Err(error) => {
718 tracing::warn!("config migration failed: {error}");
719 return false;
720 }
721 };
722 let persisted = match migrated {
723 Some(updated) => {
724 match crate::config_io::write_atomic_config_migration_checked(
725 path,
726 &updated,
727 Some(raw.as_bytes()),
728 ) {
729 Ok(()) => true,
730 Err(error) => {
731 warn_config_migration_paused_once(path, raw, &updated, &error);
732 false
733 }
734 }
735 }
736 None => true,
737 };
738 if persisted {
739 clear_config_migration_pause_marker(path);
740 }
741 if persisted && reset_pending {
742 crate::core::telemetry_consent::mark_legacy_opt_out_reset();
743 }
744 persisted
745 }
746
747 pub(super) fn migrate_v4_config_document(
748 raw: &str,
749 selected_profile: Option<&str>,
750 preserve_opt_out: bool,
751 ) -> Result<Option<String>, String> {
752 let document = raw
753 .parse::<toml_edit::DocumentMut>()
754 .map_err(|error| error.to_string())?;
755 let legacy_telemetry = document
756 .get("cloud")
757 .and_then(|table| table.get("contribute_enabled"))
758 .and_then(toml_edit::Item::as_bool)
759 == Some(true);
760 let persisted_opt_out = document
761 .get("telemetry")
762 .and_then(|table| table.get("enabled"))
763 .and_then(toml_edit::Item::as_bool)
764 == Some(false);
765 let mut updated = if legacy_telemetry {
766 migrate_legacy_contribute_document(raw, preserve_opt_out || persisted_opt_out)
767 .ok_or_else(|| "invalid legacy telemetry configuration".to_string())?
768 } else {
769 raw.to_string()
770 };
771 if let Some(next) = Self::validated_v3_compression_document(&updated, selected_profile)? {
772 updated = next;
773 }
774 if let Some(next) = super::retired::migrate_retired_document(&updated)? {
775 updated = next;
776 }
777 parse_config_with_profile(&updated, selected_profile)?;
778 Ok((updated != raw).then_some(updated))
779 }
780
781 pub(crate) fn validated_v3_compression_document(
782 raw: &str,
783 selected_profile: Option<&str>,
784 ) -> Result<Option<String>, String> {
785 let migrated = Self::migrate_v3_compression_document(raw)?;
786 if let Some(ref updated) = migrated {
787 parse_config_with_profile(updated, selected_profile)?;
788 }
789 Ok(migrated)
790 }
791
792 pub(crate) fn migrate_v3_compression_document(raw: &str) -> Result<Option<String>, String> {
793 fn migrate_table(table: &mut toml_edit::Table) -> Result<bool, String> {
794 let has_legacy = ["terse_agent", "output_density", "ultra_compact"]
795 .iter()
796 .any(|key| table.get(key).is_some());
797 if !has_legacy {
798 return Ok(false);
799 }
800
801 let terse = match table.get("terse_agent") {
802 Some(item) => Some(
803 item.as_str()
804 .ok_or_else(|| "terse_agent must be a string".to_string())?,
805 ),
806 None => None,
807 };
808 if terse.is_some_and(|value| !matches!(value, "off" | "lite" | "full" | "ultra")) {
809 return Err("invalid terse_agent".to_string());
810 }
811 let density = match table.get("output_density") {
812 Some(item) => Some(
813 item.as_str()
814 .ok_or_else(|| "output_density must be a string".to_string())?,
815 ),
816 None => None,
817 };
818 if density.is_some_and(|value| !matches!(value, "normal" | "terse" | "ultra")) {
819 return Err("invalid output_density".to_string());
820 }
821 let ultra = match table.get("ultra_compact") {
822 Some(item) => Some(
823 item.as_bool()
824 .ok_or_else(|| "ultra_compact must be a boolean".to_string())?,
825 ),
826 None => None,
827 };
828
829 let level = if let Some(item) = table.get("compression_level") {
830 let value = item
831 .as_str()
832 .ok_or_else(|| "compression_level must be a string".to_string())?;
833 if !matches!(value, "off" | "lite" | "standard" | "max" | "raw") {
834 return Err(format!("invalid compression_level: {value}"));
835 }
836 value
837 } else if ultra == Some(true) || terse == Some("ultra") || density == Some("ultra") {
838 "max"
839 } else if terse == Some("full") {
840 "standard"
841 } else if terse == Some("lite") || density == Some("terse") {
842 "lite"
843 } else {
844 "off"
845 };
846
847 table["compression_level"] = toml_edit::value(level);
848 for key in ["terse_agent", "output_density", "ultra_compact"] {
849 table.remove(key);
850 }
851 Ok(true)
852 }
853
854 let mut document = raw
855 .parse::<toml_edit::DocumentMut>()
856 .map_err(|error| error.to_string())?;
857 let mut changed = migrate_table(document.as_table_mut())?;
858 if let Some(profiles) = document
859 .get_mut("profiles")
860 .and_then(toml_edit::Item::as_table_mut)
861 {
862 for (_, profile) in profiles.iter_mut() {
863 if let Some(table) = profile.as_table_mut() {
864 changed |= migrate_table(table)?;
865 }
866 }
867 }
868 Ok(changed.then(|| document.to_string()))
869 }
870
871 pub fn load_global() -> Self {
880 Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
881 }
882
883 pub fn try_load_global() -> Result<Self, super::error::LeanCtxError> {
888 let path = Self::path().ok_or_else(|| {
889 super::error::LeanCtxError::Config("cannot determine home directory".into())
890 })?;
891 Self::try_load_global_from(&path)
892 }
893
894 pub(super) fn try_load_global_from(path: &Path) -> Result<Self, super::error::LeanCtxError> {
895 match std::fs::read_to_string(path) {
896 Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).map_err(|error| {
897 super::error::LeanCtxError::Config(
898 format!("refusing invalid global config.toml ({error})").into(),
899 )
900 }),
901 Ok(_) => Ok(Self::default()),
902 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
903 Err(error) => Err(error.into()),
904 }
905 }
906
907 pub(super) fn load_global_from(path: &Path) -> Self {
912 Self::try_load_global_from(path).unwrap_or_default()
913 }
914}
915
916const CONFIG_MIGRATION_PAUSE_MARKER: &str = "config_migration_paused";
921
922pub(super) fn config_migration_pause_marker(path: &Path) -> Option<std::path::PathBuf> {
926 let target =
927 crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
928 let key = blake3::hash(target.to_string_lossy().as_bytes()).to_hex();
929 crate::core::paths::state_dir()
930 .ok()
931 .map(|dir| dir.join(format!("{CONFIG_MIGRATION_PAUSE_MARKER}.{}", &key[..16])))
932}
933
934#[derive(Debug, Clone, PartialEq, Eq)]
938pub struct ConfigMigrationPause {
939 pub label: &'static str,
941 pub config: std::path::PathBuf,
942 pub backup: std::path::PathBuf,
943 pub receipt: std::path::PathBuf,
944 pub reason: String,
946 pub action: String,
948 pub original: Option<std::path::PathBuf>,
950}
951
952pub(super) fn describe_config_migration_pause(path: &Path) -> ConfigMigrationPause {
955 use crate::config_io::{ConfigMigrationJournalRefusal as Refusal, ConfigMigrationJournalState};
956 let config =
957 crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
958 let (backup, receipt) =
959 crate::config_io::config_migration_journal_files(path).unwrap_or_else(|| {
960 (
961 config.with_file_name("config.toml.v4-migration.bak"),
962 config.with_file_name("config.toml.v4-migration.json"),
963 )
964 });
965 let next = crate::config_io::config_migration_next_receipt_file(path)
966 .unwrap_or_else(|| config.with_file_name("config.toml.v4-migration.next.json"));
967 let dir = config
968 .parent()
969 .map_or_else(|| ".".to_string(), |dir| dir.display().to_string());
970 let (b, r, n, c) = (
971 backup.display(),
972 receipt.display(),
973 next.display(),
974 config.display(),
975 );
976 let fresh = "; the migration starts a fresh journal on the next command.";
977 let state = crate::config_io::config_migration_journal_state(path);
978 let (label, reason, action) = if let ConfigMigrationJournalState::Refused(refusal) = state {
979 let (reason, action) = match refusal {
980 Refusal::Symlink => (
981 "a migration journal file is a symbolic link, which lean-ctx does not follow."
982 .to_string(),
983 format!(
984 "Replace {b}, {r} and {n} with regular files or move them out of {dir}{fresh}"
985 ),
986 ),
987 Refusal::Unreadable => (
988 "a migration journal file cannot be read.".to_string(),
989 format!(
990 "Make {b} and {r} readable for lean-ctx; the migration retries on the next command."
991 ),
992 ),
993 Refusal::ReceiptUnverifiable => (
994 format!(
995 "the journal receipt {r} cannot be verified, so neither the migration nor \
996 `lean-ctx migrate config --rollback` can use it."
997 ),
998 format!(
999 "Check {b} (the config before the recorded migration) and {r}, then move \
1000 both out of {dir}{fresh}"
1001 ),
1002 ),
1003 Refusal::BackupMismatch => (
1004 format!(
1005 "the journal backup {b} does not match its receipt {r}, so neither the \
1006 migration nor `lean-ctx migrate config --rollback` can use it."
1007 ),
1008 format!("Check {b} and {r}, then move both out of {dir}{fresh}"),
1009 ),
1010 Refusal::OrphanBackup => (
1011 format!(
1012 "{b} has no receipt and matches neither the config nor an archived journal."
1013 ),
1014 format!("Check {b}, then move it out of {dir}{fresh}"),
1015 ),
1016 Refusal::OrphanReceipt => (
1017 format!("{r} has no backup and the config is not the source it records."),
1018 format!("Check {r}, then move it out of {dir}{fresh}"),
1019 ),
1020 Refusal::NextReceiptUnverifiable => (
1021 format!(
1022 "the unfinished journal update {n} cannot be verified and the config \
1023 matches neither recorded state."
1024 ),
1025 format!(
1026 "Check {n}, then move it out of {dir}; the migration retries on the next \
1027 command."
1028 ),
1029 ),
1030 };
1031 (refusal.label(), reason, action)
1032 } else {
1033 let mut action = format!(
1034 "Make {dir} and {c} writable for lean-ctx; the migration retries on the next command."
1035 );
1036 if state.rollback_available() {
1037 action.push_str(&format!(
1038 " `lean-ctx migrate config --rollback` restores the config recorded in {b}."
1039 ));
1040 }
1041 (
1042 "write-refused",
1043 "lean-ctx could not write the migrated config or its migration journal.".to_string(),
1044 action,
1045 )
1046 };
1047 ConfigMigrationPause {
1048 label,
1049 original: crate::config_io::original_config_migration_backup(path),
1050 config,
1051 backup,
1052 receipt,
1053 reason,
1054 action,
1055 }
1056}
1057
1058impl ConfigMigrationPause {
1059 pub fn message(&self) -> String {
1061 let original = self
1062 .original
1063 .as_deref()
1064 .map(crate::config_io::original_config_sentence)
1065 .unwrap_or_default();
1066 format!(
1067 "config migration paused for {} ({}): {} {}{original} Reported once until the config \
1068 or the journal changes.",
1069 self.config.display(),
1070 self.label,
1071 self.reason,
1072 self.action
1073 )
1074 }
1075}
1076
1077pub fn config_migration_pause_status(path: &Path) -> Option<ConfigMigrationPause> {
1081 config_migration_pause_marker(path)
1082 .is_some_and(|marker| marker.is_file())
1083 .then(|| describe_config_migration_pause(path))
1084}
1085
1086pub(super) fn config_migration_pause_fingerprint(
1090 path: &Path,
1091 raw: &str,
1092 updated: &str,
1093 label: &str,
1094) -> String {
1095 let mut hasher = blake3::Hasher::new();
1096 let target =
1097 crate::config_io::config_migration_target(path).unwrap_or_else(|| path.to_path_buf());
1098 let target = target.to_string_lossy();
1099 for part in [
1100 label.as_bytes(),
1101 target.as_bytes(),
1102 raw.as_bytes(),
1103 updated.as_bytes(),
1104 ] {
1105 hasher.update(&(part.len() as u64).to_le_bytes());
1106 hasher.update(part);
1107 }
1108 let journal = crate::config_io::config_migration_journal_files(path)
1109 .map(|(backup, receipt)| vec![backup, receipt])
1110 .unwrap_or_default();
1111 let next = crate::config_io::config_migration_next_receipt_file(path);
1112 for file in journal.into_iter().chain(next) {
1113 let bytes = std::fs::read(file).unwrap_or_default();
1114 hasher.update(&(bytes.len() as u64).to_le_bytes());
1115 hasher.update(&bytes);
1116 }
1117 hasher.finalize().to_hex().to_string()
1118}
1119
1120pub(super) fn warn_config_migration_paused_once(
1123 path: &Path,
1124 raw: &str,
1125 updated: &str,
1126 error: &str,
1127) -> bool {
1128 let pause = describe_config_migration_pause(path);
1129 let fingerprint = config_migration_pause_fingerprint(path, raw, updated, pause.label);
1130 let marker = config_migration_pause_marker(path);
1131 let reported = marker
1132 .as_deref()
1133 .and_then(|marker| std::fs::read_to_string(marker).ok())
1134 .is_some_and(|seen| seen.lines().next() == Some(fingerprint.as_str()));
1135 tracing::debug!("config migration paused ({}): {error}", pause.label);
1137 if reported {
1138 return false;
1139 }
1140 tracing::warn!("{}", pause.message());
1141 if let Some(marker) = marker {
1142 if let Some(parent) = marker.parent() {
1143 let _ = std::fs::create_dir_all(parent);
1144 }
1145 let _ = std::fs::write(marker, format!("{fingerprint}\n{}\n", pause.label));
1146 }
1147 true
1148}
1149
1150fn clear_config_migration_pause_marker(path: &Path) {
1151 if let Some(marker) = config_migration_pause_marker(path)
1152 && marker.exists()
1153 {
1154 let _ = std::fs::remove_file(marker);
1155 }
1156}
1157
1158fn allows_automatic_config_migration(path: &Path) -> bool {
1161 path.parent().is_some_and(|parent| {
1162 [path, parent].iter().all(|entry| {
1163 std::fs::metadata(entry).is_ok_and(|metadata| !metadata.permissions().readonly())
1164 })
1165 })
1166}
1167
1168pub(super) fn reset_legacy_telemetry_opt_out(raw: &str) -> Option<String> {
1172 let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
1173 let telemetry = document.get("telemetry")?;
1174 let disabled = telemetry.get("enabled").and_then(toml_edit::Item::as_bool) == Some(false);
1175 let implicit = matches!(
1176 telemetry.get("preference").map(toml_edit::Item::as_str),
1177 None | Some(Some("default_on"))
1178 );
1179 if !(disabled && implicit) {
1180 return None;
1181 }
1182 document["telemetry"]["enabled"] = toml_edit::value(true);
1183 Some(document.to_string())
1184}
1185
1186fn migrate_legacy_contribute_document(raw: &str, preserve_opt_out: bool) -> Option<String> {
1187 let mut document = raw.parse::<toml_edit::DocumentMut>().ok()?;
1188 document["cloud"]["contribute_enabled"] = toml_edit::value(false);
1189 if !preserve_opt_out {
1190 document["telemetry"]["enabled"] = toml_edit::value(true);
1191 document["telemetry"]["preference"] = toml_edit::value("explicitly_enabled");
1192 }
1193 Some(document.to_string())
1194}
1195
1196#[cfg(test)]
1197mod telemetry_migration_tests {
1198 use super::*;
1199
1200 #[test]
1201 #[cfg(unix)]
1202 fn automatic_migration_respects_readonly_file_and_directory() {
1203 use std::os::unix::fs::PermissionsExt;
1204 let directory = tempfile::tempdir().expect("migration directory");
1205 let path = directory.path().join("config.toml");
1206 std::fs::write(&path, "ultra_compact = true\n").expect("legacy config");
1207 assert!(allows_automatic_config_migration(&path));
1208 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
1209 let readonly_file = allows_automatic_config_migration(&path);
1210 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1211 std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
1212 let readonly_directory = allows_automatic_config_migration(&path);
1213 std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1214 assert!(!readonly_file);
1215 assert!(!readonly_directory);
1216 assert!(!allows_automatic_config_migration(
1217 &directory.path().join("missing.toml")
1218 ));
1219 }
1220
1221 #[test]
1222 fn legacy_opt_in_becomes_explicit_and_send_eligible() {
1223 let migrated = migrate_legacy_contribute_document(
1224 "# keep me\n[cloud]\ncontribute_enabled = true\n",
1225 false,
1226 )
1227 .expect("valid TOML");
1228 assert!(migrated.contains("# keep me"));
1229 let cfg: Config = toml::from_str(&migrated).expect("migrated config");
1230 assert!(!cfg.cloud.contribute_enabled);
1231 assert!(cfg.telemetry.enabled);
1232 assert_eq!(
1233 cfg.telemetry.preference,
1234 super::super::TelemetryPreference::ExplicitlyEnabled
1235 );
1236 assert!(cfg.telemetry.send_eligible(None, None));
1237 }
1238
1239 #[test]
1240 fn legacy_contribute_never_overrides_explicit_opt_out() {
1241 let migrated = migrate_legacy_contribute_document(
1242 "[cloud]\ncontribute_enabled = true\n[telemetry]\nenabled = false\n",
1243 true,
1244 )
1245 .expect("valid TOML");
1246 let cfg: Config = toml::from_str(&migrated).expect("migrated config");
1247 assert!(!cfg.cloud.contribute_enabled);
1248 assert!(cfg.telemetry.explicitly_disabled());
1249 }
1250}