use anyhow::{Context, Result};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(rename = "root-key")]
pub root_key: String,
pub phase: IndexMap<String, PhaseConfig>,
pub ignore: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseConfig {
pub face: String,
}
impl Default for Config {
fn default() -> Self {
let default_config = include_str!("default-coursemap.yml");
let config: Config = serde_yaml::from_str(default_config)
.with_context(|| "Failed to parse default configuration")
.expect("Parse error");
config
}
}
impl Config {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
let config: Config = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path.as_ref().display()))?;
Ok(config)
}
pub fn load_default() -> Result<Self> {
Self::load_default_from_dir(".")
}
pub fn load_default_from_dir<P: AsRef<Path>>(dir: P) -> Result<Self> {
let dir = dir.as_ref();
let config_paths = ["coursemap.yml", "coursemap.yaml", ".coursemap.yml"];
for path in &config_paths {
let full_path = dir.join(path);
if full_path.exists() {
return Self::from_file(full_path);
}
}
let config = Self::default();
Ok(config)
}
pub fn get_phase_color(&self, phase: &str) -> String {
self.phase
.get(phase)
.map(|p| p.face.clone())
.unwrap_or_else(|| {
self.phase
.get("Unknown")
.map(|p| p.face.clone())
.unwrap_or_else(|| "lightgray".to_string())
})
}
pub fn should_ignore(&self, file_path: &str) -> bool {
self.ignore.iter().any(|pattern| {
if let Some(stripped) = pattern.strip_prefix('/') {
file_path.ends_with(stripped)
} else if pattern.contains('*') {
self.matches_glob_pattern(file_path, pattern)
} else {
file_path.contains(pattern)
}
})
}
fn matches_glob_pattern(&self, file_path: &str, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(extension) = pattern.strip_prefix("*.") {
file_path.ends_with(&format!(".{extension}"))
} else if let Some(prefix) = pattern.strip_suffix("*") {
file_path.contains(prefix)
} else {
file_path.contains(pattern)
}
}
pub fn get_phases(&self) -> HashSet<String> {
self.phase.keys().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.root_key, "course-map");
assert!(config.phase.contains_key("Pre"));
assert!(config.phase.contains_key("InClass"));
assert!(config.phase.contains_key("Post"));
assert!(config.phase.contains_key("Unknown"));
}
#[test]
fn test_phase_color() {
let config = Config::default();
assert_eq!(config.get_phase_color("Pre"), "lightblue");
assert_eq!(config.get_phase_color("NonExistent"), "lightgray");
}
#[test]
fn test_should_ignore() {
let config = Config::default();
assert!(config.should_ignore("some/path/index.qmd"));
assert!(!config.should_ignore("some/path/intro.qmd"));
}
#[test]
fn test_config_from_file() -> Result<()> {
let mut temp_file = NamedTempFile::new()?;
writeln!(
temp_file,
r#"
root-key: test-map
phase:
Test:
face: red
ignore:
- test.qmd
"#
)?;
let config = Config::from_file(temp_file.path())?;
assert_eq!(config.root_key, "test-map");
assert_eq!(config.get_phase_color("Test"), "red");
assert!(config.should_ignore("test.qmd"));
Ok(())
}
#[test]
fn test_load_default_with_embedded_config() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let original_dir = std::env::current_dir()?;
std::env::set_current_dir(&temp_dir)?;
let config = Config::load_default()?;
std::env::set_current_dir(original_dir)?;
assert_eq!(config.root_key, "course-map");
assert_eq!(config.get_phase_color("Pre"), "lightblue");
assert_eq!(config.get_phase_color("InClass"), "lightgreen");
assert_eq!(config.get_phase_color("Post"), "orange");
assert_eq!(config.get_phase_color("Unknown"), "lightgray");
assert!(config.should_ignore("some/path/index.qmd"));
Ok(())
}
#[test]
fn test_load_default_with_user_config() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let config_path = temp_dir.path().join("coursemap.yml");
std::fs::write(
&config_path,
r#"root-key: user-config
phase:
Custom:
face: purple
ignore:
- custom.qmd
"#,
)?;
let config = Config::from_file(&config_path)?;
assert_eq!(config.root_key, "user-config");
assert_eq!(config.get_phase_color("Custom"), "purple");
assert!(config.should_ignore("custom.qmd"));
Ok(())
}
#[test]
fn test_config_file_priority() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let yaml_path = temp_dir.path().join("coursemap.yaml");
std::fs::write(
&yaml_path,
r#"root-key: yaml-config
phase:
Test:
face: red
ignore: []
"#,
)?;
let config = Config::load_default_from_dir(temp_dir.path())?;
assert_eq!(config.root_key, "yaml-config");
std::fs::remove_file(&yaml_path)?;
let yml_path = temp_dir.path().join("coursemap.yml");
std::fs::write(
&yml_path,
r#"root-key: yml-config
phase:
Test:
face: blue
ignore: []
"#,
)?;
let config = Config::load_default_from_dir(temp_dir.path())?;
assert_eq!(config.root_key, "yml-config");
Ok(())
}
#[test]
fn test_get_phases() {
let config = Config::default();
let phases = config.get_phases();
assert!(phases.contains("Pre"));
assert!(phases.contains("InClass"));
assert!(phases.contains("Post"));
assert!(phases.contains("Unknown"));
assert_eq!(phases.len(), 4);
}
#[test]
fn test_ignore_patterns() {
let mut config = Config::default();
config.ignore = vec![
"/index.qmd".to_string(),
"README.md".to_string(),
"*.tmp".to_string(),
];
assert!(config.should_ignore("some/path/index.qmd"));
assert!(config.should_ignore("README.md"));
assert!(config.should_ignore("file.tmp"));
assert!(!config.should_ignore("intro.qmd"));
}
}