nu_analytics/core/report/formats/
markdown.rs

1//! Markdown report generator
2//!
3//! Generates curriculum reports in Markdown format with embedded Mermaid diagrams
4//! for visualization. These reports render well in GitHub, GitLab, and VS Code.
5
6use 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
14/// Embedded Markdown report template
15const MARKDOWN_TEMPLATE: &str = include_str!("../templates/report.md");
16
17/// Markdown report generator
18pub struct MarkdownReporter;
19
20impl MarkdownReporter {
21    /// Create a new Markdown reporter
22    #[must_use]
23    pub const fn new() -> Self {
24        Self
25    }
26
27    /// Render the report using template substitution
28    #[allow(clippy::unused_self)]
29    fn render_template(&self, ctx: &ReportContext) -> String {
30        let mut output = MARKDOWN_TEMPLATE.to_string();
31
32        // Substitute header metadata
33        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        // Substitute summary metrics
43        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        // Generate longest delay path
62        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        // Generate term schedule table
70        let schedule_table = Self::generate_schedule_table(ctx);
71        output = output.replace("{{term_schedule}}", &schedule_table);
72
73        // Generate course metrics table
74        let metrics_table = Self::generate_metrics_table(ctx);
75        output = output.replace("{{course_metrics}}", &metrics_table);
76
77        // Generate Mermaid diagram
78        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    /// Generate the term-by-term schedule table
90    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        // Add unscheduled courses if any
122        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    /// Generate the course metrics table
134    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        // Sort courses by complexity (descending)
142        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}