arct_config/
lib.rs

1//! Configuration management for Arc Academy Terminal
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fs;
7use std::path::PathBuf;
8
9/// Main configuration structure
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Config {
12    /// General application settings
13    #[serde(default)]
14    pub general: GeneralConfig,
15
16    /// Theme configuration
17    #[serde(default)]
18    pub theme: ThemeConfig,
19
20    /// AI integration settings
21    #[serde(default)]
22    pub ai: AIConfig,
23
24    /// Telemetry settings
25    #[serde(default)]
26    pub telemetry: TelemetryConfig,
27
28    /// Shell settings
29    #[serde(default)]
30    pub shell: ShellConfig,
31
32    /// Keybinding customization
33    #[serde(default)]
34    pub keybindings: KeybindingsConfig,
35}
36
37/// General application settings
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct GeneralConfig {
40    /// User's name for personalization
41    #[serde(default)]
42    pub user_name: Option<String>,
43
44    /// Whether first-run setup is complete
45    #[serde(default = "default_false")]
46    pub setup_complete: bool,
47
48    /// Default shell to use (bash, zsh, fish, etc.)
49    #[serde(default = "default_shell")]
50    pub shell: String,
51
52    /// Command history limit
53    #[serde(default = "default_history_limit")]
54    pub history_limit: usize,
55
56    /// Command timeout in seconds
57    #[serde(default = "default_command_timeout")]
58    pub command_timeout: u64,
59
60    /// Enable auto-save for session
61    #[serde(default = "default_true")]
62    pub auto_save: bool,
63}
64
65/// Theme configuration
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ThemeConfig {
68    /// Default theme name
69    #[serde(default = "default_theme")]
70    pub default_theme: String,
71
72    /// Enable ANSI colors
73    #[serde(default = "default_true")]
74    pub enable_colors: bool,
75
76    /// Color depth (16, 256, or "true")
77    #[serde(default = "default_color_depth")]
78    pub color_depth: String,
79}
80
81/// AI integration settings
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct AIConfig {
84    /// Enable AI assistant
85    #[serde(default = "default_false")]
86    pub enabled: bool,
87
88    /// AI provider (anthropic, openai, local, managed)
89    #[serde(default = "default_ai_provider")]
90    pub provider: String,
91
92    /// API key (use env var for security)
93    #[serde(default)]
94    pub api_key: Option<String>,
95
96    /// Model name
97    #[serde(default)]
98    pub model: Option<String>,
99
100    /// Custom API endpoint (for local/self-hosted)
101    #[serde(default)]
102    pub endpoint: Option<String>,
103
104    /// Max tokens per request
105    #[serde(default = "default_max_tokens")]
106    pub max_tokens: usize,
107}
108
109/// Telemetry settings
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct TelemetryConfig {
112    /// Enable telemetry (opt-in)
113    #[serde(default = "default_false")]
114    pub enabled: bool,
115
116    /// Anonymous user ID
117    #[serde(default)]
118    pub user_id: Option<String>,
119
120    /// Send usage statistics
121    #[serde(default = "default_false")]
122    pub usage_stats: bool,
123
124    /// Send error reports
125    #[serde(default = "default_false")]
126    pub error_reports: bool,
127}
128
129/// Shell settings
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ShellConfig {
132    /// Persistent environment variables
133    #[serde(default)]
134    pub environment: HashMap<String, String>,
135
136    /// Persistent aliases
137    #[serde(default)]
138    pub aliases: HashMap<String, String>,
139
140    /// Startup commands to run
141    #[serde(default)]
142    pub startup_commands: Vec<String>,
143}
144
145/// Keybinding customization
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct KeybindingsConfig {
148    /// Custom keybindings (key -> action)
149    #[serde(default)]
150    pub custom: HashMap<String, String>,
151}
152
153impl Config {
154    /// Create a new configuration with defaults
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Load configuration from disk
160    pub fn load() -> Result<Self> {
161        let config_path = get_config_file_path()?;
162
163        if !config_path.exists() {
164            // Create default config if it doesn't exist
165            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        // Override with environment variables
177        config.apply_env_overrides();
178
179        Ok(config)
180    }
181
182    /// Save configuration to disk
183    pub fn save(&self) -> Result<()> {
184        let config_path = get_config_file_path()?;
185
186        // Ensure config directory exists
187        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    /// Apply environment variable overrides
202    fn apply_env_overrides(&mut self) {
203        // AI API key from environment
204        if let Ok(api_key) = std::env::var("ARCT_AI_API_KEY") {
205            self.ai.api_key = Some(api_key);
206        }
207
208        // AI provider from environment
209        if let Ok(provider) = std::env::var("ARCT_AI_PROVIDER") {
210            self.ai.provider = provider;
211        }
212
213        // Telemetry opt-out
214        if let Ok(telemetry) = std::env::var("ARCT_TELEMETRY") {
215            self.telemetry.enabled = telemetry == "1" || telemetry.to_lowercase() == "true";
216        }
217
218        // Shell override
219        if let Ok(shell) = std::env::var("ARCT_SHELL") {
220            self.general.shell = shell;
221        }
222    }
223
224    /// Get config file path
225    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
308/// Get the configuration file path (XDG-compliant)
309pub 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
323/// Generate a default configuration file as a string
324pub 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
329// Default value functions
330fn 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}