1use anyhow::{Context, Result};
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use std::collections::HashSet;
7use std::fs;
8use std::path::Path;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Config {
12 #[serde(rename = "root-key")]
13 pub root_key: String,
14 pub phase: IndexMap<String, PhaseConfig>,
15 pub ignore: Vec<String>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PhaseConfig {
20 pub face: String,
21}
22
23impl Default for Config {
24 fn default() -> Self {
25 let default_config = include_str!("default-coursemap.yml");
26 let config: Config = serde_yaml::from_str(default_config)
27 .with_context(|| "Failed to parse default configuration")
28 .expect("Parse error");
29 config
30 }
31}
32
33impl Config {
34 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
36 let content = fs::read_to_string(&path)
37 .with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
38
39 let config: Config = serde_yaml::from_str(&content)
40 .with_context(|| format!("Failed to parse config file: {}", path.as_ref().display()))?;
41
42 Ok(config)
43 }
44
45 pub fn load_default() -> Result<Self> {
47 Self::load_default_from_dir(".")
48 }
49
50 pub fn load_default_from_dir<P: AsRef<Path>>(dir: P) -> Result<Self> {
52 let dir = dir.as_ref();
53
54 let config_paths = ["coursemap.yml", "coursemap.yaml", ".coursemap.yml"];
56
57 for path in &config_paths {
58 let full_path = dir.join(path);
59 if full_path.exists() {
60 return Self::from_file(full_path);
61 }
62 }
63
64 let config = Self::default();
66
67 Ok(config)
68 }
69
70 pub fn get_phase_color(&self, phase: &str) -> String {
72 self.phase
73 .get(phase)
74 .map(|p| p.face.clone())
75 .unwrap_or_else(|| {
76 self.phase
77 .get("Unknown")
78 .map(|p| p.face.clone())
79 .unwrap_or_else(|| "lightgray".to_string())
80 })
81 }
82
83 pub fn should_ignore(&self, file_path: &str) -> bool {
85 self.ignore.iter().any(|pattern| {
86 if let Some(stripped) = pattern.strip_prefix('/') {
87 file_path.ends_with(stripped)
89 } else if pattern.contains('*') {
90 self.matches_glob_pattern(file_path, pattern)
92 } else {
93 file_path.contains(pattern)
95 }
96 })
97 }
98
99 fn matches_glob_pattern(&self, file_path: &str, pattern: &str) -> bool {
101 if pattern == "*" {
102 return true;
103 }
104
105 if let Some(extension) = pattern.strip_prefix("*.") {
106 file_path.ends_with(&format!(".{extension}"))
108 } else if let Some(prefix) = pattern.strip_suffix("*") {
109 file_path.contains(prefix)
111 } else {
112 file_path.contains(pattern)
114 }
115 }
116
117 pub fn get_phases(&self) -> HashSet<String> {
119 self.phase.keys().cloned().collect()
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use std::io::Write;
127 use tempfile::NamedTempFile;
128
129 #[test]
130 fn test_default_config() {
131 let config = Config::default();
132 assert_eq!(config.root_key, "course-map");
133 assert!(config.phase.contains_key("Pre"));
134 assert!(config.phase.contains_key("InClass"));
135 assert!(config.phase.contains_key("Post"));
136 assert!(config.phase.contains_key("Unknown"));
137 }
138
139 #[test]
140 fn test_phase_color() {
141 let config = Config::default();
142 assert_eq!(config.get_phase_color("Pre"), "lightblue");
143 assert_eq!(config.get_phase_color("NonExistent"), "lightgray");
144 }
145
146 #[test]
147 fn test_should_ignore() {
148 let config = Config::default();
149 assert!(config.should_ignore("some/path/index.qmd"));
150 assert!(!config.should_ignore("some/path/intro.qmd"));
151 }
152
153 #[test]
154 fn test_config_from_file() -> Result<()> {
155 let mut temp_file = NamedTempFile::new()?;
156 writeln!(
157 temp_file,
158 r#"
159root-key: test-map
160phase:
161 Test:
162 face: red
163ignore:
164 - test.qmd
165"#
166 )?;
167
168 let config = Config::from_file(temp_file.path())?;
169 assert_eq!(config.root_key, "test-map");
170 assert_eq!(config.get_phase_color("Test"), "red");
171 assert!(config.should_ignore("test.qmd"));
172
173 Ok(())
174 }
175
176 #[test]
177 fn test_load_default_with_embedded_config() -> Result<()> {
178 let temp_dir = tempfile::tempdir()?;
181 let original_dir = std::env::current_dir()?;
182
183 std::env::set_current_dir(&temp_dir)?;
185
186 let config = Config::load_default()?;
188
189 std::env::set_current_dir(original_dir)?;
191
192 assert_eq!(config.root_key, "course-map");
194 assert_eq!(config.get_phase_color("Pre"), "lightblue");
195 assert_eq!(config.get_phase_color("InClass"), "lightgreen");
196 assert_eq!(config.get_phase_color("Post"), "orange");
197 assert_eq!(config.get_phase_color("Unknown"), "lightgray");
198 assert!(config.should_ignore("some/path/index.qmd"));
199
200 Ok(())
201 }
202
203 #[test]
204 fn test_load_default_with_user_config() -> Result<()> {
205 let temp_dir = tempfile::tempdir()?;
206 let config_path = temp_dir.path().join("coursemap.yml");
207
208 std::fs::write(
210 &config_path,
211 r#"root-key: user-config
212phase:
213 Custom:
214 face: purple
215ignore:
216 - custom.qmd
217"#,
218 )?;
219
220 let config = Config::from_file(&config_path)?;
222
223 assert_eq!(config.root_key, "user-config");
225 assert_eq!(config.get_phase_color("Custom"), "purple");
226 assert!(config.should_ignore("custom.qmd"));
227
228 Ok(())
229 }
230
231 #[test]
232 fn test_config_file_priority() -> Result<()> {
233 let temp_dir = tempfile::tempdir()?;
234
235 let yaml_path = temp_dir.path().join("coursemap.yaml");
237 std::fs::write(
238 &yaml_path,
239 r#"root-key: yaml-config
240phase:
241 Test:
242 face: red
243ignore: []
244"#,
245 )?;
246
247 let config = Config::load_default_from_dir(temp_dir.path())?;
248 assert_eq!(config.root_key, "yaml-config");
249
250 std::fs::remove_file(&yaml_path)?;
252
253 let yml_path = temp_dir.path().join("coursemap.yml");
255 std::fs::write(
256 &yml_path,
257 r#"root-key: yml-config
258phase:
259 Test:
260 face: blue
261ignore: []
262"#,
263 )?;
264
265 let config = Config::load_default_from_dir(temp_dir.path())?;
267 assert_eq!(config.root_key, "yml-config");
268
269 Ok(())
270 }
271
272 #[test]
273 fn test_get_phases() {
274 let config = Config::default();
275 let phases = config.get_phases();
276
277 assert!(phases.contains("Pre"));
278 assert!(phases.contains("InClass"));
279 assert!(phases.contains("Post"));
280 assert!(phases.contains("Unknown"));
281 assert_eq!(phases.len(), 4);
282 }
283
284 #[test]
285 fn test_ignore_patterns() {
286 let mut config = Config::default();
287 config.ignore = vec![
288 "/index.qmd".to_string(),
289 "README.md".to_string(),
290 "*.tmp".to_string(),
291 ];
292
293 assert!(config.should_ignore("some/path/index.qmd"));
294 assert!(config.should_ignore("README.md"));
295 assert!(config.should_ignore("file.tmp"));
296 assert!(!config.should_ignore("intro.qmd"));
297 }
298}