Skip to main content

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::{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    /// Lesson practice settings
33    #[serde(default)]
34    pub lessons: LessonsConfig,
35
36    /// Keybinding customization
37    #[serde(default)]
38    pub keybindings: KeybindingsConfig,
39}
40
41/// General application settings
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct GeneralConfig {
44    /// User's name for personalization
45    #[serde(default)]
46    pub user_name: Option<String>,
47
48    /// Whether first-run setup is complete
49    #[serde(default = "default_false")]
50    pub setup_complete: bool,
51
52    /// Default shell to use (bash, zsh, fish, etc.)
53    #[serde(default = "default_shell")]
54    pub shell: String,
55
56    /// Command history limit
57    #[serde(default = "default_history_limit")]
58    pub history_limit: usize,
59
60    /// Command timeout in seconds
61    #[serde(default = "default_command_timeout")]
62    pub command_timeout: u64,
63
64    /// Enable auto-save for session
65    #[serde(default = "default_true")]
66    pub auto_save: bool,
67}
68
69/// Theme configuration
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ThemeConfig {
72    /// Default theme name
73    #[serde(default = "default_theme")]
74    pub default_theme: String,
75
76    /// Enable ANSI colors
77    #[serde(default = "default_true")]
78    pub enable_colors: bool,
79
80    /// Color depth (16, 256, or "true")
81    #[serde(default = "default_color_depth")]
82    pub color_depth: String,
83
84    /// Use Nerd Font glyphs for icons (needs a patched font). Off by
85    /// default: plain one-cell symbols work in every terminal.
86    #[serde(default = "default_false")]
87    pub nerd_fonts: bool,
88}
89
90/// AI integration settings
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct AIConfig {
93    /// Enable AI assistant
94    #[serde(default = "default_false")]
95    pub enabled: bool,
96
97    /// AI provider (anthropic, openai, local, managed)
98    #[serde(default = "default_ai_provider")]
99    pub provider: String,
100
101    /// API key (use env var for security)
102    #[serde(default)]
103    pub api_key: Option<String>,
104
105    /// Model name
106    #[serde(default)]
107    pub model: Option<String>,
108
109    /// Custom API endpoint (for local/self-hosted)
110    #[serde(default)]
111    pub endpoint: Option<String>,
112
113    /// Max tokens per request
114    #[serde(default = "default_max_tokens")]
115    pub max_tokens: usize,
116}
117
118/// Telemetry settings
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct TelemetryConfig {
121    /// Enable telemetry (opt-in)
122    #[serde(default = "default_false")]
123    pub enabled: bool,
124
125    /// Anonymous user ID
126    #[serde(default)]
127    pub user_id: Option<String>,
128
129    /// Send usage statistics
130    #[serde(default = "default_false")]
131    pub usage_stats: bool,
132
133    /// Send error reports
134    #[serde(default = "default_false")]
135    pub error_reports: bool,
136}
137
138/// Shell settings
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ShellConfig {
141    /// Persistent environment variables
142    #[serde(default)]
143    pub environment: HashMap<String, String>,
144
145    /// Persistent aliases
146    #[serde(default)]
147    pub aliases: HashMap<String, String>,
148
149    /// Startup commands to run
150    #[serde(default)]
151    pub startup_commands: Vec<String>,
152
153    /// Safe mode (default on): the free shell runs inside ~/ArcAcademy/home
154    /// and the safety guard keeps commands there. Off: commands run in your
155    /// real directories, and only catastrophic patterns are refused.
156    #[serde(default = "default_true")]
157    pub safe_mode: bool,
158}
159
160/// Lesson practice settings
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct LessonsConfig {
163    /// Where lesson commands execute: "simulated" (safe virtual sandbox,
164    /// the default) or "real" (real shell inside ~/ArcAcademy/playground)
165    #[serde(default = "default_practice_mode")]
166    pub practice_mode: String,
167
168    /// Learning track shown first: "explorer" (younger learners, story
169    /// lessons) or "operator" (teens and adults)
170    #[serde(default = "default_track")]
171    pub track: String,
172
173    /// Whether the one-time real-mode explainer has been shown
174    #[serde(default = "default_false")]
175    pub real_mode_intro_shown: bool,
176}
177
178impl LessonsConfig {
179    /// True when lesson practice runs on the real filesystem playground
180    pub fn is_real(&self) -> bool {
181        self.practice_mode == "real"
182    }
183}
184
185/// Keybinding customization
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct KeybindingsConfig {
188    /// Custom keybindings (key -> action)
189    #[serde(default)]
190    pub custom: HashMap<String, String>,
191}
192
193impl Config {
194    /// Create a new configuration with defaults
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Load configuration from disk
200    pub fn load() -> Result<Self> {
201        let config_path = get_config_file_path()?;
202
203        if !config_path.exists() {
204            // Create default config if it doesn't exist
205            let config = Self::default();
206            config.save()?;
207            return Ok(config);
208        }
209
210        Self::load_from(&config_path)
211    }
212
213    /// Load configuration from a specific path
214    pub fn load_from(path: &Path) -> Result<Self> {
215        let config_str = fs::read_to_string(path)
216            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
217
218        let mut config: Config = toml::from_str(&config_str)
219            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
220
221        // Override with environment variables
222        config.apply_env_overrides();
223
224        Ok(config)
225    }
226
227    /// Save configuration to disk
228    pub fn save(&self) -> Result<()> {
229        let config_path = get_config_file_path()?;
230        self.save_to(&config_path)
231    }
232
233    /// Save configuration to a specific path
234    pub fn save_to(&self, path: &Path) -> Result<()> {
235        // Ensure config directory exists
236        if let Some(parent) = path.parent() {
237            fs::create_dir_all(parent)
238                .with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
239        }
240
241        let config_str = toml::to_string_pretty(self)
242            .context("Failed to serialize config")?;
243
244        fs::write(path, config_str)
245            .with_context(|| format!("Failed to write config file: {}", path.display()))?;
246
247        // The config may contain secrets (ai.api_key) — restrict it to
248        // owner read/write on both create and rewrite
249        #[cfg(unix)]
250        {
251            use std::os::unix::fs::PermissionsExt;
252            fs::set_permissions(path, fs::Permissions::from_mode(0o600))
253                .with_context(|| {
254                    format!("Failed to set permissions on config file: {}", path.display())
255                })?;
256        }
257
258        Ok(())
259    }
260
261    /// Apply environment variable overrides
262    fn apply_env_overrides(&mut self) {
263        // AI API key from environment
264        if let Ok(api_key) = std::env::var("ARCT_AI_API_KEY") {
265            self.ai.api_key = Some(api_key);
266        }
267
268        // AI provider from environment
269        if let Ok(provider) = std::env::var("ARCT_AI_PROVIDER") {
270            self.ai.provider = provider;
271        }
272
273        // Telemetry opt-out
274        if let Ok(telemetry) = std::env::var("ARCT_TELEMETRY") {
275            self.telemetry.enabled = telemetry == "1" || telemetry.to_lowercase() == "true";
276        }
277
278        // Shell override
279        if let Ok(shell) = std::env::var("ARCT_SHELL") {
280            self.general.shell = shell;
281        }
282    }
283
284    /// Get config file path
285    pub fn config_path() -> Result<PathBuf> {
286        get_config_file_path()
287    }
288}
289
290impl Default for Config {
291    fn default() -> Self {
292        Self {
293            general: GeneralConfig::default(),
294            theme: ThemeConfig::default(),
295            ai: AIConfig::default(),
296            telemetry: TelemetryConfig::default(),
297            shell: ShellConfig::default(),
298            lessons: LessonsConfig::default(),
299            keybindings: KeybindingsConfig::default(),
300        }
301    }
302}
303
304impl Default for GeneralConfig {
305    fn default() -> Self {
306        Self {
307            user_name: None,
308            setup_complete: false,
309            shell: default_shell(),
310            history_limit: default_history_limit(),
311            command_timeout: default_command_timeout(),
312            auto_save: true,
313        }
314    }
315}
316
317impl Default for ThemeConfig {
318    fn default() -> Self {
319        Self {
320            default_theme: default_theme(),
321            enable_colors: true,
322            color_depth: default_color_depth(),
323            nerd_fonts: false,
324        }
325    }
326}
327
328impl Default for AIConfig {
329    fn default() -> Self {
330        Self {
331            enabled: false,
332            provider: default_ai_provider(),
333            api_key: None,
334            model: None,
335            endpoint: None,
336            max_tokens: default_max_tokens(),
337        }
338    }
339}
340
341impl Default for TelemetryConfig {
342    fn default() -> Self {
343        Self {
344            enabled: false,
345            user_id: None,
346            usage_stats: false,
347            error_reports: false,
348        }
349    }
350}
351
352impl Default for ShellConfig {
353    fn default() -> Self {
354        Self {
355            environment: HashMap::new(),
356            aliases: HashMap::new(),
357            startup_commands: Vec::new(),
358            safe_mode: true,
359        }
360    }
361}
362
363impl Default for LessonsConfig {
364    fn default() -> Self {
365        Self {
366            practice_mode: default_practice_mode(),
367            track: default_track(),
368            real_mode_intro_shown: false,
369        }
370    }
371}
372
373impl Default for KeybindingsConfig {
374    fn default() -> Self {
375        Self {
376            custom: HashMap::new(),
377        }
378    }
379}
380
381/// Get the configuration file path (XDG-compliant)
382pub fn get_config_file_path() -> Result<PathBuf> {
383    let config_dir = dirs::config_dir()
384        .context("Could not find config directory")?;
385
386    let arct_config_dir = config_dir.join("arct");
387
388    if !arct_config_dir.exists() {
389        fs::create_dir_all(&arct_config_dir)
390            .with_context(|| format!("Failed to create config directory: {}", arct_config_dir.display()))?;
391    }
392
393    Ok(arct_config_dir.join("config.toml"))
394}
395
396/// Generate a default configuration file as a string
397pub fn generate_default_config() -> String {
398    let config = Config::default();
399    toml::to_string_pretty(&config).unwrap_or_else(|_| String::from("# Failed to generate config"))
400}
401
402// Default value functions
403fn default_shell() -> String {
404    std::env::var("SHELL")
405        .unwrap_or_else(|_| "bash".to_string())
406        .split('/')
407        .last()
408        .unwrap_or("bash")
409        .to_string()
410}
411
412fn default_history_limit() -> usize {
413    1000
414}
415
416fn default_command_timeout() -> u64 {
417    5
418}
419
420fn default_theme() -> String {
421    "Arc Academy Orange".to_string()
422}
423
424fn default_color_depth() -> String {
425    "256".to_string()
426}
427
428fn default_ai_provider() -> String {
429    "anthropic".to_string()
430}
431
432fn default_max_tokens() -> usize {
433    4096
434}
435
436fn default_practice_mode() -> String {
437    "simulated".to_string()
438}
439
440fn default_track() -> String {
441    "explorer".to_string()
442}
443
444fn default_true() -> bool {
445    true
446}
447
448fn default_false() -> bool {
449    false
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn test_default_config() {
458        let config = Config::default();
459        assert_eq!(config.general.history_limit, 1000);
460        assert_eq!(config.theme.default_theme, "Arc Academy Orange");
461        assert!(!config.ai.enabled);
462        assert!(!config.telemetry.enabled);
463    }
464
465    #[test]
466    fn test_lessons_practice_mode_defaults_to_simulated() {
467        let config = Config::default();
468        assert_eq!(config.lessons.practice_mode, "simulated");
469        assert!(!config.lessons.is_real());
470        assert!(!config.lessons.real_mode_intro_shown);
471
472        // Configs written before the [lessons] section existed still parse
473        let legacy = "[general]\nshell = \"zsh\"\n";
474        let parsed: Config = toml::from_str(legacy).unwrap();
475        assert_eq!(parsed.lessons.practice_mode, "simulated");
476
477        // And the key is settable
478        let toml_str = "[lessons]\npractice_mode = \"real\"\n";
479        let parsed: Config = toml::from_str(toml_str).unwrap();
480        assert!(parsed.lessons.is_real());
481    }
482
483    #[test]
484    fn test_serialize_config() {
485        let config = Config::default();
486        let toml_str = toml::to_string(&config).unwrap();
487        assert!(toml_str.contains("[general]"));
488        assert!(toml_str.contains("[theme]"));
489    }
490
491    #[test]
492    fn test_deserialize_config() {
493        let toml_str = r#"
494            [general]
495            shell = "zsh"
496            history_limit = 500
497
498            [theme]
499            default_theme = "Arc Dark"
500        "#;
501
502        let config: Config = toml::from_str(toml_str).unwrap();
503        assert_eq!(config.general.shell, "zsh");
504        assert_eq!(config.general.history_limit, 500);
505        assert_eq!(config.theme.default_theme, "Arc Dark");
506    }
507
508    /// A config path in a fresh temp directory, removed when the test ends
509    struct TempConfig {
510        dir: PathBuf,
511        path: PathBuf,
512    }
513
514    impl std::ops::Deref for TempConfig {
515        type Target = PathBuf;
516        fn deref(&self) -> &PathBuf {
517            &self.path
518        }
519    }
520
521    impl AsRef<std::path::Path> for TempConfig {
522        fn as_ref(&self) -> &std::path::Path {
523            &self.path
524        }
525    }
526
527    impl Drop for TempConfig {
528        fn drop(&mut self) {
529            let _ = fs::remove_dir_all(&self.dir);
530        }
531    }
532
533    fn temp_config_path(name: &str) -> TempConfig {
534        let dir = std::env::temp_dir().join(format!(
535            "arct-config-test-{}-{}",
536            name,
537            std::process::id()
538        ));
539        let _ = fs::remove_dir_all(&dir);
540        let path = dir.join("config.toml");
541        TempConfig { dir, path }
542    }
543
544    #[test]
545    fn test_save_to_and_load_from_roundtrip() {
546        let path = temp_config_path("roundtrip");
547
548        let mut config = Config::default();
549        config.general.user_name = Some("tester".to_string());
550        config.save_to(&path).unwrap();
551
552        let loaded = Config::load_from(&path).unwrap();
553        assert_eq!(loaded.general.user_name.as_deref(), Some("tester"));
554    }
555
556    #[test]
557    fn test_load_from_missing_path_errors() {
558        let path = temp_config_path("missing");
559        assert!(Config::load_from(&path).is_err());
560    }
561
562    #[cfg(unix)]
563    #[test]
564    fn test_save_sets_owner_only_permissions() {
565        use std::os::unix::fs::PermissionsExt;
566
567        let path = temp_config_path("perms");
568
569        let config = Config::default();
570        config.save_to(&path).unwrap();
571
572        let mode = fs::metadata(&path).unwrap().permissions().mode();
573        assert_eq!(mode & 0o777, 0o600);
574
575        // Rewriting an existing file keeps the restrictive permissions
576        config.save_to(&path).unwrap();
577        let mode = fs::metadata(&path).unwrap().permissions().mode();
578        assert_eq!(mode & 0o777, 0o600);
579    }
580}