Dev (#1)
Sync README to GitHub / sync (push) Successful in 12s
CI / check (push) Successful in 8m31s
Deploy docs / deploy (push) Successful in 6m44s
Nightly / nightly (push) Successful in 10m33s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-07 15:48:11 -04:00
parent 228a0da47f
commit abc0bdf621
79 changed files with 31370 additions and 0 deletions
+386
View File
@@ -0,0 +1,386 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The responses-to-report pipeline.
//!
//! Once an assessment has been given, responses come back through [`ingest`],
//! statistics come out of [`analyze`] (classical, IRT, or per-student), [`calibrate`]
//! writes those statistics back onto the items, and [`report`] produces the
//! student and cohort documents. [`data`] lists what the response store holds.
use coursebank::calibrate;
use coursebank::canvas;
use coursebank::classical::{self, Thresholds};
use coursebank::error::Result;
use coursebank::gradescope;
use coursebank::irt;
use coursebank::layout::Layout;
use coursebank::report;
use coursebank::store::{self, Store};
use coursebank::students;
use coursebank::yaml;
use crate::cli::{AnalyzeCommand, CalibrateArgs, Cli, IngestCommand, ReportCommand};
use crate::commands::Outcome;
use crate::helpers::{context, load, load_record, read_salt, responses_for, truncate};
/// `ingest`: read a Gradescope directory or a Canvas CSV into the response store.
///
/// Enriches the parsed responses against the record, optionally pseudonymizes the
/// identifiers, and — unless `--dry-run` — writes them in the chosen format.
pub(crate) fn ingest(cli: &Cli, sub: &IngestCommand) -> Result<Outcome> {
let catalog = load(cli)?;
let (common, mut set) = match sub {
IngestCommand::Gradescope { dir, common } => {
let record = load_record(&catalog, &common.assessment)?;
let ctx = context(&catalog, &record, common)?;
let import = gradescope::ingest_dir(dir, &ctx)?;
// Grading-time partial credit is an ambiguity signal worth surfacing
// right here, while the exam is fresh.
for question in &import.questions {
for (letter, value, note) in question.partial_credit() {
println!(
"! q{}: option {letter} earned {value} of {} points at grading time{}",
question.number,
question.points_possible(),
note.map(|n| format!("{n}")).unwrap_or_default()
);
}
}
(common, import.responses)
}
IngestCommand::Canvas { file, common } => {
let record = load_record(&catalog, &common.assessment)?;
let ctx = context(&catalog, &record, common)?;
let set = canvas::ingest(file, &ctx, Some(&record), Some(&catalog))?;
(common, set)
}
};
let record = load_record(&catalog, &common.assessment)?;
set.enrich(&record, Some(&catalog));
if common.pseudonymize {
let salt = read_salt(common.salt_file.as_deref())?;
set.pseudonymize(&salt);
println!("identifiers replaced with keyed pseudonyms");
}
for warning in &set.warnings {
println!("! {warning}");
}
println!(
"\n{} response(s): {} student(s) x {} item(s)",
set.rows.len(),
set.students().len(),
set.all_items().len()
);
if common.dry_run {
println!("(dry run, nothing written)");
return Ok(Outcome::Ok);
}
let mut store = Store::open(catalog.layout.data())?;
if let Some(format) = common.format {
store = store.with_format(format.as_format())?;
}
for path in store.write(&set)? {
println!("wrote {}", path.display());
}
println!(
"\nNext: coursebank analyze items {}\n coursebank report cohort {}",
common.assessment, common.assessment
);
Ok(Outcome::Ok)
}
/// `analyze`: classical item analysis, an IRT fit, or a per-student summary.
///
/// `items` returns [`Outcome::Findings`] when there is a revise queue.
pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
let catalog = load(cli)?;
let store = Store::open(catalog.layout.data())?;
match sub {
AnalyzeCommand::Items { id, pooled } => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, *pooled)?;
let analysis =
classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
for w in &analysis.warnings {
println!("! {w}\n");
}
println!("{}\n", analysis.reliability.interpretation());
println!(
"{:>3} {:>5} {:>6} {:>6} {:>6} FLAGS",
"Q", "p", "r", "D", "blank"
);
for item in &analysis.items {
println!(
"{:>3} {:>5.2} {:>6} {:>6} {:>5.0}% {}",
item.number,
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(" ")
);
}
let queue = analysis.revise_queue();
if !queue.is_empty() {
println!("\n{} item(s) to look at, worst first:", queue.len());
for item in queue.iter().take(10) {
println!(
" q{:<3} {}",
item.number,
item.notes.first().map(|s| s.as_str()).unwrap_or("")
);
}
return Ok(Outcome::Findings);
}
Ok(Outcome::Ok)
}
AnalyzeCommand::Irt {
id,
model,
no_priors,
} => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, false)?;
let mut opts = irt::Options {
model: model.as_model(),
..irt::Options::default()
};
opts.priors.enabled = !no_priors;
let fit = irt::fit(&set.matrix(false), &opts);
for w in &fit.warnings {
println!("! {w}\n");
}
println!(
"{} model, {} iteration(s), {}",
model.as_model().as_str(),
fit.iterations,
if fit.converged {
"converged"
} else {
"did NOT converge"
}
);
println!(
"measures most precisely near θ = {:+.1}\n",
fit.peak_information()
);
println!(
"{:>3} {:>6} {:>7} {:>7} {:>7} NOTES",
"Q", "a", "b", "SE(a)", "SE(b)"
);
for item in &fit.items {
println!(
"{:>3} {:>6.2} {:>+7.2} {:>7} {:>7} {}",
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.notes.first().map(|s| s.as_str()).unwrap_or("")
);
}
let mut abilities = fit.abilities.clone();
abilities.sort_by(|a, b| {
b.theta
.partial_cmp(&a.theta)
.unwrap_or(std::cmp::Ordering::Equal)
});
println!(
"\nability range {:+.2} to {:+.2}",
abilities.last().map(|a| a.theta).unwrap_or(0.0),
abilities.first().map(|a| a.theta).unwrap_or(0.0)
);
Ok(Outcome::Ok)
}
AnalyzeCommand::Students { id } => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, false)?;
let cohort = students::summarize(&set, &catalog.course, Some(&catalog), None);
println!(
"{} student(s), mean {:.0}% (SD {:.1})\n",
cohort.students.len(),
cohort.mean_percent,
cohort.sd_percent
);
print!("{}", report::roster(&cohort));
if !cohort.class_gaps.is_empty() {
println!("\nObjectives the class did not meet:");
for (objective, rate) in &cohort.class_gaps {
println!(
" {:>4.0}% {}",
rate * 100.0,
catalog.course.objective_text(objective)
);
}
}
if !cohort.archetypes.is_empty() {
println!("\nPatterns:");
for a in &cohort.archetypes {
println!(" {:<40} {} student(s)", a.label, a.members.len());
}
}
Ok(Outcome::Ok)
}
}
}
/// `calibrate`: fold stored statistics back onto the items.
///
/// Prints the plan and stops unless `--apply` is given; refuses to write until at
/// least `--min-n` examinees are pooled.
pub(crate) fn calibrate(cli: &Cli, args: &CalibrateArgs) -> Result<Outcome> {
let catalog = load(cli)?;
let store = Store::open(catalog.layout.data())?;
let opts = calibrate::Options {
irt: !args.no_irt,
include_practice: args.include_practice,
minimum_n: args.min_n,
..calibrate::Options::default()
};
let plan = calibrate::plan(&catalog, &store, &opts)?;
print!("{}", plan.render());
if plan.is_empty() {
return Ok(Outcome::Ok);
}
if !args.apply {
println!(
"Nothing written. Re-run with --apply to write these {} change(s) into the bank \
files, then review the git diff.",
plan.changes.len()
);
return Ok(Outcome::Ok);
}
for path in calibrate::apply(&plan)? {
println!("updated {}", path.display());
}
println!("\nReview the diff before committing: git diff banks/");
Ok(Outcome::Ok)
}
/// `report`: write per-student reports or the instructor's cohort item analysis.
pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result<Outcome> {
let catalog = load(cli)?;
let store = Store::open(catalog.layout.data())?;
match sub {
ReportCommand::Students {
id,
html,
out,
ability,
no_comparison,
} => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, false)?;
let fit = if *ability {
Some(irt::fit(&set.matrix(false), &irt::Options::default()))
} else {
None
};
let cohort = students::summarize(&set, &catalog.course, Some(&catalog), fit.as_ref());
let opts = report::StudentOptions {
ability: *ability,
comparison: !no_comparison,
..report::StudentOptions::default()
};
let dir = out
.clone()
.unwrap_or_else(|| catalog.layout.reports().join(id));
let written =
report::write_all_students(&dir, &cohort, &catalog.course, &record, &opts, *html)?;
println!(
"wrote {} file(s) for {} student(s) in {}",
written.len(),
cohort.students.len(),
dir.display()
);
Ok(Outcome::Ok)
}
ReportCommand::Cohort { id, html, out } => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, false)?;
let analysis =
classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
let fit = irt::fit(&set.matrix(false), &irt::Options::default());
let cohort = students::summarize(&set, &catalog.course, Some(&catalog), Some(&fit));
let markdown = report::cohort(&analysis, &cohort, &catalog, &record, Some(&fit));
let path = out
.clone()
.unwrap_or_else(|| catalog.layout.reports().join(format!("{id}-cohort.md")));
yaml::write_text(&path, &markdown)?;
println!("wrote {}", path.display());
if *html {
let html_path = path.with_extension("html");
let title = format!("{} — item analysis", record.assessment.title);
yaml::write_text(&html_path, &report::to_html(&markdown, &title))?;
println!("wrote {}", html_path.display());
}
Ok(Outcome::Ok)
}
}
}
/// `data`: list every administration held in the response store.
pub(crate) fn data(cli: &Cli) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
let store = Store::open(layout.data())?;
let summaries = store::summarize(&store)?;
if summaries.is_empty() {
println!("no stored responses in {}", store.dir.display());
return Ok(Outcome::Ok);
}
println!(
"{:<40} {:>7} {:>9} {:>7} FILE",
"ADMINISTRATION", "ROWS", "STUDENTS", "ITEMS"
);
for s in &summaries {
println!(
"{:<40} {:>7} {:>9} {:>7} {}",
truncate(&s.administration_id, 40),
s.rows,
s.students,
s.items,
s.path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default()
);
}
Ok(Outcome::Ok)
}
+263
View File
@@ -0,0 +1,263 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Managing items and building assessments from them.
//!
//! [`bank`] and [`assessment`] create and list the two record types. [`assemble`]
//! draws a new assessment from the pool against a blueprint, and [`usage`] reports
//! where items have already been used so a draw can avoid repeats.
use std::collections::BTreeMap;
use coursebank::assessment::{AssessmentFile, Blueprint};
use coursebank::bank::BankFile;
use coursebank::date::Date;
use coursebank::error::{Error, Result};
use coursebank::history::History;
use coursebank::layout::Layout;
use coursebank::select;
use coursebank::yaml;
use crate::cli::{AssembleArgs, AssessmentCommand, BankCommand, Cli, UsageCommand};
use crate::commands::Outcome;
use crate::helpers::{
load, load_record, parse_level_map, parse_string_map, print_record, truncate,
};
/// `bank`: create an empty bank, list banks with counts, or import legacy JSON.
pub(crate) fn bank(cli: &Cli, sub: &BankCommand) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
match sub {
BankCommand::New { id, title } => {
let path = layout.banks().join(format!("{id}.yaml"));
if path.exists() {
return Err(Error::usage(format!("{} already exists", path.display())));
}
let bank = BankFile::skeleton(id, title.as_deref().unwrap_or(id));
yaml::write(&path, &bank)?;
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
BankCommand::List => {
let catalog = load(cli)?;
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for entry in &catalog.entries {
*counts.entry(entry.bank.as_str()).or_insert(0) += 1;
}
for (id, meta) in &catalog.banks {
println!(
"{:<20} {:>4} item(s) {}",
id,
counts.get(id.as_str()).copied().unwrap_or(0),
meta.title
);
}
Ok(Outcome::Ok)
}
}
}
/// `assessment`: create an empty record, list records, or show one in detail.
///
/// `show` returns [`Outcome::Findings`] when the record has validation problems.
pub(crate) fn assessment(cli: &Cli, sub: &AssessmentCommand) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
match sub {
AssessmentCommand::New { id, title, kind } => {
let path = layout.assessments().join(format!("{id}.yaml"));
if path.exists() {
return Err(Error::usage(format!("{} already exists", path.display())));
}
let record =
AssessmentFile::skeleton(id, title.as_deref().unwrap_or(id), kind.as_kind());
record.save(&path)?;
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
AssessmentCommand::List => {
let records = AssessmentFile::load_all(&layout.assessments())?;
let history = History::load(&layout.assessments())?;
let _ = &history;
if records.is_empty() {
println!("no assessment records yet");
return Ok(Outcome::Ok);
}
println!(
"{:<16} {:<10} {:<12} {:>6} {:>8}",
"ID", "KIND", "DATE", "ITEMS", "POINTS"
);
for record in &records {
println!(
"{:<16} {:<10} {:<12} {:>6} {:>8.1}",
record.assessment.id,
record.assessment.kind.as_str(),
record
.assessment
.date
.map(|d| d.to_string())
.unwrap_or_else(|| "-".into()),
record.items.len(),
record.total_points(1.0)
);
}
Ok(Outcome::Ok)
}
AssessmentCommand::Show { id } => {
let catalog = load(cli)?;
let record = load_record(&catalog, id)?;
print_record(&catalog, &record);
let issues = catalog.validate_record(&record);
if !issues.is_empty() {
println!("\n{} problem(s):", issues.len());
for issue in &issues {
println!(" - {issue}");
}
return Ok(Outcome::Findings);
}
Ok(Outcome::Ok)
}
}
}
/// `assemble`: draw a new assessment from the pool against a blueprint.
///
/// The blueprint is built from the `--levels`/`--bonus`/`--require` flags and the
/// various restrictions. Without `--dry-run` the resulting record is written; the
/// draw is reproducible from `--seed`.
pub(crate) fn assemble(cli: &Cli, args: &AssembleArgs) -> Result<Outcome> {
let catalog = load(cli)?;
let layout = &catalog.layout;
let path = layout.assessments().join(format!("{}.yaml", args.id));
if path.exists() && !args.force {
return Err(Error::usage(format!(
"{} already exists; pass --force to replace it. Replacing an administered \
assessment invalidates the responses already stored against it",
path.display()
)));
}
let date = match &args.date {
Some(s) => s.parse::<Date>()?,
None => Date::today(),
};
let blueprint = Blueprint {
level_counts: parse_level_map(&args.levels)?,
bonus_counts: parse_level_map(&args.bonus)?,
objective_minimums: parse_string_map(&args.require)?,
lectures: args.lectures.clone(),
topics: args.topics.clone(),
banks: args.banks.clone(),
max_per_bank: args.max_per_bank,
cooldown_days: Some(args.cooldown),
seed: Some(args.seed),
};
if blueprint.scored_total() == 0 {
return Err(Error::usage(
"no items requested; pass --levels, e.g. --levels 1=6,2=8,3=10,4=6".to_string(),
));
}
let history = History::load(&layout.assessments())?;
let selection = select::select(&catalog, &blueprint, &history, date)?;
let record = select::to_record(
&catalog,
&selection,
&args.id,
args.title.as_deref().unwrap_or(&args.id),
args.kind.as_kind(),
date,
args.platform.as_platform(),
&blueprint,
args.forms.max(1),
)?;
for note in &selection.notes {
println!("! {note}");
}
if !selection.notes.is_empty() {
println!();
}
print_record(&catalog, &record);
let drift = select::check_blueprint(&record);
if !drift.is_empty() {
println!("\nBlueprint not fully satisfied:");
for d in &drift {
println!(" - {d}");
}
}
if args.dry_run {
println!("\n(dry run, nothing written)");
return Ok(Outcome::Ok);
}
record.save(&path)?;
println!("\nwrote {}", path.display());
println!(
"Next: review the draw, then\n coursebank export typst {} --form A\n \
coursebank export qti {} --form A",
args.id, args.id
);
Ok(Outcome::Ok)
}
/// `usage`: report item use history, or the approved items never used.
pub(crate) fn usage(cli: &Cli, sub: &UsageCommand) -> Result<Outcome> {
let catalog = load(cli)?;
let history = History::load(&catalog.layout.assessments())?;
match sub {
UsageCommand::History { item } => {
let uids: Vec<String> = match item {
Some(id) => vec![catalog.resolve(id)?],
None => catalog.entries.iter().map(|e| e.uid.clone()).collect(),
};
println!("{:<34} {:>5} {:<12} WHERE", "ITEM", "USES", "LAST");
for uid in uids {
let uses = history.for_item(&uid);
if uses.is_empty() && item.is_none() {
continue;
}
let where_used: Vec<String> = uses
.iter()
.map(|u| format!("{}#{}", u.assessment, u.number))
.collect();
println!(
"{:<34} {:>5} {:<12} {}",
truncate(&uid, 34),
uses.len(),
history
.last_used(&uid)
.map(|d| d.to_string())
.unwrap_or_else(|| "-".into()),
where_used.join(", ")
);
}
Ok(Outcome::Ok)
}
UsageCommand::Unused => {
let mut unused: Vec<&str> = catalog
.assemblable()
.into_iter()
.filter(|e| history.use_count(&e.uid) == 0)
.map(|e| e.uid.as_str())
.collect();
unused.sort_unstable();
if unused.is_empty() {
println!("every approved item has been used at least once");
return Ok(Outcome::Ok);
}
println!("{} approved item(s) never used:", unused.len());
for uid in unused {
println!(" {uid}");
}
Ok(Outcome::Ok)
}
}
}
+342
View File
@@ -0,0 +1,342 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Turning an assembled assessment into deliverables.
//!
//! [`export`] writes the three output formats (a Canvas QTI package, rendered
//! Typst documents, and review Markdown). [`template`] inspects, dumps, and
//! configures the Typst templates those documents are injected into. Both share
//! [`pick_variants`], which resolves the `--variant` flags into a canonical list.
use coursebank::assessment::Form;
use coursebank::error::{Error, Result};
use coursebank::layout::Layout;
use coursebank::qti;
use coursebank::typst;
use coursebank::yaml;
use crate::cli::{Cli, ExportCommand, TemplateCommand};
use crate::commands::Outcome;
use crate::helpers::{load, load_record, markdown_export, pick_form};
/// `export`: build a QTI package, render Typst documents, or write Markdown.
pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
let catalog = load(cli)?;
let build = catalog.layout.build();
match sub {
ExportCommand::Qti {
id,
form,
out,
no_feedback,
} => {
let record = load_record(&catalog, id)?;
let form = pick_form(&record, form)?;
let opts = qti::QtiOptions {
form: form.clone(),
include_feedback: !no_feedback,
shuffle_in_canvas: record.assessment.shuffle.unwrap_or(false),
attempts: record.assessment.attempts.unwrap_or(1),
scoring_policy: record
.assessment
.scoring_policy
.unwrap_or(coursebank::assessment::ScoringPolicy::KeepHighest),
};
let package = qti::build(&catalog, &record, &opts)?;
let path = out
.clone()
.unwrap_or_else(|| build.join(format!("{id}-{}.zip", form.id)));
package.write_zip(&path)?;
println!("wrote {}", path.display());
println!("Import in Canvas: Settings -> Import Course Content -> QTI .zip file");
Ok(Outcome::Ok)
}
ExportCommand::Typst {
id,
form,
out,
variant,
template,
json,
dry_run,
} => {
let record = load_record(&catalog, id)?;
let dir = out.clone().unwrap_or(build);
let variants = pick_variants(variant)?;
if template.is_some() && variants.len() > 1 {
return Err(Error::usage(
"--template applies to one document, but more than one --variant was \
requested; pass --variant exam (or key, or answer-sheet) alongside it"
.to_string(),
));
}
let forms: Vec<Form> = if form == "all" {
if record.forms.is_empty() {
vec![typst::Options::default().form]
} else {
record.forms.clone()
}
} else {
vec![pick_form(&record, form)?]
};
// Read once, outside both loops: the config describes the course, not
// the form, and re-reading it per form would let a mid-run edit make
// form A and form B disagree.
let config_file = typst::load_config(&catalog.layout)?;
let mut warned = false;
let mut used_embedded = false;
for f in &forms {
for variant in &variants {
let opts = typst::Options {
form: f.clone(),
variant: *variant,
template: template.clone(),
config: config_file.resolve(*variant),
};
let rendered = typst::render(&catalog, &record, &opts)?;
used_embedded |= rendered.origin == typst::Origin::Embedded;
for warning in &rendered.warnings {
eprintln!("warning: {warning}");
warned = true;
}
let stem = format!("{id}-{}{}", f.id, variant.suffix());
if *dry_run {
println!(
"{}: {} question(s) from {} via {}",
stem,
rendered.payload.questions.len(),
rendered.origin,
rendered
.slots
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" + ")
);
continue;
}
let path = dir.join(format!("{stem}.typ"));
yaml::write_text(&path, &rendered.text)?;
println!("wrote {} (from {})", path.display(), rendered.origin);
if *json {
let json_path = dir.join(format!("{stem}.json"));
yaml::write_text(&json_path, &rendered.payload.to_json()?)?;
println!("wrote {}", json_path.display());
}
}
}
if *dry_run {
return Ok(Outcome::Ok);
}
if !cli.quiet {
println!("\nCompile with: pixi run -e docs typst compile <file>.typ");
if used_embedded {
println!(
"Some of these used a built-in template. To take over the layout:\n \
coursebank template dump"
);
}
}
Ok(if warned {
Outcome::Findings
} else {
Outcome::Ok
})
}
ExportCommand::Md { id, with_key, out } => {
let record = load_record(&catalog, id)?;
let markdown = markdown_export(&catalog, &record, *with_key)?;
let path = out
.clone()
.unwrap_or_else(|| build.join(format!("{id}.md")));
yaml::write_text(&path, &markdown)?;
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
}
}
/// Resolves the `--variant` flags, defaulting to every document.
///
/// # Arguments
///
/// * `names` - the raw flag values, possibly empty.
///
/// # Returns
///
/// The variants, deduplicated and in canonical order so that
/// `--variant key --variant exam` still writes the paper first.
///
/// # Errors
///
/// Returns [`Error::Usage`] naming the valid tokens.
fn pick_variants(names: &[String]) -> Result<Vec<typst::Variant>> {
if names.is_empty() {
return Ok(typst::Variant::ALL.to_vec());
}
let mut wanted = Vec::new();
for name in names {
let variant = typst::Variant::parse(name)?;
if !wanted.contains(&variant) {
wanted.push(variant);
}
}
// Canonical order, not the order they were typed.
Ok(typst::Variant::ALL
.into_iter()
.filter(|v| wanted.contains(v))
.collect())
}
/// `template`: list the template lookup, dump the built-ins to edit, or write and
/// inspect the render configuration.
pub(crate) fn template(cli: &Cli, sub: &TemplateCommand) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
match sub {
TemplateCommand::List { assessment } => {
let id = assessment.as_deref();
println!("Templates are looked up in this order, first match wins:\n");
for variant in typst::Variant::ALL {
println!("{}", variant.as_str());
let mut resolved = false;
for path in typst::template::candidates(&layout, variant, id) {
let present = path.is_file();
let mark = if present && !resolved {
resolved = true;
"->"
} else {
" "
};
let state = if present { "" } else { " (absent)" };
println!(" {mark} {}{state}", path.display());
}
let mark = if resolved { " " } else { "->" };
println!(" {mark} built-in");
// Reporting the slots requires parsing, and a template with broken
// markers should be named here rather than at export time.
match typst::template::load(&layout, variant, id, None) {
Ok(template) => {
let slots: Vec<&str> =
template.slots().iter().map(|s| s.as_str()).collect();
if slots.is_empty() {
println!(" slots: none — this template injects nothing");
} else {
println!(" slots: {}", slots.join(", "));
}
}
Err(e) => println!(" unusable: {e}"),
}
println!();
}
let config = typst::config_path(&layout);
if config.is_file() {
println!("Config: {}", config.display());
} else {
println!(
"Config: none ({} is absent, so built-in defaults apply)",
config.display()
);
}
Ok(Outcome::Ok)
}
TemplateCommand::Dump {
variant,
out,
force,
stdout,
} => {
let variants = pick_variants(variant)?;
if *stdout {
for (index, v) in variants.iter().enumerate() {
if index > 0 {
println!();
}
if variants.len() > 1 {
println!("// ── {} ──", v.template_file());
}
print!("{}", typst::template::embedded(*v));
}
return Ok(Outcome::Ok);
}
let dir = out.clone().unwrap_or_else(|| layout.templates());
let (written, skipped) = typst::template::dump(&dir, &variants, *force)?;
for path in &written {
println!("wrote {}", path.display());
}
for path in &skipped {
println!(
"kept {} (already exists; --force to overwrite)",
path.display()
);
}
if !written.is_empty() && !cli.quiet {
println!(
"\nThese are yours to edit. Only the marked regions are replaced on \
export,\nso restyle freely:\n typst watch {}",
dir.join(typst::Variant::Exam.template_file()).display()
);
}
// Skipped files are worth an exit code: a script that expected to
// refresh them did not.
Ok(if skipped.is_empty() {
Outcome::Ok
} else {
Outcome::Findings
})
}
TemplateCommand::Config {
resolved,
out,
force,
} => {
if let Some(name) = resolved {
let variant = typst::Variant::parse(name)?;
let config_file = typst::load_config(&layout)?;
let config = config_file.resolve(variant);
println!(
"# Resolved configuration for `{}`, after every layer.",
variant.as_str()
);
print!("{}", config.to_yaml()?);
return Ok(Outcome::Ok);
}
let path = out.clone().unwrap_or_else(|| typst::config_path(&layout));
if path.exists() && !force {
return Err(Error::usage(format!(
"{} already exists; pass --force to overwrite it, or --resolved <variant> to \
see what it currently produces",
path.display()
)));
}
yaml::write_text(&path, typst::CONFIG_TEMPLATE)?;
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
}
}
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Setting up a course and checking it stays well-formed.
//!
//! These are the commands you reach for before and around authoring: create the
//! directory (`init`), write editor schemas (`schema`), and run the two kinds of
//! checking — [`validate`] for problems that must be fixed and [`lint`] for
//! item-writing guidance. [`catalog`] summarizes the pool that results.
use std::collections::BTreeMap;
use coursebank::assessment::AssessmentFile;
use coursebank::bank::BankFile;
use coursebank::course::{COURSE_FILE, CourseFile};
use coursebank::error::{Error, Result};
use coursebank::jsonschema;
use coursebank::layout::Layout;
use coursebank::lint::{self, Rule};
use coursebank::taxonomy::Level;
use coursebank::yaml;
use crate::cli::{CatalogArgs, Cli, InitArgs, LintArgs};
use crate::commands::Outcome;
use crate::helpers::{load, truncate};
/// The `.gitignore` written by `init`.
pub(crate) const GITIGNORE: &str = "\
# Generated output: exports, rendered exams, reports.
build/
reports/
# Typst and PDF artifacts.
*.pdf
# The pseudonymization salt must never be committed. Without it, hashed student
# ids can be reversed by brute force; with it in the repository, so can they.
*.salt
.coursebank-salt
# Editor and OS noise.
.DS_Store
*.swp
";
/// `init`: create a new course directory, refusing to clobber an existing one.
pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
let course_path = layout.course_file();
if course_path.exists() {
return Err(Error::usage(format!(
"{} already exists; refusing to overwrite it",
course_path.display()
)));
}
layout.create_all()?;
let course = CourseFile::skeleton(&args.code, &args.title, &args.term);
course.save(&course_path)?;
println!("wrote {}", course_path.display());
let schemas = jsonschema::write_all(&layout.schema())?;
println!("wrote {} JSON Schema files", schemas.len());
if args.with_examples {
let bank = BankFile::skeleton("example", "Example bank");
let bank_path = layout.banks().join("example.yaml");
yaml::write(&bank_path, &bank)?;
println!("wrote {}", bank_path.display());
}
yaml::write_text(&cli.course.join(".gitignore"), GITIGNORE)?;
println!(
"\nNext: edit {} to add your learning objectives and lectures, then\n \
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
COURSE_FILE
);
Ok(Outcome::Ok)
}
/// `schema`: (re)write the JSON Schemas an editor uses to validate the YAML.
pub(crate) fn schema(cli: &Cli) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
let written = jsonschema::write_all(&layout.schema())?;
for path in &written {
println!("wrote {}", path.display());
}
println!(
"\nAdd this to the top of a bank file so your editor validates as you type:\n {}",
jsonschema::Kind::Bank.modeline("../.coursebank/schema")
);
Ok(Outcome::Ok)
}
/// `validate`: check every bank and assessment record for hard problems.
///
/// Returns [`Outcome::Findings`] when anything is wrong so CI can fail on it.
pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
let catalog = load(cli)?;
let issues = catalog.validate()?;
let records = AssessmentFile::load_all(&catalog.layout.assessments())?;
let mut all = issues;
for record in &records {
for issue in catalog.validate_record(record) {
all.push(format!("{}: {issue}", record.assessment.id));
}
}
if all.is_empty() {
if !cli.quiet {
println!(
"{} item(s) in {} bank(s) and {} assessment record(s): no problems found",
catalog.entries.len(),
catalog.banks.len(),
records.len()
);
}
return Ok(Outcome::Ok);
}
println!("{} problem(s):\n", all.len());
for issue in &all {
println!(" - {issue}");
}
Ok(Outcome::Findings)
}
/// `lint`: run item-writing rules, grouped by subject, honoring the filter flags.
///
/// With `--list-rules` it prints the rule table and stops. Otherwise it returns
/// [`Outcome::Findings`] when anything fires, unless `--no-fail` was given.
pub(crate) fn lint(cli: &Cli, args: &LintArgs) -> Result<Outcome> {
if args.list_rules {
println!("{:<32} {:<8} WHAT IT CATCHES", "CODE", "SEVERITY");
for rule in Rule::ALL {
println!(
"{:<32} {:<8} {}",
rule.code(),
rule.severity().label(),
rule.description()
);
}
return Ok(Outcome::Ok);
}
let catalog = load(cli)?;
let thresholds = lint::Thresholds::default();
let mut findings = lint::lint_catalog(&catalog, &thresholds);
let minimum = args.min_severity.as_severity();
findings.retain(|f| f.severity >= minimum);
if !args.only.is_empty() {
findings.retain(|f| args.only.iter().any(|c| c == f.rule.code()));
}
if !args.ignore.is_empty() {
findings.retain(|f| !args.ignore.iter().any(|c| c == f.rule.code()));
}
if findings.is_empty() {
if !cli.quiet {
println!("no lint findings across {} item(s)", catalog.entries.len());
}
return Ok(Outcome::Ok);
}
let mut by_subject: BTreeMap<&str, Vec<&lint::Finding>> = BTreeMap::new();
for f in &findings {
by_subject.entry(f.subject.as_str()).or_default().push(f);
}
for (subject, items) in &by_subject {
println!("{subject}");
for f in items {
println!(
" [{}] {}{}",
f.severity.label(),
f.rule.code(),
f.message
);
}
println!();
}
let counts = lint::summarize(&findings);
println!(
"{} finding(s) across {} rule(s)",
findings.len(),
counts.len()
);
println!("Silence a rule with --ignore <code>, or see them all with --list-rules.");
if args.no_fail {
Ok(Outcome::Ok)
} else {
Ok(Outcome::Findings)
}
}
/// `catalog`: summarize the pool — counts by status, level, optionally topic, and
/// optionally per-objective coverage with its gaps.
pub(crate) fn catalog(cli: &Cli, args: &CatalogArgs) -> Result<Outcome> {
let catalog = load(cli)?;
println!(
"{}{} ({})",
catalog.course.course.code, catalog.course.course.title, catalog.course.course.term
);
println!(
"{} item(s) across {} bank(s); {} assemblable\n",
catalog.entries.len(),
catalog.banks.len(),
catalog.assemblable().len()
);
println!("By status:");
for (status, count) in catalog.status_counts() {
println!(" {:<16} {count}", status.as_str());
}
println!("\nBy level:");
for level in Level::ALL {
let count = catalog.level_counts().get(&level).copied().unwrap_or(0);
println!(" {} {:<12} {count}", level.code(), level.name());
}
if args.topics {
println!("\nBy topic:");
for (topic, count) in catalog.topics() {
println!(" {topic:<30} {count}");
}
}
if args.coverage {
let coverage = catalog.coverage();
println!("\nObjective coverage:");
println!(
" {:<40} {:>6} {:>6} MAX LEVEL",
"OBJECTIVE", "ITEMS", "READY"
);
for row in &coverage.rows {
println!(
" {:<40} {:>6} {:>6} {}",
truncate(&row.objective, 40),
row.total,
row.assemblable,
row.max_level
.map(|l| l.code().to_string())
.unwrap_or_else(|| "-".into())
);
}
if !coverage.gaps.is_empty() {
println!("\n{} gap(s):", coverage.gaps.len());
for gap in &coverage.gaps {
println!(" [{}] {}", gap.severity().label(), gap.message());
}
return Ok(Outcome::Findings);
}
}
Ok(Outcome::Ok)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_gitignore_protects_the_salt() {
assert!(GITIGNORE.contains(".coursebank-salt"));
assert!(GITIGNORE.contains("build/"));
}
}