1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use std::sync::Mutex;
4use std::time::SystemTime;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
7#[serde(rename_all = "lowercase")]
8pub enum TeeMode {
9 Never,
10 #[default]
11 Failures,
12 Always,
13}
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "lowercase")]
17pub enum OutputDensity {
18 #[default]
19 Normal,
20 Terse,
21 Ultra,
22}
23
24impl OutputDensity {
25 pub fn from_env() -> Self {
26 match std::env::var("LEAN_CTX_OUTPUT_DENSITY")
27 .unwrap_or_default()
28 .to_lowercase()
29 .as_str()
30 {
31 "terse" => Self::Terse,
32 "ultra" => Self::Ultra,
33 _ => Self::Normal,
34 }
35 }
36
37 pub fn effective(config_val: &OutputDensity) -> Self {
38 let env_val = Self::from_env();
39 if env_val != Self::Normal {
40 return env_val;
41 }
42 config_val.clone()
43 }
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(default)]
48pub struct Config {
49 pub ultra_compact: bool,
50 #[serde(default, deserialize_with = "deserialize_tee_mode")]
51 pub tee_mode: TeeMode,
52 #[serde(default)]
53 pub output_density: OutputDensity,
54 pub checkpoint_interval: u32,
55 pub excluded_commands: Vec<String>,
56 pub passthrough_urls: Vec<String>,
57 pub custom_aliases: Vec<AliasEntry>,
58 pub slow_command_threshold_ms: u64,
61 #[serde(default = "default_theme")]
62 pub theme: String,
63 #[serde(default)]
64 pub cloud: CloudConfig,
65 #[serde(default)]
66 pub autonomy: AutonomyConfig,
67 #[serde(default = "default_buddy_enabled")]
68 pub buddy_enabled: bool,
69 #[serde(default)]
70 pub redirect_exclude: Vec<String>,
71 #[serde(default)]
75 pub disabled_tools: Vec<String>,
76 #[serde(default)]
77 pub loop_detection: LoopDetectionConfig,
78}
79
80fn default_buddy_enabled() -> bool {
81 true
82}
83
84fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
85where
86 D: serde::Deserializer<'de>,
87{
88 use serde::de::Error;
89 let v = serde_json::Value::deserialize(deserializer)?;
90 match &v {
91 serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
92 serde_json::Value::Bool(false) => Ok(TeeMode::Never),
93 serde_json::Value::String(s) => match s.as_str() {
94 "never" => Ok(TeeMode::Never),
95 "failures" => Ok(TeeMode::Failures),
96 "always" => Ok(TeeMode::Always),
97 other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
98 },
99 _ => Err(D::Error::custom("tee_mode must be string or bool")),
100 }
101}
102
103fn default_theme() -> String {
104 "default".to_string()
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(default)]
109pub struct AutonomyConfig {
110 pub enabled: bool,
111 pub auto_preload: bool,
112 pub auto_dedup: bool,
113 pub auto_related: bool,
114 pub auto_consolidate: bool,
115 pub silent_preload: bool,
116 pub dedup_threshold: usize,
117 pub consolidate_every_calls: u32,
118 pub consolidate_cooldown_secs: u64,
119}
120
121impl Default for AutonomyConfig {
122 fn default() -> Self {
123 Self {
124 enabled: true,
125 auto_preload: true,
126 auto_dedup: true,
127 auto_related: true,
128 auto_consolidate: true,
129 silent_preload: true,
130 dedup_threshold: 8,
131 consolidate_every_calls: 25,
132 consolidate_cooldown_secs: 120,
133 }
134 }
135}
136
137impl AutonomyConfig {
138 pub fn from_env() -> Self {
139 let mut cfg = Self::default();
140 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
141 if v == "false" || v == "0" {
142 cfg.enabled = false;
143 }
144 }
145 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
146 cfg.auto_preload = v != "false" && v != "0";
147 }
148 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
149 cfg.auto_dedup = v != "false" && v != "0";
150 }
151 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
152 cfg.auto_related = v != "false" && v != "0";
153 }
154 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
155 cfg.auto_consolidate = v != "false" && v != "0";
156 }
157 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
158 cfg.silent_preload = v != "false" && v != "0";
159 }
160 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
161 if let Ok(n) = v.parse() {
162 cfg.dedup_threshold = n;
163 }
164 }
165 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS") {
166 if let Ok(n) = v.parse() {
167 cfg.consolidate_every_calls = n;
168 }
169 }
170 if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS") {
171 if let Ok(n) = v.parse() {
172 cfg.consolidate_cooldown_secs = n;
173 }
174 }
175 cfg
176 }
177
178 pub fn load() -> Self {
179 let file_cfg = Config::load().autonomy;
180 let mut cfg = file_cfg;
181 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
182 if v == "false" || v == "0" {
183 cfg.enabled = false;
184 }
185 }
186 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
187 cfg.auto_preload = v != "false" && v != "0";
188 }
189 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
190 cfg.auto_dedup = v != "false" && v != "0";
191 }
192 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
193 cfg.auto_related = v != "false" && v != "0";
194 }
195 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
196 cfg.silent_preload = v != "false" && v != "0";
197 }
198 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
199 if let Ok(n) = v.parse() {
200 cfg.dedup_threshold = n;
201 }
202 }
203 cfg
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, Default)]
208#[serde(default)]
209pub struct CloudConfig {
210 pub contribute_enabled: bool,
211 pub last_contribute: Option<String>,
212 pub last_sync: Option<String>,
213 pub last_gain_sync: Option<String>,
214 pub last_model_pull: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct AliasEntry {
219 pub command: String,
220 pub alias: String,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(default)]
225pub struct LoopDetectionConfig {
226 pub normal_threshold: u32,
227 pub reduced_threshold: u32,
228 pub blocked_threshold: u32,
229 pub window_secs: u64,
230 pub search_group_limit: u32,
231}
232
233impl Default for LoopDetectionConfig {
234 fn default() -> Self {
235 Self {
236 normal_threshold: 2,
237 reduced_threshold: 4,
238 blocked_threshold: 6,
239 window_secs: 300,
240 search_group_limit: 10,
241 }
242 }
243}
244
245impl Default for Config {
246 fn default() -> Self {
247 Self {
248 ultra_compact: false,
249 tee_mode: TeeMode::default(),
250 output_density: OutputDensity::default(),
251 checkpoint_interval: 15,
252 excluded_commands: Vec::new(),
253 passthrough_urls: Vec::new(),
254 custom_aliases: Vec::new(),
255 slow_command_threshold_ms: 5000,
256 theme: default_theme(),
257 cloud: CloudConfig::default(),
258 autonomy: AutonomyConfig::default(),
259 buddy_enabled: default_buddy_enabled(),
260 redirect_exclude: Vec::new(),
261 disabled_tools: Vec::new(),
262 loop_detection: LoopDetectionConfig::default(),
263 }
264 }
265}
266
267impl Config {
268 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
269 val.split(',')
270 .map(|s| s.trim().to_string())
271 .filter(|s| !s.is_empty())
272 .collect()
273 }
274
275 pub fn disabled_tools_effective(&self) -> Vec<String> {
276 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
277 Self::parse_disabled_tools_env(&val)
278 } else {
279 self.disabled_tools.clone()
280 }
281 }
282}
283
284#[cfg(test)]
285mod disabled_tools_tests {
286 use super::*;
287
288 #[test]
289 fn config_field_default_is_empty() {
290 let cfg = Config::default();
291 assert!(cfg.disabled_tools.is_empty());
292 }
293
294 #[test]
295 fn effective_returns_config_field_when_no_env_var() {
296 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
298 return;
299 }
300 let cfg = Config {
301 disabled_tools: vec!["ctx_graph".to_string(), "ctx_agent".to_string()],
302 ..Default::default()
303 };
304 assert_eq!(
305 cfg.disabled_tools_effective(),
306 vec!["ctx_graph", "ctx_agent"]
307 );
308 }
309
310 #[test]
311 fn parse_env_basic() {
312 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
313 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
314 }
315
316 #[test]
317 fn parse_env_trims_whitespace_and_skips_empty() {
318 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
319 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
320 }
321
322 #[test]
323 fn parse_env_single_entry() {
324 let result = Config::parse_disabled_tools_env("ctx_graph");
325 assert_eq!(result, vec!["ctx_graph"]);
326 }
327
328 #[test]
329 fn parse_env_empty_string_returns_empty() {
330 let result = Config::parse_disabled_tools_env("");
331 assert!(result.is_empty());
332 }
333
334 #[test]
335 fn disabled_tools_deserialization_defaults_to_empty() {
336 let cfg: Config = toml::from_str("").unwrap();
337 assert!(cfg.disabled_tools.is_empty());
338 }
339
340 #[test]
341 fn disabled_tools_deserialization_from_toml() {
342 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
343 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
344 }
345}
346
347#[cfg(test)]
348mod loop_detection_config_tests {
349 use super::*;
350
351 #[test]
352 fn defaults_are_reasonable() {
353 let cfg = LoopDetectionConfig::default();
354 assert_eq!(cfg.normal_threshold, 2);
355 assert_eq!(cfg.reduced_threshold, 4);
356 assert_eq!(cfg.blocked_threshold, 6);
357 assert_eq!(cfg.window_secs, 300);
358 assert_eq!(cfg.search_group_limit, 10);
359 }
360
361 #[test]
362 fn deserialization_defaults_when_missing() {
363 let cfg: Config = toml::from_str("").unwrap();
364 assert_eq!(cfg.loop_detection.blocked_threshold, 6);
365 assert_eq!(cfg.loop_detection.search_group_limit, 10);
366 }
367
368 #[test]
369 fn deserialization_from_toml() {
370 let cfg: Config = toml::from_str(
371 r#"
372 [loop_detection]
373 normal_threshold = 1
374 reduced_threshold = 3
375 blocked_threshold = 5
376 window_secs = 120
377 search_group_limit = 8
378 "#,
379 )
380 .unwrap();
381 assert_eq!(cfg.loop_detection.normal_threshold, 1);
382 assert_eq!(cfg.loop_detection.reduced_threshold, 3);
383 assert_eq!(cfg.loop_detection.blocked_threshold, 5);
384 assert_eq!(cfg.loop_detection.window_secs, 120);
385 assert_eq!(cfg.loop_detection.search_group_limit, 8);
386 }
387
388 #[test]
389 fn partial_override_keeps_defaults() {
390 let cfg: Config = toml::from_str(
391 r#"
392 [loop_detection]
393 blocked_threshold = 10
394 "#,
395 )
396 .unwrap();
397 assert_eq!(cfg.loop_detection.blocked_threshold, 10);
398 assert_eq!(cfg.loop_detection.normal_threshold, 2);
399 assert_eq!(cfg.loop_detection.search_group_limit, 10);
400 }
401}
402
403impl Config {
404 pub fn path() -> Option<PathBuf> {
405 crate::core::data_dir::lean_ctx_data_dir()
406 .ok()
407 .map(|d| d.join("config.toml"))
408 }
409
410 pub fn local_path(project_root: &str) -> PathBuf {
411 PathBuf::from(project_root).join(".lean-ctx.toml")
412 }
413
414 fn find_project_root() -> Option<String> {
415 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
416 }
417
418 pub fn load() -> Self {
419 static CACHE: Mutex<Option<(Config, SystemTime, Option<SystemTime>)>> = Mutex::new(None);
420
421 let path = match Self::path() {
422 Some(p) => p,
423 None => return Self::default(),
424 };
425
426 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
427
428 let mtime = std::fs::metadata(&path)
429 .and_then(|m| m.modified())
430 .unwrap_or(SystemTime::UNIX_EPOCH);
431
432 let local_mtime = local_path
433 .as_ref()
434 .and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok());
435
436 if let Ok(guard) = CACHE.lock() {
437 if let Some((ref cfg, ref cached_mtime, ref cached_local_mtime)) = *guard {
438 if *cached_mtime == mtime && *cached_local_mtime == local_mtime {
439 return cfg.clone();
440 }
441 }
442 }
443
444 let mut cfg: Config = match std::fs::read_to_string(&path) {
445 Ok(content) => toml::from_str(&content).unwrap_or_default(),
446 Err(_) => Self::default(),
447 };
448
449 if let Some(ref lp) = local_path {
450 if let Ok(local_content) = std::fs::read_to_string(lp) {
451 cfg.merge_local(&local_content);
452 }
453 }
454
455 if let Ok(mut guard) = CACHE.lock() {
456 *guard = Some((cfg.clone(), mtime, local_mtime));
457 }
458
459 cfg
460 }
461
462 fn merge_local(&mut self, local_toml: &str) {
463 let local: Config = match toml::from_str(local_toml) {
464 Ok(c) => c,
465 Err(_) => return,
466 };
467 if local.ultra_compact {
468 self.ultra_compact = true;
469 }
470 if local.tee_mode != TeeMode::default() {
471 self.tee_mode = local.tee_mode;
472 }
473 if local.output_density != OutputDensity::default() {
474 self.output_density = local.output_density;
475 }
476 if local.checkpoint_interval != 15 {
477 self.checkpoint_interval = local.checkpoint_interval;
478 }
479 if !local.excluded_commands.is_empty() {
480 self.excluded_commands.extend(local.excluded_commands);
481 }
482 if !local.passthrough_urls.is_empty() {
483 self.passthrough_urls.extend(local.passthrough_urls);
484 }
485 if !local.custom_aliases.is_empty() {
486 self.custom_aliases.extend(local.custom_aliases);
487 }
488 if local.slow_command_threshold_ms != 5000 {
489 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
490 }
491 if local.theme != "default" {
492 self.theme = local.theme;
493 }
494 if !local.buddy_enabled {
495 self.buddy_enabled = false;
496 }
497 if !local.redirect_exclude.is_empty() {
498 self.redirect_exclude.extend(local.redirect_exclude);
499 }
500 if !local.disabled_tools.is_empty() {
501 self.disabled_tools.extend(local.disabled_tools);
502 }
503 }
504
505 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
506 let path = Self::path().ok_or_else(|| {
507 super::error::LeanCtxError::Config("cannot determine home directory".into())
508 })?;
509 if let Some(parent) = path.parent() {
510 std::fs::create_dir_all(parent)?;
511 }
512 let content = toml::to_string_pretty(self)
513 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
514 std::fs::write(&path, content)?;
515 Ok(())
516 }
517
518 pub fn show(&self) -> String {
519 let global_path = Self::path()
520 .map(|p| p.to_string_lossy().to_string())
521 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
522 let content = toml::to_string_pretty(self).unwrap_or_default();
523 let mut out = format!("Global config: {global_path}\n\n{content}");
524
525 if let Some(root) = Self::find_project_root() {
526 let local = Self::local_path(&root);
527 if local.exists() {
528 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
529 } else {
530 out.push_str(&format!(
531 "\n\nLocal config: not found (create {} to override per-project)\n",
532 local.display()
533 ));
534 }
535 }
536 out
537 }
538}