1use crate::core::metrics::CourseMetrics;
7use crate::core::report::{ReportContext, ReportGenerator};
8use std::error::Error;
9use std::fmt::Write;
10use std::fs;
11use std::path::Path;
12
13const HTML_TEMPLATE: &str = include_str!("../templates/report.html");
15
16pub struct HtmlReporter;
18
19impl HtmlReporter {
20 #[must_use]
22 pub const fn new() -> Self {
23 Self
24 }
25
26 #[allow(clippy::unused_self)]
28 fn render_template(&self, ctx: &ReportContext) -> String {
29 let mut output = HTML_TEMPLATE.to_string();
30
31 output = output.replace("{{plan_name}}", &ctx.plan.name);
33 output = output.replace("{{institution}}", ctx.institution_name());
34 output = output.replace("{{degree_name}}", &ctx.degree_name());
35 output = output.replace("{{system_type}}", ctx.system_type());
36 output = output.replace("{{cip_code}}", ctx.cip_code());
37 output = output.replace("{{years}}", &format!("{:.0}", ctx.years()));
38 output = output.replace("{{total_credits}}", &format!("{:.1}", ctx.total_credits()));
39 output = output.replace("{{course_count}}", &ctx.course_count().to_string());
40
41 output = output.replace(
43 "{{total_complexity}}",
44 &ctx.summary.total_complexity.to_string(),
45 );
46 output = output.replace("{{longest_delay}}", &ctx.summary.longest_delay.to_string());
47 output = output.replace(
48 "{{longest_delay_course}}",
49 &ctx.summary.longest_delay_course,
50 );
51 output = output.replace(
52 "{{highest_centrality}}",
53 &ctx.summary.highest_centrality.to_string(),
54 );
55 output = output.replace(
56 "{{highest_centrality_course}}",
57 &ctx.summary.highest_centrality_course,
58 );
59
60 let delay_path = if ctx.summary.longest_delay_path.is_empty() {
62 "N/A".to_string()
63 } else {
64 ctx.summary.longest_delay_path.join(" → ")
65 };
66 output = output.replace("{{longest_delay_path}}", &delay_path);
67
68 let schedule_html = Self::generate_schedule_html(ctx);
70 output = output.replace("{{term_schedule}}", &schedule_html);
71
72 let metrics_html = Self::generate_metrics_html(ctx);
74 output = output.replace("{{course_metrics}}", &metrics_html);
75
76 let term_graph = Self::generate_term_graph(ctx);
78 output = output.replace("{{term_graph}}", &term_graph);
79
80 let svg_paths = Self::generate_svg_paths(ctx);
82 output = output.replace("{{svg_paths}}", &svg_paths);
83
84 let edges = Self::generate_edge_data(ctx);
86 output = output.replace("{{graph_edges}}", &edges);
87
88 let critical_path_ids = Self::generate_critical_path_ids(ctx);
90 output = output.replace("{{critical_path_ids}}", &critical_path_ids);
91
92 output
93 }
94
95 fn generate_critical_path_ids(ctx: &ReportContext) -> String {
100 let mut all_ids: Vec<String> = Vec::new();
101
102 for entry in &ctx.summary.longest_delay_path {
103 let trimmed = entry.trim();
105 if trimmed.starts_with('(') && trimmed.ends_with(')') {
106 let inner = &trimmed[1..trimmed.len() - 1]; for id in inner.split('+') {
109 all_ids.push(format!("\"{}\"", id.trim()));
110 }
111 } else {
112 all_ids.push(format!("\"{trimmed}\""));
114 }
115 }
116
117 format!("[{}]", all_ids.join(", "))
118 }
119
120 fn generate_term_graph(ctx: &ReportContext) -> String {
122 let mut html = String::new();
123
124 for term in &ctx.term_plan.terms {
125 let _ = writeln!(html, "<div class=\"term-column\">");
126 let _ = writeln!(
127 html,
128 " <div class=\"term-header\">Semester {}</div>",
129 term.number
130 );
131 let _ = writeln!(html, " <div class=\"term-courses\">");
132
133 for course_key in &term.courses {
134 let course = ctx.school.get_course(course_key);
135 let metrics = ctx.metrics.get(course_key);
136
137 let name = course.map_or("", |c| &c.name);
138 let short_name = if name.len() > 25 { &name[..22] } else { name };
139 let complexity = metrics.map_or(0, |m| m.complexity);
140
141 let complexity_class = match complexity {
142 0..=5 => "complexity-low",
143 6..=15 => "complexity-medium",
144 _ => "complexity-high",
145 };
146
147 let _ = writeln!(
148 html,
149 " <div class=\"course-node\" data-course-id=\"{course_key}\">"
150 );
151 let _ = writeln!(
152 html,
153 " <span class=\"complexity-badge {complexity_class}\">{complexity}</span>"
154 );
155 let _ = writeln!(html, " <div class=\"course-id\">{course_key}</div>");
156 let _ = writeln!(html, " <div class=\"course-name\">{short_name}</div>");
157 let _ = writeln!(html, " </div>");
158 }
159
160 let _ = writeln!(html, " </div>");
161 let _ = writeln!(html, "</div>");
162 }
163
164 html
165 }
166
167 fn generate_edge_data(ctx: &ReportContext) -> String {
169 let mut edges = Vec::new();
170
171 for (course, prereqs) in &ctx.dag.dependencies {
173 if !ctx.plan.courses.contains(course) {
174 continue;
175 }
176 for prereq in prereqs {
177 if !ctx.plan.courses.contains(prereq) {
178 continue;
179 }
180 edges.push(format!(
181 "{{ \"from\": \"{prereq}\", \"to\": \"{course}\", \"dashes\": false }}"
182 ));
183 }
184 }
185
186 for (course, coreqs) in &ctx.dag.corequisites {
188 if !ctx.plan.courses.contains(course) {
189 continue;
190 }
191 for coreq in coreqs {
192 if !ctx.plan.courses.contains(coreq) {
193 continue;
194 }
195 edges.push(format!(
196 "{{ \"from\": \"{coreq}\", \"to\": \"{course}\", \"dashes\": true }}"
197 ));
198 }
199 }
200
201 format!("[{}]", edges.join(", "))
202 }
203
204 fn generate_schedule_html(ctx: &ReportContext) -> String {
206 let mut html = String::new();
207
208 for term in &ctx.term_plan.terms {
209 if term.courses.is_empty() {
210 continue;
211 }
212
213 let courses_html: Vec<String> = term
214 .courses
215 .iter()
216 .map(|key| {
217 let name = ctx.school.get_course(key).map_or(key.as_str(), |c| &c.name);
218 format!("<span class=\"course-badge\">{key}</span> {name}")
219 })
220 .collect();
221
222 let _ = writeln!(
223 html,
224 "<tr><td>{}</td><td>{}</td><td>{:.1}</td></tr>",
225 term.number,
226 courses_html.join("<br>"),
227 term.total_credits
228 );
229 }
230
231 if !ctx.term_plan.unscheduled.is_empty() {
233 let _ = writeln!(
234 html,
235 "<tr class=\"unscheduled\"><td>⚠️</td><td>{}</td><td>-</td></tr>",
236 ctx.term_plan.unscheduled.join(", ")
237 );
238 }
239
240 html
241 }
242
243 fn generate_metrics_html(ctx: &ReportContext) -> String {
245 let mut html = String::new();
246
247 let mut courses: Vec<_> = ctx.plan.courses.iter().collect();
249 courses.sort_by(|a, b| {
250 let ma = ctx.metrics.get(*a).map_or(0, |m| m.complexity);
251 let mb = ctx.metrics.get(*b).map_or(0, |m| m.complexity);
252 mb.cmp(&ma)
253 });
254
255 for course_key in courses {
256 let course = ctx.school.get_course(course_key);
257 let metrics = ctx.metrics.get(course_key);
258
259 let name = course.map_or("-", |c| &c.name);
260 let credits = course.map_or(0.0, |c| c.credit_hours);
261 let (complexity, blocking, delay, centrality) =
262 metrics.map_or((0, 0, 0, 0), CourseMetrics::as_export_tuple);
263
264 let complexity_class = match complexity {
266 0..=5 => "low",
267 6..=15 => "medium",
268 _ => "high",
269 };
270
271 let _ = writeln!(
272 html,
273 "<tr class=\"complexity-{complexity_class}\"><td>{course_key}</td><td>{name}</td><td>{credits:.1}</td><td>{complexity}</td><td>{blocking}</td><td>{delay}</td><td>{centrality}</td></tr>"
274 );
275 }
276
277 html
278 }
279
280 fn generate_svg_paths(ctx: &ReportContext) -> String {
283 const TERM_WIDTH: f32 = 130.0;
285 const TERM_X_OFFSET: f32 = 20.0;
286 const COURSE_HEIGHT: f32 = 115.0;
287 const COURSE_Y_OFFSET: f32 = 50.0;
288 const COURSE_CENTER_X: f32 = 65.0;
289 const COURSE_CENTER_Y: f32 = 30.0;
290
291 let mut positions = std::collections::HashMap::new();
293 for (term_idx, term) in ctx.term_plan.terms.iter().enumerate() {
294 #[allow(clippy::cast_precision_loss)]
295 let term_x = (term_idx as f32).mul_add(TERM_WIDTH, TERM_X_OFFSET);
296 for (course_idx, course_key) in term.courses.iter().enumerate() {
297 #[allow(clippy::cast_precision_loss)]
298 let course_y = (course_idx as f32).mul_add(COURSE_HEIGHT, COURSE_Y_OFFSET);
299 positions.insert(
300 course_key.clone(),
301 (term_x + COURSE_CENTER_X, course_y + COURSE_CENTER_Y),
302 );
303 }
304 }
305
306 let mut paths = Vec::new();
307
308 for (course, prereqs) in &ctx.dag.dependencies {
310 if !ctx.plan.courses.contains(course) || !positions.contains_key(course) {
311 continue;
312 }
313 for prereq in prereqs {
314 if !ctx.plan.courses.contains(prereq) || !positions.contains_key(prereq) {
315 continue;
316 }
317
318 if let (Some(&(x1, y1)), Some(&(x2, y2))) =
319 (positions.get(prereq), positions.get(course))
320 {
321 let mid_x = f32::midpoint(x1, x2);
323 let mid_y = f32::midpoint(y1, y2);
324 let path = format!(
325 "<path class=\"prereq-line\" d=\"M {x1:.1} {y1:.1} Q {mid_x:.1} {mid_y:.1} {x2:.1} {y2:.1}\" data-from=\"{prereq}\" data-to=\"{course}\"></path>"
326 );
327 paths.push(path);
328 }
329 }
330 }
331
332 for (course, coreqs) in &ctx.dag.corequisites {
334 if !ctx.plan.courses.contains(course) || !positions.contains_key(course) {
335 continue;
336 }
337 for coreq in coreqs {
338 if !ctx.plan.courses.contains(coreq) || !positions.contains_key(coreq) {
339 continue;
340 }
341
342 if let (Some(&(x1, y1)), Some(&(x2, y2))) =
343 (positions.get(coreq), positions.get(course))
344 {
345 let mid_x = f32::midpoint(x1, x2);
347 let mid_y = f32::midpoint(y1, y2);
348 let path = format!(
349 "<path class=\"coreq-line\" d=\"M {x1:.1} {y1:.1} Q {mid_x:.1} {mid_y:.1} {x2:.1} {y2:.1}\" data-from=\"{coreq}\" data-to=\"{course}\"></path>"
350 );
351 paths.push(path);
352 }
353 }
354 }
355
356 paths.join("\n")
357 }
358
359 #[allow(dead_code)]
362 fn generate_graph_data(_ctx: &ReportContext) -> (String, String) {
363 (String::from("[]"), String::from("[]"))
365 }
366}
367
368impl Default for HtmlReporter {
369 fn default() -> Self {
370 Self::new()
371 }
372}
373
374impl ReportGenerator for HtmlReporter {
375 fn generate(&self, ctx: &ReportContext, output_path: &Path) -> Result<(), Box<dyn Error>> {
376 let report_content = self.render(ctx)?;
377 fs::write(output_path, report_content)?;
378 Ok(())
379 }
380
381 fn render(&self, ctx: &ReportContext) -> Result<String, Box<dyn Error>> {
382 Ok(self.render_template(ctx))
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::core::metrics::CourseMetrics;
390 use crate::core::metrics_export::CurriculumSummary;
391 use crate::core::models::{Course, Degree, Plan, School, DAG};
392 use crate::core::report::term_scheduler::TermPlan;
393 use std::collections::HashMap;
394
395 fn create_test_context() -> (
396 School,
397 Plan,
398 Degree,
399 HashMap<String, CourseMetrics>,
400 CurriculumSummary,
401 DAG,
402 TermPlan,
403 ) {
404 let mut school = School::new("Test University".to_string());
405
406 let cs101 = Course::new(
407 "Intro to CS".to_string(),
408 "CS".to_string(),
409 "101".to_string(),
410 3.0,
411 );
412 let mut cs201 = Course::new(
413 "Data Structures".to_string(),
414 "CS".to_string(),
415 "201".to_string(),
416 4.0,
417 );
418 cs201.add_prerequisite("CS101".to_string());
419
420 school.add_course(cs101);
421 school.add_course(cs201);
422
423 let degree = Degree::new(
424 "Computer Science".to_string(),
425 "BS".to_string(),
426 "11.0701".to_string(),
427 "semester".to_string(),
428 );
429
430 let mut plan = Plan::new("CS Plan".to_string(), degree.id());
431 plan.add_course("CS101".to_string());
432 plan.add_course("CS201".to_string());
433
434 let mut metrics = HashMap::new();
435 metrics.insert(
436 "CS101".to_string(),
437 CourseMetrics {
438 complexity: 3,
439 blocking: 1,
440 delay: 1,
441 centrality: 1,
442 },
443 );
444 metrics.insert(
445 "CS201".to_string(),
446 CourseMetrics {
447 complexity: 5,
448 blocking: 0,
449 delay: 2,
450 centrality: 1,
451 },
452 );
453
454 let summary = CurriculumSummary {
455 total_complexity: 8,
456 highest_centrality: 1,
457 highest_centrality_course: "CS101".to_string(),
458 longest_delay: 2,
459 longest_delay_course: "CS201".to_string(),
460 longest_delay_path: vec!["CS101".to_string(), "CS201".to_string()],
461 };
462
463 let mut dag = DAG::new();
464 dag.add_course("CS101".to_string());
465 dag.add_course("CS201".to_string());
466 dag.add_prerequisite("CS201".to_string(), "CS101");
467
468 let mut term_plan = TermPlan::new(8, false, 15.0);
469 term_plan.terms[0].add_course("CS101".to_string(), 3.0);
470 term_plan.terms[1].add_course("CS201".to_string(), 4.0);
471
472 (school, plan, degree, metrics, summary, dag, term_plan)
473 }
474
475 #[test]
476 fn test_html_reporter_new() {
477 let reporter = HtmlReporter::new();
478 let (school, plan, degree, metrics, summary, dag, term_plan) = create_test_context();
480 let ctx = ReportContext::new(
481 &school,
482 &plan,
483 Some(°ree),
484 &metrics,
485 &summary,
486 &dag,
487 &term_plan,
488 );
489 let result = reporter.render(&ctx);
490 assert!(result.is_ok());
491 }
492
493 #[test]
494 fn test_html_reporter_default() {
495 let reporter = HtmlReporter;
496 let (school, plan, degree, metrics, summary, dag, term_plan) = create_test_context();
497 let ctx = ReportContext::new(
498 &school,
499 &plan,
500 Some(°ree),
501 &metrics,
502 &summary,
503 &dag,
504 &term_plan,
505 );
506 let result = reporter.render(&ctx);
507 assert!(result.is_ok());
508 }
509
510 #[test]
511 fn test_render_produces_html() {
512 let (school, plan, degree, metrics, summary, dag, term_plan) = create_test_context();
513
514 let ctx = ReportContext::new(
515 &school,
516 &plan,
517 Some(°ree),
518 &metrics,
519 &summary,
520 &dag,
521 &term_plan,
522 );
523
524 let reporter = HtmlReporter::new();
525 let html = reporter.render(&ctx).unwrap();
526
527 assert!(html.contains("<!DOCTYPE html>"));
529 assert!(html.contains("Test University"));
530 assert!(html.contains("CS Plan"));
531 assert!(html.contains("CS101"));
532 assert!(html.contains("CS201"));
533 }
534
535 #[test]
536 fn test_generate_critical_path_ids() {
537 let (school, plan, degree, metrics, summary, dag, term_plan) = create_test_context();
538
539 let ctx = ReportContext::new(
540 &school,
541 &plan,
542 Some(°ree),
543 &metrics,
544 &summary,
545 &dag,
546 &term_plan,
547 );
548
549 let ids = HtmlReporter::generate_critical_path_ids(&ctx);
550
551 assert!(ids.contains("CS101"));
552 assert!(ids.contains("CS201"));
553 assert!(ids.starts_with('['));
554 assert!(ids.ends_with(']'));
555 }
556
557 #[test]
558 fn test_generate_critical_path_ids_with_corequisite_group() {
559 let summary = CurriculumSummary {
560 total_complexity: 10,
561 highest_centrality: 1,
562 highest_centrality_course: "CS101".to_string(),
563 longest_delay: 2,
564 longest_delay_course: "CS201".to_string(),
565 longest_delay_path: vec!["(CS101+CS101L)".to_string(), "CS201".to_string()],
566 };
567
568 let (school, plan, degree, metrics, _, dag, term_plan) = create_test_context();
569
570 let ctx = ReportContext::new(
571 &school,
572 &plan,
573 Some(°ree),
574 &metrics,
575 &summary,
576 &dag,
577 &term_plan,
578 );
579
580 let ids = HtmlReporter::generate_critical_path_ids(&ctx);
581
582 assert!(ids.contains("CS101"));
584 assert!(ids.contains("CS101L"));
585 assert!(ids.contains("CS201"));
586 }
587}