Skip to main content

coursemap/
config.rs

1//! Configuration management for the course map tool
2
3use 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    /// Load configuration from a YAML file
35    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    /// Load configuration from the default locations
46    pub fn load_default() -> Result<Self> {
47        Self::load_default_from_dir(".")
48    }
49
50    /// Load configuration from the default locations within a specific directory
51    pub fn load_default_from_dir<P: AsRef<Path>>(dir: P) -> Result<Self> {
52        let dir = dir.as_ref();
53
54        // Try to load from user config file locations
55        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        // If no user config file found, use package default configuration
65        let config = Self::default();
66
67        Ok(config)
68    }
69
70    /// Get the color for a given phase
71    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    /// Check if a file should be ignored
84    pub fn should_ignore(&self, file_path: &str) -> bool {
85        self.ignore.iter().any(|pattern| {
86            if let Some(stripped) = pattern.strip_prefix('/') {
87                // Absolute path pattern - check if file path ends with the pattern (without leading /)
88                file_path.ends_with(stripped)
89            } else if pattern.contains('*') {
90                // Wildcard pattern - simple glob matching
91                self.matches_glob_pattern(file_path, pattern)
92            } else {
93                // Simple substring matching
94                file_path.contains(pattern)
95            }
96        })
97    }
98
99    /// Simple glob pattern matching for basic wildcards
100    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            // Extension matching like "*.tmp"
107            file_path.ends_with(&format!(".{extension}"))
108        } else if let Some(prefix) = pattern.strip_suffix("*") {
109            // Prefix matching like "temp*"
110            file_path.contains(prefix)
111        } else {
112            // For more complex patterns, fall back to simple contains
113            file_path.contains(pattern)
114        }
115    }
116
117    /// Get all available phases
118    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        // Test that load_default() uses embedded config when no user config exists
179        // This test runs in a temporary directory where no coursemap.yml exists
180        let temp_dir = tempfile::tempdir()?;
181        let original_dir = std::env::current_dir()?;
182
183        // Change to temp directory
184        std::env::set_current_dir(&temp_dir)?;
185
186        // Load default config (should use embedded config)
187        let config = Config::load_default()?;
188
189        // Restore original directory
190        std::env::set_current_dir(original_dir)?;
191
192        // Verify it matches our embedded default
193        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        // Create a user config file in temp directory
209        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        // Load config directly from the file
221        let config = Config::from_file(&config_path)?;
222
223        // Verify it uses user config
224        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        // First test: only coursemap.yaml exists
236        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        // Clean up the first file before creating the second
251        std::fs::remove_file(&yaml_path)?;
252
253        // Now add coursemap.yml (higher priority)
254        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        // Load config again (should now prioritize coursemap.yml)
266        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}