Skip to main content

cli_tutor/
config.rs

1use anyhow::Result;
2use serde::Deserialize;
3use std::path::PathBuf;
4
5// config_file.CONFIG.1
6#[derive(Debug, Clone, Deserialize, Default)]
7pub struct Config {
8    // config_file.CONFIG.2
9    #[serde(default)]
10    pub no_color: bool,
11    #[serde(default)]
12    pub default_module: Option<String>,
13    #[serde(default)]
14    pub skip_completed: bool,
15    #[serde(default)]
16    pub timed_challenge: bool,
17}
18
19impl Config {
20    // config_file.CONFIG.3 — load at startup, fallback to defaults
21    pub fn load() -> Self {
22        match Self::try_load() {
23            Ok(c) => c,
24            Err(e) => {
25                // config_file.CONFIG.5 — warn on corrupt, never panic
26                eprintln!("Warning: could not load config: {e}. Using defaults.");
27                Self::default()
28            }
29        }
30    }
31
32    fn try_load() -> Result<Self> {
33        let path = Self::path()?;
34        // config_file.CONFIG.4 — missing file → silent default
35        if !path.exists() {
36            return Ok(Self::default());
37        }
38        let content = std::fs::read_to_string(&path)?;
39        Ok(toml::from_str(&content)?)
40    }
41
42    // config_file.CONFIG.1 — XDG_CONFIG_HOME or ~/.config
43    fn path() -> Result<PathBuf> {
44        let base = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
45            PathBuf::from(xdg)
46        } else {
47            let home = std::env::var("HOME")?;
48            PathBuf::from(home).join(".config")
49        };
50        Ok(base.join("cli-tutor").join("config.toml"))
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use std::sync::atomic::{AtomicU64, Ordering};
58
59    static COUNTER: AtomicU64 = AtomicU64::new(0);
60
61    fn with_config_dir<F: FnOnce(PathBuf)>(f: F) {
62        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
63        let tmp = std::env::temp_dir()
64            .join(format!("cli-tutor-cfg-{}-{}", std::process::id(), n));
65        std::fs::create_dir_all(&tmp).unwrap();
66        f(tmp.clone());
67        let _ = std::fs::remove_dir_all(&tmp);
68    }
69
70    fn write_config(dir: &PathBuf, content: &str) {
71        let path = dir.join("cli-tutor").join("config.toml");
72        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
73        std::fs::write(&path, content).unwrap();
74    }
75
76    fn load_from(xdg: &PathBuf) -> Config {
77        let path = xdg.join("cli-tutor").join("config.toml");
78        if !path.exists() {
79            return Config::default();
80        }
81        let content = std::fs::read_to_string(&path).unwrap_or_default();
82        toml::from_str(&content).unwrap_or_default()
83    }
84
85    #[test]
86    fn config_defaults_when_file_missing() {
87        with_config_dir(|dir| {
88            let c = load_from(&dir);
89            assert!(!c.no_color);
90            assert!(c.default_module.is_none());
91            assert!(!c.skip_completed);
92            assert!(!c.timed_challenge);
93        });
94    }
95
96    #[test]
97    fn config_no_color_from_file() {
98        with_config_dir(|dir| {
99            write_config(&dir, "no_color = true\n");
100            let c = load_from(&dir);
101            assert!(c.no_color);
102        });
103    }
104
105    #[test]
106    fn config_timed_challenge_from_file() {
107        with_config_dir(|dir| {
108            write_config(&dir, "timed_challenge = true\n");
109            let c = load_from(&dir);
110            assert!(c.timed_challenge);
111        });
112    }
113
114    #[test]
115    fn config_default_module_from_file() {
116        with_config_dir(|dir| {
117            write_config(&dir, "default_module = \"sed\"\n");
118            let c = load_from(&dir);
119            assert_eq!(c.default_module.as_deref(), Some("sed"));
120        });
121    }
122
123    #[test]
124    fn config_returns_default_on_corrupt_file() {
125        with_config_dir(|dir| {
126            write_config(&dir, "{{not valid toml at all");
127            let result: Result<Config, _> = toml::from_str("{{not valid toml at all");
128            assert!(result.is_err());
129            // Config::load() falls back gracefully
130            let c = Config::default();
131            assert!(!c.no_color);
132        });
133    }
134}