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 "off" | "false" | "0" | "none" => PermissionInheritance::Off,
92 _ => PermissionInheritance::On,
97 }
98 }
99
100 #[must_use]
107 pub fn dedicated_session_context_active(&self) -> bool {
108 self.rules_injection_effective() == RulesInjection::Dedicated
109 && self.rules_scope_effective() != RulesScope::Project
110 }
111
112 pub(super) fn parse_disabled_tools_env(val: &str) -> Vec<String> {
113 val.split(',')
114 .map(|s| s.trim().to_string())
115 .filter(|s| !s.is_empty())
116 .collect()
117 }
118
119 pub fn disabled_tools_effective(&self) -> Vec<String> {
123 let mut list = if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
124 Self::parse_disabled_tools_env(&val)
125 } else {
126 self.disabled_tools.clone()
127 };
128 if self.prefer_native_editor_effective() {
129 for name in EDIT_TOOL_NAMES {
130 if !list.iter().any(|t| t == name) {
131 list.push((*name).to_string());
132 }
133 }
134 }
135 list
136 }
137
138 pub fn prefer_native_editor_effective(&self) -> bool {
141 match std::env::var("LEAN_CTX_PREFER_NATIVE_EDITOR") {
142 Ok(raw) => matches!(
143 raw.trim().to_lowercase().as_str(),
144 "1" | "true" | "yes" | "on"
145 ),
146 Err(_) => self.prefer_native_editor,
147 }
148 }
149
150 pub fn max_index_threads_effective(&self) -> usize {
153 std::env::var("LEANCTX_INDEX_THREADS")
154 .ok()
155 .and_then(|raw| raw.trim().parse::<usize>().ok())
156 .unwrap_or(self.max_index_threads)
157 }
158
159 pub fn edit_tool_blocked(&self, name: &str) -> bool {
163 self.prefer_native_editor_effective() && EDIT_TOOL_NAMES.contains(&name)
164 }
165
166 pub fn minimal_overhead_effective(&self) -> bool {
168 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
169 }
170
171 pub fn structure_first_effective(&self) -> bool {
177 match std::env::var("LEAN_CTX_STRUCTURE_FIRST") {
178 Ok(raw) => matches!(
179 raw.trim().to_lowercase().as_str(),
180 "1" | "true" | "yes" | "on"
181 ),
182 Err(_) => self.structure_first,
183 }
184 }
185
186 pub fn session_token_limit_effective(&self) -> usize {
188 std::env::var("LEAN_CTX_SESSION_TOKEN_LIMIT")
189 .ok()
190 .and_then(|v| v.trim().parse().ok())
191 .unwrap_or(self.session_token_limit)
192 }
193
194 pub fn turn_fresh_limit_effective(&self) -> usize {
196 std::env::var("LEAN_CTX_TURN_FRESH_LIMIT")
197 .ok()
198 .and_then(|v| v.trim().parse().ok())
199 .unwrap_or(self.turn_fresh_limit)
200 }
201
202 pub fn progressive_disclosure_effective(&self) -> bool {
205 match std::env::var("LEAN_CTX_PROGRESSIVE_DISCLOSURE") {
206 Ok(raw) => matches!(
207 raw.trim().to_lowercase().as_str(),
208 "1" | "true" | "yes" | "on"
209 ),
210 Err(_) => self.progressive_disclosure,
211 }
212 }
213
214 pub fn auto_mode_learning_effective(&self) -> bool {
219 match std::env::var("LEAN_CTX_AUTO_MODE_LEARNING") {
220 Ok(raw) => matches!(
221 raw.trim().to_lowercase().as_str(),
222 "1" | "true" | "yes" | "on"
223 ),
224 Err(_) => self.auto_mode_learning,
225 }
226 }
227
228 pub fn is_stochastic_enabled(&self) -> bool {
236 match std::env::var("LEAN_CTX_STOCHASTIC") {
237 Ok(raw) => matches!(
238 raw.trim().to_lowercase().as_str(),
239 "1" | "true" | "yes" | "on"
240 ),
241 Err(_) => self.auto_mode_learning_effective(),
242 }
243 }
244
245 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
253 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
254 match raw.trim().to_lowercase().as_str() {
255 "minimal" => return true,
256 "full" => return self.minimal_overhead_effective(),
257 _ => {}
258 }
259 }
260
261 if self.minimal_overhead_effective() {
262 return true;
263 }
264
265 let client_lower = client_name.trim().to_lowercase();
266 if !client_lower.is_empty() {
267 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
268 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
269 if !needle.is_empty() && client_lower.contains(&needle) {
270 return true;
271 }
272 }
273 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
274 return true;
275 }
276 }
277
278 let model = std::env::var("LEAN_CTX_MODEL")
279 .or_else(|_| std::env::var("LCTX_MODEL"))
280 .unwrap_or_default();
281 let model = model.trim().to_lowercase();
282 if !model.is_empty() {
283 let m = model.replace(['_', ' '], "-");
284 if m.contains("minimax")
285 || m.contains("mini-max")
286 || m.contains("m2.7")
287 || m.contains("m2-7")
288 {
289 return true;
290 }
291 }
292
293 false
294 }
295
296 pub fn shell_hook_disabled_effective(&self) -> bool {
298 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
299 }
300
301 pub fn shell_activation_effective(&self) -> ShellActivation {
303 ShellActivation::effective(self)
304 }
305
306 pub fn shell_allow_writes_effective(&self) -> bool {
310 match std::env::var("LEAN_CTX_SHELL_ALLOW_WRITES") {
311 Ok(raw) => matches!(
312 raw.trim().to_ascii_lowercase().as_str(),
313 "1" | "true" | "yes" | "on"
314 ),
315 Err(_) => self.shell_allow_writes,
316 }
317 }
318
319 pub fn shell_write_allow_paths_effective(&self) -> Vec<String> {
322 if self.write_allow_paths.is_empty() {
323 default_shell_write_allow_paths()
324 } else {
325 self.write_allow_paths.clone()
326 }
327 }
328
329 pub fn shell_allow_inline_scripts_effective(&self) -> bool {
334 match std::env::var("LEAN_CTX_SHELL_ALLOW_INLINE_SCRIPTS") {
335 Ok(raw) => matches!(
336 raw.trim().to_ascii_lowercase().as_str(),
337 "1" | "true" | "yes" | "on"
338 ),
339 Err(_) => self.shell_allow_inline_scripts,
340 }
341 }
342
343 pub fn update_check_disabled_effective(&self) -> bool {
345 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
346 }
347
348 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
349 let mut policy = self.memory.clone();
350 policy.apply_env_overrides();
351
352 let budget = self.max_disk_mb_effective();
353 if budget > 0 {
354 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
355 let default_policy = MemoryPolicy::default();
356 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
357 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
358 }
359 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
360 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
361 }
362 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
363 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
364 }
365 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
366 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
367 }
368 }
369
370 policy.validate()?;
371 Ok(policy)
372 }
373
374 pub fn default_tool_categories_effective(&self) -> Vec<String> {
377 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
378 return val
379 .split(',')
380 .map(|s| s.trim().to_lowercase())
381 .filter(|s| !s.is_empty())
382 .collect();
383 }
384 if !self.default_tool_categories.is_empty() {
385 return self
386 .default_tool_categories
387 .iter()
388 .map(|s| s.to_lowercase())
389 .collect();
390 }
391 vec!["core".to_string(), "session".to_string()]
392 }
393
394 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
402 super::persona::Persona::resolve(self).effective_tool_profile(self)
403 }
404
405 #[must_use]
411 pub fn sensitivity_effective(&self) -> crate::core::sensitivity::SensitivityConfig {
412 self.sensitivity
413 .clone()
414 .with_persona_floor(super::persona::Persona::resolve(self).sensitivity_floor)
415 }
416
417 pub fn no_degrade_effective(&self) -> bool {
420 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
421 return val == "1" || val.eq_ignore_ascii_case("true");
422 }
423 self.no_degrade
424 }
425
426 pub fn delta_explicit_effective(&self) -> bool {
435 if let Ok(val) = std::env::var("LCTX_DELTA_EXPLICIT") {
436 return val == "1" || val.eq_ignore_ascii_case("true");
437 }
438 self.delta_explicit
439 }
440
441 pub fn max_disk_mb_effective(&self) -> u64 {
443 std::env::var("LEAN_CTX_MAX_DISK_MB")
444 .ok()
445 .and_then(|v| v.parse().ok())
446 .unwrap_or(self.max_disk_mb)
447 }
448
449 pub fn max_staleness_days_effective(&self) -> u32 {
451 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
452 .ok()
453 .and_then(|v| v.parse().ok())
454 .unwrap_or(self.max_staleness_days)
455 }
456
457 pub fn context_budget_tokens_effective(&self) -> usize {
461 std::env::var("LEAN_CTX_CONTEXT_BUDGET_TOKENS")
462 .ok()
463 .and_then(|v| v.parse().ok())
464 .unwrap_or(self.context.budget_tokens)
465 }
466
467 pub fn proactive_expansion_effective(&self) -> bool {
469 self.context.proactive_expansion
470 }
471
472 pub fn proactive_expansion_budget_tokens_effective(&self) -> usize {
474 self.context.proactive_expansion_budget_tokens
475 }
476
477 pub fn proactive_expansion_threshold_effective(&self) -> f64 {
479 self.context.proactive_expansion_threshold
480 }
481
482 pub fn proactive_expansion_max_age_secs_effective(&self) -> u64 {
484 self.context.proactive_expansion_max_age_secs
485 }
486
487 pub fn archive_max_disk_mb_effective(&self) -> u64 {
490 let budget = self.max_disk_mb_effective();
491 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
492 budget * 25 / 100
493 } else {
494 self.archive.max_disk_mb
495 }
496 }
497
498 pub fn archive_max_age_hours_effective(&self) -> u64 {
501 let staleness = self.max_staleness_days_effective();
502 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
503 staleness as u64 * 24
504 } else {
505 self.archive.max_age_hours
506 }
507 }
508
509 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
517 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
518 return self.bm25_max_cache_mb;
519 }
520 let budget = self.max_disk_mb_effective();
521 if budget > 0 {
522 return budget * 10 / 100;
523 }
524 DEFAULT_BM25_PERSIST_MB
525 }
526}
527
528impl Config {
529 pub fn update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
538 where
539 F: FnOnce(&mut Self),
540 {
541 let path = Self::path().ok_or_else(|| {
542 super::error::LeanCtxError::Config("cannot determine home directory".into())
543 })?;
544 Self::update_global_at(&path, f)
545 }
546
547 pub(super) fn update_global_at<F>(
549 path: &Path,
550 f: F,
551 ) -> std::result::Result<Self, super::error::LeanCtxError>
552 where
553 F: FnOnce(&mut Self),
554 {
555 let mut cfg = match std::fs::read_to_string(path) {
556 Ok(raw) if !raw.trim().is_empty() => toml::from_str::<Self>(&raw).map_err(|e| {
557 super::error::LeanCtxError::Config(
558 format!(
559 "refusing to modify an unparseable config.toml ({e}); fix it \
560 manually or run `lean-ctx doctor --fix`, then retry"
561 )
562 .into(),
563 )
564 })?,
565 _ => Self::default(),
566 };
567 f(&mut cfg);
568 cfg.save_to(path)?;
569 Ok(cfg)
570 }
571
572 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
578 let path = Self::path().ok_or_else(|| {
579 super::error::LeanCtxError::Config("cannot determine home directory".into())
580 })?;
581 self.save_to(&path)
582 }
583
584 pub(super) fn save_to(
586 &self,
587 path: &Path,
588 ) -> std::result::Result<(), super::error::LeanCtxError> {
589 if let Some(parent) = path.parent() {
590 std::fs::create_dir_all(parent)?;
591 }
592 let content = toml::to_string_pretty(self)
593 .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
594 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
599 let defaults = toml::to_string_pretty(&baseline)
600 .map_err(|e| super::error::LeanCtxError::Config(e.to_string().into()))?;
601 crate::config_io::write_toml_preserving_minimal(path, &content, &defaults)
602 .map_err(|e| super::error::LeanCtxError::Config(e.into()))?;
603 Ok(())
604 }
605
606 pub fn show(&self) -> String {
608 let global_path = Self::path().map_or_else(
609 || "~/.lean-ctx/config.toml".to_string(),
610 |p| p.to_string_lossy().to_string(),
611 );
612 let content = toml::to_string_pretty(self).unwrap_or_default();
613 let mut out = format!("Global config: {global_path}\n\n{content}");
614
615 if let Some(root) = Self::find_project_root() {
616 let local = Self::local_path(&root);
617 if local.exists() {
618 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
619 } else {
620 out.push_str(&format!(
621 "\n\nLocal config: not found (create {} to override per-project)\n",
622 local.display()
623 ));
624 }
625 }
626 out
627 }
628}