588 lines
17 KiB
Rust
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());
|
|
}
|
|
}
|