refactor: split main.rs
This commit is contained in:
+583
@@ -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<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());
|
||||
}
|
||||
}
|
||||
@@ -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<Outcome> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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<Outcome> {
|
||||
let catalog = load(cli)?;
|
||||
|
||||
let (common, mut set) = match sub {
|
||||
IngestCommand::Gradescope { dir, common } => {
|
||||
let record = load_record(&catalog, &common.assessment)?;
|
||||
let ctx = context(&catalog, &record, common)?;
|
||||
let import = gradescope::ingest_dir(dir, &ctx)?;
|
||||
|
||||
// Grading-time partial credit is an ambiguity signal worth surfacing
|
||||
// right here, while the exam is fresh.
|
||||
for question in &import.questions {
|
||||
for (letter, value, note) in question.partial_credit() {
|
||||
println!(
|
||||
"! q{}: option {letter} earned {value} of {} points at grading time{}",
|
||||
question.number,
|
||||
question.points_possible(),
|
||||
note.map(|n| format!(" — {n}")).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
(common, import.responses)
|
||||
}
|
||||
IngestCommand::Canvas { file, common } => {
|
||||
let record = load_record(&catalog, &common.assessment)?;
|
||||
let ctx = context(&catalog, &record, common)?;
|
||||
let set = canvas::ingest(file, &ctx, Some(&record), Some(&catalog))?;
|
||||
(common, set)
|
||||
}
|
||||
};
|
||||
|
||||
let record = load_record(&catalog, &common.assessment)?;
|
||||
set.enrich(&record, Some(&catalog));
|
||||
|
||||
if common.pseudonymize {
|
||||
let salt = read_salt(common.salt_file.as_deref())?;
|
||||
set.pseudonymize(&salt);
|
||||
println!("identifiers replaced with keyed pseudonyms");
|
||||
}
|
||||
|
||||
for warning in &set.warnings {
|
||||
println!("! {warning}");
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n{} response(s): {} student(s) x {} item(s)",
|
||||
set.rows.len(),
|
||||
set.students().len(),
|
||||
set.all_items().len()
|
||||
);
|
||||
|
||||
if common.dry_run {
|
||||
println!("(dry run, nothing written)");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
let mut store = Store::open(catalog.layout.data())?;
|
||||
if let Some(format) = common.format {
|
||||
store = store.with_format(format.as_format())?;
|
||||
}
|
||||
for path in store.write(&set)? {
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
println!(
|
||||
"\nNext: coursebank analyze items {}\n coursebank report cohort {}",
|
||||
common.assessment, common.assessment
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// `analyze`: classical item analysis, an IRT fit, or a per-student summary.
|
||||
///
|
||||
/// `items` returns [`Outcome::Findings`] when there is a revise queue.
|
||||
pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
|
||||
let catalog = load(cli)?;
|
||||
let store = Store::open(catalog.layout.data())?;
|
||||
|
||||
match sub {
|
||||
AnalyzeCommand::Items { id, pooled } => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let set = responses_for(&store, &catalog, &record, *pooled)?;
|
||||
let analysis =
|
||||
classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
|
||||
|
||||
for w in &analysis.warnings {
|
||||
println!("! {w}\n");
|
||||
}
|
||||
println!("{}\n", analysis.reliability.interpretation());
|
||||
println!(
|
||||
"{:>3} {:>5} {:>6} {:>6} {:>6} {}",
|
||||
"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::<Vec<_>>()
|
||||
.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
let queue = analysis.revise_queue();
|
||||
if !queue.is_empty() {
|
||||
println!("\n{} item(s) to look at, worst first:", queue.len());
|
||||
for item in queue.iter().take(10) {
|
||||
println!(
|
||||
" q{:<3} {}",
|
||||
item.number,
|
||||
item.notes.first().map(|s| s.as_str()).unwrap_or("")
|
||||
);
|
||||
}
|
||||
return Ok(Outcome::Findings);
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
AnalyzeCommand::Irt {
|
||||
id,
|
||||
model,
|
||||
no_priors,
|
||||
} => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let set = responses_for(&store, &catalog, &record, false)?;
|
||||
let mut opts = irt::Options {
|
||||
model: model.as_model(),
|
||||
..irt::Options::default()
|
||||
};
|
||||
opts.priors.enabled = !no_priors;
|
||||
|
||||
let fit = irt::fit(&set.matrix(false), &opts);
|
||||
for w in &fit.warnings {
|
||||
println!("! {w}\n");
|
||||
}
|
||||
println!(
|
||||
"{} model, {} iteration(s), {}",
|
||||
model.as_model().as_str(),
|
||||
fit.iterations,
|
||||
if fit.converged {
|
||||
"converged"
|
||||
} else {
|
||||
"did NOT converge"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
"measures most precisely near θ = {:+.1}\n",
|
||||
fit.peak_information()
|
||||
);
|
||||
|
||||
println!(
|
||||
"{:>3} {:>6} {:>7} {:>7} {:>7} {}",
|
||||
"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<Outcome> {
|
||||
let catalog = load(cli)?;
|
||||
let store = Store::open(catalog.layout.data())?;
|
||||
let opts = calibrate::Options {
|
||||
irt: !args.no_irt,
|
||||
include_practice: args.include_practice,
|
||||
minimum_n: args.min_n,
|
||||
..calibrate::Options::default()
|
||||
};
|
||||
|
||||
let plan = calibrate::plan(&catalog, &store, &opts)?;
|
||||
print!("{}", plan.render());
|
||||
|
||||
if plan.is_empty() {
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
if !args.apply {
|
||||
println!(
|
||||
"Nothing written. Re-run with --apply to write these {} change(s) into the bank \
|
||||
files, then review the git diff.",
|
||||
plan.changes.len()
|
||||
);
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
for path in calibrate::apply(&plan)? {
|
||||
println!("updated {}", path.display());
|
||||
}
|
||||
println!("\nReview the diff before committing: git diff banks/");
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// `report`: write per-student reports or the instructor's cohort item analysis.
|
||||
pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result<Outcome> {
|
||||
let catalog = load(cli)?;
|
||||
let store = Store::open(catalog.layout.data())?;
|
||||
|
||||
match sub {
|
||||
ReportCommand::Students {
|
||||
id,
|
||||
html,
|
||||
out,
|
||||
ability,
|
||||
no_comparison,
|
||||
} => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let set = responses_for(&store, &catalog, &record, false)?;
|
||||
let fit = if *ability {
|
||||
Some(irt::fit(&set.matrix(false), &irt::Options::default()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cohort = students::summarize(&set, &catalog.course, Some(&catalog), fit.as_ref());
|
||||
|
||||
let opts = report::StudentOptions {
|
||||
ability: *ability,
|
||||
comparison: !no_comparison,
|
||||
..report::StudentOptions::default()
|
||||
};
|
||||
let dir = out
|
||||
.clone()
|
||||
.unwrap_or_else(|| catalog.layout.reports().join(id));
|
||||
let written =
|
||||
report::write_all_students(&dir, &cohort, &catalog.course, &record, &opts, *html)?;
|
||||
println!(
|
||||
"wrote {} file(s) for {} student(s) in {}",
|
||||
written.len(),
|
||||
cohort.students.len(),
|
||||
dir.display()
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
ReportCommand::Cohort { id, html, out } => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let set = responses_for(&store, &catalog, &record, false)?;
|
||||
let analysis =
|
||||
classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
|
||||
let fit = irt::fit(&set.matrix(false), &irt::Options::default());
|
||||
let cohort = students::summarize(&set, &catalog.course, Some(&catalog), Some(&fit));
|
||||
|
||||
let markdown = report::cohort(&analysis, &cohort, &catalog, &record, Some(&fit));
|
||||
let path = out
|
||||
.clone()
|
||||
.unwrap_or_else(|| catalog.layout.reports().join(format!("{id}-cohort.md")));
|
||||
yaml::write_text(&path, &markdown)?;
|
||||
println!("wrote {}", path.display());
|
||||
|
||||
if *html {
|
||||
let html_path = path.with_extension("html");
|
||||
let title = format!("{} — item analysis", record.assessment.title);
|
||||
yaml::write_text(&html_path, &report::to_html(&markdown, &title))?;
|
||||
println!("wrote {}", html_path.display());
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `data`: list every administration held in the response store.
|
||||
pub(crate) fn data(cli: &Cli) -> Result<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
let store = Store::open(layout.data())?;
|
||||
let summaries = store::summarize(&store)?;
|
||||
if summaries.is_empty() {
|
||||
println!("no stored responses in {}", store.dir.display());
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!(
|
||||
"{:<40} {:>7} {:>9} {:>7} FILE",
|
||||
"ADMINISTRATION", "ROWS", "STUDENTS", "ITEMS"
|
||||
);
|
||||
for s in &summaries {
|
||||
println!(
|
||||
"{:<40} {:>7} {:>9} {:>7} {}",
|
||||
truncate(&s.administration_id, 40),
|
||||
s.rows,
|
||||
s.students,
|
||||
s.items,
|
||||
s.path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
@@ -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<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
match sub {
|
||||
BankCommand::New { id, title } => {
|
||||
let path = layout.banks().join(format!("{id}.yaml"));
|
||||
if path.exists() {
|
||||
return Err(Error::usage(format!("{} already exists", path.display())));
|
||||
}
|
||||
let bank = BankFile::skeleton(id, title.as_deref().unwrap_or(id));
|
||||
yaml::write(&path, &bank)?;
|
||||
println!("wrote {}", path.display());
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
BankCommand::List => {
|
||||
let catalog = load(cli)?;
|
||||
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for entry in &catalog.entries {
|
||||
*counts.entry(entry.bank.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
for (id, meta) in &catalog.banks {
|
||||
println!(
|
||||
"{:<20} {:>4} item(s) {}",
|
||||
id,
|
||||
counts.get(id.as_str()).copied().unwrap_or(0),
|
||||
meta.title
|
||||
);
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `assessment`: create an empty record, list records, or show one in detail.
|
||||
///
|
||||
/// `show` returns [`Outcome::Findings`] when the record has validation problems.
|
||||
pub(crate) fn assessment(cli: &Cli, sub: &AssessmentCommand) -> Result<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
match sub {
|
||||
AssessmentCommand::New { id, title, kind } => {
|
||||
let path = layout.assessments().join(format!("{id}.yaml"));
|
||||
if path.exists() {
|
||||
return Err(Error::usage(format!("{} already exists", path.display())));
|
||||
}
|
||||
let record =
|
||||
AssessmentFile::skeleton(id, title.as_deref().unwrap_or(id), kind.as_kind());
|
||||
record.save(&path)?;
|
||||
println!("wrote {}", path.display());
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
AssessmentCommand::List => {
|
||||
let records = AssessmentFile::load_all(&layout.assessments())?;
|
||||
let history = History::load(&layout.assessments())?;
|
||||
let _ = &history;
|
||||
if records.is_empty() {
|
||||
println!("no assessment records yet");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!(
|
||||
"{:<16} {:<10} {:<12} {:>6} {:>8}",
|
||||
"ID", "KIND", "DATE", "ITEMS", "POINTS"
|
||||
);
|
||||
for record in &records {
|
||||
println!(
|
||||
"{:<16} {:<10} {:<12} {:>6} {:>8.1}",
|
||||
record.assessment.id,
|
||||
record.assessment.kind.as_str(),
|
||||
record
|
||||
.assessment
|
||||
.date
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
record.items.len(),
|
||||
record.total_points(1.0)
|
||||
);
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
AssessmentCommand::Show { id } => {
|
||||
let catalog = load(cli)?;
|
||||
let record = load_record(&catalog, id)?;
|
||||
print_record(&catalog, &record);
|
||||
let issues = 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<Outcome> {
|
||||
let catalog = load(cli)?;
|
||||
let layout = &catalog.layout;
|
||||
let path = layout.assessments().join(format!("{}.yaml", args.id));
|
||||
if path.exists() && !args.force {
|
||||
return Err(Error::usage(format!(
|
||||
"{} already exists; pass --force to replace it. Replacing an administered \
|
||||
assessment invalidates the responses already stored against it",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let date = match &args.date {
|
||||
Some(s) => s.parse::<Date>()?,
|
||||
None => Date::today(),
|
||||
};
|
||||
|
||||
let blueprint = Blueprint {
|
||||
level_counts: parse_level_map(&args.levels)?,
|
||||
bonus_counts: parse_level_map(&args.bonus)?,
|
||||
objective_minimums: parse_string_map(&args.require)?,
|
||||
lectures: args.lectures.clone(),
|
||||
topics: args.topics.clone(),
|
||||
banks: args.banks.clone(),
|
||||
max_per_bank: args.max_per_bank,
|
||||
cooldown_days: Some(args.cooldown),
|
||||
seed: Some(args.seed),
|
||||
};
|
||||
|
||||
if blueprint.scored_total() == 0 {
|
||||
return Err(Error::usage(
|
||||
"no items requested; pass --levels, e.g. --levels 1=6,2=8,3=10,4=6".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let history = History::load(&layout.assessments())?;
|
||||
let selection = select::select(&catalog, &blueprint, &history, date)?;
|
||||
|
||||
let record = select::to_record(
|
||||
&catalog,
|
||||
&selection,
|
||||
&args.id,
|
||||
args.title.as_deref().unwrap_or(&args.id),
|
||||
args.kind.as_kind(),
|
||||
date,
|
||||
args.platform.as_platform(),
|
||||
&blueprint,
|
||||
args.forms.max(1),
|
||||
)?;
|
||||
|
||||
for note in &selection.notes {
|
||||
println!("! {note}");
|
||||
}
|
||||
if !selection.notes.is_empty() {
|
||||
println!();
|
||||
}
|
||||
|
||||
print_record(&catalog, &record);
|
||||
|
||||
let drift = select::check_blueprint(&record);
|
||||
if !drift.is_empty() {
|
||||
println!("\nBlueprint not fully satisfied:");
|
||||
for d in &drift {
|
||||
println!(" - {d}");
|
||||
}
|
||||
}
|
||||
|
||||
if args.dry_run {
|
||||
println!("\n(dry run, nothing written)");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
record.save(&path)?;
|
||||
println!("\nwrote {}", path.display());
|
||||
println!(
|
||||
"Next: review the draw, then\n coursebank export typst {} --form A\n \
|
||||
coursebank export qti {} --form A",
|
||||
args.id, args.id
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// `usage`: report item use history, or the approved items never used.
|
||||
pub(crate) fn usage(cli: &Cli, sub: &UsageCommand) -> Result<Outcome> {
|
||||
let catalog = load(cli)?;
|
||||
let history = History::load(&catalog.layout.assessments())?;
|
||||
|
||||
match sub {
|
||||
UsageCommand::History { item } => {
|
||||
let uids: Vec<String> = match item {
|
||||
Some(id) => vec![catalog.resolve(id)?],
|
||||
None => catalog.entries.iter().map(|e| e.uid.clone()).collect(),
|
||||
};
|
||||
println!("{:<34} {:>5} {:<12} {}", "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<String> = uses
|
||||
.iter()
|
||||
.map(|u| format!("{}#{}", u.assessment, u.number))
|
||||
.collect();
|
||||
println!(
|
||||
"{:<34} {:>5} {:<12} {}",
|
||||
truncate(&uid, 34),
|
||||
uses.len(),
|
||||
history
|
||||
.last_used(&uid)
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
where_used.join(", ")
|
||||
);
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
UsageCommand::Unused => {
|
||||
let mut unused: Vec<&str> = catalog
|
||||
.assemblable()
|
||||
.into_iter()
|
||||
.filter(|e| history.use_count(&e.uid) == 0)
|
||||
.map(|e| e.uid.as_str())
|
||||
.collect();
|
||||
unused.sort_unstable();
|
||||
if unused.is_empty() {
|
||||
println!("every approved item has been used at least once");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!("{} approved item(s) never used:", unused.len());
|
||||
for uid in unused {
|
||||
println!(" {uid}");
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Outcome> {
|
||||
let catalog = load(cli)?;
|
||||
let build = catalog.layout.build();
|
||||
|
||||
match sub {
|
||||
ExportCommand::Qti {
|
||||
id,
|
||||
form,
|
||||
out,
|
||||
no_feedback,
|
||||
} => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let form = pick_form(&record, form)?;
|
||||
let opts = qti::QtiOptions {
|
||||
form: form.clone(),
|
||||
include_feedback: !no_feedback,
|
||||
shuffle_in_canvas: record.assessment.shuffle.unwrap_or(false),
|
||||
attempts: record.assessment.attempts.unwrap_or(1),
|
||||
scoring_policy: record
|
||||
.assessment
|
||||
.scoring_policy
|
||||
.unwrap_or(coursebank::assessment::ScoringPolicy::KeepHighest),
|
||||
};
|
||||
let package = qti::build(&catalog, &record, &opts)?;
|
||||
let path = out
|
||||
.clone()
|
||||
.unwrap_or_else(|| build.join(format!("{id}-{}.zip", form.id)));
|
||||
package.write_zip(&path)?;
|
||||
println!("wrote {}", path.display());
|
||||
println!("Import in Canvas: Settings -> Import Course Content -> QTI .zip file");
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
ExportCommand::Typst {
|
||||
id,
|
||||
form,
|
||||
out,
|
||||
variant,
|
||||
template,
|
||||
json,
|
||||
dry_run,
|
||||
} => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let dir = out.clone().unwrap_or(build);
|
||||
let variants = pick_variants(variant)?;
|
||||
|
||||
if template.is_some() && variants.len() > 1 {
|
||||
return Err(Error::usage(
|
||||
"--template applies to one document, but more than one --variant was \
|
||||
requested; pass --variant exam (or key, or answer-sheet) alongside it"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let forms: Vec<Form> = if form == "all" {
|
||||
if record.forms.is_empty() {
|
||||
vec![typst::Options::default().form]
|
||||
} else {
|
||||
record.forms.clone()
|
||||
}
|
||||
} else {
|
||||
vec![pick_form(&record, form)?]
|
||||
};
|
||||
|
||||
// Read once, outside both loops: the config describes the course, not
|
||||
// the form, and re-reading it per form would let a mid-run edit make
|
||||
// form A and form B disagree.
|
||||
let config_file = typst::load_config(&catalog.layout)?;
|
||||
let mut warned = false;
|
||||
let mut used_embedded = false;
|
||||
|
||||
for f in &forms {
|
||||
for variant in &variants {
|
||||
let opts = typst::Options {
|
||||
form: f.clone(),
|
||||
variant: *variant,
|
||||
template: template.clone(),
|
||||
config: config_file.resolve(*variant),
|
||||
};
|
||||
|
||||
let rendered = typst::render(&catalog, &record, &opts)?;
|
||||
used_embedded |= rendered.origin == typst::Origin::Embedded;
|
||||
|
||||
for warning in &rendered.warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
warned = true;
|
||||
}
|
||||
|
||||
let stem = format!("{id}-{}{}", f.id, variant.suffix());
|
||||
|
||||
if *dry_run {
|
||||
println!(
|
||||
"{}: {} question(s) from {} via {}",
|
||||
stem,
|
||||
rendered.payload.questions.len(),
|
||||
rendered.origin,
|
||||
rendered
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" + ")
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = dir.join(format!("{stem}.typ"));
|
||||
yaml::write_text(&path, &rendered.text)?;
|
||||
println!("wrote {} (from {})", path.display(), rendered.origin);
|
||||
|
||||
if *json {
|
||||
let json_path = dir.join(format!("{stem}.json"));
|
||||
yaml::write_text(&json_path, &rendered.payload.to_json()?)?;
|
||||
println!("wrote {}", json_path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *dry_run {
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
if !cli.quiet {
|
||||
println!("\nCompile with: pixi run -e docs typst compile <file>.typ");
|
||||
if used_embedded {
|
||||
println!(
|
||||
"Some of these used a built-in template. To take over the layout:\n \
|
||||
coursebank template dump"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if warned {
|
||||
Outcome::Findings
|
||||
} else {
|
||||
Outcome::Ok
|
||||
})
|
||||
}
|
||||
ExportCommand::Md { id, with_key, out } => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let markdown = markdown_export(&catalog, &record, *with_key)?;
|
||||
let path = out
|
||||
.clone()
|
||||
.unwrap_or_else(|| build.join(format!("{id}.md")));
|
||||
yaml::write_text(&path, &markdown)?;
|
||||
println!("wrote {}", path.display());
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the `--variant` flags, defaulting to every document.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `names` - the raw flag values, possibly empty.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The variants, deduplicated and in canonical order so that
|
||||
/// `--variant key --variant exam` still writes the paper first.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Usage`] naming the valid tokens.
|
||||
fn pick_variants(names: &[String]) -> Result<Vec<typst::Variant>> {
|
||||
if names.is_empty() {
|
||||
return Ok(typst::Variant::ALL.to_vec());
|
||||
}
|
||||
let mut wanted = Vec::new();
|
||||
for name in names {
|
||||
let variant = typst::Variant::parse(name)?;
|
||||
if !wanted.contains(&variant) {
|
||||
wanted.push(variant);
|
||||
}
|
||||
}
|
||||
// Canonical order, not the order they were typed.
|
||||
Ok(typst::Variant::ALL
|
||||
.into_iter()
|
||||
.filter(|v| wanted.contains(v))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `template`: list the template lookup, dump the built-ins to edit, or write and
|
||||
/// inspect the render configuration.
|
||||
pub(crate) fn template(cli: &Cli, sub: &TemplateCommand) -> Result<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
|
||||
match sub {
|
||||
TemplateCommand::List { assessment } => {
|
||||
let id = assessment.as_deref();
|
||||
println!("Templates are looked up in this order, first match wins:\n");
|
||||
|
||||
for variant in typst::Variant::ALL {
|
||||
println!("{}", variant.as_str());
|
||||
let mut resolved = false;
|
||||
for path in typst::template::candidates(&layout, variant, id) {
|
||||
let present = path.is_file();
|
||||
let mark = if present && !resolved {
|
||||
resolved = true;
|
||||
"->"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let state = if present { "" } else { " (absent)" };
|
||||
println!(" {mark} {}{state}", path.display());
|
||||
}
|
||||
let mark = if resolved { " " } else { "->" };
|
||||
println!(" {mark} built-in");
|
||||
|
||||
// Reporting the slots requires parsing, and a template with broken
|
||||
// markers should be named here rather than at export time.
|
||||
match typst::template::load(&layout, variant, id, None) {
|
||||
Ok(template) => {
|
||||
let slots: Vec<&str> =
|
||||
template.slots().iter().map(|s| s.as_str()).collect();
|
||||
if slots.is_empty() {
|
||||
println!(" slots: none — this template injects nothing");
|
||||
} else {
|
||||
println!(" slots: {}", slots.join(", "));
|
||||
}
|
||||
}
|
||||
Err(e) => println!(" unusable: {e}"),
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
let config = typst::config_path(&layout);
|
||||
if config.is_file() {
|
||||
println!("Config: {}", config.display());
|
||||
} else {
|
||||
println!(
|
||||
"Config: none ({} is absent, so built-in defaults apply)",
|
||||
config.display()
|
||||
);
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
TemplateCommand::Dump {
|
||||
variant,
|
||||
out,
|
||||
force,
|
||||
stdout,
|
||||
} => {
|
||||
let variants = pick_variants(variant)?;
|
||||
|
||||
if *stdout {
|
||||
for (index, v) in variants.iter().enumerate() {
|
||||
if index > 0 {
|
||||
println!();
|
||||
}
|
||||
if variants.len() > 1 {
|
||||
println!("// ── {} ──", v.template_file());
|
||||
}
|
||||
print!("{}", typst::template::embedded(*v));
|
||||
}
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
let dir = out.clone().unwrap_or_else(|| layout.templates());
|
||||
let (written, skipped) = typst::template::dump(&dir, &variants, *force)?;
|
||||
|
||||
for path in &written {
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
for path in &skipped {
|
||||
println!(
|
||||
"kept {} (already exists; --force to overwrite)",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !written.is_empty() && !cli.quiet {
|
||||
println!(
|
||||
"\nThese are yours to edit. Only the marked regions are replaced on \
|
||||
export,\nso restyle freely:\n typst watch {}",
|
||||
dir.join(typst::Variant::Exam.template_file()).display()
|
||||
);
|
||||
}
|
||||
// Skipped files are worth an exit code: a script that expected to
|
||||
// refresh them did not.
|
||||
Ok(if skipped.is_empty() {
|
||||
Outcome::Ok
|
||||
} else {
|
||||
Outcome::Findings
|
||||
})
|
||||
}
|
||||
|
||||
TemplateCommand::Config {
|
||||
resolved,
|
||||
out,
|
||||
force,
|
||||
} => {
|
||||
if let Some(name) = resolved {
|
||||
let variant = typst::Variant::parse(name)?;
|
||||
let config_file = typst::load_config(&layout)?;
|
||||
let config = config_file.resolve(variant);
|
||||
println!(
|
||||
"# Resolved configuration for `{}`, after every layer.",
|
||||
variant.as_str()
|
||||
);
|
||||
print!("{}", config.to_yaml()?);
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
let path = out.clone().unwrap_or_else(|| typst::config_path(&layout));
|
||||
if path.exists() && !force {
|
||||
return Err(Error::usage(format!(
|
||||
"{} already exists; pass --force to overwrite it, or --resolved <variant> to \
|
||||
see what it currently produces",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
yaml::write_text(&path, typst::CONFIG_TEMPLATE)?;
|
||||
println!("wrote {}", path.display());
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
let course_path = layout.course_file();
|
||||
if course_path.exists() {
|
||||
return Err(Error::usage(format!(
|
||||
"{} already exists; refusing to overwrite it",
|
||||
course_path.display()
|
||||
)));
|
||||
}
|
||||
layout.create_all()?;
|
||||
|
||||
let course = CourseFile::skeleton(&args.code, &args.title, &args.term);
|
||||
course.save(&course_path)?;
|
||||
println!("wrote {}", course_path.display());
|
||||
|
||||
let schemas = jsonschema::write_all(&layout.schema())?;
|
||||
println!("wrote {} JSON Schema files", schemas.len());
|
||||
|
||||
if args.with_examples {
|
||||
let bank = BankFile::skeleton("example", "Example bank");
|
||||
let bank_path = layout.banks().join("example.yaml");
|
||||
yaml::write(&bank_path, &bank)?;
|
||||
println!("wrote {}", bank_path.display());
|
||||
}
|
||||
|
||||
yaml::write_text(&cli.course.join(".gitignore"), GITIGNORE)?;
|
||||
println!(
|
||||
"\nNext: edit {} to add your learning objectives and lectures, then\n \
|
||||
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
|
||||
COURSE_FILE
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// `schema`: (re)write the JSON Schemas an editor uses to validate the YAML.
|
||||
pub(crate) fn schema(cli: &Cli) -> Result<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
let written = jsonschema::write_all(&layout.schema())?;
|
||||
for path in &written {
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
println!(
|
||||
"\nAdd this to the top of a bank file so your editor validates as you type:\n {}",
|
||||
jsonschema::Kind::Bank.modeline("../.coursebank/schema")
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// `validate`: check every bank and assessment record for hard problems.
|
||||
///
|
||||
/// Returns [`Outcome::Findings`] when anything is wrong so CI can fail on it.
|
||||
pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
|
||||
let catalog = load(cli)?;
|
||||
let issues = catalog.validate()?;
|
||||
|
||||
let records = AssessmentFile::load_all(&catalog.layout.assessments())?;
|
||||
let mut all = issues;
|
||||
for record in &records {
|
||||
for issue in 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<Outcome> {
|
||||
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 <code>, or see them all with --list-rules.");
|
||||
|
||||
if args.no_fail {
|
||||
Ok(Outcome::Ok)
|
||||
} else {
|
||||
Ok(Outcome::Findings)
|
||||
}
|
||||
}
|
||||
|
||||
/// `catalog`: summarize the pool — counts by status, level, optionally topic, and
|
||||
/// optionally per-objective coverage with its gaps.
|
||||
pub(crate) fn catalog(cli: &Cli, args: &CatalogArgs) -> Result<Outcome> {
|
||||
let catalog = load(cli)?;
|
||||
|
||||
println!(
|
||||
"{} — {} ({})",
|
||||
catalog.course.course.code, catalog.course.course.title, catalog.course.course.term
|
||||
);
|
||||
println!(
|
||||
"{} item(s) across {} bank(s); {} assemblable\n",
|
||||
catalog.entries.len(),
|
||||
catalog.banks.len(),
|
||||
catalog.assemblable().len()
|
||||
);
|
||||
|
||||
println!("By status:");
|
||||
for (status, count) in catalog.status_counts() {
|
||||
println!(" {:<16} {count}", status.as_str());
|
||||
}
|
||||
|
||||
println!("\nBy level:");
|
||||
for level in Level::ALL {
|
||||
let count = catalog.level_counts().get(&level).copied().unwrap_or(0);
|
||||
println!(" {} {:<12} {count}", level.code(), level.name());
|
||||
}
|
||||
|
||||
if args.topics {
|
||||
println!("\nBy topic:");
|
||||
for (topic, count) in catalog.topics() {
|
||||
println!(" {topic:<30} {count}");
|
||||
}
|
||||
}
|
||||
|
||||
if args.coverage {
|
||||
let coverage = catalog.coverage();
|
||||
println!("\nObjective coverage:");
|
||||
println!(
|
||||
" {:<40} {:>6} {:>6} {}",
|
||||
"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/"));
|
||||
}
|
||||
}
|
||||
+379
@@ -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<Catalog> {
|
||||
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<AssessmentFile> {
|
||||
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<Form> {
|
||||
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::<Vec<_>>()
|
||||
.join(", ")
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the ingest context from a record and the command line.
|
||||
pub(crate) fn context(
|
||||
catalog: &Catalog,
|
||||
record: &AssessmentFile,
|
||||
common: &IngestCommon,
|
||||
) -> Result<gradescope::Context> {
|
||||
let date = match &common.date {
|
||||
Some(s) => Some(s.parse::<Date>()?),
|
||||
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<ResponseSet> {
|
||||
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<Vec<u8>> {
|
||||
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<u8> = 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<BTreeMap<Level, usize>> {
|
||||
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<BTreeMap<String, usize>> {
|
||||
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<String> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
+4
-8
@@ -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;
|
||||
|
||||
+25
-2319
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user