lean_ctx/core/config/
loader.rs1use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5
6use super::{CognitiveMode, Config, ConfigCacheSlot, default_shell_allowlist};
7
8const CONFIG_PROFILE_ENV: &str = "LEAN_CTX_CONFIG_PROFILE";
9
10pub(super) fn environment_config_profile() -> Option<String> {
11 std::env::var(CONFIG_PROFILE_ENV)
12 .ok()
13 .map(|name| name.trim().to_string())
14 .filter(|name| !name.is_empty())
15}
16
17pub(super) fn parse_config_with_profile(
20 raw: &str,
21 explicit_profile: Option<&str>,
22) -> Result<Config, String> {
23 let mut value: toml::Value = toml::from_str(raw).map_err(|error| error.to_string())?;
24 let configured_profile = value.get("config_profile").and_then(toml::Value::as_str);
25 let selected = explicit_profile
26 .map(str::trim)
27 .filter(|name| !name.is_empty())
28 .or(configured_profile);
29
30 if let Some(name) = selected {
31 let profiles = value
32 .get("profiles")
33 .and_then(toml::Value::as_table)
34 .ok_or_else(|| format!("config profile '{name}' selected but [profiles] is missing"))?;
35 let mut overlay = profiles
36 .get(name)
37 .and_then(toml::Value::as_table)
38 .cloned()
39 .ok_or_else(|| format!("config profile '{name}' is not defined"))?;
40 if overlay.remove("profiles").is_some() || overlay.remove("config_profile").is_some() {
41 return Err(format!(
42 "config profile '{name}' cannot override reserved profile keys"
43 ));
44 }
45 merge_toml_tables(
46 value
47 .as_table_mut()
48 .expect("a TOML document always has a root table"),
49 overlay,
50 );
51 }
52
53 value.try_into().map_err(|error| error.to_string())
54}
55
56fn merge_toml_tables(base: &mut toml::Table, overlay: toml::Table) {
57 for (key, overlay_value) in overlay {
58 match (base.get_mut(&key), overlay_value) {
59 (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
60 merge_toml_tables(base_table, overlay_table);
61 }
62 (_, replacement) => {
63 base.insert(key, replacement);
64 }
65 }
66 }
67}
68
69static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
75
76#[must_use]
79pub fn last_config_parse_error() -> Option<String> {
80 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
81}
82
83fn record_parse_error(err: Option<String>) {
84 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
85 *guard = err;
86 }
87}
88
89pub(crate) fn strip_sensitive_overrides(local: &mut Config) -> Vec<&'static str> {
102 let mut withheld: Vec<&'static str> = Vec::new();
103
104 if local.shell_allowlist != default_shell_allowlist() {
105 local.shell_allowlist = default_shell_allowlist();
106 withheld.push("shell_allowlist");
107 }
108 if !local.shell_allowlist_extra.is_empty() {
109 local.shell_allowlist_extra.clear();
110 withheld.push("shell_allowlist_extra");
111 }
112 if !local.allow_paths.is_empty() {
113 local.allow_paths.clear();
114 withheld.push("allow_paths");
115 }
116 if !local.extra_roots.is_empty() {
117 local.extra_roots.clear();
118 withheld.push("extra_roots");
119 }
120 if !local.allow_symlink_roots.is_empty() {
121 local.allow_symlink_roots.clear();
122 withheld.push("allow_symlink_roots");
123 }
124 if !local.custom_aliases.is_empty() {
125 local.custom_aliases.clear();
126 withheld.push("custom_aliases");
127 }
128 if !local.passthrough_urls.is_empty() {
129 local.passthrough_urls.clear();
130 withheld.push("passthrough_urls");
131 }
132 if local.proxy.anthropic_upstream.is_some()
133 || local.proxy.openai_upstream.is_some()
134 || local.proxy.chatgpt_upstream.is_some()
135 || local.proxy.gemini_upstream.is_some()
136 {
137 local.proxy.anthropic_upstream = None;
138 local.proxy.openai_upstream = None;
139 local.proxy.chatgpt_upstream = None;
140 local.proxy.gemini_upstream = None;
141 withheld.push("proxy.*_upstream");
142 }
143 if local.rules_scope.is_some() {
144 local.rules_scope = None;
145 withheld.push("rules_scope");
146 }
147 if local.rules_injection.is_some() {
148 local.rules_injection = None;
149 withheld.push("rules_injection");
150 }
151 if local.permission_inheritance.is_some() {
152 local.permission_inheritance = None;
153 withheld.push("permission_inheritance");
154 }
155 if !local.disabled_tools.is_empty() {
156 local.disabled_tools.clear();
157 withheld.push("disabled_tools");
158 }
159 if local.tool_profile.is_some() {
160 local.tool_profile = None;
161 withheld.push("tool_profile");
162 }
163 if !local.tools_enabled.is_empty() {
164 local.tools_enabled.clear();
165 withheld.push("tools_enabled");
166 }
167 if !local.default_tool_categories.is_empty() {
168 local.default_tool_categories.clear();
169 withheld.push("default_tool_categories");
170 }
171 if !local.index.respect_gitignore {
172 local.index.respect_gitignore = true;
173 withheld.push("index.respect_gitignore");
174 }
175
176 withheld
177}
178
179#[must_use]
184pub fn local_sensitive_overrides(local_toml: &str) -> Vec<&'static str> {
185 let selected = environment_config_profile();
186 match parse_config_with_profile(local_toml, selected.as_deref()) {
187 Ok(mut parsed) => strip_sensitive_overrides(&mut parsed),
188 Err(_) => Vec::new(),
189 }
190}
191
192impl Config {
193 pub fn path() -> Option<PathBuf> {
199 crate::core::paths::config_dir()
200 .ok()
201 .map(|d| d.join("config.toml"))
202 }
203
204 #[must_use]
215 pub fn missing_config_path() -> Option<PathBuf> {
216 match Self::path() {
217 Some(p) if !p.exists() => Some(p),
218 _ => None,
219 }
220 }
221
222 pub fn local_path(project_root: &str) -> PathBuf {
224 PathBuf::from(project_root).join(".lean-ctx.toml")
225 }
226
227 pub(crate) fn find_project_root() -> Option<String> {
232 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
233 ROOT_CACHE
234 .get_or_init(Self::find_project_root_inner)
235 .clone()
236 }
237
238 fn find_project_root_inner() -> Option<String> {
239 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
240 && !env_root.is_empty()
241 {
242 return Some(env_root);
243 }
244
245 let cwd = std::env::current_dir().ok();
246
247 if let Some(root) =
248 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
249 {
250 let root_path = std::path::Path::new(&root);
251 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
252 let has_marker = crate::core::pathutil::has_project_marker(root_path);
257
258 if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
259 {
260 return Some(root);
261 }
262 }
263
264 if let Some(ref cwd) = cwd {
265 let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
269 let git_root = if may_probe_cwd {
270 std::process::Command::new("git")
271 .args(["rev-parse", "--show-toplevel"])
272 .current_dir(cwd)
273 .stdout(std::process::Stdio::piped())
274 .stderr(std::process::Stdio::null())
275 .output()
276 .ok()
277 .and_then(|o| {
278 if o.status.success() {
279 String::from_utf8(o.stdout)
280 .ok()
281 .map(|s| s.trim().to_string())
282 } else {
283 None
284 }
285 })
286 } else {
287 None
288 };
289 if let Some(root) = git_root {
290 return Some(root);
291 }
292 if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
293 return Some(cwd.to_string_lossy().to_string());
294 }
295 }
296 None
297 }
298
299 pub fn load() -> Self {
310 (*Self::load_arc()).clone()
311 }
312
313 pub fn load_arc() -> Arc<Self> {
320 static CACHE: Mutex<ConfigCacheSlot> = Mutex::new(None);
321
322 let Some(path) = Self::path() else {
323 return Arc::new(Self::default());
324 };
325
326 let project_root = Self::find_project_root();
327 let local_path = project_root.as_deref().map(Self::local_path);
328
329 let global_content = std::fs::read_to_string(&path).ok();
331 let local_content = local_path
336 .as_ref()
337 .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
338 .and_then(|p| std::fs::read_to_string(p).ok());
339
340 let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
341 let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
342 let selected_profile = environment_config_profile();
343
344 if let Ok(guard) = CACHE.lock()
345 && let Some((ref cfg, ref cached_global, ref cached_local, ref cached_profile)) = *guard
346 && *cached_global == global_hash
347 && *cached_local == local_hash
348 && *cached_profile == selected_profile
349 {
350 return Arc::clone(cfg);
351 }
352
353 let mut cfg: Config = if let Some(ref content) = global_content {
354 match parse_config_with_profile(content, selected_profile.as_deref()) {
355 Ok(c) => {
356 record_parse_error(None);
357 c
358 }
359 Err(e) => {
360 record_parse_error(Some(e.clone()));
361 tracing::warn!("config parse error in {}: {e}", path.display());
362 eprintln!(
363 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
364 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
365 path.display()
366 );
367 Self::default()
368 }
369 }
370 } else {
371 record_parse_error(None);
372 Self::default()
373 };
374
375 if let Some(ref local) = local_content {
376 let trusted = project_root.as_deref().is_some_and(|r| {
382 crate::core::workspace_trust::is_trusted_for(
383 std::path::Path::new(r),
384 local_hash.as_deref().unwrap_or_default(),
385 )
386 });
387 cfg.merge_local(local, trusted);
388 }
389
390 cfg.migrate_contribute_to_telemetry();
391 cfg.migrate_cognitive_mode_to_full();
392
393 let cfg = Arc::new(cfg);
394 if let Ok(mut guard) = CACHE.lock() {
395 *guard = Some((Arc::clone(&cfg), global_hash, local_hash, selected_profile));
396 }
397
398 cfg
399 }
400
401 pub(crate) fn migrate_contribute_to_telemetry(&mut self) {
410 if self.cloud.contribute_enabled && !self.telemetry.enabled {
411 self.telemetry.enabled = true;
412 self.cloud.contribute_enabled = false;
413
414 if let Some(path) = Self::path() {
415 if let Ok(raw) = std::fs::read_to_string(&path) {
416 let mut updated =
417 raw.replace("contribute_enabled = true", "contribute_enabled = false");
418 if !updated.contains("[telemetry]") {
419 if !updated.ends_with('\n') {
420 updated.push('\n');
421 }
422 updated.push_str("\n[telemetry]\nenabled = true\n");
423 } else if let Some(tpos) = updated.find("[telemetry]") {
424 let after = &updated[tpos..];
425 if let Some(epos) = after.find("enabled = false") {
426 let abs_pos = tpos + epos;
427 updated.replace_range(
428 abs_pos..abs_pos + "enabled = false".len(),
429 "enabled = true",
430 );
431 }
432 }
433 let _ = crate::config_io::write_atomic_with_backup(&path, &updated);
434 }
435 }
436 }
437 }
438
439 pub(crate) fn migrate_cognitive_mode_to_full(&mut self) {
444 if !matches!(self.cognitive_mode, CognitiveMode::Basic) {
445 return;
446 }
447 self.cognitive_mode = CognitiveMode::Full;
448
449 if let Some(path) = Self::path() {
450 if let Ok(raw) = std::fs::read_to_string(&path) {
451 let updated =
452 raw.replace("cognitive_mode = \"basic\"", "cognitive_mode = \"full\"");
453 let _ = crate::config_io::write_atomic_with_backup(&path, &updated);
454 }
455 }
456 }
457
458 pub fn load_global() -> Self {
467 Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
468 }
469
470 pub(super) fn load_global_from(path: &Path) -> Self {
475 match std::fs::read_to_string(path) {
476 Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).unwrap_or_default(),
477 _ => Self::default(),
478 }
479 }
480}