Files
coursebank/src/commands/banks.rs
T
alexm 8ec48fb185
Pipeline / check (pull_request) Failing after 3m25s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped
feat: add learning objective targets
2026-09-21 14:02:34 -04:00

264 lines
9.2 KiB
Rust

// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! 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};
use coursebank::bank::BankFile;
use coursebank::date::Date;
use coursebank::error::{Error, Result};
use coursebank::history::History;
use coursebank::layout::Layout;
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 = catalog.validate_record(&record);
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, &catalog.course);
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} WHERE", "ITEM", "USES", "LAST");
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)
}
}
}