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}
35
36fn deserialize_tee_mode<'de, D>(deserializer: D) -> Result<TeeMode, D::Error>
37where
38 D: serde::Deserializer<'de>,
39{
40 use serde::de::Error;
41 let v = serde_json::Value::deserialize(deserializer)?;
42 match &v {
43 serde_json::Value::Bool(true) => Ok(TeeMode::Failures),
44 serde_json::Value::Bool(false) => Ok(TeeMode::Never),
45 serde_json::Value::String(s) => match s.as_str() {
46 "never" => Ok(TeeMode::Never),
47 "failures" => Ok(TeeMode::Failures),
48 "always" => Ok(TeeMode::Always),
49 other => Err(D::Error::custom(format!("unknown tee_mode: {other}"))),
50 },
51 _ => Err(D::Error::custom("tee_mode must be string or bool")),
52 }
53}
54
55fn default_theme() -> String {
56 "default".to_string()
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(default)]
61pub struct AutonomyConfig {
62 pub enabled: bool,
63 pub auto_preload: bool,
64 pub auto_dedup: bool,
65 pub auto_related: bool,
66 pub silent_preload: bool,
67 pub dedup_threshold: usize,
68}
69
70impl Default for AutonomyConfig {
71 fn default() -> Self {
72 Self {
73 enabled: true,
74 auto_preload: true,
75 auto_dedup: true,
76 auto_related: true,
77 silent_preload: true,
78 dedup_threshold: 8,
79 }
80 }
81}
82
83impl AutonomyConfig {
84 pub fn from_env() -> Self {
85 let mut cfg = Self::default();
86 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
87 if v == "false" || v == "0" {
88 cfg.enabled = false;
89 }
90 }
91 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
92 cfg.auto_preload = v != "false" && v != "0";
93 }
94 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
95 cfg.auto_dedup = v != "false" && v != "0";
96 }
97 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
98 cfg.auto_related = v != "false" && v != "0";
99 }
100 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
101 cfg.silent_preload = v != "false" && v != "0";
102 }
103 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
104 if let Ok(n) = v.parse() {
105 cfg.dedup_threshold = n;
106 }
107 }
108 cfg
109 }
110
111 pub fn load() -> Self {
112 let file_cfg = Config::load().autonomy;
113 let mut cfg = file_cfg;
114 if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY") {
115 if v == "false" || v == "0" {
116 cfg.enabled = false;
117 }
118 }
119 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_PRELOAD") {
120 cfg.auto_preload = v != "false" && v != "0";
121 }
122 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_DEDUP") {
123 cfg.auto_dedup = v != "false" && v != "0";
124 }
125 if let Ok(v) = std::env::var("LEAN_CTX_AUTO_RELATED") {
126 cfg.auto_related = v != "false" && v != "0";
127 }
128 if let Ok(v) = std::env::var("LEAN_CTX_SILENT_PRELOAD") {
129 cfg.silent_preload = v != "false" && v != "0";
130 }
131 if let Ok(v) = std::env::var("LEAN_CTX_DEDUP_THRESHOLD") {
132 if let Ok(n) = v.parse() {
133 cfg.dedup_threshold = n;
134 }
135 }
136 cfg
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, Default)]
141#[serde(default)]
142pub struct CloudConfig {
143 pub contribute_enabled: bool,
144 pub last_contribute: Option<String>,
145 pub last_sync: Option<String>,
146 pub last_model_pull: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct AliasEntry {
151 pub command: String,
152 pub alias: String,
153}
154
155impl Default for Config {
156 fn default() -> Self {
157 Self {
158 ultra_compact: false,
159 tee_mode: TeeMode::default(),
160 checkpoint_interval: 15,
161 excluded_commands: Vec::new(),
162 passthrough_urls: Vec::new(),
163 custom_aliases: Vec::new(),
164 slow_command_threshold_ms: 5000,
165 theme: default_theme(),
166 cloud: CloudConfig::default(),
167 autonomy: AutonomyConfig::default(),
168 }
169 }
170}
171
172impl Config {
173 pub fn path() -> Option<PathBuf> {
174 dirs::home_dir().map(|h| h.join(".lean-ctx").join("config.toml"))
175 }
176
177 pub fn load() -> Self {
178 static CACHE: Mutex<Option<(Config, SystemTime)>> = Mutex::new(None);
179
180 let path = match Self::path() {
181 Some(p) => p,
182 None => return Self::default(),
183 };
184
185 let mtime = std::fs::metadata(&path)
186 .and_then(|m| m.modified())
187 .unwrap_or(SystemTime::UNIX_EPOCH);
188
189 if let Ok(guard) = CACHE.lock() {
190 if let Some((ref cfg, ref cached_mtime)) = *guard {
191 if *cached_mtime == mtime {
192 return cfg.clone();
193 }
194 }
195 }
196
197 let cfg = match std::fs::read_to_string(&path) {
198 Ok(content) => toml::from_str(&content).unwrap_or_default(),
199 Err(_) => Self::default(),
200 };
201
202 if let Ok(mut guard) = CACHE.lock() {
203 *guard = Some((cfg.clone(), mtime));
204 }
205
206 cfg
207 }
208
209 pub fn save(&self) -> Result<(), String> {
210 let path = Self::path().ok_or("cannot determine home directory")?;
211 if let Some(parent) = path.parent() {
212 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
213 }
214 let content = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
215 std::fs::write(&path, content).map_err(|e| e.to_string())
216 }
217
218 pub fn show(&self) -> String {
219 let path = Self::path()
220 .map(|p| p.to_string_lossy().to_string())
221 .unwrap_or_else(|| "~/.lean-ctx/config.toml".to_string());
222 let content = toml::to_string_pretty(self).unwrap_or_default();
223 format!("Config: {path}\n\n{content}")
224 }
225}