1use anyhow::Result;
2use serde::Deserialize;
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Deserialize, Default)]
7pub struct Config {
8 #[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 pub fn load() -> Self {
22 match Self::try_load() {
23 Ok(c) => c,
24 Err(e) => {
25 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 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 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 let c = Config::default();
131 assert!(!c.no_color);
132 });
133 }
134}