// 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, /// 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()); } }