Files
coursebank/src/cli.rs
T
alexm abc0bdf621
Sync README to GitHub / sync (push) Successful in 12s
CI / check (push) Successful in 8m31s
Deploy docs / deploy (push) Successful in 6m44s
Nightly / nightly (push) Successful in 10m33s
Dev (#1)
Reviewed-on: #1
2026-08-07 15:48:11 -04:00

588 lines
17 KiB
Rust

// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! 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<String>,
/// Skip these rule codes.
#[arg(long, value_delimiter = ',')]
pub(crate) ignore: Vec<String>,
/// 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<String>,
},
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// Bonus items per level, same syntax.
#[arg(long, value_delimiter = ',')]
pub(crate) bonus: Vec<String>,
/// Minimum items per objective, e.g. --require lo-kinetics=2.
#[arg(long, value_delimiter = ',')]
pub(crate) require: Vec<String>,
/// Restrict the draw to these lectures.
#[arg(long, value_delimiter = ',')]
pub(crate) lectures: Vec<String>,
/// Restrict the draw to these topics.
#[arg(long, value_delimiter = ',')]
pub(crate) topics: Vec<String>,
/// Restrict the draw to these banks.
#[arg(long, value_delimiter = ',')]
pub(crate) banks: Vec<String>,
/// At most this many items from any one bank.
#[arg(long)]
pub(crate) max_per_bank: Option<usize>,
/// 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<String>,
},
/// 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/<id>-<form>.zip.
#[arg(long)]
out: Option<PathBuf>,
/// 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<PathBuf>,
/// Which documents to write; defaults to all three.
#[arg(long, value_name = "VARIANT")]
variant: Vec<String>,
/// 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<PathBuf>,
/// 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/<id>.md.
#[arg(long)]
out: Option<PathBuf>,
},
}
#[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<String>,
},
/// 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<String>,
/// Destination directory; defaults to templates/.
#[arg(long)]
out: Option<PathBuf>,
/// 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<String>,
/// Destination path; defaults to templates/typst.yaml.
#[arg(long)]
out: Option<PathBuf>,
/// 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<String>,
/// Which form, if forms were used.
#[arg(long)]
pub(crate) form: Option<String>,
/// 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<PathBuf>,
/// Storage format.
#[arg(long, value_enum)]
pub(crate) format: Option<FormatArg>,
/// 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/<id>/.
#[arg(long)]
out: Option<PathBuf>,
/// 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/<id>-cohort.md.
#[arg(long)]
out: Option<PathBuf>,
},
}
#[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());
}
}