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, Serialize, Deserialize)]
16#[serde(default)]
17pub struct Config {
18 pub ultra_compact: bool,
19 #[serde(default, deserialize_with = "deserialize_tee_mode")]
20 pub tee_mode: TeeMode,
21 pub checkpoint_interval: u32,
22 pub excluded_commands: Vec<String>,
23 pub passthrough_urls: Vec<String>,
24 pub custom_aliases: Vec<AliasEntry>,
25 pub slow_command_threshold_ms: u64,
28 #[serde(default = "default_theme")]
29 pub theme: String,
30 #[serde(default)]
31 pub cloud: CloudConfig,
32 #[serde(default)]
33 pub autonomy: AutonomyConfig,
34 #[serde(default = "default_buddy_enabled")]
35 pub buddy_enabled: bool,
36 #[serde(default)]
37 pub redirect_exclude: Vec<String>,
38 #[serde(default)]
42 pub disabled_tools: Vec<String>,
43}
44
45fn default_buddy_enabled() -> bool {
46 true
47}
48
49fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
50where
51 D: serde::Deserializer<'de>,
52{
53 use serde::de::Error;
54 let v = serde_json::Value::deserialize(deserializer)?;
55 match &v {
56 serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
57 serde_json::Value::Bool(false) => Ok(TeeMode::Never),
58 serde_json::Value::String(s) => match s.as_str() {
59 "never" => Ok(TeeMode::Never),
60 "failures" => Ok(TeeMode::Failures),
61 "always" => Ok(TeeMode::Always),
62 other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
63 },
64 _ => Err(D::Error::custom("tee_mode must be string or bool")),
65 }
66}
67
68fn default_theme() -> String {
69 "default".to_string()
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(default)]
74pub struct AutonomyConfig {
75 pub enabled: bool,
76 pub auto_preload: bool,
77 pub auto_dedup: bool,
78 pub auto_related: bool,
79 pub silent_preload: bool,
80 pub dedup_threshold: usize,
81}
82
83impl Default for AutonomyConfig {
84 fn default() -> Self {
85 Self {
86 enabled: true,
87 auto_preload: true,
88 auto_dedup: true,
89 auto_related: true,
90 silent_preload: true,
91 dedup_threshold: 8,
92 }
93 }
94}
95
96impl AutonomyConfig {
97 pub fn from_env() -> Self {
98 let mut cfg = Self::default();
99 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
100 if v == "false" || v == "0" {
101 cfg.enabled = false;
102 }
103 }
104 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
105 cfg.auto_preload = v != "false" && v != "0";
106 }
107 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
108 cfg.auto_dedup = v != "false" && v != "0";
109 }
110 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
111 cfg.auto_related = v != "false" && v != "0";
112 }
113 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
114 cfg.silent_preload = v != "false" && v != "0";
115 }
116 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
117 if let Ok(n) = v.parse() {
118 cfg.dedup_threshold = n;
119 }
120 }
121 cfg
122 }
123
124 pub fn load() -> Self {
125 let file_cfg = Config::load().autonomy;
126 let mut cfg = file_cfg;
127 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
128 if v == "false" || v == "0" {
129 cfg.enabled = false;
130 }
131 }
132 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
133 cfg.auto_preload = v != "false" && v != "0";
134 }
135 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
136 cfg.auto_dedup = v != "false" && v != "0";
137 }
138 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
139 cfg.auto_related = v != "false" && v != "0";
140 }
141 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
142 cfg.silent_preload = v != "false" && v != "0";
143 }
144 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
145 if let Ok(n) = v.parse() {
146 cfg.dedup_threshold = n;
147 }
148 }
149 cfg
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154#[serde(default)]
155pub struct CloudConfig {
156 pub contribute_enabled: bool,
157 pub last_contribute: Option<String>,
158 pub last_sync: Option<String>,
159 pub last_model_pull: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct AliasEntry {
164 pub command: String,
165 pub alias: String,
166}
167
168impl Default for Config {
169 fn default() -> Self {
170 Self {
171 ultra_compact: false,
172 tee_mode: TeeMode::default(),
173 checkpoint_interval: 15,
174 excluded_commands: Vec::new(),
175 passthrough_urls: Vec::new(),
176 custom_aliases: Vec::new(),
177 slow_command_threshold_ms: 5000,
178 theme: default_theme(),
179 cloud: CloudConfig::default(),
180 autonomy: AutonomyConfig::default(),
181 buddy_enabled: default_buddy_enabled(),
182 redirect_exclude: Vec::new(),
183 disabled_tools: Vec::new(),
184 }
185 }
186}
187
188impl Config {
189 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
190 val.split(',')
191 .map(|s| s.trim().to_string())
192 .filter(|s| !s.is_empty())
193 .collect()
194 }
195
196 pub fn disabled_tools_effective(&self) -> Vec<String> {
197 if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
198 Self::parse_disabled_tools_env(&val)
199 } else {
200 self.disabled_tools.clone()
201 }
202 }
203}
204
205#[cfg(test)]
206mod disabled_tools_tests {
207 use super::*;
208
209 #[test]
210 fn config_field_default_is_empty() {
211 let cfg = Config::default();
212 assert!(cfg.disabled_tools.is_empty());
213 }
214
215 #[test]
216 fn effective_returns_config_field_when_no_env_var() {
217 if std::env::var("LEAN_CTX_DISABLED_TOOLS").is_ok() {
219 return;
220 }
221 let mut cfg = Config::default();
222 cfg.disabled_tools = vec!["ctx_graph".to_string(), "ctx_agent".to_string()];
223 assert_eq!(
224 cfg.disabled_tools_effective(),
225 vec!["ctx_graph", "ctx_agent"]
226 );
227 }
228
229 #[test]
230 fn parse_env_basic() {
231 let result = Config::parse_disabled_tools_env("ctx_graph,ctx_agent");
232 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
233 }
234
235 #[test]
236 fn parse_env_trims_whitespace_and_skips_empty() {
237 let result = Config::parse_disabled_tools_env(" ctx_graph , , ctx_agent ");
238 assert_eq!(result, vec!["ctx_graph", "ctx_agent"]);
239 }
240
241 #[test]
242 fn parse_env_single_entry() {
243 let result = Config::parse_disabled_tools_env("ctx_graph");
244 assert_eq!(result, vec!["ctx_graph"]);
245 }
246
247 #[test]
248 fn parse_env_empty_string_returns_empty() {
249 let result = Config::parse_disabled_tools_env("");
250 assert!(result.is_empty());
251 }
252
253 #[test]
254 fn disabled_tools_deserialization_defaults_to_empty() {
255 let cfg: Config = toml::from_str("").unwrap();
256 assert!(cfg.disabled_tools.is_empty());
257 }
258
259 #[test]
260 fn disabled_tools_deserialization_from_toml() {
261 let cfg: Config = toml::from_str(r#"disabled_tools = ["ctx_graph", "ctx_agent"]"#).unwrap();
262 assert_eq!(cfg.disabled_tools, vec!["ctx_graph", "ctx_agent"]);
263 }
264}
265
266impl Config {
267 pub fn path() -> Option<PathBuf> {
268 dirs::home_dir().map(|h| h.join(".lean-ctx").join("config.toml"))
269 }
270
271 pub fn load() -> Self {
272 static CACHE: Mutex<Option<(Config, SystemTime)>> = Mutex::new(None);
273
274 let path = match Self::path() {
275 Some(p) => p,
276 None => return Self::default(),
277 };
278
279 let mtime = std::fs::metadata(&path)
280 .and_then(|m| m.modified())
281 .unwrap_or(SystemTime::UNIX_EPOCH);
282
283 if let Ok(guard) = CACHE.lock() {
284 if let Some((ref cfg, ref cached_mtime)) = *guard {
285 if *cached_mtime == mtime {
286 return cfg.clone();
287 }
288 }
289 }
290
291 let cfg = match std::fs::read_to_string(&path) {
292 Ok(content) => toml::from_str(&content).unwrap_or_default(),
293 Err(_) => Self::default(),
294 };
295
296 if let Ok(mut guard) = CACHE.lock() {
297 *guard = Some((cfg.clone(), mtime));
298 }
299
300 cfg
301 }
302
303 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
304 let path = Self::path().ok_or_else(|| {
305 super::error::LeanCtxError::Config("cannot determine home directory".into())
306 })?;
307 if let Some(parent) = path.parent() {
308 std::fs::create_dir_all(parent)?;
309 }
310 let content = toml::to_string_pretty(self)
311 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
312 std::fs::write(&path, content)?;
313 Ok(())
314 }
315
316 pub fn show(&self) -> String {
317 let path = Self::path()
318 .map(|p| p.to_string_lossy().to_string())
319 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
320 let content = toml::to_string_pretty(self).unwrap_or_default();
321 format!("Config: {path}\n\n{content}")
322 }
323}