Skip to main content

better_ctx/core/
config.rs

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    /// Commands taking longer than this threshold (ms) are recorded in the slow log.
59    /// Set to 0 to disable slow logging.
60    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    /// Tools to exclude from the MCP tool list returned by list_tools.
72    /// Accepts exact tool names (e.g. ["ctx_graph", "ctx_agent"]).
73    /// Empty by default — all tools listed, no behaviour change.
74    #[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        // Only meaningful when BETTER_CTX_DISABLED_TOOLS is unset; skip otherwise.
252        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}