refactor: split main.rs

This commit is contained in:
2026-08-06 21:12:45 -04:00
parent fde6382617
commit eb3c510911
9 changed files with 2305 additions and 2327 deletions
+583
View File
@@ -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());
}
}
+67
View File
@@ -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),
}
}
+382
View File
@@ -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)
}
+258
View File
@@ -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)
}
}
}
+338
View File
@@ -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)
}
}
}
+269
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff