1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fs;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Config {
12 #[serde(default)]
14 pub general: GeneralConfig,
15
16 #[serde(default)]
18 pub theme: ThemeConfig,
19
20 #[serde(default)]
22 pub ai: AIConfig,
23
24 #[serde(default)]
26 pub telemetry: TelemetryConfig,
27
28 #[serde(default)]
30 pub shell: ShellConfig,
31
32 #[serde(default)]
34 pub keybindings: KeybindingsConfig,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct GeneralConfig {
40 #[serde(default)]
42 pub user_name: Option<String>,
43
44 #[serde(default = "default_false")]
46 pub setup_complete: bool,
47
48 #[serde(default = "default_shell")]
50 pub shell: String,
51
52 #[serde(default = "default_history_limit")]
54 pub history_limit: usize,
55
56 #[serde(default = "default_command_timeout")]
58 pub command_timeout: u64,
59
60 #[serde(default = "default_true")]
62 pub auto_save: bool,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ThemeConfig {
68 #[serde(default = "default_theme")]
70 pub default_theme: String,
71
72 #[serde(default = "default_true")]
74 pub enable_colors: bool,
75
76 #[serde(default = "default_color_depth")]
78 pub color_depth: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct AIConfig {
84 #[serde(default = "default_false")]
86 pub enabled: bool,
87
88 #[serde(default = "default_ai_provider")]
90 pub provider: String,
91
92 #[serde(default)]
94 pub api_key: Option<String>,
95
96 #[serde(default)]
98 pub model: Option<String>,
99
100 #[serde(default)]
102 pub endpoint: Option<String>,
103
104 #[serde(default = "default_max_tokens")]
106 pub max_tokens: usize,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct TelemetryConfig {
112 #[serde(default = "default_false")]
114 pub enabled: bool,
115
116 #[serde(default)]
118 pub user_id: Option<String>,
119
120 #[serde(default = "default_false")]
122 pub usage_stats: bool,
123
124 #[serde(default = "default_false")]
126 pub error_reports: bool,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ShellConfig {
132 #[serde(default)]
134 pub environment: HashMap<String, String>,
135
136 #[serde(default)]
138 pub aliases: HashMap<String, String>,
139
140 #[serde(default)]
142 pub startup_commands: Vec<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct KeybindingsConfig {
148 #[serde(default)]
150 pub custom: HashMap<String, String>,
151}
152
153impl Config {
154 pub fn new() -> Self {
156 Self::default()
157 }
158
159 pub fn load() -> Result<Self> {
161 let config_path = get_config_file_path()?;
162
163 if !config_path.exists() {
164 let config = Self::default();
166 config.save()?;
167 return Ok(config);
168 }
169
170 let config_str = fs::read_to_string(&config_path)
171 .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
172
173 let mut config: Config = toml::from_str(&config_str)
174 .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
175
176 config.apply_env_overrides();
178
179 Ok(config)
180 }
181
182 pub fn save(&self) -> Result<()> {
184 let config_path = get_config_file_path()?;
185
186 if let Some(parent) = config_path.parent() {
188 fs::create_dir_all(parent)
189 .with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
190 }
191
192 let config_str = toml::to_string_pretty(self)
193 .context("Failed to serialize config")?;
194
195 fs::write(&config_path, config_str)
196 .with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
197
198 Ok(())
199 }
200
201 fn apply_env_overrides(&mut self) {
203 if let Ok(api_key) = std::env::var("ARCT_AI_API_KEY") {
205 self.ai.api_key = Some(api_key);
206 }
207
208 if let Ok(provider) = std::env::var("ARCT_AI_PROVIDER") {
210 self.ai.provider = provider;
211 }
212
213 if let Ok(telemetry) = std::env::var("ARCT_TELEMETRY") {
215 self.telemetry.enabled = telemetry == "1" || telemetry.to_lowercase() == "true";
216 }
217
218 if let Ok(shell) = std::env::var("ARCT_SHELL") {
220 self.general.shell = shell;
221 }
222 }
223
224 pub fn config_path() -> Result<PathBuf> {
226 get_config_file_path()
227 }
228}
229
230impl Default for Config {
231 fn default() -> Self {
232 Self {
233 general: GeneralConfig::default(),
234 theme: ThemeConfig::default(),
235 ai: AIConfig::default(),
236 telemetry: TelemetryConfig::default(),
237 shell: ShellConfig::default(),
238 keybindings: KeybindingsConfig::default(),
239 }
240 }
241}
242
243impl Default for GeneralConfig {
244 fn default() -> Self {
245 Self {
246 user_name: None,
247 setup_complete: false,
248 shell: default_shell(),
249 history_limit: default_history_limit(),
250 command_timeout: default_command_timeout(),
251 auto_save: true,
252 }
253 }
254}
255
256impl Default for ThemeConfig {
257 fn default() -> Self {
258 Self {
259 default_theme: default_theme(),
260 enable_colors: true,
261 color_depth: default_color_depth(),
262 }
263 }
264}
265
266impl Default for AIConfig {
267 fn default() -> Self {
268 Self {
269 enabled: false,
270 provider: default_ai_provider(),
271 api_key: None,
272 model: None,
273 endpoint: None,
274 max_tokens: default_max_tokens(),
275 }
276 }
277}
278
279impl Default for TelemetryConfig {
280 fn default() -> Self {
281 Self {
282 enabled: false,
283 user_id: None,
284 usage_stats: false,
285 error_reports: false,
286 }
287 }
288}
289
290impl Default for ShellConfig {
291 fn default() -> Self {
292 Self {
293 environment: HashMap::new(),
294 aliases: HashMap::new(),
295 startup_commands: Vec::new(),
296 }
297 }
298}
299
300impl Default for KeybindingsConfig {
301 fn default() -> Self {
302 Self {
303 custom: HashMap::new(),
304 }
305 }
306}
307
308pub fn get_config_file_path() -> Result<PathBuf> {
310 let config_dir = dirs::config_dir()
311 .context("Could not find config directory")?;
312
313 let arct_config_dir = config_dir.join("arct");
314
315 if !arct_config_dir.exists() {
316 fs::create_dir_all(&arct_config_dir)
317 .with_context(|| format!("Failed to create config directory: {}", arct_config_dir.display()))?;
318 }
319
320 Ok(arct_config_dir.join("config.toml"))
321}
322
323pub fn generate_default_config() -> String {
325 let config = Config::default();
326 toml::to_string_pretty(&config).unwrap_or_else(|_| String::from("# Failed to generate config"))
327}
328
329fn default_shell() -> String {
331 std::env::var("SHELL")
332 .unwrap_or_else(|_| "bash".to_string())
333 .split('/')
334 .last()
335 .unwrap_or("bash")
336 .to_string()
337}
338
339fn default_history_limit() -> usize {
340 1000
341}
342
343fn default_command_timeout() -> u64 {
344 5
345}
346
347fn default_theme() -> String {
348 "Arc Academy Orange".to_string()
349}
350
351fn default_color_depth() -> String {
352 "256".to_string()
353}
354
355fn default_ai_provider() -> String {
356 "anthropic".to_string()
357}
358
359fn default_max_tokens() -> usize {
360 4096
361}
362
363fn default_true() -> bool {
364 true
365}
366
367fn default_false() -> bool {
368 false
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn test_default_config() {
377 let config = Config::default();
378 assert_eq!(config.general.history_limit, 1000);
379 assert_eq!(config.theme.default_theme, "Arc Academy Orange");
380 assert!(!config.ai.enabled);
381 assert!(!config.telemetry.enabled);
382 }
383
384 #[test]
385 fn test_serialize_config() {
386 let config = Config::default();
387 let toml_str = toml::to_string(&config).unwrap();
388 assert!(toml_str.contains("[general]"));
389 assert!(toml_str.contains("[theme]"));
390 }
391
392 #[test]
393 fn test_deserialize_config() {
394 let toml_str = r#"
395 [general]
396 shell = "zsh"
397 history_limit = 500
398
399 [theme]
400 default_theme = "Arc Dark"
401 "#;
402
403 let config: Config = toml::from_str(toml_str).unwrap();
404 assert_eq!(config.general.shell, "zsh");
405 assert_eq!(config.general.history_limit, 500);
406 assert_eq!(config.theme.default_theme, "Arc Dark");
407 }
408}