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