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}