1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fs;
7use std::path::{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 lessons: LessonsConfig,
35
36 #[serde(default)]
38 pub keybindings: KeybindingsConfig,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct GeneralConfig {
44 #[serde(default)]
46 pub user_name: Option<String>,
47
48 #[serde(default = "default_false")]
50 pub setup_complete: bool,
51
52 #[serde(default = "default_shell")]
54 pub shell: String,
55
56 #[serde(default = "default_history_limit")]
58 pub history_limit: usize,
59
60 #[serde(default = "default_command_timeout")]
62 pub command_timeout: u64,
63
64 #[serde(default = "default_true")]
66 pub auto_save: bool,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ThemeConfig {
72 #[serde(default = "default_theme")]
74 pub default_theme: String,
75
76 #[serde(default = "default_true")]
78 pub enable_colors: bool,
79
80 #[serde(default = "default_color_depth")]
82 pub color_depth: String,
83
84 #[serde(default = "default_false")]
87 pub nerd_fonts: bool,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct AIConfig {
93 #[serde(default = "default_false")]
95 pub enabled: bool,
96
97 #[serde(default = "default_ai_provider")]
99 pub provider: String,
100
101 #[serde(default)]
103 pub api_key: Option<String>,
104
105 #[serde(default)]
107 pub model: Option<String>,
108
109 #[serde(default)]
111 pub endpoint: Option<String>,
112
113 #[serde(default = "default_max_tokens")]
115 pub max_tokens: usize,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct TelemetryConfig {
121 #[serde(default = "default_false")]
123 pub enabled: bool,
124
125 #[serde(default)]
127 pub user_id: Option<String>,
128
129 #[serde(default = "default_false")]
131 pub usage_stats: bool,
132
133 #[serde(default = "default_false")]
135 pub error_reports: bool,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ShellConfig {
141 #[serde(default)]
143 pub environment: HashMap<String, String>,
144
145 #[serde(default)]
147 pub aliases: HashMap<String, String>,
148
149 #[serde(default)]
151 pub startup_commands: Vec<String>,
152
153 #[serde(default = "default_true")]
157 pub safe_mode: bool,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct LessonsConfig {
163 #[serde(default = "default_practice_mode")]
166 pub practice_mode: String,
167
168 #[serde(default = "default_track")]
171 pub track: String,
172
173 #[serde(default = "default_false")]
175 pub real_mode_intro_shown: bool,
176}
177
178impl LessonsConfig {
179 pub fn is_real(&self) -> bool {
181 self.practice_mode == "real"
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct KeybindingsConfig {
188 #[serde(default)]
190 pub custom: HashMap<String, String>,
191}
192
193impl Config {
194 pub fn new() -> Self {
196 Self::default()
197 }
198
199 pub fn load() -> Result<Self> {
201 let config_path = get_config_file_path()?;
202
203 if !config_path.exists() {
204 let config = Self::default();
206 config.save()?;
207 return Ok(config);
208 }
209
210 Self::load_from(&config_path)
211 }
212
213 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 config.apply_env_overrides();
223
224 Ok(config)
225 }
226
227 pub fn save(&self) -> Result<()> {
229 let config_path = get_config_file_path()?;
230 self.save_to(&config_path)
231 }
232
233 pub fn save_to(&self, path: &Path) -> Result<()> {
235 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 #[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 fn apply_env_overrides(&mut self) {
263 if let Ok(api_key) = std::env::var("ARCT_AI_API_KEY") {
265 self.ai.api_key = Some(api_key);
266 }
267
268 if let Ok(provider) = std::env::var("ARCT_AI_PROVIDER") {
270 self.ai.provider = provider;
271 }
272
273 if let Ok(telemetry) = std::env::var("ARCT_TELEMETRY") {
275 self.telemetry.enabled = telemetry == "1" || telemetry.to_lowercase() == "true";
276 }
277
278 if let Ok(shell) = std::env::var("ARCT_SHELL") {
280 self.general.shell = shell;
281 }
282 }
283
284 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
381pub 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
396pub 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
402fn 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 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 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 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 config.save_to(&path).unwrap();
577 let mode = fs::metadata(&path).unwrap().permissions().mode();
578 assert_eq!(mode & 0o777, 0o600);
579 }
580}