feat: support readings and lecture rendering

This commit is contained in:
2026-08-08 16:24:50 -04:00
parent 468af3a815
commit f475c630e0
10 changed files with 1535 additions and 11 deletions
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering lecture pages, and checking what backs each objective.
//!
//! Both handlers here read the course file and nothing else, so neither needs a
//! bank or a single response. That is deliberate: a reading list is useful in week
//! one, before any item exists.
use coursebank::course::CourseFile;
use coursebank::error::Result;
use coursebank::lecture::{objectives_markdown, readings_markdown};
use coursebank::yaml;
use crate::cli::{Cli, LectureCommand};
use crate::commands::Outcome;
/// `lecture`: render a reading list, or report reading coverage.
pub(crate) fn lecture(cli: &Cli, sub: &LectureCommand) -> Result<Outcome> {
let course = CourseFile::load_dir(&cli.course)?;
match sub {
LectureCommand::Readings { id, style, out } => emit(
readings_markdown(&course, id, style.as_style())?,
out.as_deref(),
),
LectureCommand::Objectives { id, style, out } => emit(
objectives_markdown(&course, id, style.as_style())?,
out.as_deref(),
),
LectureCommand::Coverage { lecture: only } => coverage(&course, only.as_deref(), cli.quiet),
}
}
/// Writes rendered Markdown to a file, or to stdout when no path was given.
fn emit(markdown: String, out: Option<&std::path::Path>) -> Result<Outcome> {
match out {
Some(path) => {
yaml::write_text(path, &markdown)?;
println!("wrote {}", path.display());
}
None => print!("{markdown}"),
}
Ok(Outcome::Ok)
}
/// Prints the readings behind each objective.
///
/// Returns [`Outcome::Findings`] when an assessed objective has no reading, since
/// that is the case where a student report can name what was missed but not where
/// to go and read about it.
fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result<Outcome> {
let ids: Vec<String> = match lecture {
Some(l) => course
.lecture_objectives(l)
.into_iter()
.map(str::to_string)
.collect(),
None => course.objectives_in_order(),
};
for id in &ids {
let readings = course.readings_for_objective(id);
println!("{id}");
if readings.is_empty() {
println!(" (no reading)");
continue;
}
for (lecture_id, reading) in readings {
let Some(key) = reading.reference.as_deref() else {
continue;
};
let reference = course.reference(key, lecture_id)?;
let supplemental = match reading.role {
coursebank::course::ReadingRole::Supplemental => " (supplemental)",
coursebank::course::ReadingRole::Assigned => "",
};
println!(
" {lecture_id} {}{supplemental}",
reading.cite(key, reference)
);
if let Some(focus) = &reading.focus {
println!(
" {}",
course.expand_objective_refs(focus, |o| { course.objective_text(o) })
);
}
}
}
let gaps = course.objectives_without_readings();
if gaps.is_empty() {
if !quiet {
println!("\nevery assessed objective has a reading behind it");
}
return Ok(Outcome::Ok);
}
println!(
"\n{} assessed objective(s) with no reading, so a student report cannot say \
where to go back to:",
gaps.len()
);
for id in gaps {
println!(" - {id}");
}
Ok(Outcome::Findings)
}
+5 -4
View File
@@ -183,13 +183,14 @@ fn merge_gitignore(existing: &str) -> GitignoreMerge {
/// Writes the `.gitignore`, merging into one that is already there.
///
/// `init` is often run in a repository that already has a `.gitignore`.
/// An existing file keeps everything it had and gains only the patterns
/// it was missing, at the top where they are easy to see in the diff.
/// `init` is often run in a repository that already has a `.gitignore`, and the
/// first version of this overwrote it. An existing file now keeps everything it
/// had and gains only the patterns it was missing, at the top where they are easy
/// to see in the diff.
fn write_gitignore(path: &Path) -> Result<()> {
let existing = match fs::read_to_string(path) {
Ok(text) => text,
// Nothing to merge with. Stay quiet about it.
// Nothing to merge with. Stay quiet about it, the way this always has.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return yaml::write_text(path, GITIGNORE);
}