@@ -0,0 +1,386 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! 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::error::Result;
|
||||
use coursebank::gradescope;
|
||||
use coursebank::irt;
|
||||
use coursebank::layout::Layout;
|
||||
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} FLAGS",
|
||||
"Q", "p", "r", "D", "blank"
|
||||
);
|
||||
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} NOTES",
|
||||
"Q", "a", "b", "SE(a)", "SE(b)"
|
||||
);
|
||||
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,263 @@
|
||||
// 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);
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! 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::error::{Error, Result};
|
||||
use coursebank::layout::Layout;
|
||||
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,274 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! 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::{COURSE_FILE, CourseFile};
|
||||
use coursebank::error::{Error, Result};
|
||||
use coursebank::jsonschema;
|
||||
use coursebank::layout::Layout;
|
||||
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 catalog.validate_record(record) {
|
||||
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} WHAT IT CATCHES", "CODE", "SEVERITY");
|
||||
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} MAX LEVEL",
|
||||
"OBJECTIVE", "ITEMS", "READY"
|
||||
);
|
||||
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/"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user