Files
coursebank/src/export/report.rs
T
2026-08-07 11:47:30 -04:00

1085 lines
37 KiB
Rust

// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Writing reports for students and for yourself.
//!
//! Two audiences, two documents, and the difference between them is not tone but
//! content.
//!
//! The **student report** answers "what should I do next?". It gives a score, a
//! coarse position in the class, per-objective standing, and — the part that
//! actually helps — for each missed question, the misconception that the specific
//! distractor they chose was written to detect, plus where in the course to go
//! back to. It never prints a correct answer, never names another student, and
//! never reports a rank. Those omissions are deliberate: a report that reveals
//! keys cannot be sent out before a makeup exam, and a report that gives a rank
//! invites the student to read it as a verdict rather than as instructions.
//!
//! The **instructor report** answers "what should I fix?". Item statistics, the
//! revision queue, distractor tables, reliability, blueprint coverage, and the
//! class-level objectives that nobody met. That last section is the one that
//! should change your teaching rather than any individual student's studying.
//!
//! Output is Markdown. It is readable as-is in a terminal or a text editor, it
//! diffs cleanly, and [`to_html`] converts it for emailing or posting. The HTML
//! converter handles exactly the Markdown this module emits — headings, tables,
//! lists, bold, italic, code, blockquotes, rules — and nothing more; it is not a
//! general Markdown implementation and does not pretend to be.
use std::collections::BTreeMap;
use crate::assessment::AssessmentFile;
use crate::catalog::Catalog;
use crate::classical::Analysis;
use crate::course::CourseFile;
use crate::date::Date;
use crate::irt::Fit;
use crate::students::{Cohort, Mastery, StudentSummary};
use crate::taxonomy::Level;
/// What to include in a student report.
#[derive(Debug, Clone)]
pub struct StudentOptions {
/// Whether to include the per-objective table.
pub objectives: bool,
/// Whether to include the per-level comparison to the class.
pub levels: bool,
/// Whether to include per-question guidance on missed items.
pub missed: bool,
/// Whether to show the class mean and the student's band. Turn this off for a
/// course where any comparison is unwelcome.
pub comparison: bool,
/// Whether to include the IRT ability estimate. Off by default: it is not
/// meaningful to most students and invites misreading.
pub ability: bool,
/// A closing note appended verbatim, e.g. office-hours information.
pub closing: Option<String>,
}
impl Default for StudentOptions {
fn default() -> StudentOptions {
StudentOptions {
objectives: true,
levels: true,
missed: true,
comparison: true,
ability: false,
closing: None,
}
}
}
/// Writes one student's report.
///
/// # Arguments
///
/// * `summary` - the student's summary.
/// * `cohort` - the class context, for means.
/// * `course` - the course, for titles.
/// * `record` - the assessment record, for the title and date.
/// * `opts` - what to include.
///
/// # Returns
///
/// A Markdown document.
pub fn student(
summary: &StudentSummary,
cohort: &Cohort,
course: &CourseFile,
record: &AssessmentFile,
opts: &StudentOptions,
) -> String {
let mut out = String::new();
out.push_str(&format!(
"# {}{}\n\n",
record.assessment.title, course.course.code
));
out.push_str(&format!("**{}**\n\n", summary.display_name()));
// ---------------------------------------------------------------- score
out.push_str(&format!(
"You scored **{:.1} of {:.1} points ({:.0}%)**",
summary.points, summary.points_possible, summary.percent
));
if summary.bonus_points > 0.0 {
out.push_str(&format!(
", plus {:.1} bonus point{}",
summary.bonus_points,
if (summary.bonus_points - 1.0).abs() < 1e-9 {
""
} else {
"s"
}
));
}
out.push_str(&format!(
", answering {} of {} questions correctly.\n\n",
summary.correct, summary.n_items
));
if opts.comparison {
out.push_str(&format!(
"The class averaged {:.0}%. Your score is in the {}.\n\n",
cohort.mean_percent, summary.band
));
}
if opts.ability {
if let (Some(theta), Some(se)) = (summary.theta, summary.theta_se) {
out.push_str(&format!(
"Adjusting for how difficult each question turned out to be, your estimated \
standing is {} (θ = {theta:+.2}, ± {:.2}). The margin is wide because a single \
exam is a small amount of evidence.\n\n",
crate::irt::Ability {
student_key: summary.student_key.clone(),
theta,
se,
n_items: summary.n_items,
}
.band(),
se * 1.96
));
}
}
// ----------------------------------------------------------- objectives
if opts.objectives && !summary.objectives.is_empty() {
out.push_str("## What this exam says about each learning objective\n\n");
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
for o in &summary.objectives {
let you = format!("{:.0}%", o.rate * 100.0);
out.push_str(&format!(
"| {} | {} | {} | {:.0}% | {} |\n",
o.status.symbol(),
escape_pipes(&o.text),
you,
o.cohort_rate * 100.0,
o.n_items
));
}
out.push('\n');
out.push_str("✓ meeting · ~ developing · ✗ not yet · ? too few questions to tell\n\n");
// The "too few questions" cases are an honest caveat about the exam, and
// saying so protects the student from over-reading a single data point.
let thin: Vec<&str> = summary
.objectives
.iter()
.filter(|o| o.status == Mastery::NotEnoughEvidence)
.map(|o| o.text.as_str())
.collect();
if !thin.is_empty() {
out.push_str(&format!(
"This exam had too few questions on {} to say anything reliable about {}. Treat \
those rows as information about the exam, not about you.\n\n",
list(&thin),
if thin.len() == 1 { "it" } else { "them" }
));
}
}
// --------------------------------------------------------------- levels
if opts.levels && summary.levels.len() > 1 {
out.push_str("## Kinds of thinking\n\n");
out.push_str(
"Questions on this exam asked for different kinds of thinking. Comparing your rate \
across them often shows more than the total score does.\n\n",
);
out.push_str("| Kind of question | You | Class | |\n|:--|--:|--:|:--|\n");
for l in &summary.levels {
out.push_str(&format!(
"| {}{} | {:.0}% | {:.0}% | {} |\n",
l.level.name(),
l.level.blurb(),
l.rate * 100.0,
l.cohort_rate * 100.0,
bar(l.rate)
));
}
out.push('\n');
// The interesting pattern: fine on recall, falling apart on application.
let recall: Vec<f64> = summary
.levels
.iter()
.filter(|l| l.level.code() <= 2)
.map(|l| l.rate)
.collect();
let applied: Vec<f64> = summary
.levels
.iter()
.filter(|l| l.level.code() >= 3)
.map(|l| l.rate)
.collect();
if !recall.is_empty() && !applied.is_empty() {
let drop = average(&recall) - average(&applied);
if drop > 0.2 {
out.push_str(
"You are recalling the material but losing ground when you have to use it. \
That usually means more practice working problems rather than more rereading \
— rereading feels productive and mostly rebuilds recognition.\n\n",
);
} else if drop < -0.2 {
out.push_str(
"You reason well with the material when it is in front of you, but specific \
facts and terms are costing you points. That is the more tractable of the two \
problems: targeted memorization of the terms below will help.\n\n",
);
}
}
}
// ---------------------------------------------------------- what to do
if !summary.focus.is_empty() {
out.push_str("## Where to put your time\n\n");
out.push_str("In this order:\n\n");
for (i, id) in summary.focus.iter().take(4).enumerate() {
let text = course.objective_text(id);
out.push_str(&format!("{}. {}\n", i + 1, text));
}
out.push('\n');
// Distinguish "you missed this" from "the class missed this", because the
// second is going to be retaught and does not need solo review.
let class_gaps: Vec<&str> = cohort
.class_gaps
.iter()
.filter(|(id, _)| summary.focus.contains(id))
.map(|(id, _)| id.as_str())
.collect();
if !class_gaps.is_empty() {
let texts: Vec<String> = class_gaps
.iter()
.map(|id| course.objective_text(id))
.collect();
let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
out.push_str(&format!(
"Most of the class also struggled with {}, so expect it to come back in class. \
Prioritize the other items above for solo review.\n\n",
list(&refs)
));
}
}
if !summary.strengths.is_empty() {
let texts: Vec<String> = summary
.strengths
.iter()
.take(4)
.map(|id| course.objective_text(id))
.collect();
let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
out.push_str(&format!("You have clearly got {}.\n\n", list(&refs)));
}
// --------------------------------------------------------- missed items
if opts.missed && !summary.missed.is_empty() {
out.push_str("## Question by question\n\n");
out.push_str(
"For each question you missed, here is what the answer you chose usually indicates, \
and where to go back to. Correct answers are not listed here.\n\n",
);
for m in &summary.missed {
let partial = if m.credit > 0.0 {
format!(" (partial credit: {:.0}%)", m.credit * 100.0)
} else {
String::new()
};
out.push_str(&format!("**Question {}**{partial}\n\n", m.number));
if let Some(text) = &m.feedback {
out.push_str(&format!("{text}\n\n"));
} else if let Some(misconception) = &m.misconception {
out.push_str(&format!(
"The option you chose is the one students pick when {misconception}\n\n"
));
}
if !m.study.is_empty() {
out.push_str(&format!("Review: {}\n\n", m.study.join("; ")));
}
}
}
if let Some(closing) = &opts.closing {
out.push_str("---\n\n");
out.push_str(closing);
out.push_str("\n\n");
}
out.push_str(&format!(
"---\n\n*Generated {} for {}. Percentages on individual objectives come from a handful of \
questions each and carry real uncertainty; read them as directions, not measurements.*\n",
Date::today(),
summary.display_name()
));
out
}
/// Writes the instructor's report on one administration.
///
/// # Arguments
///
/// * `analysis` - classical item analysis.
/// * `cohort` - per-student summaries and class rates.
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `fit` - an optional IRT fit.
///
/// # Returns
///
/// A Markdown document.
pub fn cohort(
analysis: &Analysis,
cohort: &Cohort,
catalog: &Catalog,
record: &AssessmentFile,
fit: Option<&Fit>,
) -> String {
let mut out = String::new();
let course = &catalog.course;
out.push_str(&format!(
"# {} — item analysis\n\n{} · {} · {}\n\n",
record.assessment.title,
course.course.code,
record
.assessment
.term
.clone()
.unwrap_or_else(|| course.course.term.clone()),
record
.assessment
.date
.map(|d| d.to_string())
.unwrap_or_else(|| "date not recorded".into())
));
// ------------------------------------------------------------- summary
let r = &analysis.reliability;
out.push_str("## Summary\n\n");
out.push_str(&format!(
"- {} examinees, {} scored items\n- Mean score {:.1} of {} ({:.0}%), SD {:.2}\n- \
Mean p-value {:.2}, mean point-biserial {}\n",
r.n_students,
r.n_items,
r.mean,
r.n_items,
if r.n_items > 0 {
100.0 * r.mean / r.n_items as f64
} else {
0.0
},
r.sd,
r.mean_p,
r.mean_point_biserial
.map(|v| format!("{v:+.2}"))
.unwrap_or_else(|| "n/a".into())
));
out.push('\n');
out.push_str(&r.interpretation());
out.push_str("\n\n");
if let Some(f) = fit {
let peak = f.peak_information();
out.push_str(&format!(
"The IRT fit ({} model, {} iterations{}) measures most precisely around θ = {peak:+.1}",
match f.items.first().map(|i| i.model) {
Some(m) => m.as_str(),
None => "?",
},
f.iterations,
if f.converged {
""
} else {
", did not converge"
}
));
match f.standard_error(peak) {
Some(se) => out.push_str(&format!(
", where the standard error is {se:.2} logits.\n\n"
)),
None => out.push_str(".\n\n"),
}
}
for w in &analysis.warnings {
out.push_str(&format!("> {w}\n\n"));
}
// ------------------------------------------------------- revise queue
let queue = analysis.revise_queue();
out.push_str("## What to revise\n\n");
if queue.is_empty() {
out.push_str(
"Nothing was flagged. Unusual, and worth a skeptical glance at whether the \
key and the record actually matched the exam.\n\n",
);
} else {
let blocking = queue.iter().filter(|i| i.needs_revision()).count();
out.push_str(&format!(
"{} item(s) flagged; {blocking} need attention before being used again.\n\n",
queue.len()
));
for item in queue {
let label = item
.item_ref
.clone()
.unwrap_or_else(|| format!("question {}", item.number));
out.push_str(&format!(
"### Q{}{}{}\n\n",
item.number,
label,
if item.needs_revision() { " ⚠" } else { "" }
));
out.push_str(&format!(
"p = {:.2} · r = {} · flags: {}\n\n",
item.p_value,
item.point_biserial
.map(|v| format!("{v:+.2}"))
.unwrap_or_else(|| "n/a".into()),
item.flags
.iter()
.map(|f| f.as_str())
.collect::<Vec<_>>()
.join(", ")
));
for note in &item.notes {
out.push_str(&format!("- {note}\n"));
}
out.push('\n');
// The distractor table is where a poorly worded item shows itself.
if item.options.len() > 1 {
out.push_str(
"| Option | Chose | r | Upper | Lower | |\n|:--|--:|--:|--:|--:|:--|\n",
);
for o in item.options.values() {
out.push_str(&format!(
"| {} | {:.0}% | {} | {} | {} | {} |\n",
o.letter,
o.rate * 100.0,
o.point_biserial
.map(|v| format!("{v:+.2}"))
.unwrap_or_else(|| "n/a".into()),
o.upper_rate
.map(|v| format!("{:.0}%", v * 100.0))
.unwrap_or_else(|| "-".into()),
o.lower_rate
.map(|v| format!("{:.0}%", v * 100.0))
.unwrap_or_else(|| "-".into()),
if o.is_key { "**key**" } else { "" }
));
}
out.push('\n');
}
}
}
// ---------------------------------------------------------- item table
out.push_str("## Every item\n\n");
out.push_str(
"| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n",
);
for item in &analysis.items {
let level = record
.placement(item.number)
.and_then(|p| p.level)
.map(|l| l.code().to_string())
.unwrap_or_else(|| "-".into());
out.push_str(&format!(
"| {} | {} | {} | {:.2} | {} | {} | {:.0}% | {} |\n",
item.number,
item.item_ref.clone().unwrap_or_default(),
level,
item.p_value,
item.point_biserial
.map(|v| format!("{v:+.2}"))
.unwrap_or_else(|| "n/a".into()),
item.discrimination_index
.map(|v| format!("{v:+.2}"))
.unwrap_or_else(|| "-".into()),
item.blank_rate * 100.0,
item.flags
.iter()
.map(|f| f.as_str())
.collect::<Vec<_>>()
.join(" ")
));
}
out.push('\n');
if let Some(f) = fit {
out.push_str("## IRT parameters\n\n");
out.push_str("| Q | a | b | SE(a) | SE(b) | n | Notes |\n|--:|--:|--:|--:|--:|--:|:--|\n");
for item in &f.items {
out.push_str(&format!(
"| {} | {:.2} | {:+.2} | {} | {} | {} | {} |\n",
item.number,
item.a,
item.b,
item.se_a
.map(|v| format!("{v:.2}"))
.unwrap_or_else(|| "-".into()),
item.se_b
.map(|v| format!("{v:.2}"))
.unwrap_or_else(|| "-".into()),
item.n,
item.notes.join(" ")
));
}
out.push('\n');
for w in &f.warnings {
out.push_str(&format!("> {w}\n\n"));
}
}
// -------------------------------------------------------- class gaps
out.push_str("## Objectives the class did not meet\n\n");
if cohort.class_gaps.is_empty() {
out.push_str("Every assessed objective cleared the mastery threshold.\n\n");
} else {
out.push_str(&format!(
"Below the {:.0}% threshold. These are the candidates for reteaching rather than for \
individual review.\n\n",
course.policy.mastery_threshold * 100.0
));
out.push_str("| Objective | Class rate |\n|:--|--:|\n");
for (id, rate) in &cohort.class_gaps {
out.push_str(&format!(
"| {} | {:.0}% |\n",
escape_pipes(&course.objective_text(id)),
rate * 100.0
));
}
out.push('\n');
}
// ------------------------------------------------------ level coverage
out.push_str("## Coverage and class performance by level\n\n");
let counts = record.level_counts();
out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n");
for level in Level::ALL {
let n = counts.get(&level).copied().unwrap_or(0);
if n == 0 {
continue;
}
out.push_str(&format!(
"| {} {} | {} | {:.0}% |\n",
level.code(),
level.name(),
n,
cohort.level_rates.get(&level).copied().unwrap_or(0.0) * 100.0
));
}
out.push('\n');
if let Some(bp) = &record.blueprint {
let drift = crate::select::check_blueprint(record);
if !drift.is_empty() {
out.push_str("Blueprint drift:\n\n");
for d in &drift {
out.push_str(&format!("- {d}\n"));
}
out.push('\n');
}
let _ = bp;
}
// -------------------------------------------------------- archetypes
if !cohort.archetypes.is_empty() {
out.push_str("## Patterns across students\n\n");
out.push_str(
"Descriptive grouping by performance profile across levels, not a diagnosis. Useful \
for deciding whether a review session should target one gap or several.\n\n",
);
for a in &cohort.archetypes {
let means: Vec<String> = a
.level_means
.iter()
.map(|(l, v)| format!("L{}: {:.0}%", l.code(), v * 100.0))
.collect();
out.push_str(&format!(
"- **{}** — {} student(s); {}\n",
a.label,
a.members.len(),
means.join(", ")
));
}
out.push('\n');
}
out.push_str(&format!("---\n\n*Generated {}.*\n", Date::today()));
out
}
/// Writes a one-line-per-student roster of scores.
///
/// # Arguments
///
/// * `cohort` - the class.
///
/// # Returns
///
/// A Markdown table.
pub fn roster(cohort: &Cohort) -> String {
let mut out =
String::from("| Student | Points | % | Correct | Focus |\n|:--|--:|--:|--:|:--|\n");
let mut sorted: Vec<&StudentSummary> = cohort.students.iter().collect();
sorted.sort_by(|a, b| {
b.percent
.partial_cmp(&a.percent)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.student_key.cmp(&b.student_key))
});
for s in sorted {
out.push_str(&format!(
"| {} | {:.1} | {:.0}% | {}/{} | {} |\n",
s.display_name(),
s.points,
s.percent,
s.correct,
s.n_items,
s.focus
.iter()
.take(2)
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
out
}
/// A small unicode bar for a rate in `0.0..=1.0`.
///
/// # Arguments
///
/// * `rate` - the value.
///
/// # Returns
///
/// A ten-cell bar.
fn bar(rate: f64) -> String {
let filled = (rate.clamp(0.0, 1.0) * 10.0).round() as usize;
format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled))
}
/// The mean of a slice, zero when empty.
fn average(v: &[f64]) -> f64 {
if v.is_empty() {
0.0
} else {
v.iter().sum::<f64>() / v.len() as f64
}
}
/// Joins items into an English list.
///
/// # Arguments
///
/// * `items` - the items.
///
/// # Returns
///
/// `"a"`, `"a and b"`, or `"a, b, and c"`.
fn list(items: &[&str]) -> String {
match items.len() {
0 => String::new(),
1 => items[0].to_string(),
2 => format!("{} and {}", items[0], items[1]),
_ => {
let head = items[..items.len() - 1].join(", ");
format!("{head}, and {}", items[items.len() - 1])
}
}
}
/// Escapes pipes so objective text cannot break a Markdown table.
fn escape_pipes(s: &str) -> String {
s.replace('|', "\\|")
}
/// Converts the Markdown this module emits into a standalone HTML document.
///
/// Handles headings, paragraphs, unordered and ordered lists, tables,
/// blockquotes, horizontal rules, and inline bold, italic, and code. This is not a
/// general Markdown implementation: it covers the constructs the generators above
/// produce, and unrecognized syntax passes through as escaped text rather than
/// being silently mangled.
///
/// # Arguments
///
/// * `markdown` - the document.
/// * `title` - the HTML title.
///
/// # Returns
///
/// A complete HTML document with embedded styles, so it can be emailed or opened
/// with no other files.
pub fn to_html(markdown: &str, title: &str) -> String {
let mut body = String::new();
let mut lines = markdown.lines().peekable();
let mut list_kind: Option<&str> = None;
// Closes an open list, if any.
fn close_list(body: &mut String, list_kind: &mut Option<&str>) {
if let Some(tag) = list_kind.take() {
body.push_str(&format!("</{tag}>\n"));
}
}
while let Some(line) = lines.next() {
let trimmed = line.trim();
if trimmed.is_empty() {
close_list(&mut body, &mut list_kind);
continue;
}
if trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-') {
close_list(&mut body, &mut list_kind);
body.push_str("<hr>\n");
continue;
}
if let Some(rest) = trimmed.strip_prefix("> ") {
close_list(&mut body, &mut list_kind);
body.push_str(&format!("<blockquote>{}</blockquote>\n", inline(rest)));
continue;
}
// Headings.
let hashes = trimmed.chars().take_while(|c| *c == '#').count();
if hashes > 0 && hashes <= 6 && trimmed.chars().nth(hashes) == Some(' ') {
close_list(&mut body, &mut list_kind);
let text = trimmed[hashes + 1..].trim();
body.push_str(&format!("<h{hashes}>{}</h{hashes}>\n", inline(text)));
continue;
}
// Tables: a header row followed by an alignment row.
if trimmed.starts_with('|') {
let is_separator = |s: &str| {
s.trim().starts_with('|')
&& s.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'))
&& s.contains('-')
};
if lines.peek().map(|n| is_separator(n)).unwrap_or(false) {
close_list(&mut body, &mut list_kind);
lines.next();
body.push_str("<table>\n<thead><tr>");
for cell in split_row(trimmed) {
body.push_str(&format!("<th>{}</th>", inline(&cell)));
}
body.push_str("</tr></thead>\n<tbody>\n");
while let Some(next) = lines.peek() {
if !next.trim().starts_with('|') {
break;
}
// `peek` just succeeded, so `next` cannot be `None`; a
// `let else` says that without a panic in the path.
let Some(row) = lines.next() else { break };
body.push_str("<tr>");
for cell in split_row(row.trim()) {
body.push_str(&format!("<td>{}</td>", inline(&cell)));
}
body.push_str("</tr>\n");
}
body.push_str("</tbody>\n</table>\n");
continue;
}
}
// Lists.
if let Some(rest) = trimmed.strip_prefix("- ") {
if list_kind != Some("ul") {
close_list(&mut body, &mut list_kind);
body.push_str("<ul>\n");
list_kind = Some("ul");
}
body.push_str(&format!("<li>{}</li>\n", inline(rest)));
continue;
}
if let Some((prefix, rest)) = trimmed.split_once(". ") {
if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
if list_kind != Some("ol") {
close_list(&mut body, &mut list_kind);
body.push_str("<ol>\n");
list_kind = Some("ol");
}
body.push_str(&format!("<li>{}</li>\n", inline(rest)));
continue;
}
}
close_list(&mut body, &mut list_kind);
body.push_str(&format!("<p>{}</p>\n", inline(trimmed)));
}
close_list(&mut body, &mut list_kind);
format!(
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
<title>{}</title>\n<style>\n{}\n</style>\n</head>\n<body>\n{}</body>\n</html>\n",
crate::markup::escape_html(title),
STYLE,
body
)
}
/// Splits a Markdown table row into cells.
fn split_row(row: &str) -> Vec<String> {
let inner = row.trim().trim_start_matches('|').trim_end_matches('|');
inner
.split('|')
.map(|c| c.trim().replace("\\|", "|"))
.collect()
}
/// Converts inline Markdown to HTML.
///
/// Escapes first, then applies emphasis, so text containing angle brackets cannot
/// become markup.
fn inline(s: &str) -> String {
let escaped = crate::markup::escape_html(s);
let mut out = escaped;
out = wrap(&out, "**", "<strong>", "</strong>");
out = wrap(&out, "`", "<code>", "</code>");
out = wrap(&out, "*", "<em>", "</em>");
out
}
/// Replaces paired delimiters, leaving unpaired ones literal.
fn wrap(s: &str, delim: &str, open: &str, close: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
loop {
let Some(i) = rest.find(delim) else {
out.push_str(rest);
return out;
};
let after = &rest[i + delim.len()..];
let Some(j) = after.find(delim) else {
out.push_str(rest);
return out;
};
if j == 0 {
out.push_str(&rest[..i + delim.len()]);
rest = after;
continue;
}
out.push_str(&rest[..i]);
out.push_str(open);
out.push_str(&after[..j]);
out.push_str(close);
rest = &after[j + delim.len()..];
}
}
/// Embedded stylesheet for HTML reports.
const STYLE: &str = "\
:root { color-scheme: light dark; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 46rem; margin: 2.5rem auto; padding: 0 1.25rem; line-height: 1.55;
color: #1a1a1a; background: #fff; }
h1 { font-size: 1.6rem; margin-bottom: 0.2rem; }
h2 { font-size: 1.15rem; margin-top: 2rem; border-bottom: 1px solid #e5e5e5;
padding-bottom: 0.3rem; }
h3 { font-size: 1rem; margin-top: 1.5rem; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: 0.92rem; }
th, td { border-bottom: 1px solid #e5e5e5; padding: 0.4rem 0.6rem; text-align: left; }
th { background: #fafafa; font-weight: 600; }
td:nth-child(n+3), th:nth-child(n+3) { text-align: right; }
code { background: #f5f5f5; padding: 0.1rem 0.3rem; border-radius: 3px;
font-size: 0.9em; }
blockquote { border-left: 3px solid #d0d0d0; margin: 1rem 0; padding: 0.3rem 0 0.3rem 1rem;
color: #555; font-size: 0.95rem; }
hr { border: none; border-top: 1px solid #e5e5e5; margin: 2rem 0; }
em { color: #555; }
ul, ol { padding-left: 1.4rem; }
@media (prefers-color-scheme: dark) {
body { color: #e8e8e8; background: #1a1a1a; }
th { background: #262626; }
th, td { border-bottom-color: #333; }
code { background: #2a2a2a; }
h2 { border-bottom-color: #333; }
blockquote { border-left-color: #444; color: #aaa; }
}
";
/// Writes every student's report to a directory.
///
/// # Arguments
///
/// * `dir` - the destination directory.
/// * `cohort` - the class.
/// * `course` - the course.
/// * `record` - the assessment record.
/// * `opts` - report options.
/// * `html` - whether to write HTML alongside Markdown.
///
/// # Returns
///
/// The paths written.
///
/// # Errors
///
/// Returns [`crate::error::Error::Io`] on a write failure.
pub fn write_all_students(
dir: &std::path::Path,
cohort: &Cohort,
course: &CourseFile,
record: &AssessmentFile,
opts: &StudentOptions,
html: bool,
) -> crate::error::Result<Vec<std::path::PathBuf>> {
std::fs::create_dir_all(dir).map_err(|e| crate::error::Error::io(dir, e))?;
let mut written = Vec::new();
for s in &cohort.students {
let stem = crate::store::sanitize(&s.student_key);
let markdown = student(s, cohort, course, record, opts);
let md_path = dir.join(format!("{stem}.md"));
crate::yaml::write_text(&md_path, &markdown)?;
written.push(md_path);
if html {
let title = format!("{}{}", record.assessment.title, s.display_name());
let html_path = dir.join(format!("{stem}.html"));
crate::yaml::write_text(&html_path, &to_html(&markdown, &title))?;
written.push(html_path);
}
}
Ok(written)
}
/// Per-objective class rates as a compact table, for pasting into a syllabus
/// review or a curriculum committee document.
///
/// # Arguments
///
/// * `cohort` - the class.
/// * `course` - the course, for objective text.
///
/// # Returns
///
/// A Markdown table.
pub fn objective_summary(cohort: &Cohort, course: &CourseFile) -> String {
let mut out = String::from("| Objective | Class rate |\n|:--|--:|\n");
let mut rows: Vec<(&String, &f64)> = cohort.objective_rates.iter().collect();
rows.sort_by(|a, b| {
a.1.partial_cmp(b.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(b.0))
});
for (id, rate) in rows {
out.push_str(&format!(
"| {} | {:.0}% |\n",
escape_pipes(&course.objective_text(id)),
rate * 100.0
));
}
out
}
/// Counts flags across an analysis, for a headline figure.
///
/// # Arguments
///
/// * `analysis` - the analysis.
///
/// # Returns
///
/// How many items carry each flag.
pub fn flag_counts(analysis: &Analysis) -> BTreeMap<&'static str, usize> {
let mut out = BTreeMap::new();
for item in &analysis.items {
for flag in &item.flags {
*out.entry(flag.as_str()).or_insert(0) += 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bars_are_ten_cells_wide() {
assert_eq!(bar(0.0).chars().count(), 10);
assert_eq!(bar(1.0).chars().count(), 10);
assert_eq!(bar(0.5).chars().count(), 10);
assert!(bar(1.0).starts_with('█'));
assert!(bar(0.0).starts_with('░'));
// Out-of-range input must not panic or overflow.
assert_eq!(bar(2.0).chars().count(), 10);
assert_eq!(bar(-1.0).chars().count(), 10);
}
#[test]
fn english_lists_read_correctly() {
assert_eq!(list(&[]), "");
assert_eq!(list(&["a"]), "a");
assert_eq!(list(&["a", "b"]), "a and b");
assert_eq!(list(&["a", "b", "c"]), "a, b, and c");
}
#[test]
fn pipes_in_objective_text_do_not_break_tables() {
assert_eq!(escape_pipes("a | b"), "a \\| b");
}
#[test]
fn html_conversion_handles_headings_and_paragraphs() {
let html = to_html("# Title\n\nSome text.\n", "T");
assert!(html.contains("<h1>Title</h1>"));
assert!(html.contains("<p>Some text.</p>"));
assert!(html.starts_with("<!DOCTYPE html>"));
assert!(html.contains("<title>T</title>"));
}
#[test]
fn html_conversion_builds_tables() {
let md = "| A | B |\n|:--|--:|\n| 1 | 2 |\n";
let html = to_html(md, "T");
assert!(html.contains("<table>"));
assert!(html.contains("<th>A</th>"));
assert!(html.contains("<td>2</td>"));
assert!(html.contains("</tbody>"));
}
#[test]
fn html_conversion_handles_both_list_kinds() {
let html = to_html("- one\n- two\n\n1. first\n2. second\n", "T");
assert!(html.contains("<ul>"));
assert!(html.contains("<li>one</li>"));
assert!(html.contains("<ol>"));
assert!(html.contains("<li>first</li>"));
// Lists must be closed, not left dangling.
assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
assert_eq!(html.matches("<ol>").count(), html.matches("</ol>").count());
}
#[test]
fn html_conversion_escapes_before_emphasis() {
let html = to_html("**bold** and <script>alert(1)</script>\n", "T");
assert!(html.contains("<strong>bold</strong>"));
assert!(!html.contains("<script>"));
assert!(html.contains("&lt;script&gt;"));
}
#[test]
fn html_conversion_handles_rules_and_quotes() {
let html = to_html("> careful\n\n---\n\ntext\n", "T");
assert!(html.contains("<blockquote>careful</blockquote>"));
assert!(html.contains("<hr>"));
}
#[test]
fn unpaired_emphasis_is_left_alone() {
let html = to_html("2 * 3 = 6\n", "T");
assert!(html.contains("2 * 3 = 6"), "{html}");
}
}