lean_ctx/core/config/
logic.rs1use std::path::Path;
2
3#[allow(clippy::wildcard_imports)]
4use super::*;
5impl Config {
6 pub fn crush_verbatim_json_enabled(&self) -> bool {
10 std::env::var("LEAN_CTX_CRUSH_VERBATIM_JSON").is_ok() || self.crush_verbatim_json
11 }
12
13 #[must_use]
19 pub fn resolved_proxy_bind_host(&self) -> std::net::IpAddr {
20 let raw = std::env::var("LEAN_CTX_PROXY_BIND_HOST")
21 .ok()
22 .filter(|v| !v.trim().is_empty())
23 .or_else(|| self.proxy_bind_host.clone());
24 match raw.as_deref().map(str::trim) {
25 Some(v) if !v.is_empty() => v.parse().unwrap_or_else(|_| {
26 tracing::warn!(
27 "proxy_bind_host '{v}' is not a valid IP address — binding 127.0.0.1"
28 );
29 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
30 }),
31 _ => std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
32 }
33 }
34
35 pub fn rules_scope_effective(&self) -> RulesScope {
37 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
38 .ok()
39 .or_else(|| self.rules_scope.clone())
40 .unwrap_or_default();
41 match raw.trim().to_lowercase().as_str() {
42 "global" => RulesScope::Global,
43 "project" => RulesScope::Project,
44 _ => RulesScope::Both,
45 }
46 }
47
48 pub fn rules_injection_effective(&self) -> RulesInjection {
51 let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
52 .ok()
53 .or_else(|| self.rules_injection.clone())
54 .unwrap_or_default();
55 match raw.trim().to_lowercase().as_str() {
56 "dedicated" => RulesInjection::Dedicated,
57 "off" | "none" | "disabled" => RulesInjection::Off,
58 _ => RulesInjection::Shared,
59 }
60 }
61
62 #[must_use]
65 pub fn dashboard_cache_hit_rate(&self) -> Option<f64> {
66 std::env::var("LEAN_CTX_CACHE_HIT_RATE")
67 .ok()
68 .and_then(|v| v.parse().ok())
69 .or(self.dashboard_cache_hit_rate)
70 }
71 #[must_use]
74 pub fn hook_mode_override(&self) -> Option<crate::hooks::HookMode> {
75 let raw = std::env::var("LEAN_CTX_HOOK_MODE")
76 .ok()
77 .or_else(|| self.hook_mode.clone())?;
78 crate::hooks::HookMode::from_str_loose(raw.trim())
79 }
80
81 #[must_use]
85 pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
86 let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
87 .ok()
88 .or_else(|| self.permission_inheritance.clone())
89 .unwrap_or_default();
90 match raw.trim().to_lowercase().as_str() {
91 "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
92 _ => PermissionInheritance::Off,
93 }
94 }
95
96 #[must_use]
103 pub fn dedicated_session_context_active(&self) -> bool {
104 self.rules_injection_effective() == RulesInjection::Dedicated
105 && self.rules_scope_effective() != RulesScope::Project
106 }
107
108 pub(super) fn parse_disabled_tools_env(val: &str) -> Vec<String> {
109 val.split(',')
110 .map(|s| s.trim().to_string())
111 .filter(|s| !s.is_empty())
112 .collect()
113 }
114
115 pub fn disabled_tools_effective(&self) -> Vec<String> {
119 let mut list = if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
120 Self::parse_disabled_tools_env(&val)
121 } else {
122 self.disabled_tools.clone()
123 };
124 if self.prefer_native_editor_effective() {
125 for name in EDIT_TOOL_NAMES {
126 if !list.iter().any(|t| t == name) {
127 list.push((*name).to_string());
128 }
129 }
130 }
131 list
132 }
133
134 pub fn prefer_native_editor_effective(&self) -> bool {
137 match std::env::var("LEAN_CTX_PREFER_NATIVE_EDITOR") {
138 Ok(raw) => matches!(
139 raw.trim().to_lowercase().as_str(),
140 "1" | "true" | "yes" | "on"
141 ),
142 Err(_) => self.prefer_native_editor,
143 }
144 }
145
146 pub fn max_index_threads_effective(&self) -> usize {
149 std::env::var("LEANCTX_INDEX_THREADS")
150 .ok()
151 .and_then(|raw| raw.trim().parse::<usize>().ok())
152 .unwrap_or(self.max_index_threads)
153 }
154
155 pub fn edit_tool_blocked(&self, name: &str) -> bool {
159 self.prefer_native_editor_effective() && EDIT_TOOL_NAMES.contains(&name)
160 }
161
162 pub fn minimal_overhead_effective(&self) -> bool {
164 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
165 }
166
167 pub fn structure_first_effective(&self) -> bool {
173 match std::env::var("LEAN_CTX_STRUCTURE_FIRST") {
174 Ok(raw) => matches!(
175 raw.trim().to_lowercase().as_str(),
176 "1" | "true" | "yes" | "on"
177 ),
178 Err(_) => self.structure_first,
179 }
180 }
181
182 pub fn session_token_limit_effective(&self) -> usize {
184 std::env::var("LEAN_CTX_SESSION_TOKEN_LIMIT")
185 .ok()
186 .and_then(|v| v.trim().parse().ok())
187 .unwrap_or(self.session_token_limit)
188 }
189
190 pub fn turn_fresh_limit_effective(&self) -> usize {
192 std::env::var("LEAN_CTX_TURN_FRESH_LIMIT")
193 .ok()
194 .and_then(|v| v.trim().parse().ok())
195 .unwrap_or(self.turn_fresh_limit)
196 }
197
198 pub fn progressive_disclosure_effective(&self) -> bool {
201 match std::env::var("LEAN_CTX_PROGRESSIVE_DISCLOSURE") {
202 Ok(raw) => matches!(
203 raw.trim().to_lowercase().as_str(),
204 "1" | "true" | "yes" | "on"
205 ),
206 Err(_) => self.progressive_disclosure,
207 }
208 }
209
210 pub fn auto_mode_learning_effective(&self) -> bool {
215 match std::env::var("LEAN_CTX_AUTO_MODE_LEARNING") {
216 Ok(raw) => matches!(
217 raw.trim().to_lowercase().as_str(),
218 "1" | "true" | "yes" | "on"
219 ),
220 Err(_) => self.auto_mode_learning,
221 }
222 }
223
224 pub fn is_stochastic_enabled(&self) -> bool {
232 match std::env::var("LEAN_CTX_STOCHASTIC") {
233 Ok(raw) => matches!(
234 raw.trim().to_lowercase().as_str(),
235 "1" | "true" | "yes" | "on"
236 ),
237 Err(_) => self.auto_mode_learning_effective(),
238 }
239 }
240
241 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
249 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
250 match raw.trim().to_lowercase().as_str() {
251 "minimal" => return true,
252 "full" => return self.minimal_overhead_effective(),
253 _ => {}
254 }
255 }
256
257 if self.minimal_overhead_effective() {
258 return true;
259 }
260
261 let client_lower = client_name.trim().to_lowercase();
262 if !client_lower.is_empty() {
263 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
264 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
265 if !needle.is_empty() && client_lower.contains(&needle) {
266 return true;
267 }
268 }
269 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
270 return true;
271 }
272 }
273
274 let model = std::env::var("LEAN_CTX_MODEL")
275 .or_else(|_| std::env::var("LCTX_MODEL"))
276 .unwrap_or_default();
277 let model = model.trim().to_lowercase();
278 if !model.is_empty() {
279 let m = model.replace(['_', ' '], "-");
280 if m.contains("minimax")
281 || m.contains("mini-max")
282 || m.contains("m2.7")
283 || m.contains("m2-7")
284 {
285 return true;
286 }
287 }
288
289 false
290 }
291
292 pub fn shell_hook_disabled_effective(&self) -> bool {
294 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
295 }
296
297 pub fn shell_activation_effective(&self) -> ShellActivation {
299 ShellActivation::effective(self)
300 }
301
302 pub fn shell_allow_writes_effective(&self) -> bool {
306 match std::env::var("LEAN_CTX_SHELL_ALLOW_WRITES") {
307 Ok(raw) => matches!(
308 raw.trim().to_ascii_lowercase().as_str(),
309 "1" | "true" | "yes" | "on"
310 ),
311 Err(_) => self.shell_allow_writes,
312 }
313 }
314
315 pub fn shell_write_allow_paths_effective(&self) -> Vec<String> {
318 if self.write_allow_paths.is_empty() {
319 default_shell_write_allow_paths()
320 } else {
321 self.write_allow_paths.clone()
322 }
323 }
324
325 pub fn shell_allow_inline_scripts_effective(&self) -> bool {
330 match std::env::var("LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS") {
331 Ok(raw) => matches!(
332 raw.trim().to_ascii_lowercase().as_str(),
333 "1" | "true" | "yes" | "on"
334 ),
335 Err(_) => self.shell_allow_inline_scripts,
336 }
337 }
338
339 pub fn update_check_disabled_effective(&self) -> bool {
341 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
342 }
343
344 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
345 let mut policy = self.memory.clone();
346 policy.apply_env_overrides();
347
348 let budget = self.max_disk_mb_effective();
349 if budget > 0 {
350 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
351 let default_policy = MemoryPolicy::default();
352 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
353 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
354 }
355 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
356 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
357 }
358 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
359 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
360 }
361 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
362 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
363 }
364 }
365
366 policy.validate()?;
367 Ok(policy)
368 }
369
370 pub fn default_tool_categories_effective(&self) -> Vec<String> {
373 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
374 return val
375 .split(',')
376 .map(|s| s.trim().to_lowercase())
377 .filter(|s| !s.is_empty())
378 .collect();
379 }
380 if !self.default_tool_categories.is_empty() {
381 return self
382 .default_tool_categories
383 .iter()
384 .map(|s| s.to_lowercase())
385 .collect();
386 }
387 vec!["core".to_string(), "session".to_string()]
388 }
389
390 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
398 super::persona::Persona::resolve(self).effective_tool_profile(self)
399 }
400
401 #[must_use]
407 pub fn sensitivity_effective(&self) -> crate::core::sensitivity::SensitivityConfig {
408 self.sensitivity
409 .clone()
410 .with_persona_floor(super::persona::Persona::resolve(self).sensitivity_floor)
411 }
412
413 pub fn no_degrade_effective(&self) -> bool {
416 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
417 return val == "1" || val.eq_ignore_ascii_case("true");
418 }
419 self.no_degrade
420 }
421
422 pub fn delta_explicit_effective(&self) -> bool {
431 if let Ok(val) = std::env::var("LCTX_DELTA_EXPLICIT") {
432 return val == "1" || val.eq_ignore_ascii_case("true");
433 }
434 self.delta_explicit
435 }
436
437 pub fn max_disk_mb_effective(&self) -> u64 {
439 std::env::var("LEAN_CTX_MAX_DISK_MB")
440 .ok()
441 .and_then(|v| v.parse().ok())
442 .unwrap_or(self.max_disk_mb)
443 }
444
445 pub fn max_staleness_days_effective(&self) -> u32 {
447 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
448 .ok()
449 .and_then(|v| v.parse().ok())
450 .unwrap_or(self.max_staleness_days)
451 }
452
453 pub fn context_budget_tokens_effective(&self) -> usize {
457 std::env::var("LEAN_CTX_CONTEXT_BUDGET_TOKENS")
458 .ok()
459 .and_then(|v| v.parse().ok())
460 .unwrap_or(self.context.budget_tokens)
461 }
462
463 pub fn proactive_expansion_effective(&self) -> bool {
465 self.context.proactive_expansion
466 }
467
468 pub fn proactive_expansion_budget_tokens_effective(&self) -> usize {
470 self.context.proactive_expansion_budget_tokens
471 }
472
473 pub fn proactive_expansion_threshold_effective(&self) -> f64 {
475 self.context.proactive_expansion_threshold
476 }
477
478 pub fn proactive_expansion_max_age_secs_effective(&self) -> u64 {
480 self.context.proactive_expansion_max_age_secs
481 }
482
483 pub fn archive_max_disk_mb_effective(&self) -> u64 {
486 let budget = self.max_disk_mb_effective();
487 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
488 budget * 25 / 100
489 } else {
490 self.archive.max_disk_mb
491 }
492 }
493
494 pub fn archive_max_age_hours_effective(&self) -> u64 {
497 let staleness = self.max_staleness_days_effective();
498 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
499 staleness as u64 * 24
500 } else {
501 self.archive.max_age_hours
502 }
503 }
504
505 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
513 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
514 return self.bm25_max_cache_mb;
515 }
516 let budget = self.max_disk_mb_effective();
517 if budget > 0 {
518 return budget * 10 / 100;
519 }
520 DEFAULT_BM25_PERSIST_MB
521 }
522}
523
524impl Config {
525 pub fn update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
534 where
535 F: FnOnce(&mut Self),
536 {
537 let path = Self::path().ok_or_else(|| {
538 super::error::LeanCtxError::Config("cannot determine home directory".into())
539 })?;
540 Self::update_global_at(&path, f)
541 }
542
543 pub(super) fn update_global_at<F>(
545 path: &Path,
546 f: F,
547 ) -> std::result::Result<Self, super::error::LeanCtxError>
548 where
549 F: FnOnce(&mut Self),
550 {
551 let mut cfg = match std::fs::read_to_string(path) {
552 Ok(raw) if !raw.trim().is_empty() => toml::from_str::<Self>(&raw).map_err(|e| {
553 super::error::LeanCtxError::Config(
554 format!(
555 "refusing to modify an unparseable config.toml ({e}); fix it \
556 manually or run `lean-ctx doctor --fix`, then retry"
557 )
558 .into(),
559 )
560 })?,
561 _ => Self::default(),
562 };
563 f(&mut cfg);
564 cfg.save_to(path)?;
565 Ok(cfg)
566 }
567
568 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
574 let path = Self::path().ok_or_else(|| {
575 super::error::LeanCtxError::Config("cannot determine home directory".into())
576 })?;
577 self.save_to(&path)
578 }
579
580 pub(super) fn save_to(
582 &self,
583 path: &Path,
584 ) -> std::result::Result<(), super::error::LeanCtxError> {
585 if let Some(parent) = path.parent() {
586 std::fs::create_dir_all(parent)?;
587 }
588 let content = toml::to_string_pretty(self)
589 .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
590 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
595 let defaults = toml::to_string_pretty(&baseline)
596 .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
597 crate::config_io::write_toml_preserving_minimal(path, &content, &defaults)
598 .map_err(|e| super::error::LeanCtxError::Config(e.into()))?;
599 Ok(())
600 }
601
602 pub fn show(&self) -> String {
604 let global_path = Self::path().map_or_else(
605 || "~/.lean-ctx/config.toml".to_string(),
606 |p| p.to_string_lossy().to_string(),
607 );
608 let content = toml::to_string_pretty(self).unwrap_or_default();
609 let mut out = format!("Global config: {global_path}\n\n{content}");
610
611 if let Some(root) = Self::find_project_root() {
612 let local = Self::local_path(&root);
613 if local.exists() {
614 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
615 } else {
616 out.push_str(&format!(
617 "\n\nLocal config: not found (create {} to override per-project)\n",
618 local.display()
619 ));
620 }
621 }
622 out
623 }
624}