timetable_core/
config.rs

1//! Configuration management for timetable formatting.
2//!
3//! This module handles loading TOML configuration files, managing room-to-department
4//! mappings, and applying lesson overrides.
5
6use serde::Deserialize;
7use std::fs;
8use std::path::Path;
9use thiserror::Error;
10
11/// Errors that can occur during configuration operations.
12#[derive(Error, Debug)]
13pub enum ConfigError {
14    /// I/O error reading configuration file
15    #[error("IO error: {0}")]
16    Io(#[from] std::io::Error),
17    /// TOML parsing error
18    #[error("TOML parsing error: {0}")]
19    Toml(#[from] toml::de::Error),
20}
21
22#[cfg(test)]
23mod tests {
24    use super::*;
25
26    #[test]
27    fn test_get_style_for_room_longest_prefix() {
28        let toml = r###"
29            [[mappings]]
30            prefix = "M"
31            bg_color = "#fff"
32            fg_color = "#000"
33            map_id = "M_rooms"
34
35            [[mappings]]
36            prefix = "MA"
37            bg_color = "#abc"
38            fg_color = "#111"
39            map_id = "MA_rooms"
40        "###;
41
42        let cfg: Config = toml::from_str(toml).unwrap();
43        let m = cfg.get_style_for_room("MA12").unwrap();
44        assert_eq!(m.prefix, "MA");
45        assert_eq!(m.bg_color, "#abc");
46    }
47
48    #[test]
49    fn test_default_fg_color() {
50        let toml = r###"
51            [[mappings]]
52            prefix = "EN"
53            color = "#ddeeff"
54            map_id = "EN_rooms"
55        "###;
56
57        let cfg: Config = toml::from_str(toml).unwrap();
58        let m = cfg.get_style_for_room("EN4").unwrap();
59        assert_eq!(m.fg_color, "#231f20");
60    }
61
62    #[test]
63    fn test_apply_overrides_updates_lesson() {
64        use crate::parser::{Lesson, Week};
65
66        let lessons = vec![Lesson {
67            subject: "Maths".into(),
68            room: "MA3".into(),
69            teacher: "Mr A".into(),
70            class_code: "MA3".into(),
71            day_index: 3,    // Thursday
72            period_index: 1, // L1
73        }];
74
75        let mut weeks = vec![Week {
76            lessons,
77            week_name: "Week 1".into(),
78            student_name: None,
79            form: None,
80        }];
81
82        let toml = r###"
83            mappings = []
84            [[overrides]]
85            week = 1
86            day = "Thursday"
87            period = "L1"
88            room = "SC6"
89            teacher = "Mr Test B"
90        "###;
91
92        let cfg: Config = toml::from_str(toml).unwrap();
93        cfg.apply_overrides(&mut weeks);
94
95        let lesson = &weeks[0].lessons[0];
96        assert_eq!(lesson.room, "SC6");
97        assert_eq!(lesson.teacher, "Mr Test B");
98    }
99}
100
101/// Configuration for timetable formatting and room mappings.
102///
103/// Loaded from a TOML file containing room-to-department mappings and
104/// optional per-lesson overrides.
105#[derive(Debug, Deserialize)]
106pub struct Config {
107    /// Room-to-department mapping rules
108    pub mappings: Vec<Mapping>,
109    /// Per-week/day/period lesson overrides
110    #[serde(default)]
111    pub overrides: Vec<Override>,
112}
113
114/// Maps a room code prefix to visual styling and map element.
115///
116/// Used to color-code timetable cells and highlight map regions
117/// based on the room where a lesson takes place.
118#[derive(Debug, Deserialize)]
119pub struct Mapping {
120    /// Room code prefix to match (e.g., "MA" matches MA1, MA2, MA3, etc.)
121    pub prefix: String,
122    /// Background color for cell and map (hex code, e.g., "#fcdcd8")
123    #[serde(alias = "color")]
124    pub bg_color: String,
125    /// Foreground/text color for labels (hex code, defaults to "#231f20")
126    #[serde(default = "default_fg_color")]
127    pub fg_color: String,
128    /// SVG element ID in map file to highlight
129    pub map_id: String,
130    /// Human-readable department label (e.g., "Maths", "Science")
131    pub label: Option<String>,
132}
133
134/// Override for a specific lesson in the timetable.
135///
136/// Allows correcting parsing errors or making manual adjustments
137/// to specific lessons by week, day, and period.
138#[derive(Debug, Deserialize, Clone)]
139pub struct Override {
140    /// Week number (1-based, e.g., 1 = Week 1, 2 = Week 2)
141    pub week: usize,
142    /// Day name ("Monday", "Tuesday", etc. or abbreviated "Mon", "Tue")
143    pub day: String,
144    /// Period identifier ("PD", "L1", "L2", "L3", "L4", "L5")
145    pub period: String,
146    /// Override subject name (optional)
147    pub subject: Option<String>,
148    /// Override room code (optional)
149    pub room: Option<String>,
150    /// Override teacher name (optional)
151    pub teacher: Option<String>,
152    /// Override class code (optional)
153    pub class_code: Option<String>,
154}
155
156fn default_fg_color() -> String {
157    "#231f20".to_string()
158}
159
160impl Config {
161    /// Load configuration from a TOML file.
162    ///
163    /// # Arguments
164    ///
165    /// * `path` - Path to the config.toml file
166    ///
167    /// # Returns
168    ///
169    /// A parsed [`Config`] structure.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`ConfigError`] if:
174    /// - The file cannot be read
175    /// - The TOML syntax is invalid
176    /// - Required fields are missing
177    ///
178    /// # Example
179    ///
180    /// ```no_run
181    /// use timetable_core::config::Config;
182    /// use std::path::Path;
183    ///
184    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
185    /// let config = Config::load(Path::new("config.toml"))?;
186    /// println!("Loaded {} room mappings", config.mappings.len());
187    /// # Ok(())
188    /// # }
189    /// ```
190    pub fn load(path: &Path) -> Result<Self, ConfigError> {
191        let content = fs::read_to_string(path)?;
192        let config: Config = toml::from_str(&content)?;
193        Ok(config)
194    }
195
196    /// Find the mapping for a given room code.
197    ///
198    /// Returns the mapping with the longest matching prefix. If both 'MA' and 'MA1'
199    /// are configured, room 'MA10' matches 'MA1' (3 chars) over 'MA' (2 chars),
200    /// regardless of configuration order.
201    ///
202    /// # Arguments
203    ///
204    /// * `room_code` - Room code to look up (e.g., "MA3", "SC8")
205    ///
206    /// # Returns
207    ///
208    /// The matching [`Mapping`], or `None` if no prefix matches.
209    ///
210    /// # Example
211    ///
212    /// ```no_run
213    /// # use timetable_core::config::Config;
214    /// # use std::path::Path;
215    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
216    /// # let config = Config::load(Path::new("config.toml"))?;
217    /// if let Some(mapping) = config.get_style_for_room("MA3") {
218    ///     println!("Room MA3 maps to: {}", mapping.map_id);
219    /// }
220    /// # Ok(())
221    /// # }
222    /// ```
223    pub fn get_style_for_room(&self, room_code: &str) -> Option<&Mapping> {
224        // Find the longest matching prefix
225        self.mappings
226            .iter()
227            .filter(|m| room_code.starts_with(&m.prefix))
228            .max_by_key(|m| m.prefix.len())
229    }
230
231    /// Apply configured overrides to parsed weeks.
232    ///
233    /// Modifies lessons in-place based on override rules. Each override
234    /// specifies a week, day, and period, and can update any combination
235    /// of subject, room, teacher, or class code.
236    ///
237    /// # Arguments
238    ///
239    /// * `weeks` - Mutable slice of week data to modify
240    ///
241    /// # Warnings
242    ///
243    /// Prints warnings to stderr if:
244    /// - Week number is out of range
245    /// - Day or period name is invalid
246    /// - No matching lesson is found for an override
247    ///
248    /// # Example
249    ///
250    /// ```no_run
251    /// # use timetable_core::{config::Config, parser::parse_pdf};
252    /// # use std::path::Path;
253    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
254    /// let config = Config::load(Path::new("config.toml"))?;
255    /// let mut weeks = parse_pdf(Path::new("input/timetable.pdf"))?;
256    ///
257    /// config.apply_overrides(&mut weeks);
258    /// # Ok(())
259    /// # }
260    /// ```
261    pub fn apply_overrides(&self, weeks: &mut [crate::parser::Week]) {
262        for override_rule in &self.overrides {
263            // Find the target week (1-based index)
264            if override_rule.week == 0 || override_rule.week > weeks.len() {
265                eprintln!(
266                    "Warning: Override week {} is out of range",
267                    override_rule.week
268                );
269                continue;
270            }
271
272            let week = &mut weeks[override_rule.week - 1];
273
274            // Parse day to index
275            let day_index = match override_rule.day.to_lowercase().as_str() {
276                "monday" | "mon" => 0,
277                "tuesday" | "tue" => 1,
278                "wednesday" | "wed" => 2,
279                "thursday" | "thu" => 3,
280                "friday" | "fri" => 4,
281                _ => {
282                    eprintln!("Warning: Unknown day '{}'", override_rule.day);
283                    continue;
284                }
285            };
286
287            // Parse period to index
288            let period_index = match override_rule.period.to_uppercase().as_str() {
289                "PD" => 0,
290                "L1" => 1,
291                "L2" => 2,
292                "L3" => 3,
293                "L4" => 4,
294                "L5" => 5,
295                _ => {
296                    eprintln!("Warning: Unknown period '{}'", override_rule.period);
297                    continue;
298                }
299            };
300
301            // Find and update the lesson
302            if let Some(lesson) = week
303                .lessons
304                .iter_mut()
305                .find(|l| l.day_index == day_index && l.period_index == period_index)
306            {
307                if let Some(subject) = &override_rule.subject {
308                    lesson.subject = subject.clone();
309                }
310                if let Some(room) = &override_rule.room {
311                    lesson.room = room.clone();
312                }
313                if let Some(teacher) = &override_rule.teacher {
314                    lesson.teacher = teacher.clone();
315                }
316                if let Some(class_code) = &override_rule.class_code {
317                    lesson.class_code = class_code.clone();
318                }
319                println!(
320                    "Applied override: Week {}, {}, {}",
321                    override_rule.week, override_rule.day, override_rule.period
322                );
323            } else {
324                eprintln!(
325                    "Warning: No lesson found for Week {}, {}, {}",
326                    override_rule.week, override_rule.day, override_rule.period
327                );
328            }
329        }
330    }
331}