nu_analytics/core/report/formats/
markdown.rs1use crate::core::metrics::CourseMetrics;
7use crate::core::report::visualization::MermaidGenerator;
8use crate::core::report::{ReportContext, ReportGenerator};
9use std::error::Error;
10use std::fmt::Write;
11use std::fs;
12use std::path::Path;
13
14const MARKDOWN_TEMPLATE: &str = include_str!("../templates/report.md");
16
17pub struct MarkdownReporter;
19
20impl MarkdownReporter {
21 #[must_use]
23 pub const fn new() -> Self {
24 Self
25 }
26
27 #[allow(clippy::unused_self)]
29 fn render_template(&self, ctx: &ReportContext) -> String {
30 let mut output = MARKDOWN_TEMPLATE.to_string();
31
32 output = output.replace("{{plan_name}}", &ctx.plan.name);
34 output = output.replace("{{institution}}", ctx.institution_name());
35 output = output.replace("{{degree_name}}", &ctx.degree_name());
36 output = output.replace("{{system_type}}", ctx.system_type());
37 output = output.replace("{{years}}", &format!("{:.0}", ctx.years()));
38 output = output.replace("{{cip_code}}", ctx.cip_code());
39 output = output.replace("{{total_credits}}", &format!("{:.1}", ctx.total_credits()));
40 output = output.replace("{{course_count}}", &ctx.course_count().to_string());
41
42 output = output.replace(
44 "{{total_complexity}}",
45 &ctx.summary.total_complexity.to_string(),
46 );
47 output = output.replace("{{longest_delay}}", &ctx.summary.longest_delay.to_string());
48 output = output.replace(
49 "{{longest_delay_course}}",
50 &ctx.summary.longest_delay_course,
51 );
52 output = output.replace(
53 "{{highest_centrality}}",
54 &ctx.summary.highest_centrality.to_string(),
55 );
56 output = output.replace(
57 "{{highest_centrality_course}}",
58 &ctx.summary.highest_centrality_course,
59 );
60
61 let delay_path = if ctx.summary.longest_delay_path.is_empty() {
63 "N/A".to_string()
64 } else {
65 ctx.summary.longest_delay_path.join(" → ")
66 };
67 output = output.replace("{{longest_delay_path}}", &delay_path);
68
69 let schedule_table = Self::generate_schedule_table(ctx);
71 output = output.replace("{{term_schedule}}", &schedule_table);
72
73 let metrics_table = Self::generate_metrics_table(ctx);
75 output = output.replace("{{course_metrics}}", &metrics_table);
76
77 let mermaid_diagram = MermaidGenerator::generate_term_diagram(
79 ctx.term_plan,
80 ctx.dag,
81 ctx.school,
82 ctx.metrics,
83 );
84 output = output.replace("{{mermaid_diagram}}", &mermaid_diagram);
85
86 output
87 }
88
89 fn generate_schedule_table(ctx: &ReportContext) -> String {
91 let mut table = String::new();
92 let term_label = ctx.term_plan.term_label();
93
94 let _ = writeln!(table, "| {term_label} | Courses | Credits |");
95 table.push_str("|---|---|---|\n");
96
97 for term in &ctx.term_plan.terms {
98 if term.courses.is_empty() {
99 continue;
100 }
101
102 let courses_str: Vec<String> = term
103 .courses
104 .iter()
105 .map(|key| {
106 ctx.school
107 .get_course(key)
108 .map_or_else(|| key.clone(), |c| format!("{key} - {}", c.name))
109 })
110 .collect();
111
112 let _ = writeln!(
113 table,
114 "| {} | {} | {:.1} |",
115 term.number,
116 courses_str.join(", "),
117 term.total_credits
118 );
119 }
120
121 if !ctx.term_plan.unscheduled.is_empty() {
123 let _ = writeln!(
124 table,
125 "| ⚠️ Unscheduled | {} | - |",
126 ctx.term_plan.unscheduled.join(", ")
127 );
128 }
129
130 table
131 }
132
133 fn generate_metrics_table(ctx: &ReportContext) -> String {
135 let mut table = String::new();
136
137 table
138 .push_str("| Course | Name | Credits | Complexity | Blocking | Delay | Centrality |\n");
139 table.push_str("|---|---|---|---|---|---|---|\n");
140
141 let mut courses: Vec<_> = ctx.plan.courses.iter().collect();
143 courses.sort_by(|a, b| {
144 let ma = ctx.metrics.get(*a).map_or(0, |m| m.complexity);
145 let mb = ctx.metrics.get(*b).map_or(0, |m| m.complexity);
146 mb.cmp(&ma)
147 });
148
149 for course_key in courses {
150 let course = ctx.school.get_course(course_key);
151 let metrics = ctx.metrics.get(course_key);
152
153 let name = course.map_or("-", |c| &c.name);
154 let credits = course.map_or(0.0, |c| c.credit_hours);
155 let (complexity, blocking, delay, centrality) =
156 metrics.map_or((0, 0, 0, 0), CourseMetrics::as_export_tuple);
157
158 let _ = writeln!(
159 table,
160 "| {course_key} | {name} | {credits:.1} | {complexity} | {blocking} | {delay} | {centrality} |"
161 );
162 }
163
164 table
165 }
166}
167
168impl Default for MarkdownReporter {
169 fn default() -> Self {
170 Self::new()
171 }
172}
173
174impl ReportGenerator for MarkdownReporter {
175 fn generate(&self, ctx: &ReportContext, output_path: &Path) -> Result<(), Box<dyn Error>> {
176 let report_content = self.render(ctx)?;
177 fs::write(output_path, report_content)?;
178 Ok(())
179 }
180
181 fn render(&self, ctx: &ReportContext) -> Result<String, Box<dyn Error>> {
182 Ok(self.render_template(ctx))
183 }
184}