nu_analytics/core/models/
school.rs

1//! School model
2
3use super::{Course, Degree, Plan};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Represents an educational institution
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct School {
10    /// School name
11    pub name: String,
12
13    /// Courses offered by the school, indexed by course key (PREFIXNUMBER)
14    courses: HashMap<String, Course>,
15
16    /// Degrees offered by the school
17    pub degrees: Vec<Degree>,
18
19    /// Curriculum plans offered by the school
20    pub plans: Vec<Plan>,
21}
22
23impl School {
24    /// Create a new school
25    ///
26    /// # Arguments
27    /// * `name` - School name
28    #[must_use]
29    pub fn new(name: String) -> Self {
30        Self {
31            name,
32            courses: HashMap::new(),
33            degrees: Vec::new(),
34            plans: Vec::new(),
35        }
36    }
37
38    /// Add a course to the school
39    ///
40    /// # Arguments
41    /// * `course` - Course to add
42    ///
43    /// # Returns
44    /// `true` if the course was added, `false` if a course with that key already exists
45    /// Add a course to the school (fails if key already exists)
46    ///
47    /// # Arguments
48    /// * `course` - The course to add
49    ///
50    /// # Returns
51    /// true if the course was added, false if a course with the same key already exists
52    pub fn add_course(&mut self, course: Course) -> bool {
53        let key = course.key();
54        self.courses.insert(key, course).is_none()
55    }
56
57    /// Add a course with a custom key (for handling deduplication)
58    ///
59    /// # Arguments
60    /// * `key` - Custom key for the course
61    /// * `course` - The course to add
62    pub fn add_course_with_key(&mut self, key: String, course: Course) {
63        self.courses.insert(key, course);
64    }
65
66    /// Get a course by its storage key (which may include deduplicated suffixes)
67    ///
68    /// # Arguments
69    /// * `storage_key` - The key used to store the course in the `HashMap`
70    ///
71    /// # Returns
72    /// A reference to the course, or `None` if not found
73    #[must_use]
74    pub fn get_course(&self, storage_key: &str) -> Option<&Course> {
75        self.courses.get(storage_key)
76    }
77
78    /// Get a course by its natural key (PREFIX NUMBER) - returns the first match if there are duplicates
79    ///
80    /// # Arguments
81    /// * `natural_key` - The course key (e.g., "CS2510")
82    ///
83    /// # Returns
84    /// A reference to the course, or `None` if not found
85    #[must_use]
86    pub fn get_course_by_natural_key(&self, natural_key: &str) -> Option<&Course> {
87        self.courses
88            .iter()
89            .find(|(_, course)| course.key() == natural_key)
90            .map(|(_, course)| course)
91    }
92
93    /// Get the storage key for a course (which may differ from its natural key due to deduplication)
94    ///
95    /// # Arguments
96    /// * `natural_key` - The course key (e.g., "CS2510")
97    ///
98    /// # Returns
99    /// The storage key (e.g., "`CS2510_2`" for a deduplicated course), or None if not found
100    #[must_use]
101    pub fn get_storage_key(&self, natural_key: &str) -> Option<String> {
102        self.courses
103            .iter()
104            .find(|(_, course)| course.key() == natural_key)
105            .map(|(storage_key, _)| storage_key.clone())
106    }
107
108    /// Get a mutable reference to a course by its key
109    ///
110    /// # Arguments
111    /// * `key` - Course key (e.g., "CS 2510")
112    ///
113    /// # Returns
114    /// A mutable reference to the course, or `None` if not found
115    pub fn get_course_mut(&mut self, key: &str) -> Option<&mut Course> {
116        self.courses.get_mut(key)
117    }
118
119    /// Get all courses
120    #[must_use]
121    pub fn courses(&self) -> Vec<&Course> {
122        self.courses.values().collect()
123    }
124
125    /// Get all courses with their storage keys
126    ///
127    /// # Returns
128    /// An iterator over (`storage_key`, course) pairs
129    pub fn courses_with_keys(&self) -> impl Iterator<Item = (&String, &Course)> {
130        self.courses.iter()
131    }
132
133    /// Add a degree to the school
134    pub fn add_degree(&mut self, degree: Degree) {
135        self.degrees.push(degree);
136    }
137
138    /// Get a degree by its ID
139    ///
140    /// # Arguments
141    /// * `degree_id` - Degree identifier (e.g., "BS Computer Science")
142    ///
143    /// # Returns
144    /// A reference to the degree, or `None` if not found
145    #[must_use]
146    pub fn get_degree(&self, degree_id: &str) -> Option<&Degree> {
147        self.degrees.iter().find(|d| d.id() == degree_id)
148    }
149
150    /// Add a plan to the school
151    pub fn add_plan(&mut self, plan: Plan) {
152        self.plans.push(plan);
153    }
154
155    /// Get plans associated with a specific degree
156    ///
157    /// # Arguments
158    /// * `degree_id` - Degree identifier
159    ///
160    /// # Returns
161    /// A vector of references to plans for that degree
162    #[must_use]
163    pub fn get_plans_for_degree(&self, degree_id: &str) -> Vec<&Plan> {
164        self.plans
165            .iter()
166            .filter(|p| p.degree_id == degree_id)
167            .collect()
168    }
169
170    /// Validate that all courses in all plans exist in the school
171    ///
172    /// # Returns
173    /// `Ok(())` if all courses exist, `Err(Vec<String>)` with missing course keys
174    ///
175    /// # Errors
176    /// Returns `Err` with a list of error messages for courses referenced in plans that don't exist
177    pub fn validate_plans(&self) -> Result<(), Vec<String>> {
178        let mut missing = Vec::new();
179
180        for plan in &self.plans {
181            for course_key in &plan.courses {
182                if self.get_course(course_key).is_none() {
183                    missing.push(format!(
184                        "Plan '{}': missing course '{}'",
185                        plan.name, course_key
186                    ));
187                }
188            }
189        }
190
191        if missing.is_empty() {
192            Ok(())
193        } else {
194            Err(missing)
195        }
196    }
197
198    /// Validate that all prerequisites and corequisites exist
199    ///
200    /// # Returns
201    /// `Ok(())` if all references are valid, `Err(Vec<String>)` with invalid references
202    ///
203    /// # Errors
204    /// Returns `Err` with a list of error messages for invalid prerequisite or corequisite references
205    pub fn validate_course_dependencies(&self) -> Result<(), Vec<String>> {
206        let mut invalid = Vec::new();
207
208        for course in self.courses.values() {
209            for prereq in &course.prerequisites {
210                if self.get_course(prereq).is_none() {
211                    invalid.push(format!(
212                        "Course '{}': prerequisite '{}' not found",
213                        course.key(),
214                        prereq
215                    ));
216                }
217            }
218
219            for coreq in &course.corequisites {
220                if self.get_course(coreq).is_none() {
221                    invalid.push(format!(
222                        "Course '{}': corequisite '{}' not found",
223                        course.key(),
224                        coreq
225                    ));
226                }
227            }
228        }
229
230        if invalid.is_empty() {
231            Ok(())
232        } else {
233            Err(invalid)
234        }
235    }
236
237    /// Build a directed acyclic graph (DAG) of course prerequisites
238    ///
239    /// # Returns
240    /// A DAG with all courses and their prerequisite relationships
241    #[must_use]
242    pub fn build_dag(&self) -> super::DAG {
243        let mut dag = super::DAG::new();
244
245        // Add all courses to the DAG using the keys they're stored under
246        for stored_key in self.courses.keys() {
247            dag.add_course(stored_key.clone());
248        }
249
250        // Add prerequisite relationships
251        // Note: prerequisite keys stored in course.prerequisites are already the stored keys
252        // (including deduplication suffixes), so we can add them directly to the DAG
253        for (stored_key, course) in &self.courses {
254            for prereq_key in &course.prerequisites {
255                // Check if this prerequisite key exists in our courses
256                if self.courses.contains_key(prereq_key) {
257                    dag.add_prerequisite(stored_key.clone(), prereq_key.as_str());
258                }
259            }
260
261            for coreq_key in &course.corequisites {
262                // Check if this corequisite key exists in our courses
263                if self.courses.contains_key(coreq_key) {
264                    dag.add_corequisite(stored_key.clone(), coreq_key.as_str());
265                }
266            }
267
268            for coreq_key in &course.strict_corequisites {
269                if self.courses.contains_key(coreq_key) {
270                    dag.add_corequisite(stored_key.clone(), coreq_key.as_str());
271                }
272            }
273        }
274
275        dag
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_school_creation() {
285        let school = School::new("Northeastern University".to_string());
286
287        assert_eq!(school.name, "Northeastern University");
288        assert!(school.courses().is_empty());
289        assert!(school.degrees.is_empty());
290        assert!(school.plans.is_empty());
291    }
292
293    #[test]
294    fn test_add_and_get_course() {
295        let mut school = School::new("Test University".to_string());
296
297        let course = Course::new(
298            "Discrete Structures".to_string(),
299            "CS".to_string(),
300            "1800".to_string(),
301            4.0,
302        );
303
304        assert!(school.add_course(course));
305
306        let retrieved = school.get_course("CS1800");
307        assert!(retrieved.is_some());
308        assert_eq!(retrieved.unwrap().name, "Discrete Structures");
309    }
310
311    #[test]
312    fn test_add_duplicate_course() {
313        let mut school = School::new("Test University".to_string());
314
315        let course1 = Course::new(
316            "Discrete Structures".to_string(),
317            "CS".to_string(),
318            "1800".to_string(),
319            4.0,
320        );
321
322        let course2 = Course::new(
323            "Different Name".to_string(),
324            "CS".to_string(),
325            "1800".to_string(),
326            4.0,
327        );
328
329        assert!(school.add_course(course1));
330        assert!(!school.add_course(course2)); // Should fail - duplicate key
331
332        assert_eq!(school.courses().len(), 1);
333    }
334
335    #[test]
336    fn test_course_lookup() {
337        let mut school = School::new("Test University".to_string());
338
339        for i in 1..=5 {
340            let course = Course::new(
341                format!("Course {i}"),
342                "CS".to_string(),
343                format!("{i}000"),
344                4.0,
345            );
346            school.add_course(course);
347        }
348
349        assert!(school.get_course("CS3000").is_some());
350        assert!(school.get_course("CS9999").is_none());
351    }
352
353    #[test]
354    fn test_add_degree() {
355        let mut school = School::new("Test University".to_string());
356
357        let degree = Degree::new(
358            "Computer Science".to_string(),
359            "BS".to_string(),
360            "11.0701".to_string(),
361            "semester".to_string(),
362        );
363
364        school.add_degree(degree);
365
366        assert_eq!(school.degrees.len(), 1);
367
368        let retrieved = school.get_degree("BS Computer Science");
369        assert!(retrieved.is_some());
370        assert_eq!(retrieved.unwrap().cip_code, "11.0701");
371    }
372
373    #[test]
374    fn test_add_plan() {
375        let mut school = School::new("Test University".to_string());
376
377        let plan = Plan::new(
378            "Standard Track".to_string(),
379            "BS Computer Science".to_string(),
380        );
381
382        school.add_plan(plan);
383
384        assert_eq!(school.plans.len(), 1);
385    }
386
387    #[test]
388    fn test_get_plans_for_degree() {
389        let mut school = School::new("Test University".to_string());
390
391        let plan1 = Plan::new("Track 1".to_string(), "BS Computer Science".to_string());
392        let plan2 = Plan::new("Track 2".to_string(), "BS Computer Science".to_string());
393        let plan3 = Plan::new("Track 3".to_string(), "BA Data Science".to_string());
394
395        school.add_plan(plan1);
396        school.add_plan(plan2);
397        school.add_plan(plan3);
398
399        let cs_plans = school.get_plans_for_degree("BS Computer Science");
400        assert_eq!(cs_plans.len(), 2);
401
402        let ds_plans = school.get_plans_for_degree("BA Data Science");
403        assert_eq!(ds_plans.len(), 1);
404    }
405
406    #[test]
407    fn test_validate_plans_success() {
408        let mut school = School::new("Test University".to_string());
409
410        // Add courses
411        school.add_course(Course::new(
412            "Course 1".to_string(),
413            "CS".to_string(),
414            "1800".to_string(),
415            4.0,
416        ));
417        school.add_course(Course::new(
418            "Course 2".to_string(),
419            "CS".to_string(),
420            "2510".to_string(),
421            4.0,
422        ));
423
424        // Add plan with valid courses
425        let mut plan = Plan::new("Track".to_string(), "BS CS".to_string());
426        plan.add_course("CS1800".to_string());
427        plan.add_course("CS2510".to_string());
428        school.add_plan(plan);
429
430        assert!(school.validate_plans().is_ok());
431    }
432
433    #[test]
434    fn test_validate_plans_failure() {
435        let mut school = School::new("Test University".to_string());
436
437        // Add only one course
438        school.add_course(Course::new(
439            "Course 1".to_string(),
440            "CS".to_string(),
441            "1800".to_string(),
442            4.0,
443        ));
444
445        // Add plan with invalid course
446        let mut plan = Plan::new("Track".to_string(), "BS CS".to_string());
447        plan.add_course("CS1800".to_string());
448        plan.add_course("CS9999".to_string()); // Doesn't exist
449        school.add_plan(plan);
450
451        let result = school.validate_plans();
452        assert!(result.is_err());
453        let errors = result.unwrap_err();
454        assert_eq!(errors.len(), 1);
455        assert!(errors[0].contains("CS9999"));
456    }
457
458    #[test]
459    fn test_validate_course_dependencies_success() {
460        let mut school = School::new("Test University".to_string());
461
462        // Add courses
463        school.add_course(Course::new(
464            "Discrete".to_string(),
465            "CS".to_string(),
466            "1800".to_string(),
467            4.0,
468        ));
469
470        let mut course2 = Course::new(
471            "Data Structures".to_string(),
472            "CS".to_string(),
473            "2510".to_string(),
474            4.0,
475        );
476        course2.add_prerequisite("CS1800".to_string());
477        school.add_course(course2);
478
479        assert!(school.validate_course_dependencies().is_ok());
480    }
481
482    #[test]
483    fn test_validate_course_dependencies_failure() {
484        let mut school = School::new("Test University".to_string());
485
486        let mut course = Course::new(
487            "Data Structures".to_string(),
488            "CS".to_string(),
489            "2510".to_string(),
490            4.0,
491        );
492        course.add_prerequisite("CS9999".to_string()); // Doesn't exist
493        school.add_course(course);
494
495        let result = school.validate_course_dependencies();
496        assert!(result.is_err());
497        let errors = result.unwrap_err();
498        assert_eq!(errors.len(), 1);
499        assert!(errors[0].contains("prerequisite"));
500    }
501
502    #[test]
503    fn test_get_course_mut() {
504        let mut school = School::new("Test University".to_string());
505
506        let course = Course::new(
507            "Test".to_string(),
508            "CS".to_string(),
509            "1800".to_string(),
510            4.0,
511        );
512        school.add_course(course);
513
514        {
515            let course_mut = school.get_course_mut("CS1800").unwrap();
516            course_mut.set_canonical_name("Testing 101".to_string());
517        }
518
519        let course = school.get_course("CS1800").unwrap();
520        assert_eq!(course.canonical_name, Some("Testing 101".to_string()));
521    }
522
523    #[test]
524    fn test_courses_iteration() {
525        let mut school = School::new("Test University".to_string());
526
527        school.add_course(Course::new(
528            "C1".to_string(),
529            "CS".to_string(),
530            "1800".to_string(),
531            4.0,
532        ));
533        school.add_course(Course::new(
534            "C2".to_string(),
535            "CS".to_string(),
536            "2510".to_string(),
537            4.0,
538        ));
539
540        let courses = school.courses();
541        assert_eq!(courses.len(), 2);
542
543        let keys: Vec<String> = courses.iter().map(|c| c.key()).collect();
544        assert!(keys.contains(&"CS1800".to_string()));
545        assert!(keys.contains(&"CS2510".to_string()));
546    }
547}