From eb3c510911ec78ab8bdc6f0ed17af38c3c05cdec Mon Sep 17 00:00:00 2001 From: Alex Maldonado Date: Thu, 6 Aug 2026 21:12:45 -0400 Subject: [PATCH] refactor: split main.rs --- src/cli.rs | 583 ++++++++++ src/commands.rs | 67 ++ src/commands/analysis.rs | 382 +++++++ src/commands/banks.rs | 258 +++++ src/commands/export.rs | 338 ++++++ src/commands/project.rs | 269 +++++ src/helpers.rs | 379 ++++++ src/lib.rs | 12 +- src/main.rs | 2344 +------------------------------------- 9 files changed, 2305 insertions(+), 2327 deletions(-) create mode 100644 src/cli.rs create mode 100644 src/commands.rs create mode 100644 src/commands/analysis.rs create mode 100644 src/commands/banks.rs create mode 100644 src/commands/export.rs create mode 100644 src/commands/project.rs create mode 100644 src/helpers.rs diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..778838e --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,583 @@ +//! The command-line argument model. +//! +//! Everything here is `clap` derive input: the top-level [`Cli`], the [`Command`] +//! enum, one args struct or subcommand enum per command, and a handful of small +//! `ValueEnum`s that mirror a library enum so it can appear on the command line. +//! +//! Those mirror enums each carry an `as_*` method that converts the CLI-facing +//! value into the corresponding [`coursebank`] domain type. Keeping the conversion +//! next to the enum means the mapping is in one place and the command handlers in +//! [`crate::commands`] never match on a raw CLI enum. +//! +//! Fields are `pub(crate)` because the handlers read them directly; nothing here is +//! exported beyond the binary. + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; + +use coursebank::assessment::{Kind as AssessmentKind, Platform}; +use coursebank::catalog::Severity; +use coursebank::item::IrtModel; +use coursebank::store; + +/// Manage course item banks, assessments, and the analysis that comes back. +#[derive(Debug, Parser)] +#[command(name = "coursebank", version, about, long_about = None)] +pub(crate) struct Cli { + /// Course directory, the one holding course.yaml. + #[arg(long, short = 'C', global = true, default_value = ".")] + pub(crate) course: PathBuf, + + /// Print less. + #[arg(long, short, global = true)] + pub(crate) quiet: bool, + + #[command(subcommand)] + pub(crate) command: Command, +} + +/// The top-level command set. Each variant maps to one handler in +/// [`crate::commands`]. +#[derive(Debug, Subcommand)] +pub(crate) enum Command { + /// Create a new course directory. + Init(InitArgs), + /// Write JSON Schemas so your editor can validate the YAML as you type. + Schema, + /// Check every file for problems that must be fixed. + Validate, + /// Check items against item-writing guidance. + Lint(LintArgs), + /// Summarize the item pool and objective coverage. + Catalog(CatalogArgs), + /// Work with item banks. + #[command(subcommand)] + Bank(BankCommand), + /// Work with assessment records. + #[command(subcommand)] + Assessment(AssessmentCommand), + /// Draw a new assessment from the pool. + Assemble(AssembleArgs), + /// Show which items have been used, and when. + #[command(subcommand)] + Usage(UsageCommand), + /// Produce a Canvas package, a printable exam, or Markdown. + #[command(subcommand)] + Export(ExportCommand), + /// Inspect, dump, and configure the Typst export templates. + #[command(subcommand)] + Template(TemplateCommand), + /// Read a grading export into the response store. + #[command(subcommand)] + Ingest(IngestCommand), + /// Compute statistics from stored responses. + #[command(subcommand)] + Analyze(AnalyzeCommand), + /// Write statistics back onto the items. + Calibrate(CalibrateArgs), + /// Write reports. + #[command(subcommand)] + Report(ReportCommand), + /// List what is in the response store. + Data, +} + +#[derive(Debug, Args)] +pub(crate) struct InitArgs { + /// Course code, e.g. "BIOSC 1540". + #[arg(long)] + pub(crate) code: String, + /// Course title. + #[arg(long)] + pub(crate) title: String, + /// Term, e.g. 2026s. + #[arg(long)] + pub(crate) term: String, + /// Also write an example bank and assessment. + #[arg(long)] + pub(crate) with_examples: bool, +} + +#[derive(Debug, Args)] +pub(crate) struct LintArgs { + /// List every rule and its code, then exit. + #[arg(long)] + pub(crate) list_rules: bool, + /// Only run these rule codes. + #[arg(long, value_delimiter = ',')] + pub(crate) only: Vec, + /// Skip these rule codes. + #[arg(long, value_delimiter = ',')] + pub(crate) ignore: Vec, + /// Only report findings at this severity or above. + #[arg(long, value_enum, default_value = "low")] + pub(crate) min_severity: SeverityArg, + /// Exit 0 even when findings exist. + #[arg(long)] + pub(crate) no_fail: bool, +} + +/// CLI mirror of [`coursebank::catalog::Severity`]. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum SeverityArg { + Low, + Medium, + High, +} + +impl SeverityArg { + /// Converts the CLI value into the library's [`Severity`]. + pub(crate) fn as_severity(self) -> Severity { + match self { + SeverityArg::Low => Severity::Low, + SeverityArg::Medium => Severity::Medium, + SeverityArg::High => Severity::High, + } + } +} + +#[derive(Debug, Args)] +pub(crate) struct CatalogArgs { + /// Show per-objective coverage and the gaps in it. + #[arg(long)] + pub(crate) coverage: bool, + /// Show topic counts. + #[arg(long)] + pub(crate) topics: bool, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum BankCommand { + /// Create an empty bank file. + New { + /// Bank id. + id: String, + /// Bank title. + #[arg(long)] + title: Option, + }, + /// List banks and their item counts. + List, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum AssessmentCommand { + /// Create an empty assessment record. + New { + /// Assessment id. + id: String, + /// Title. + #[arg(long)] + title: Option, + /// Kind of assessment. + #[arg(long, value_enum, default_value = "exam")] + kind: KindArg, + }, + /// List assessment records. + List, + /// Show one record in detail. + Show { + /// Assessment id. + id: String, + }, +} + +/// CLI mirror of [`coursebank::assessment::Kind`]. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum KindArg { + Exam, + Quiz, + Homework, + Practice, + Final, +} + +impl KindArg { + /// Converts the CLI value into the library's [`AssessmentKind`]. + pub(crate) fn as_kind(self) -> AssessmentKind { + match self { + KindArg::Exam => AssessmentKind::Exam, + KindArg::Quiz => AssessmentKind::Quiz, + KindArg::Homework => AssessmentKind::Homework, + KindArg::Practice => AssessmentKind::Practice, + KindArg::Final => AssessmentKind::Final, + } + } +} + +#[derive(Debug, Args)] +pub(crate) struct AssembleArgs { + /// Assessment id to create. + pub(crate) id: String, + /// Title. + #[arg(long)] + pub(crate) title: Option, + /// Kind of assessment. + #[arg(long, value_enum, default_value = "exam")] + pub(crate) kind: KindArg, + /// Administration date, YYYY-MM-DD. Defaults to today. + #[arg(long)] + pub(crate) date: Option, + /// Where it will be given. + #[arg(long, value_enum, default_value = "paper")] + pub(crate) platform: PlatformArg, + /// How many items at each level, e.g. --levels 1=6,2=8,3=10,4=6. + #[arg(long, value_delimiter = ',')] + pub(crate) levels: Vec, + /// Bonus items per level, same syntax. + #[arg(long, value_delimiter = ',')] + pub(crate) bonus: Vec, + /// Minimum items per objective, e.g. --require lo-kinetics=2. + #[arg(long, value_delimiter = ',')] + pub(crate) require: Vec, + /// Restrict the draw to these lectures. + #[arg(long, value_delimiter = ',')] + pub(crate) lectures: Vec, + /// Restrict the draw to these topics. + #[arg(long, value_delimiter = ',')] + pub(crate) topics: Vec, + /// Restrict the draw to these banks. + #[arg(long, value_delimiter = ',')] + pub(crate) banks: Vec, + /// At most this many items from any one bank. + #[arg(long)] + pub(crate) max_per_bank: Option, + /// Avoid items used within this many days. + #[arg(long, default_value_t = 180)] + pub(crate) cooldown: i64, + /// Seed, for a reproducible draw. + #[arg(long, default_value_t = 0)] + pub(crate) seed: u64, + /// How many alternate forms to declare. + #[arg(long, default_value_t = 1)] + pub(crate) forms: usize, + /// Show the draw without writing the record. + #[arg(long)] + pub(crate) dry_run: bool, + /// Overwrite an existing record. + #[arg(long)] + pub(crate) force: bool, +} + +/// CLI mirror of [`coursebank::assessment::Platform`]. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum PlatformArg { + Paper, + Canvas, + Other, +} + +impl PlatformArg { + /// Converts the CLI value into the library's [`Platform`]. + pub(crate) fn as_platform(self) -> Platform { + match self { + PlatformArg::Paper => Platform::Paper, + PlatformArg::Canvas => Platform::Canvas, + PlatformArg::Other => Platform::Other, + } + } +} + +#[derive(Debug, Subcommand)] +pub(crate) enum UsageCommand { + /// Show when each item was used. + History { + /// Restrict to one item id. + item: Option, + }, + /// Show approved items that have never been used. + Unused, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum ExportCommand { + /// Build a Canvas-importable QTI 1.2 package. + Qti { + /// Assessment id. + id: String, + /// Which form. + #[arg(long, default_value = "A")] + form: String, + /// Output path; defaults to build/-
.zip. + #[arg(long)] + out: Option, + /// Leave per-option feedback out of the package. + #[arg(long)] + no_feedback: bool, + }, + /// Render a printable exam, answer key, and answer sheet. + /// + /// Each document is produced by injecting data into a Typst template rather + /// than being built from scratch, so the layout is yours to change. Run + /// `coursebank template dump` to get the defaults as editable files. + Typst { + /// Assessment id. + id: String, + /// Which form; repeat or pass "all". + #[arg(long, default_value = "A")] + form: String, + /// Output directory; defaults to build/. + #[arg(long)] + out: Option, + /// Which documents to write; defaults to all three. + #[arg(long, value_name = "VARIANT")] + variant: Vec, + /// Use this template file instead of the usual lookup. Only valid with a + /// single --variant, since one file cannot be three documents. + #[arg(long)] + template: Option, + /// Also write the payload as JSON, for a template that reads it with + /// `json("...")` rather than taking an injected region. + #[arg(long)] + json: bool, + /// Print the payload and the resolved template path without writing. + #[arg(long)] + dry_run: bool, + }, + /// Write the items as Markdown, for review. + Md { + /// Assessment id. + id: String, + /// Include the answer key and rationales. + #[arg(long)] + with_key: bool, + /// Output path; defaults to build/.md. + #[arg(long)] + out: Option, + }, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum TemplateCommand { + /// Show which template each document would use, and why. + List { + /// Resolve as if exporting this assessment, which brings the + /// per-assessment template override into the lookup. + #[arg(long)] + assessment: Option, + }, + /// Write the built-in templates into templates/ so you can edit them. + Dump { + /// Which documents; defaults to all three. + #[arg(long, value_name = "VARIANT")] + variant: Vec, + /// Destination directory; defaults to templates/. + #[arg(long)] + out: Option, + /// Overwrite files that already exist. + #[arg(long)] + force: bool, + /// Print to stdout instead of writing files. + #[arg(long)] + stdout: bool, + }, + /// Write or show the render configuration. + Config { + /// Print the fully resolved configuration for this document, after every + /// layer has been applied, instead of writing a starter file. + #[arg(long, value_name = "VARIANT")] + resolved: Option, + /// Destination path; defaults to templates/typst.yaml. + #[arg(long)] + out: Option, + /// Overwrite a config file that already exists. + #[arg(long)] + force: bool, + }, +} + +/// Flags shared by every `ingest` subcommand, flattened into each variant. +#[derive(Debug, Args)] +pub(crate) struct IngestCommon { + /// The assessment record these responses belong to. + #[arg(long)] + pub(crate) assessment: String, + /// Administration date, YYYY-MM-DD. Defaults to the record's date. + #[arg(long)] + pub(crate) date: Option, + /// Which form, if forms were used. + #[arg(long)] + pub(crate) form: Option, + /// Replace student identifiers with keyed pseudonyms. + #[arg(long)] + pub(crate) pseudonymize: bool, + /// File holding the HMAC salt. Keep it outside the repository. + #[arg(long)] + pub(crate) salt_file: Option, + /// Storage format. + #[arg(long, value_enum)] + pub(crate) format: Option, + /// Parse and report without writing to the store. + #[arg(long)] + pub(crate) dry_run: bool, +} + +/// CLI mirror of [`coursebank::store::Format`]. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum FormatArg { + Parquet, + Csv, +} + +impl FormatArg { + /// Converts the CLI value into the library's [`store::Format`]. + pub(crate) fn as_format(self) -> store::Format { + match self { + FormatArg::Parquet => store::Format::Parquet, + FormatArg::Csv => store::Format::Csv, + } + } +} + +#[derive(Debug, Subcommand)] +pub(crate) enum IngestCommand { + /// Read a directory of Gradescope per-question CSV exports. + Gradescope { + /// The directory holding 1.csv .. N.csv. + dir: PathBuf, + #[command(flatten)] + common: IngestCommon, + }, + /// Read a Canvas "Student Analysis" CSV. + Canvas { + /// The CSV file. + file: PathBuf, + #[command(flatten)] + common: IngestCommon, + }, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum AnalyzeCommand { + /// Classical item analysis. + Items { + /// Assessment id. + id: String, + /// Pool every stored administration of this assessment. + #[arg(long)] + pooled: bool, + }, + /// Fit an IRT model. + Irt { + /// Assessment id. + id: String, + /// Which model. + #[arg(long, value_enum, default_value = "two-pl")] + model: ModelArg, + /// Estimate without priors. Expect divergence on a single class. + #[arg(long)] + no_priors: bool, + }, + /// Per-student mastery and cohort patterns. + Students { + /// Assessment id. + id: String, + }, +} + +/// CLI mirror of [`coursebank::item::IrtModel`]. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub(crate) enum ModelArg { + Rasch, + TwoPl, + ThreePl, +} + +impl ModelArg { + /// Converts the CLI value into the library's [`IrtModel`]. + pub(crate) fn as_model(self) -> IrtModel { + match self { + ModelArg::Rasch => IrtModel::Rasch, + ModelArg::TwoPl => IrtModel::TwoPl, + ModelArg::ThreePl => IrtModel::ThreePl, + } + } +} + +#[derive(Debug, Args)] +pub(crate) struct CalibrateArgs { + /// Write the changes. Without this, the diff is printed and nothing is saved. + #[arg(long)] + pub(crate) apply: bool, + /// Skip the IRT fit. + #[arg(long)] + pub(crate) no_irt: bool, + /// Include practice assessments in the pool. + #[arg(long)] + pub(crate) include_practice: bool, + /// Require at least this many pooled examinees before writing anything. + #[arg(long, default_value_t = 10)] + pub(crate) min_n: usize, +} + +#[derive(Debug, Subcommand)] +pub(crate) enum ReportCommand { + /// One report per student. + Students { + /// Assessment id. + id: String, + /// Also write HTML. + #[arg(long)] + html: bool, + /// Output directory; defaults to reports//. + #[arg(long)] + out: Option, + /// Include the IRT ability estimate. + #[arg(long)] + ability: bool, + /// Leave out the comparison to the class. + #[arg(long)] + no_comparison: bool, + }, + /// The instructor's item analysis. + Cohort { + /// Assessment id. + id: String, + /// Also write HTML. + #[arg(long)] + html: bool, + /// Output path; defaults to reports/-cohort.md. + #[arg(long)] + out: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_cli_parses_a_realistic_invocation() { + let cli = Cli::try_parse_from([ + "coursebank", + "--course", + "/tmp/course", + "assemble", + "exam-4", + "--title", + "Exam 4", + "--levels", + "1=6,2=8,3=10", + "--require", + "lo-kinetics=2", + "--forms", + "2", + ]) + .unwrap(); + match cli.command { + Command::Assemble(args) => { + assert_eq!(args.id, "exam-4"); + assert_eq!(args.forms, 2); + assert_eq!(args.levels.len(), 3); + assert_eq!(args.require, vec!["lo-kinetics=2".to_string()]); + } + other => panic!("parsed as {other:?}"), + } + } + + #[test] + fn the_cli_rejects_an_unknown_subcommand() { + assert!(Cli::try_parse_from(["coursebank", "frobnicate"]).is_err()); + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..ad9fc5e --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,67 @@ +//! Command handlers, grouped by workflow stage. +//! +//! [`run`] is the single dispatch point: it matches the parsed [`Command`] and +//! calls the matching handler. The handlers themselves live in submodules that +//! follow the workflow described in the crate documentation: +//! +//! - [`project`] — set up and check a course: `init`, `schema`, `validate`, +//! `lint`, `catalog`. +//! - [`banks`] — manage items and build assessments: `bank`, `assessment`, +//! `assemble`, `usage`. +//! - [`export`] — turn an assessment into deliverables: `export`, `template`. +//! - [`analysis`] — the responses-to-report pipeline: `ingest`, `analyze`, +//! `calibrate`, `report`, `data`. +//! +//! Every handler returns [`Outcome`] so the dispatcher can distinguish "finished +//! cleanly" from "finished, but found problems worth a non-zero exit code". + +pub(crate) mod analysis; +pub(crate) mod banks; +pub(crate) mod export; +pub(crate) mod project; + +use coursebank::error::Result; + +use crate::cli::{Cli, Command}; + +/// What a command concluded. +pub(crate) enum Outcome { + /// Nothing to report. + Ok, + /// Problems were found, which is not the same as the command failing. + Findings, +} + +/// Dispatches a command. +/// +/// # Arguments +/// +/// * `cli` - the parsed arguments. +/// +/// # Returns +/// +/// Whether findings were reported. +/// +/// # Errors +/// +/// Propagates any failure from the underlying operation. +pub(crate) fn run(cli: &Cli) -> Result { + match &cli.command { + Command::Init(args) => project::init(cli, args), + Command::Schema => project::schema(cli), + Command::Validate => project::validate(cli), + Command::Lint(args) => project::lint(cli, args), + Command::Catalog(args) => project::catalog(cli, args), + Command::Bank(sub) => banks::bank(cli, sub), + Command::Assessment(sub) => banks::assessment(cli, sub), + Command::Assemble(args) => banks::assemble(cli, args), + Command::Usage(sub) => banks::usage(cli, sub), + Command::Export(sub) => export::export(cli, sub), + Command::Template(sub) => export::template(cli, sub), + Command::Ingest(sub) => analysis::ingest(cli, sub), + Command::Analyze(sub) => analysis::analyze(cli, sub), + Command::Calibrate(args) => analysis::calibrate(cli, args), + Command::Report(sub) => analysis::report(cli, sub), + Command::Data => analysis::data(cli), + } +} diff --git a/src/commands/analysis.rs b/src/commands/analysis.rs new file mode 100644 index 0000000..a23d1aa --- /dev/null +++ b/src/commands/analysis.rs @@ -0,0 +1,382 @@ +//! 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::course::Layout; +use coursebank::error::Result; +use coursebank::gradescope; +use coursebank::irt; +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 { + 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 { + 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} {}", + "Q", "p", "r", "D", "blank", "FLAGS" + ); + 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::>() + .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} {}", + "Q", "a", "b", "SE(a)", "SE(b)", "NOTES" + ); + 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 { + 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 { + 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 { + 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) +} diff --git a/src/commands/banks.rs b/src/commands/banks.rs new file mode 100644 index 0000000..0499b50 --- /dev/null +++ b/src/commands/banks.rs @@ -0,0 +1,258 @@ +//! 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, History}; +use coursebank::bank::BankFile; +use coursebank::course::Layout; +use coursebank::date::Date; +use coursebank::error::{Error, Result}; +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 { + 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 { + 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 = record.validate(Some(&catalog)); + 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 { + 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::()?, + 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 { + let catalog = load(cli)?; + let history = History::load(&catalog.layout.assessments())?; + + match sub { + UsageCommand::History { item } => { + let uids: Vec = match item { + Some(id) => vec![catalog.resolve(id)?], + None => catalog.entries.iter().map(|e| e.uid.clone()).collect(), + }; + println!("{:<34} {:>5} {:<12} {}", "ITEM", "USES", "LAST", "WHERE"); + for uid in uids { + let uses = history.for_item(&uid); + if uses.is_empty() && item.is_none() { + continue; + } + let where_used: Vec = 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) + } + } +} diff --git a/src/commands/export.rs b/src/commands/export.rs new file mode 100644 index 0000000..28b1259 --- /dev/null +++ b/src/commands/export.rs @@ -0,0 +1,338 @@ +//! 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::course::Layout; +use coursebank::error::{Error, Result}; +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 { + 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 = 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::>() + .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 .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> { + 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 { + 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 to \ + see what it currently produces", + path.display() + ))); + } + yaml::write_text(&path, typst::CONFIG_TEMPLATE)?; + println!("wrote {}", path.display()); + Ok(Outcome::Ok) + } + } +} diff --git a/src/commands/project.rs b/src/commands/project.rs new file mode 100644 index 0000000..dcf723c --- /dev/null +++ b/src/commands/project.rs @@ -0,0 +1,269 @@ +//! 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::{CourseFile, Layout, COURSE_FILE}; +use coursebank::error::{Error, Result}; +use coursebank::jsonschema; +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 { + 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 { + 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 { + 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 record.validate(Some(&catalog)) { + 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 { + if args.list_rules { + println!("{:<32} {:<8} {}", "CODE", "SEVERITY", "WHAT IT CATCHES"); + 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 , 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 { + 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} {}", + "OBJECTIVE", "ITEMS", "READY", "MAX LEVEL" + ); + 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/")); + } +} diff --git a/src/helpers.rs b/src/helpers.rs new file mode 100644 index 0000000..aa5512c --- /dev/null +++ b/src/helpers.rs @@ -0,0 +1,379 @@ +//! Plumbing shared by more than one command handler. +//! +//! Loading and resolving ([`load`], [`load_record`], [`pick_form`]), building an +//! ingest context ([`context`]) and reading responses ([`responses_for`]), the +//! security-sensitive [`read_salt`], the `KEY=VALUE` flag parsers +//! ([`parse_level_map`], [`parse_string_map`]), and the shared text formatting +//! ([`print_record`], [`markdown_export`], [`truncate`]). +//! +//! Anything used by only one handler stays with that handler; the items here +//! earned a home in the shared module by having more than one caller. + +use std::collections::BTreeMap; +use std::path::Path; + +use coursebank::assessment::{AssessmentFile, Form}; +use coursebank::catalog::Catalog; +use coursebank::course::{Layout, COURSE_FILE}; +use coursebank::date::Date; +use coursebank::error::{Error, Result}; +use coursebank::gradescope; +use coursebank::responses::ResponseSet; +use coursebank::store::Store; +use coursebank::taxonomy::Level; + +use crate::cli::{Cli, IngestCommon}; + +/// Loads the catalog, with a friendlier message when the directory is not a course. +pub(crate) fn load(cli: &Cli) -> Result { + let course_file = Layout::new(&cli.course).course_file(); + if !course_file.exists() { + return Err(Error::usage(format!( + "{} has no {COURSE_FILE}. Run `coursebank init --code ... --title ... --term ...` \ + here, or point at a course with --course", + cli.course.display() + ))); + } + Catalog::load(&cli.course) +} + +/// Loads one assessment record by id. +pub(crate) fn load_record(catalog: &Catalog, id: &str) -> Result { + let path = catalog.layout.assessments().join(format!("{id}.yaml")); + if path.exists() { + return AssessmentFile::load(&path); + } + // Fall back to scanning, in case the file name and the id differ. + let all = AssessmentFile::load_all(&catalog.layout.assessments())?; + all.into_iter() + .find(|r| r.assessment.id == id) + .ok_or_else(|| Error::Unresolved { + kind: "assessment", + id: id.to_string(), + context: Some(catalog.layout.assessments().display().to_string()), + }) +} + +/// Picks a form by id, defaulting sensibly when the record declares none. +pub(crate) fn pick_form(record: &AssessmentFile, id: &str) -> Result { + if record.forms.is_empty() { + return Ok(Form { + id: id.to_string(), + seed: 0, + shuffle_items: false, + shuffle_options: false, + }); + } + record + .forms + .iter() + .find(|f| f.id.eq_ignore_ascii_case(id)) + .cloned() + .ok_or_else(|| { + Error::usage(format!( + "no form `{id}` on this assessment; it declares {}", + record + .forms + .iter() + .map(|f| f.id.as_str()) + .collect::>() + .join(", ") + )) + }) +} + +/// Builds the ingest context from a record and the command line. +pub(crate) fn context( + catalog: &Catalog, + record: &AssessmentFile, + common: &IngestCommon, +) -> Result { + let date = match &common.date { + Some(s) => Some(s.parse::()?), + None => record.assessment.date, + }; + Ok(gradescope::Context { + course: catalog.course.course.code.clone(), + term: record + .assessment + .term + .clone() + .unwrap_or_else(|| catalog.course.course.term.clone()), + assessment_id: record.assessment.id.clone(), + date, + form: common.form.clone(), + }) +} + +/// Reads the responses to analyze, either one administration or all of them. +pub(crate) fn responses_for( + store: &Store, + catalog: &Catalog, + record: &AssessmentFile, + pooled: bool, +) -> Result { + let set = if pooled { + store.read_assessment(&record.assessment.id)? + } else { + let admin = coursebank::responses::administration_id( + &catalog.course.course.code, + record + .assessment + .term + .as_deref() + .unwrap_or(&catalog.course.course.term), + &record.assessment.id, + ); + store.read(&admin)? + }; + if set.rows.is_empty() { + return Err(Error::Other(format!( + "no stored responses for `{}`. Run `coursebank ingest` first", + record.assessment.id + ))); + } + Ok(set) +} + +/// Reads the pseudonymization salt. +/// +/// A salt is mandatory rather than optional. Hashing a seven-digit student id +/// without a key is not de-identification — the entire space can be enumerated in +/// under a second — so silently defaulting to an unkeyed hash would hand back a +/// file that looks anonymous and is not. +pub(crate) fn read_salt(path: Option<&Path>) -> Result> { + let Some(path) = path else { + return Err(Error::usage( + "--pseudonymize needs --salt-file. Generate one with `openssl rand -hex 32 > \ + ~/.coursebank-salt` and keep it OUT of the course repository: without a secret key, \ + hashed student ids can be reversed by brute force in under a second" + .to_string(), + )); + }; + let salt = std::fs::read(path).map_err(|e| Error::io(path, e))?; + let trimmed: Vec = salt + .into_iter() + .filter(|b| !b.is_ascii_whitespace()) + .collect(); + if trimmed.len() < 16 { + return Err(Error::usage(format!( + "the salt in {} is only {} byte(s); use at least 16", + path.display(), + trimmed.len() + ))); + } + Ok(trimmed) +} + +/// Parses `1=6,2=8` into a level map. +pub(crate) fn parse_level_map(pairs: &[String]) -> Result> { + let mut out = BTreeMap::new(); + for pair in pairs { + let (key, value) = pair.split_once('=').ok_or_else(|| { + Error::usage(format!("expected LEVEL=COUNT, got `{pair}` (e.g. 3=10)")) + })?; + let code: u8 = key + .trim() + .parse() + .map_err(|_| Error::usage(format!("`{key}` is not a level number 1-5")))?; + let level = Level::from_code(code) + .ok_or_else(|| Error::usage(format!("`{code}` is not a level number 1-5")))?; + let count: usize = value + .trim() + .parse() + .map_err(|_| Error::usage(format!("`{value}` is not a count")))?; + out.insert(level, count); + } + Ok(out) +} + +/// Parses `lo-a=2,lo-b=1` into a string map. +pub(crate) fn parse_string_map(pairs: &[String]) -> Result> { + let mut out = BTreeMap::new(); + for pair in pairs { + let (key, value) = pair + .split_once('=') + .ok_or_else(|| Error::usage(format!("expected ID=COUNT, got `{pair}`")))?; + let count: usize = value + .trim() + .parse() + .map_err(|_| Error::usage(format!("`{value}` is not a count")))?; + out.insert(key.trim().to_string(), count); + } + Ok(out) +} + +/// Prints a record summary. +pub(crate) fn print_record(catalog: &Catalog, record: &AssessmentFile) { + println!( + "{} — {} ({})", + record.assessment.id, + record.assessment.title, + record.assessment.kind.as_str() + ); + println!( + "{} item(s), {:.1} point(s){}", + record.items.iter().filter(|p| !p.bonus).count(), + record.total_points(catalog.course.policy.points_per_item), + record + .assessment + .date + .map(|d| format!(", {d}")) + .unwrap_or_default() + ); + println!( + "estimated {:.0} minutes of working time", + record.estimated_minutes(catalog) + ); + + println!("\nBy level:"); + for (level, count) in record.level_counts() { + println!(" {} {:<12} {count}", level.code(), level.name()); + } + + println!("\n{:>3} {:<34} {:<6} {:>6}", "#", "ITEM", "KEY", "POINTS"); + for placement in &record.items { + println!( + "{:>3} {:<34} {:<6} {:>6.1}{}", + placement.number, + truncate(&placement.item, 34), + placement.key.join(""), + placement + .points + .unwrap_or(catalog.course.policy.points_per_item), + if placement.bonus { " (bonus)" } else { "" } + ); + } +} + +/// Renders an assessment as Markdown, for review before printing. +pub(crate) fn markdown_export( + catalog: &Catalog, + record: &AssessmentFile, + with_key: bool, +) -> Result { + let mut out = format!( + "# {}\n\n{} · {}\n\n", + record.assessment.title, + catalog.course.course.code, + record + .assessment + .date + .map(|d| d.to_string()) + .unwrap_or_else(|| "undated".into()) + ); + + for placement in &record.items { + let entry = catalog.require(&placement.item)?; + let item = &entry.item; + out.push_str(&format!( + "## {}. {}{}\n\n{}\n\n", + placement.number, + if placement.bonus { "(bonus) " } else { "" }, + item.display_title(), + item.stem + )); + for option in &item.options { + let marker = if with_key && option.correct { + " ✓" + } else { + "" + }; + out.push_str(&format!("- **{}.** {}{marker}\n", option.id, option.text)); + } + out.push('\n'); + + if with_key { + if let Some(design) = &item.design { + if let Some(rationale) = &design.rationale { + out.push_str(&format!("> {rationale}\n\n")); + } + } + for option in &item.options { + if let Some(explanation) = &option.explanation { + out.push_str(&format!("- {}: {explanation}\n", option.id)); + } + } + out.push('\n'); + } + } + Ok(out) +} + +/// Truncates a string to a width, with an ellipsis. +pub(crate) fn truncate(s: &str, width: usize) -> String { + if s.chars().count() <= width { + return s.to_string(); + } + let kept: String = s.chars().take(width.saturating_sub(1)).collect(); + format!("{kept}…") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn level_maps_parse() { + let map = parse_level_map(&["1=6".to_string(), "3=10".to_string()]).unwrap(); + assert_eq!(map.get(&Level::Remember), Some(&6)); + assert_eq!(map.get(&Level::Apply), Some(&10)); + assert_eq!(map.len(), 2); + } + + #[test] + fn bad_level_maps_explain_themselves() { + let err = parse_level_map(&["nonsense".to_string()]).unwrap_err(); + assert!(err.to_string().contains("LEVEL=COUNT")); + let err = parse_level_map(&["9=1".to_string()]).unwrap_err(); + assert!(err.to_string().contains("level number")); + let err = parse_level_map(&["1=many".to_string()]).unwrap_err(); + assert!(err.to_string().contains("not a count")); + } + + #[test] + fn objective_requirements_parse() { + let map = parse_string_map(&["lo-a=2".to_string(), " lo-b = 1 ".to_string()]).unwrap(); + assert_eq!(map.get("lo-a"), Some(&2)); + assert_eq!(map.get("lo-b"), Some(&1)); + } + + #[test] + fn truncation_keeps_the_width() { + assert_eq!(truncate("short", 10), "short"); + assert_eq!(truncate("abcdefghij", 5).chars().count(), 5); + assert!(truncate("abcdefghij", 5).ends_with('…')); + } + + #[test] + fn pseudonymizing_without_a_salt_is_refused() { + let err = read_salt(None).unwrap_err(); + assert!(err.to_string().contains("salt-file")); + assert!( + err.to_string().contains("brute force"), + "the message must explain why, not just what" + ); + } + + #[test] + fn a_short_salt_is_refused() { + let dir = std::env::temp_dir().join(format!("cb-salt-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("weak.salt"); + std::fs::write(&path, b"tooshort\n").unwrap(); + let err = read_salt(Some(&path)).unwrap_err(); + assert!(err.to_string().contains("at least 16")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_good_salt_is_read_and_trimmed() { + let dir = std::env::temp_dir().join(format!("cb-salt-ok-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("good.salt"); + std::fs::write(&path, b"0123456789abcdef0123456789abcdef\n").unwrap(); + let salt = read_salt(Some(&path)).unwrap(); + assert_eq!(salt.len(), 32, "whitespace is stripped"); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6ff32a6..62f4b80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,18 +47,18 @@ //! already get right and then drifts from it. [`assessment::History`] derives usage //! by scanning the records. //! -//! **Fingerprints cover only what a student saw.** Retag an item's metadata and its +//! Fingerprints cover only what a student saw. Retag an item's metadata and its //! pooled statistics stay valid; reword the stem and they are marked stale. See //! [`item::Item::fingerprint`]. //! -//! **Validation reports everything at once.** Fixing one typo per run is not a +//! Validation reports everything at once. Fixing one typo per run is not a //! workflow. [`error::Error::Invalid`] carries a list. //! -//! **Validation and linting are separate.** [`bank::BankFile::validate`] enforces +//! Validation and linting are separate. [`bank::BankFile::validate`] enforces //! what must be true; [`lint`] advises on what is usually a mistake, and every rule //! has a code you can silence. //! -//! **Small samples are labelled as such.** Every statistic computed from a class of +//! Small samples are labelled as such. Every statistic computed from a class of //! twenty-five is reported with the caveat it deserves rather than three decimal //! places of false precision. //! @@ -78,15 +78,11 @@ #![warn(missing_docs)] #![forbid(unsafe_code)] -// A broken link in a tutorial is a silent lie about the API, so it fails the build -// rather than warning into a log nobody reads. #![deny(rustdoc::broken_intra_doc_links)] #![warn(rustdoc::invalid_codeblock_attributes)] #![warn(rustdoc::invalid_html_tags)] #![warn(rustdoc::bare_urls)] #![warn(rustdoc::private_intra_doc_links)] -// Lets `cargo doc` mark feature-gated items with the feature that enables them, on -// a nightly toolchain or on docs.rs. Ignored elsewhere. #![cfg_attr(docsrs, feature(doc_cfg))] pub mod analysis; diff --git a/src/main.rs b/src/main.rs index 5f3a3a4..f8b9974 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,559 +9,36 @@ //! Exit codes are meaningful for scripting and CI: `0` for success, `1` for a //! failure, `2` when validation or linting found problems. That last one is the //! reason a course repository can have a pre-commit hook. +//! +//! # Module layout +//! +//! `main.rs` is deliberately thin. The pieces live in sibling modules: +//! +//! - [`cli`] — the `clap` argument model (every flag, subcommand, and the small +//! conversions from CLI-facing enums into the library's domain enums). +//! - [`commands`] — the [`run`](commands::run) dispatcher plus one submodule per +//! workflow stage, each holding the handlers for its subcommands. +//! - [`helpers`] — shared plumbing used by more than one handler: loading the +//! catalog, resolving records and forms, parsing `KEY=VALUE` flags, and the +//! text formatting used by the various listings. +//! - [`import`] — the one-off legacy-JSON importer, kept apart because it is large +//! and touched rarely. + +mod cli; +mod commands; +mod helpers; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; use std::process::ExitCode; -use clap::{Args, Parser, Subcommand, ValueEnum}; +use clap::Parser; -use coursebank::assessment::{ - AssessmentFile, Blueprint, Form, History, Kind as AssessmentKind, Platform, -}; -use coursebank::bank::BankFile; -use coursebank::calibrate; -use coursebank::canvas; -use coursebank::catalog::{Catalog, Severity}; -use coursebank::classical::{self, Thresholds}; -use coursebank::course::{CourseFile, Layout, COURSE_FILE}; -use coursebank::date::Date; -use coursebank::error::{Error, Result}; -use coursebank::gradescope; -use coursebank::irt; -use coursebank::item::IrtModel; -use coursebank::jsonschema; -use coursebank::lint::{self, Rule}; -use coursebank::qti; -use coursebank::report; -use coursebank::responses::ResponseSet; -use coursebank::select; -use coursebank::store::{self, Store}; -use coursebank::students; -use coursebank::taxonomy::Level; -use coursebank::typst; -use coursebank::yaml; - -/// Manage course item banks, assessments, and the analysis that comes back. -#[derive(Debug, Parser)] -#[command(name = "coursebank", version, about, long_about = None)] -struct Cli { - /// Course directory, the one holding course.yaml. - #[arg(long, short = 'C', global = true, default_value = ".")] - course: PathBuf, - - /// Print less. - #[arg(long, short, global = true)] - quiet: bool, - - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// Create a new course directory. - Init(InitArgs), - /// Write JSON Schemas so your editor can validate the YAML as you type. - Schema, - /// Check every file for problems that must be fixed. - Validate, - /// Check items against item-writing guidance. - Lint(LintArgs), - /// Summarize the item pool and objective coverage. - Catalog(CatalogArgs), - /// Work with item banks. - #[command(subcommand)] - Bank(BankCommand), - /// Work with assessment records. - #[command(subcommand)] - Assessment(AssessmentCommand), - /// Draw a new assessment from the pool. - Assemble(AssembleArgs), - /// Show which items have been used, and when. - #[command(subcommand)] - Usage(UsageCommand), - /// Produce a Canvas package, a printable exam, or Markdown. - #[command(subcommand)] - Export(ExportCommand), - /// Inspect, dump, and configure the Typst export templates. - #[command(subcommand)] - Template(TemplateCommand), - /// Read a grading export into the response store. - #[command(subcommand)] - Ingest(IngestCommand), - /// Compute statistics from stored responses. - #[command(subcommand)] - Analyze(AnalyzeCommand), - /// Write statistics back onto the items. - Calibrate(CalibrateArgs), - /// Write reports. - #[command(subcommand)] - Report(ReportCommand), - /// List what is in the response store. - Data, -} - -#[derive(Debug, Args)] -struct InitArgs { - /// Course code, e.g. "BIOSC 1540". - #[arg(long)] - code: String, - /// Course title. - #[arg(long)] - title: String, - /// Term, e.g. 2026s. - #[arg(long)] - term: String, - /// Also write an example bank and assessment. - #[arg(long)] - with_examples: bool, -} - -#[derive(Debug, Args)] -struct LintArgs { - /// List every rule and its code, then exit. - #[arg(long)] - list_rules: bool, - /// Only run these rule codes. - #[arg(long, value_delimiter = ',')] - only: Vec, - /// Skip these rule codes. - #[arg(long, value_delimiter = ',')] - ignore: Vec, - /// Only report findings at this severity or above. - #[arg(long, value_enum, default_value = "low")] - min_severity: SeverityArg, - /// Exit 0 even when findings exist. - #[arg(long)] - no_fail: bool, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum SeverityArg { - Low, - Medium, - High, -} - -impl SeverityArg { - fn as_severity(self) -> Severity { - match self { - SeverityArg::Low => Severity::Low, - SeverityArg::Medium => Severity::Medium, - SeverityArg::High => Severity::High, - } - } -} - -#[derive(Debug, Args)] -struct CatalogArgs { - /// Show per-objective coverage and the gaps in it. - #[arg(long)] - coverage: bool, - /// Show topic counts. - #[arg(long)] - topics: bool, -} - -#[derive(Debug, Subcommand)] -enum BankCommand { - /// Create an empty bank file. - New { - /// Bank id. - id: String, - /// Bank title. - #[arg(long)] - title: Option, - }, - /// Import questions from a legacy JSON quiz file. - ImportJson { - /// The JSON file. - file: PathBuf, - /// Bank id to create or add to. - #[arg(long)] - bank: String, - /// Prefix for generated item ids. - #[arg(long, default_value = "imported")] - prefix: String, - }, - /// List banks and their item counts. - List, -} - -#[derive(Debug, Subcommand)] -enum AssessmentCommand { - /// Create an empty assessment record. - New { - /// Assessment id. - id: String, - /// Title. - #[arg(long)] - title: Option, - /// Kind of assessment. - #[arg(long, value_enum, default_value = "exam")] - kind: KindArg, - }, - /// List assessment records. - List, - /// Show one record in detail. - Show { - /// Assessment id. - id: String, - }, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum KindArg { - Exam, - Quiz, - Homework, - Practice, - Final, -} - -impl KindArg { - fn as_kind(self) -> AssessmentKind { - match self { - KindArg::Exam => AssessmentKind::Exam, - KindArg::Quiz => AssessmentKind::Quiz, - KindArg::Homework => AssessmentKind::Homework, - KindArg::Practice => AssessmentKind::Practice, - KindArg::Final => AssessmentKind::Final, - } - } -} - -#[derive(Debug, Args)] -struct AssembleArgs { - /// Assessment id to create. - id: String, - /// Title. - #[arg(long)] - title: Option, - /// Kind of assessment. - #[arg(long, value_enum, default_value = "exam")] - kind: KindArg, - /// Administration date, YYYY-MM-DD. Defaults to today. - #[arg(long)] - date: Option, - /// Where it will be given. - #[arg(long, value_enum, default_value = "paper")] - platform: PlatformArg, - /// How many items at each level, e.g. --levels 1=6,2=8,3=10,4=6. - #[arg(long, value_delimiter = ',')] - levels: Vec, - /// Bonus items per level, same syntax. - #[arg(long, value_delimiter = ',')] - bonus: Vec, - /// Minimum items per objective, e.g. --require lo-kinetics=2. - #[arg(long, value_delimiter = ',')] - require: Vec, - /// Restrict the draw to these lectures. - #[arg(long, value_delimiter = ',')] - lectures: Vec, - /// Restrict the draw to these topics. - #[arg(long, value_delimiter = ',')] - topics: Vec, - /// Restrict the draw to these banks. - #[arg(long, value_delimiter = ',')] - banks: Vec, - /// At most this many items from any one bank. - #[arg(long)] - max_per_bank: Option, - /// Avoid items used within this many days. - #[arg(long, default_value_t = 180)] - cooldown: i64, - /// Seed, for a reproducible draw. - #[arg(long, default_value_t = 0)] - seed: u64, - /// How many alternate forms to declare. - #[arg(long, default_value_t = 1)] - forms: usize, - /// Show the draw without writing the record. - #[arg(long)] - dry_run: bool, - /// Overwrite an existing record. - #[arg(long)] - force: bool, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum PlatformArg { - Paper, - Canvas, - Other, -} - -impl PlatformArg { - fn as_platform(self) -> Platform { - match self { - PlatformArg::Paper => Platform::Paper, - PlatformArg::Canvas => Platform::Canvas, - PlatformArg::Other => Platform::Other, - } - } -} - -#[derive(Debug, Subcommand)] -enum UsageCommand { - /// Show when each item was used. - History { - /// Restrict to one item id. - item: Option, - }, - /// Show approved items that have never been used. - Unused, -} - -#[derive(Debug, Subcommand)] -enum ExportCommand { - /// Build a Canvas-importable QTI 1.2 package. - Qti { - /// Assessment id. - id: String, - /// Which form. - #[arg(long, default_value = "A")] - form: String, - /// Output path; defaults to build/-.zip. - #[arg(long)] - out: Option, - /// Leave per-option feedback out of the package. - #[arg(long)] - no_feedback: bool, - }, - /// Render a printable exam, answer key, and answer sheet. - /// - /// Each document is produced by injecting data into a Typst template rather - /// than being built from scratch, so the layout is yours to change. Run - /// `coursebank template dump` to get the defaults as editable files. - Typst { - /// Assessment id. - id: String, - /// Which form; repeat or pass "all". - #[arg(long, default_value = "A")] - form: String, - /// Output directory; defaults to build/. - #[arg(long)] - out: Option, - /// Which documents to write; defaults to all three. - #[arg(long, value_name = "VARIANT")] - variant: Vec, - /// Use this template file instead of the usual lookup. Only valid with a - /// single --variant, since one file cannot be three documents. - #[arg(long)] - template: Option, - /// Also write the payload as JSON, for a template that reads it with - /// `json("...")` rather than taking an injected region. - #[arg(long)] - json: bool, - /// Print the payload and the resolved template path without writing. - #[arg(long)] - dry_run: bool, - }, - /// Write the items as Markdown, for review. - Md { - /// Assessment id. - id: String, - /// Include the answer key and rationales. - #[arg(long)] - with_key: bool, - /// Output path; defaults to build/.md. - #[arg(long)] - out: Option, - }, -} - -#[derive(Debug, Subcommand)] -enum TemplateCommand { - /// Show which template each document would use, and why. - List { - /// Resolve as if exporting this assessment, which brings the - /// per-assessment template override into the lookup. - #[arg(long)] - assessment: Option, - }, - /// Write the built-in templates into templates/ so you can edit them. - Dump { - /// Which documents; defaults to all three. - #[arg(long, value_name = "VARIANT")] - variant: Vec, - /// Destination directory; defaults to templates/. - #[arg(long)] - out: Option, - /// Overwrite files that already exist. - #[arg(long)] - force: bool, - /// Print to stdout instead of writing files. - #[arg(long)] - stdout: bool, - }, - /// Write or show the render configuration. - Config { - /// Print the fully resolved configuration for this document, after every - /// layer has been applied, instead of writing a starter file. - #[arg(long, value_name = "VARIANT")] - resolved: Option, - /// Destination path; defaults to templates/typst.yaml. - #[arg(long)] - out: Option, - /// Overwrite a config file that already exists. - #[arg(long)] - force: bool, - }, -} - -#[derive(Debug, Args)] -struct IngestCommon { - /// The assessment record these responses belong to. - #[arg(long)] - assessment: String, - /// Administration date, YYYY-MM-DD. Defaults to the record's date. - #[arg(long)] - date: Option, - /// Which form, if forms were used. - #[arg(long)] - form: Option, - /// Replace student identifiers with keyed pseudonyms. - #[arg(long)] - pseudonymize: bool, - /// File holding the HMAC salt. Keep it outside the repository. - #[arg(long)] - salt_file: Option, - /// Storage format. - #[arg(long, value_enum)] - format: Option, - /// Parse and report without writing to the store. - #[arg(long)] - dry_run: bool, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum FormatArg { - Parquet, - Csv, -} - -impl FormatArg { - fn as_format(self) -> store::Format { - match self { - FormatArg::Parquet => store::Format::Parquet, - FormatArg::Csv => store::Format::Csv, - } - } -} - -#[derive(Debug, Subcommand)] -enum IngestCommand { - /// Read a directory of Gradescope per-question CSV exports. - Gradescope { - /// The directory holding 1.csv .. N.csv. - dir: PathBuf, - #[command(flatten)] - common: IngestCommon, - }, - /// Read a Canvas "Student Analysis" CSV. - Canvas { - /// The CSV file. - file: PathBuf, - #[command(flatten)] - common: IngestCommon, - }, -} - -#[derive(Debug, Subcommand)] -enum AnalyzeCommand { - /// Classical item analysis. - Items { - /// Assessment id. - id: String, - /// Pool every stored administration of this assessment. - #[arg(long)] - pooled: bool, - }, - /// Fit an IRT model. - Irt { - /// Assessment id. - id: String, - /// Which model. - #[arg(long, value_enum, default_value = "two-pl")] - model: ModelArg, - /// Estimate without priors. Expect divergence on a single class. - #[arg(long)] - no_priors: bool, - }, - /// Per-student mastery and cohort patterns. - Students { - /// Assessment id. - id: String, - }, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum ModelArg { - Rasch, - TwoPl, - ThreePl, -} - -impl ModelArg { - fn as_model(self) -> IrtModel { - match self { - ModelArg::Rasch => IrtModel::Rasch, - ModelArg::TwoPl => IrtModel::TwoPl, - ModelArg::ThreePl => IrtModel::ThreePl, - } - } -} - -#[derive(Debug, Args)] -struct CalibrateArgs { - /// Write the changes. Without this, the diff is printed and nothing is saved. - #[arg(long)] - apply: bool, - /// Skip the IRT fit. - #[arg(long)] - no_irt: bool, - /// Include practice assessments in the pool. - #[arg(long)] - include_practice: bool, - /// Require at least this many pooled examinees before writing anything. - #[arg(long, default_value_t = 10)] - min_n: usize, -} - -#[derive(Debug, Subcommand)] -enum ReportCommand { - /// One report per student. - Students { - /// Assessment id. - id: String, - /// Also write HTML. - #[arg(long)] - html: bool, - /// Output directory; defaults to reports//. - #[arg(long)] - out: Option, - /// Include the IRT ability estimate. - #[arg(long)] - ability: bool, - /// Leave out the comparison to the class. - #[arg(long)] - no_comparison: bool, - }, - /// The instructor's item analysis. - Cohort { - /// Assessment id. - id: String, - /// Also write HTML. - #[arg(long)] - html: bool, - /// Output path; defaults to reports/-cohort.md. - #[arg(long)] - out: Option, - }, -} +use crate::cli::Cli; +use crate::commands::{run, Outcome}; +/// Parses the command line, runs the requested command, and maps its result onto +/// a process exit code. +/// +/// See the crate-level documentation for the meaning of each code. fn main() -> ExitCode { let cli = Cli::parse(); match run(&cli) { @@ -573,1774 +50,3 @@ fn main() -> ExitCode { } } } - -/// What a command concluded. -enum Outcome { - /// Nothing to report. - Ok, - /// Problems were found, which is not the same as the command failing. - Findings, -} - -/// Dispatches a command. -/// -/// # Arguments -/// -/// * `cli` - the parsed arguments. -/// -/// # Returns -/// -/// Whether findings were reported. -/// -/// # Errors -/// -/// Propagates any failure from the underlying operation. -fn run(cli: &Cli) -> Result { - match &cli.command { - Command::Init(args) => cmd_init(cli, args), - Command::Schema => cmd_schema(cli), - Command::Validate => cmd_validate(cli), - Command::Lint(args) => cmd_lint(cli, args), - Command::Catalog(args) => cmd_catalog(cli, args), - Command::Bank(sub) => cmd_bank(cli, sub), - Command::Assessment(sub) => cmd_assessment(cli, sub), - Command::Assemble(args) => cmd_assemble(cli, args), - Command::Usage(sub) => cmd_usage(cli, sub), - Command::Export(sub) => cmd_export(cli, sub), - Command::Template(sub) => cmd_template(cli, sub), - Command::Ingest(sub) => cmd_ingest(cli, sub), - Command::Analyze(sub) => cmd_analyze(cli, sub), - Command::Calibrate(args) => cmd_calibrate(cli, args), - Command::Report(sub) => cmd_report(cli, sub), - Command::Data => cmd_data(cli), - } -} - -fn cmd_init(cli: &Cli, args: &InitArgs) -> Result { - 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) -} - -fn cmd_schema(cli: &Cli) -> Result { - 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) -} - -fn cmd_validate(cli: &Cli) -> Result { - 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 record.validate(Some(&catalog)) { - 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) -} - -fn cmd_lint(cli: &Cli, args: &LintArgs) -> Result { - if args.list_rules { - println!("{:<32} {:<8} {}", "CODE", "SEVERITY", "WHAT IT CATCHES"); - 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 , or see them all with --list-rules."); - - if args.no_fail { - Ok(Outcome::Ok) - } else { - Ok(Outcome::Findings) - } -} - -fn cmd_catalog(cli: &Cli, args: &CatalogArgs) -> Result { - 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} {}", - "OBJECTIVE", "ITEMS", "READY", "MAX LEVEL" - ); - 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) -} - -fn cmd_bank(cli: &Cli, sub: &BankCommand) -> Result { - 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) - } - BankCommand::ImportJson { file, bank, prefix } => { - let (imported, notes) = import_legacy_json(file, bank, prefix)?; - let path = layout.banks().join(format!("{bank}.yaml")); - if path.exists() { - return Err(Error::usage(format!( - "{} already exists; import into a new bank id and merge by hand", - path.display() - ))); - } - yaml::write(&path, &imported)?; - println!( - "imported {} item(s) into {}", - imported.items.len(), - path.display() - ); - for note in ¬es { - println!(" ! {note}"); - } - println!( - "\nImported items are marked `draft`. They need learning objectives, sources, and \ - a cognitive process before `assemble` will draw them." - ); - Ok(Outcome::Ok) - } - } -} - -fn cmd_assessment(cli: &Cli, sub: &AssessmentCommand) -> Result { - 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 = record.validate(Some(&catalog)); - if !issues.is_empty() { - println!("\n{} problem(s):", issues.len()); - for issue in &issues { - println!(" - {issue}"); - } - return Ok(Outcome::Findings); - } - Ok(Outcome::Ok) - } - } -} - -fn cmd_assemble(cli: &Cli, args: &AssembleArgs) -> Result { - 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::()?, - 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) -} - -fn cmd_usage(cli: &Cli, sub: &UsageCommand) -> Result { - let catalog = load(cli)?; - let history = History::load(&catalog.layout.assessments())?; - - match sub { - UsageCommand::History { item } => { - let uids: Vec = match item { - Some(id) => vec![catalog.resolve(id)?], - None => catalog.entries.iter().map(|e| e.uid.clone()).collect(), - }; - println!("{:<34} {:>5} {:<12} {}", "ITEM", "USES", "LAST", "WHERE"); - for uid in uids { - let uses = history.for_item(&uid); - if uses.is_empty() && item.is_none() { - continue; - } - let where_used: Vec = 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) - } - } -} - -fn cmd_export(cli: &Cli, sub: &ExportCommand) -> Result { - 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 = 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::>() - .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 .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) - } - } -} - -fn cmd_ingest(cli: &Cli, sub: &IngestCommand) -> Result { - 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) -} - -fn cmd_analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result { - 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} {}", - "Q", "p", "r", "D", "blank", "FLAGS" - ); - 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::>() - .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} {}", - "Q", "a", "b", "SE(a)", "SE(b)", "NOTES" - ); - 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) - } - } -} - -fn cmd_calibrate(cli: &Cli, args: &CalibrateArgs) -> Result { - 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) -} - -fn cmd_report(cli: &Cli, sub: &ReportCommand) -> Result { - 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) - } - } -} - -fn cmd_data(cli: &Cli) -> Result { - 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) -} - -/// 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> { - 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()) -} - -fn cmd_template(cli: &Cli, sub: &TemplateCommand) -> Result { - 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 to \ - see what it currently produces", - path.display() - ))); - } - yaml::write_text(&path, typst::CONFIG_TEMPLATE)?; - println!("wrote {}", path.display()); - Ok(Outcome::Ok) - } - } -} - -/// Loads the catalog, with a friendlier message when the directory is not a course. -fn load(cli: &Cli) -> Result { - let course_file = Layout::new(&cli.course).course_file(); - if !course_file.exists() { - return Err(Error::usage(format!( - "{} has no {COURSE_FILE}. Run `coursebank init --code ... --title ... --term ...` \ - here, or point at a course with --course", - cli.course.display() - ))); - } - Catalog::load(&cli.course) -} - -/// Loads one assessment record by id. -fn load_record(catalog: &Catalog, id: &str) -> Result { - let path = catalog.layout.assessments().join(format!("{id}.yaml")); - if path.exists() { - return AssessmentFile::load(&path); - } - // Fall back to scanning, in case the file name and the id differ. - let all = AssessmentFile::load_all(&catalog.layout.assessments())?; - all.into_iter() - .find(|r| r.assessment.id == id) - .ok_or_else(|| Error::Unresolved { - kind: "assessment", - id: id.to_string(), - context: Some(catalog.layout.assessments().display().to_string()), - }) -} - -/// Picks a form by id, defaulting sensibly when the record declares none. -fn pick_form(record: &AssessmentFile, id: &str) -> Result { - if record.forms.is_empty() { - return Ok(Form { - id: id.to_string(), - seed: 0, - shuffle_items: false, - shuffle_options: false, - }); - } - record - .forms - .iter() - .find(|f| f.id.eq_ignore_ascii_case(id)) - .cloned() - .ok_or_else(|| { - Error::usage(format!( - "no form `{id}` on this assessment; it declares {}", - record - .forms - .iter() - .map(|f| f.id.as_str()) - .collect::>() - .join(", ") - )) - }) -} - -/// Builds the ingest context from a record and the command line. -fn context( - catalog: &Catalog, - record: &AssessmentFile, - common: &IngestCommon, -) -> Result { - let date = match &common.date { - Some(s) => Some(s.parse::()?), - None => record.assessment.date, - }; - Ok(gradescope::Context { - course: catalog.course.course.code.clone(), - term: record - .assessment - .term - .clone() - .unwrap_or_else(|| catalog.course.course.term.clone()), - assessment_id: record.assessment.id.clone(), - date, - form: common.form.clone(), - }) -} - -/// Reads the responses to analyze, either one administration or all of them. -fn responses_for( - store: &Store, - catalog: &Catalog, - record: &AssessmentFile, - pooled: bool, -) -> Result { - let set = if pooled { - store.read_assessment(&record.assessment.id)? - } else { - let admin = coursebank::responses::administration_id( - &catalog.course.course.code, - record - .assessment - .term - .as_deref() - .unwrap_or(&catalog.course.course.term), - &record.assessment.id, - ); - store.read(&admin)? - }; - if set.rows.is_empty() { - return Err(Error::Other(format!( - "no stored responses for `{}`. Run `coursebank ingest` first", - record.assessment.id - ))); - } - Ok(set) -} - -/// Reads the pseudonymization salt. -/// -/// A salt is mandatory rather than optional. Hashing a seven-digit student id -/// without a key is not de-identification — the entire space can be enumerated in -/// under a second — so silently defaulting to an unkeyed hash would hand back a -/// file that looks anonymous and is not. -fn read_salt(path: Option<&Path>) -> Result> { - let Some(path) = path else { - return Err(Error::usage( - "--pseudonymize needs --salt-file. Generate one with `openssl rand -hex 32 > \ - ~/.coursebank-salt` and keep it OUT of the course repository: without a secret key, \ - hashed student ids can be reversed by brute force in under a second" - .to_string(), - )); - }; - let salt = std::fs::read(path).map_err(|e| Error::io(path, e))?; - let trimmed: Vec = salt - .into_iter() - .filter(|b| !b.is_ascii_whitespace()) - .collect(); - if trimmed.len() < 16 { - return Err(Error::usage(format!( - "the salt in {} is only {} byte(s); use at least 16", - path.display(), - trimmed.len() - ))); - } - Ok(trimmed) -} - -/// Parses `1=6,2=8` into a level map. -fn parse_level_map(pairs: &[String]) -> Result> { - let mut out = BTreeMap::new(); - for pair in pairs { - let (key, value) = pair.split_once('=').ok_or_else(|| { - Error::usage(format!("expected LEVEL=COUNT, got `{pair}` (e.g. 3=10)")) - })?; - let code: u8 = key - .trim() - .parse() - .map_err(|_| Error::usage(format!("`{key}` is not a level number 1-5")))?; - let level = Level::from_code(code) - .ok_or_else(|| Error::usage(format!("`{code}` is not a level number 1-5")))?; - let count: usize = value - .trim() - .parse() - .map_err(|_| Error::usage(format!("`{value}` is not a count")))?; - out.insert(level, count); - } - Ok(out) -} - -/// Parses `lo-a=2,lo-b=1` into a string map. -fn parse_string_map(pairs: &[String]) -> Result> { - let mut out = BTreeMap::new(); - for pair in pairs { - let (key, value) = pair - .split_once('=') - .ok_or_else(|| Error::usage(format!("expected ID=COUNT, got `{pair}`")))?; - let count: usize = value - .trim() - .parse() - .map_err(|_| Error::usage(format!("`{value}` is not a count")))?; - out.insert(key.trim().to_string(), count); - } - Ok(out) -} - -/// Prints a record summary. -fn print_record(catalog: &Catalog, record: &AssessmentFile) { - println!( - "{} — {} ({})", - record.assessment.id, - record.assessment.title, - record.assessment.kind.as_str() - ); - println!( - "{} item(s), {:.1} point(s){}", - record.items.iter().filter(|p| !p.bonus).count(), - record.total_points(catalog.course.policy.points_per_item), - record - .assessment - .date - .map(|d| format!(", {d}")) - .unwrap_or_default() - ); - println!( - "estimated {:.0} minutes of working time", - record.estimated_minutes(catalog) - ); - - println!("\nBy level:"); - for (level, count) in record.level_counts() { - println!(" {} {:<12} {count}", level.code(), level.name()); - } - - println!("\n{:>3} {:<34} {:<6} {:>6}", "#", "ITEM", "KEY", "POINTS"); - for placement in &record.items { - println!( - "{:>3} {:<34} {:<6} {:>6.1}{}", - placement.number, - truncate(&placement.item, 34), - placement.key.join(""), - placement - .points - .unwrap_or(catalog.course.policy.points_per_item), - if placement.bonus { " (bonus)" } else { "" } - ); - } -} - -/// Renders an assessment as Markdown, for review before printing. -fn markdown_export(catalog: &Catalog, record: &AssessmentFile, with_key: bool) -> Result { - let mut out = format!( - "# {}\n\n{} · {}\n\n", - record.assessment.title, - catalog.course.course.code, - record - .assessment - .date - .map(|d| d.to_string()) - .unwrap_or_else(|| "undated".into()) - ); - - for placement in &record.items { - let entry = catalog.require(&placement.item)?; - let item = &entry.item; - out.push_str(&format!( - "## {}. {}{}\n\n{}\n\n", - placement.number, - if placement.bonus { "(bonus) " } else { "" }, - item.display_title(), - item.stem - )); - for option in &item.options { - let marker = if with_key && option.correct { - " ✓" - } else { - "" - }; - out.push_str(&format!("- **{}.** {}{marker}\n", option.id, option.text)); - } - out.push('\n'); - - if with_key { - if let Some(design) = &item.design { - if let Some(rationale) = &design.rationale { - out.push_str(&format!("> {rationale}\n\n")); - } - } - for option in &item.options { - if let Some(explanation) = &option.explanation { - out.push_str(&format!("- {}: {explanation}\n", option.id)); - } - } - out.push('\n'); - } - } - Ok(out) -} - -/// Truncates a string to a width, with an ellipsis. -fn truncate(s: &str, width: usize) -> String { - if s.chars().count() <= width { - return s.to_string(); - } - let kept: String = s.chars().take(width.saturating_sub(1)).collect(); - format!("{kept}…") -} - -/// Imports a legacy JSON quiz file into a bank. -/// -/// Handles the shape used by the earlier Python tooling: a top-level object with a -/// `questions` array, each having `title`, `concept`, `stem`, and `options`. -/// -/// # Arguments -/// -/// * `path` - the JSON file. -/// * `bank_id` - the bank to create. -/// * `prefix` - the prefix for generated item ids. -/// -/// # Returns -/// -/// The bank and any notes about what could not be carried over. -/// -/// # Errors -/// -/// Returns [`Error::Json`] when the file does not parse. -fn import_legacy_json(path: &Path, bank_id: &str, prefix: &str) -> Result<(BankFile, Vec)> { - use coursebank::item::{Choice, Item}; - - let value: serde_json::Value = yaml::read_json(path)?; - let mut notes = Vec::new(); - - let questions = value - .get("questions") - .and_then(|q| q.as_array()) - .ok_or_else(|| { - Error::Invalid(vec![format!( - "{} has no top-level `questions` array", - path.display() - )]) - })?; - - let mut bank = BankFile::skeleton( - bank_id, - &format!( - "Imported from {}", - path.file_name() - .map(|f| f.to_string_lossy().to_string()) - .unwrap_or_default() - ), - ); - bank.items.clear(); - - for (index, question) in questions.iter().enumerate() { - let stem = question - .get("stem") - .and_then(|s| s.as_str()) - .unwrap_or("") - .to_string(); - if stem.is_empty() { - notes.push(format!( - "question {} has no stem and was skipped", - index + 1 - )); - continue; - } - - let mut options = Vec::new(); - if let Some(list) = question.get("options").and_then(|o| o.as_array()) { - for (i, option) in list.iter().enumerate() { - if i >= 8 { - notes.push(format!( - "question {} had more than eight options; the extras were dropped", - index + 1 - )); - break; - } - options.push(Choice { - id: ((b'A' + i as u8) as char).to_string(), - text: option - .get("text") - .and_then(|t| t.as_str()) - .unwrap_or("") - .to_string(), - correct: option - .get("correct") - .and_then(|c| c.as_bool()) - .unwrap_or(false), - credit: None, - explanation: None, - hint: None, - misconception: None, - error_type: None, - defensible: false, - defense: None, - feedback_student: option - .get("feedback") - .and_then(|f| f.as_str()) - .map(|s| s.to_string()), - selection_rate_expected: None, - }); - } - } - if options.len() < 2 { - notes.push(format!( - "question {} has fewer than two options and was skipped", - index + 1 - )); - continue; - } - - // The legacy `concept` field packs a level code and a description into one - // string, e.g. "(L4.3) explain the mechanism". Pull the level out and keep - // the rest as a topic; it cannot become a real objective automatically - // because objectives have to exist in course.yaml first. - let concept = question - .get("concept") - .and_then(|c| c.as_str()) - .unwrap_or(""); - let (level, remainder) = parse_legacy_concept(concept); - let mut topics = Vec::new(); - if !remainder.is_empty() { - topics.push(remainder.to_string()); - } - - let mut item = Item::draft( - &format!("q-{prefix}-{:03}", index + 1), - level.unwrap_or(Level::Understand), - &stem, - options, - ); - item.title = question - .get("title") - .and_then(|t| t.as_str()) - .map(|s| s.to_string()); - item.topics = topics; - item.notes_private = Some(format!( - "Imported from {}. Original concept field: {concept:?}", - path.display() - )); - - if level.is_none() && !concept.is_empty() { - notes.push(format!( - "question {}: could not read a level from concept {concept:?}; defaulted to 2", - index + 1 - )); - } - if item.options.iter().all(|o| !o.correct) { - notes.push(format!( - "question {} has no correct option marked; set one before approving", - index + 1 - )); - } - - bank.items.push(item); - } - - Ok((bank, notes)) -} - -/// Splits a legacy `concept` string into a level and the remaining text. -/// -/// # Arguments -/// -/// * `concept` - e.g. `"(L4.3) explain the mechanism"`. -/// -/// # Returns -/// -/// The level, when one could be read, and the remaining description. -fn parse_legacy_concept(concept: &str) -> (Option, &str) { - let trimmed = concept.trim(); - if let Some(rest) = trimmed.strip_prefix('(') { - if let Some((inside, after)) = rest.split_once(')') { - let code = inside - .trim() - .trim_start_matches(['L', 'l']) - .split('.') - .next() - .unwrap_or(""); - if let Ok(n) = code.parse::() { - return (Level::from_code(n), after.trim()); - } - } - } - (None, trimmed) -} - -/// The `.gitignore` written by `init`. -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 -"; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn level_maps_parse() { - let map = parse_level_map(&["1=6".to_string(), "3=10".to_string()]).unwrap(); - assert_eq!(map.get(&Level::Remember), Some(&6)); - assert_eq!(map.get(&Level::Apply), Some(&10)); - assert_eq!(map.len(), 2); - } - - #[test] - fn bad_level_maps_explain_themselves() { - let err = parse_level_map(&["nonsense".to_string()]).unwrap_err(); - assert!(err.to_string().contains("LEVEL=COUNT")); - let err = parse_level_map(&["9=1".to_string()]).unwrap_err(); - assert!(err.to_string().contains("level number")); - let err = parse_level_map(&["1=many".to_string()]).unwrap_err(); - assert!(err.to_string().contains("not a count")); - } - - #[test] - fn objective_requirements_parse() { - let map = parse_string_map(&["lo-a=2".to_string(), " lo-b = 1 ".to_string()]).unwrap(); - assert_eq!(map.get("lo-a"), Some(&2)); - assert_eq!(map.get("lo-b"), Some(&1)); - } - - #[test] - fn legacy_concepts_yield_a_level() { - assert_eq!( - parse_legacy_concept("(L4.3) explain the mechanism"), - (Some(Level::Analyze), "explain the mechanism") - ); - assert_eq!( - parse_legacy_concept("(L2) something"), - (Some(Level::Understand), "something") - ); - // No level marker: keep the whole string. - assert_eq!( - parse_legacy_concept("just a description"), - (None, "just a description") - ); - assert_eq!(parse_legacy_concept(""), (None, "")); - } - - #[test] - fn truncation_keeps_the_width() { - assert_eq!(truncate("short", 10), "short"); - assert_eq!(truncate("abcdefghij", 5).chars().count(), 5); - assert!(truncate("abcdefghij", 5).ends_with('…')); - } - - #[test] - fn pseudonymizing_without_a_salt_is_refused() { - let err = read_salt(None).unwrap_err(); - assert!(err.to_string().contains("salt-file")); - assert!( - err.to_string().contains("brute force"), - "the message must explain why, not just what" - ); - } - - #[test] - fn a_short_salt_is_refused() { - let dir = std::env::temp_dir().join(format!("cb-salt-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("weak.salt"); - std::fs::write(&path, b"tooshort\n").unwrap(); - let err = read_salt(Some(&path)).unwrap_err(); - assert!(err.to_string().contains("at least 16")); - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn a_good_salt_is_read_and_trimmed() { - let dir = std::env::temp_dir().join(format!("cb-salt-ok-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("good.salt"); - std::fs::write(&path, b"0123456789abcdef0123456789abcdef\n").unwrap(); - let salt = read_salt(Some(&path)).unwrap(); - assert_eq!(salt.len(), 32, "whitespace is stripped"); - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn the_gitignore_protects_the_salt() { - assert!(GITIGNORE.contains(".coursebank-salt")); - assert!(GITIGNORE.contains("build/")); - } - - #[test] - fn the_cli_parses_a_realistic_invocation() { - let cli = Cli::try_parse_from([ - "coursebank", - "--course", - "/tmp/course", - "assemble", - "exam-4", - "--title", - "Exam 4", - "--levels", - "1=6,2=8,3=10", - "--require", - "lo-kinetics=2", - "--forms", - "2", - ]) - .unwrap(); - match cli.command { - Command::Assemble(args) => { - assert_eq!(args.id, "exam-4"); - assert_eq!(args.forms, 2); - assert_eq!(args.levels.len(), 3); - assert_eq!(args.require, vec!["lo-kinetics=2".to_string()]); - } - other => panic!("parsed as {other:?}"), - } - } - - #[test] - fn the_cli_rejects_an_unknown_subcommand() { - assert!(Cli::try_parse_from(["coursebank", "frobnicate"]).is_err()); - } -}