Dev #1

Merged
alexm merged 9 commits from dev into main 2026-08-07 15:48:12 -04:00
30 changed files with 453 additions and 413 deletions
Showing only changes of commit f1288e155e - Show all commits
+2 -2
View File
@@ -1,8 +1,8 @@
[package] [package]
name = "coursebank" name = "coursebank"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
rust-version = "1.75" rust-version = "1.85"
description = "Author, assemble, administer, and analyze leveled course assessments from YAML item banks" description = "Author, assemble, administer, and analyze leveled course assessments from YAML item banks"
authors = ["Alex Maldonado <alexm@scient.ing>"] authors = ["Alex Maldonado <alexm@scient.ing>"]
license = "MIT" license = "MIT"
+2 -1
View File
@@ -226,7 +226,8 @@ Assembling a form programmatically:
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::Path; use std::path::Path;
use coursebank::assessment::{Blueprint, History}; use coursebank::assessment::Blueprint;
use coursebank::history::History;
use coursebank::date::Date; use coursebank::date::Date;
use coursebank::{select, Catalog, Level}; use coursebank::{select, Catalog, Level};
+9 -7
View File
@@ -622,7 +622,7 @@ pub fn apply(plan: &Plan) -> Result<Vec<PathBuf>> {
"{} — the bank changed since the plan was built; re-run calibration", "{} — the bank changed since the plan was built; re-run calibration",
path.display() path.display()
)), )),
}) });
} }
} }
} }
@@ -794,12 +794,14 @@ mod tests {
next.flags = vec![Flag::NegativeDiscrimination]; next.flags = vec![Flag::NegativeDiscrimination];
let diff = diff_calibration(Some(&previous), &next); let diff = diff_calibration(Some(&previous), &next);
assert!(diff assert!(
.iter() diff.iter()
.any(|d| d.contains("flags added") && d.contains("negative_discrimination"))); .any(|d| d.contains("flags added") && d.contains("negative_discrimination"))
assert!(diff );
.iter() assert!(
.any(|d| d.contains("flags cleared") && d.contains("too_easy"))); diff.iter()
.any(|d| d.contains("flags cleared") && d.contains("too_easy"))
);
} }
#[test] #[test]
+16 -15
View File
@@ -197,11 +197,7 @@ impl ItemFit {
// For the 3PL this reduces to the 2PL form when c = 0. // For the 3PL this reduces to the 2PL form when c = 0.
let num = self.a * self.a * (p - c) * (p - c) * (1.0 - p); let num = self.a * self.a * (p - c) * (p - c) * (1.0 - p);
let den = (1.0 - c) * (1.0 - c) * p; let den = (1.0 - c) * (1.0 - c) * p;
if den <= 0.0 { if den <= 0.0 { 0.0 } else { num / den }
0.0
} else {
num / den
}
} }
} }
@@ -424,7 +420,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
let mut a = vec![1.0f64; j_count]; let mut a = vec![1.0f64; j_count];
let mut b = vec![0.0f64; j_count]; let mut b = vec![0.0f64; j_count];
let mut c = vec![0.0f64; j_count]; let mut c = vec![0.0f64; j_count];
for j in 0..j_count { for (j, b_j) in b.iter_mut().enumerate() {
let column = matrix.column(j); let column = matrix.column(j);
let answered: Vec<u8> = column.into_iter().flatten().collect(); let answered: Vec<u8> = column.into_iter().flatten().collect();
if answered.is_empty() { if answered.is_empty() {
@@ -434,7 +430,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
let clamped = p.clamp(0.03, 0.97); let clamped = p.clamp(0.03, 0.97);
// Inverse logistic of the p-value, which is the difficulty a Rasch model // Inverse logistic of the p-value, which is the difficulty a Rasch model
// implies when discrimination is one. // implies when discrimination is one.
b[j] = -(clamped / (1.0 - clamped)).ln(); *b_j = -(clamped / (1.0 - clamped)).ln();
} }
let mut iterations = 0usize; let mut iterations = 0usize;
@@ -1102,8 +1098,10 @@ mod tests {
#[test] #[test]
fn rasch_fixes_discrimination_at_one() { fn rasch_fixes_discrimination_at_one() {
let mut opts = Options::default(); let opts = Options {
opts.model = IrtModel::Rasch; model: IrtModel::Rasch,
..Default::default()
};
let f = fit(&ordered_matrix(), &opts); let f = fit(&ordered_matrix(), &opts);
assert!(f.items.iter().all(|i| (i.a - 1.0).abs() < 1e-12)); assert!(f.items.iter().all(|i| (i.a - 1.0).abs() < 1e-12));
assert!(f.items.iter().all(|i| i.c.is_none())); assert!(f.items.iter().all(|i| i.c.is_none()));
@@ -1111,14 +1109,17 @@ mod tests {
#[test] #[test]
fn three_pl_reports_a_lower_asymptote_and_warns() { fn three_pl_reports_a_lower_asymptote_and_warns() {
let mut opts = Options::default(); let opts = Options {
opts.model = IrtModel::ThreePl; model: IrtModel::ThreePl,
..Default::default()
};
let f = fit(&ordered_matrix(), &opts); let f = fit(&ordered_matrix(), &opts);
assert!(f.items.iter().all(|i| i.c.is_some())); assert!(f.items.iter().all(|i| i.c.is_some()));
assert!(f assert!(
.items f.items
.iter() .iter()
.all(|i| i.c.unwrap() >= 0.0 && i.c.unwrap() <= 0.4)); .all(|i| i.c.unwrap() >= 0.0 && i.c.unwrap() <= 0.4)
);
assert!(f.warnings.iter().any(|w| w.contains("1000"))); assert!(f.warnings.iter().any(|w| w.contains("1000")));
} }
+1 -1
View File
@@ -883,7 +883,7 @@ pub fn cluster(students: &[StudentSummary], k: usize) -> Vec<Archetype> {
level_means, level_means,
}); });
} }
out.sort_by(|a, b| b.members.len().cmp(&a.members.len())); out.sort_by_key(|b| std::cmp::Reverse(b.members.len()));
out out
} }
+6 -4
View File
@@ -13,7 +13,7 @@
//! generic derivation would emit as bare strings. The crate's own validation //! generic derivation would emit as bare strings. The crate's own validation
//! remains authoritative; the schema is for the editor. //! remains authoritative; the schema is for the editor.
use serde_json::{json, Value}; use serde_json::{Value, json};
use crate::course::SCHEMA_VERSION; use crate::course::SCHEMA_VERSION;
use crate::error::Result; use crate::error::Result;
@@ -998,9 +998,11 @@ mod tests {
"# yaml-language-server: $schema=../.coursebank/schema/bank.schema.json" "# yaml-language-server: $schema=../.coursebank/schema/bank.schema.json"
); );
// A trailing slash must not double up. // A trailing slash must not double up.
assert!(Kind::Course assert!(
.modeline("schema/") Kind::Course
.ends_with("schema/course.schema.json")); .modeline("schema/")
.ends_with("schema/course.schema.json")
);
} }
#[test] #[test]
+13 -13
View File
@@ -1058,11 +1058,7 @@ fn jaccard(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f64 {
} }
let inter = a.intersection(b).count() as f64; let inter = a.intersection(b).count() as f64;
let union = a.union(b).count() as f64; let union = a.union(b).count() as f64;
if union == 0.0 { if union == 0.0 { 0.0 } else { inter / union }
0.0
} else {
inter / union
}
} }
/// The Flesch-Kincaid grade level of a passage. /// The Flesch-Kincaid grade level of a passage.
@@ -1313,8 +1309,9 @@ options:
#[test] #[test]
fn stem_without_a_task_is_flagged_but_completions_are_not() { fn stem_without_a_task_is_flagged_but_completions_are_not() {
assert!(codes( assert!(
r#" codes(
r#"
id: q-a-001 id: q-a-001
status: draft status: draft
level: 1 level: 1
@@ -1323,11 +1320,13 @@ options:
- { id: A, text: aaaa, correct: true } - { id: A, text: aaaa, correct: true }
- { id: B, text: bbbb } - { id: B, text: bbbb }
"# "#
) )
.contains(&"clarity-no-task")); .contains(&"clarity-no-task")
);
assert!(!codes( assert!(
r#" !codes(
r#"
id: q-a-001 id: q-a-001
status: draft status: draft
level: 1 level: 1
@@ -1336,8 +1335,9 @@ options:
- { id: A, text: aaaa, correct: true } - { id: A, text: aaaa, correct: true }
- { id: B, text: bbbb } - { id: B, text: bbbb }
"# "#
) )
.contains(&"clarity-no-task")); .contains(&"clarity-no-task")
);
} }
#[test] #[test]
+10 -12
View File
@@ -23,13 +23,12 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use crate::assessment::{ use crate::assessment::{Assessment, AssessmentFile, Blueprint, Form, Kind, Placement, Platform};
Assessment, AssessmentFile, Blueprint, Form, History, Kind, Placement, Platform,
};
use crate::catalog::Catalog; use crate::catalog::Catalog;
use crate::course::SCHEMA_VERSION; use crate::course::SCHEMA_VERSION;
use crate::date::Date; use crate::date::Date;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::history::History;
use crate::rng::Rng; use crate::rng::Rng;
use crate::taxonomy::Level; use crate::taxonomy::Level;
@@ -444,14 +443,14 @@ pub fn to_record(
) -> Result<AssessmentFile> { ) -> Result<AssessmentFile> {
let default_points = catalog.course.policy.points_per_item; let default_points = catalog.course.policy.points_per_item;
let mut items = Vec::new(); let mut items = Vec::new();
let mut number = 1u32;
for (uid, is_bonus) in selection for (number, (uid, is_bonus)) in (1u32..).zip(
.scored selection
.iter() .scored
.map(|u| (u, false)) .iter()
.chain(selection.bonus.iter().map(|u| (u, true))) .map(|u| (u, false))
{ .chain(selection.bonus.iter().map(|u| (u, true))),
) {
let e = catalog.require(uid)?; let e = catalog.require(uid)?;
items.push(Placement { items.push(Placement {
number, number,
@@ -466,7 +465,6 @@ pub fn to_record(
credit_overrides: BTreeMap::new(), credit_overrides: BTreeMap::new(),
dropped: false, dropped: false,
}); });
number += 1;
} }
let form_list: Vec<Form> = (0..forms) let form_list: Vec<Form> = (0..forms)
@@ -553,7 +551,7 @@ pub fn layout(record: &AssessmentFile, form: &Form) -> Vec<Placement> {
rng.shuffle(&mut scored); rng.shuffle(&mut scored);
} }
scored.into_iter().chain(bonus.into_iter()).collect() scored.into_iter().chain(bonus).collect()
} }
/// The option order for one item on one form. /// The option order for one item on one form.
+5 -5
View File
@@ -8,10 +8,10 @@
use coursebank::calibrate; use coursebank::calibrate;
use coursebank::canvas; use coursebank::canvas;
use coursebank::classical::{self, Thresholds}; use coursebank::classical::{self, Thresholds};
use coursebank::course::Layout;
use coursebank::error::Result; use coursebank::error::Result;
use coursebank::gradescope; use coursebank::gradescope;
use coursebank::irt; use coursebank::irt;
use coursebank::layout::Layout;
use coursebank::report; use coursebank::report;
use coursebank::store::{self, Store}; use coursebank::store::{self, Store};
use coursebank::students; use coursebank::students;
@@ -114,8 +114,8 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
} }
println!("{}\n", analysis.reliability.interpretation()); println!("{}\n", analysis.reliability.interpretation());
println!( println!(
"{:>3} {:>5} {:>6} {:>6} {:>6} {}", "{:>3} {:>5} {:>6} {:>6} {:>6} FLAGS",
"Q", "p", "r", "D", "blank", "FLAGS" "Q", "p", "r", "D", "blank"
); );
for item in &analysis.items { for item in &analysis.items {
println!( println!(
@@ -184,8 +184,8 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
); );
println!( println!(
"{:>3} {:>6} {:>7} {:>7} {:>7} {}", "{:>3} {:>6} {:>7} {:>7} {:>7} NOTES",
"Q", "a", "b", "SE(a)", "SE(b)", "NOTES" "Q", "a", "b", "SE(a)", "SE(b)"
); );
for item in &fit.items { for item in &fit.items {
println!( println!(
+5 -4
View File
@@ -6,11 +6,12 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use coursebank::assessment::{AssessmentFile, Blueprint, History}; use coursebank::assessment::{AssessmentFile, Blueprint};
use coursebank::bank::BankFile; use coursebank::bank::BankFile;
use coursebank::course::Layout;
use coursebank::date::Date; use coursebank::date::Date;
use coursebank::error::{Error, Result}; use coursebank::error::{Error, Result};
use coursebank::history::History;
use coursebank::layout::Layout;
use coursebank::select; use coursebank::select;
use coursebank::yaml; use coursebank::yaml;
@@ -102,7 +103,7 @@ pub(crate) fn assessment(cli: &Cli, sub: &AssessmentCommand) -> Result<Outcome>
let catalog = load(cli)?; let catalog = load(cli)?;
let record = load_record(&catalog, id)?; let record = load_record(&catalog, id)?;
print_record(&catalog, &record); print_record(&catalog, &record);
let issues = record.validate(Some(&catalog)); let issues = catalog.validate_record(&record);
if !issues.is_empty() { if !issues.is_empty() {
println!("\n{} problem(s):", issues.len()); println!("\n{} problem(s):", issues.len());
for issue in &issues { for issue in &issues {
@@ -213,7 +214,7 @@ pub(crate) fn usage(cli: &Cli, sub: &UsageCommand) -> Result<Outcome> {
Some(id) => vec![catalog.resolve(id)?], Some(id) => vec![catalog.resolve(id)?],
None => catalog.entries.iter().map(|e| e.uid.clone()).collect(), None => catalog.entries.iter().map(|e| e.uid.clone()).collect(),
}; };
println!("{:<34} {:>5} {:<12} {}", "ITEM", "USES", "LAST", "WHERE"); println!("{:<34} {:>5} {:<12} WHERE", "ITEM", "USES", "LAST");
for uid in uids { for uid in uids {
let uses = history.for_item(&uid); let uses = history.for_item(&uid);
if uses.is_empty() && item.is_none() { if uses.is_empty() && item.is_none() {
+1 -1
View File
@@ -6,8 +6,8 @@
//! [`pick_variants`], which resolves the `--variant` flags into a canonical list. //! [`pick_variants`], which resolves the `--variant` flags into a canonical list.
use coursebank::assessment::Form; use coursebank::assessment::Form;
use coursebank::course::Layout;
use coursebank::error::{Error, Result}; use coursebank::error::{Error, Result};
use coursebank::layout::Layout;
use coursebank::qti; use coursebank::qti;
use coursebank::typst; use coursebank::typst;
use coursebank::yaml; use coursebank::yaml;
+6 -5
View File
@@ -9,9 +9,10 @@ use std::collections::BTreeMap;
use coursebank::assessment::AssessmentFile; use coursebank::assessment::AssessmentFile;
use coursebank::bank::BankFile; use coursebank::bank::BankFile;
use coursebank::course::{CourseFile, Layout, COURSE_FILE}; use coursebank::course::{COURSE_FILE, CourseFile};
use coursebank::error::{Error, Result}; use coursebank::error::{Error, Result};
use coursebank::jsonschema; use coursebank::jsonschema;
use coursebank::layout::Layout;
use coursebank::lint::{self, Rule}; use coursebank::lint::{self, Rule};
use coursebank::taxonomy::Level; use coursebank::taxonomy::Level;
use coursebank::yaml; use coursebank::yaml;
@@ -98,7 +99,7 @@ pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
let records = AssessmentFile::load_all(&catalog.layout.assessments())?; let records = AssessmentFile::load_all(&catalog.layout.assessments())?;
let mut all = issues; let mut all = issues;
for record in &records { for record in &records {
for issue in record.validate(Some(&catalog)) { for issue in catalog.validate_record(record) {
all.push(format!("{}: {issue}", record.assessment.id)); all.push(format!("{}: {issue}", record.assessment.id));
} }
} }
@@ -128,7 +129,7 @@ pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
/// [`Outcome::Findings`] when anything fires, unless `--no-fail` was given. /// [`Outcome::Findings`] when anything fires, unless `--no-fail` was given.
pub(crate) fn lint(cli: &Cli, args: &LintArgs) -> Result<Outcome> { pub(crate) fn lint(cli: &Cli, args: &LintArgs) -> Result<Outcome> {
if args.list_rules { if args.list_rules {
println!("{:<32} {:<8} {}", "CODE", "SEVERITY", "WHAT IT CATCHES"); println!("{:<32} {:<8} WHAT IT CATCHES", "CODE", "SEVERITY");
for rule in Rule::ALL { for rule in Rule::ALL {
println!( println!(
"{:<32} {:<8} {}", "{:<32} {:<8} {}",
@@ -230,8 +231,8 @@ pub(crate) fn catalog(cli: &Cli, args: &CatalogArgs) -> Result<Outcome> {
let coverage = catalog.coverage(); let coverage = catalog.coverage();
println!("\nObjective coverage:"); println!("\nObjective coverage:");
println!( println!(
" {:<40} {:>6} {:>6} {}", " {:<40} {:>6} {:>6} MAX LEVEL",
"OBJECTIVE", "ITEMS", "READY", "MAX LEVEL" "OBJECTIVE", "ITEMS", "READY"
); );
for row in &coverage.rows { for row in &coverage.rows {
println!( println!(
+9 -10
View File
@@ -26,7 +26,7 @@ use crate::assessment::AssessmentFile;
use crate::catalog::Catalog; use crate::catalog::Catalog;
use crate::date::Date; use crate::date::Date;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::responses::{administration_id, Response, ResponseSet}; use crate::responses::{Response, ResponseSet, administration_id};
/// What the ingest needs that the export does not carry. /// What the ingest needs that the export does not carry.
pub type Context = crate::gradescope::Context; pub type Context = crate::gradescope::Context;
@@ -120,11 +120,9 @@ pub fn normalize(s: &str) -> String {
if c.is_alphanumeric() { if c.is_alphanumeric() {
out.push(c); out.push(c);
last_space = false; last_space = false;
} else if c.is_whitespace() || c == '-' || c == '_' { } else if (c.is_whitespace() || c == '-' || c == '_') && !last_space {
if !last_space { out.push(' ');
out.push(' '); last_space = true;
last_space = true;
}
} }
// Everything else — punctuation, entity leftovers — is dropped. // Everything else — punctuation, entity leftovers — is dropped.
} }
@@ -579,10 +577,11 @@ mod tests {
// Two real students, two questions each. The preview row is discarded. // Two real students, two questions each. The preview row is discarded.
assert_eq!(set.rows.len(), 4, "{:?}", set.warnings); assert_eq!(set.rows.len(), 4, "{:?}", set.warnings);
assert!(set assert!(
.rows set.rows
.iter() .iter()
.all(|r| r.name.as_deref() != Some("Test Student"))); .all(|r| r.name.as_deref() != Some("Test Student"))
);
assert_eq!(set.scored_total("1234567"), 2.0); assert_eq!(set.scored_total("1234567"), 2.0);
assert_eq!(set.scored_total("7654321"), 1.0); assert_eq!(set.scored_total("7654321"), 1.0);
+2 -2
View File
@@ -45,7 +45,7 @@ use std::path::{Path, PathBuf};
use crate::date::Date; use crate::date::Date;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::responses::{administration_id, Response, ResponseSet}; use crate::responses::{Response, ResponseSet, administration_id};
use crate::taxonomy::Flag; use crate::taxonomy::Flag;
/// What a rubric column represents. /// What a rubric column represents.
@@ -358,7 +358,7 @@ pub fn parse_question(path: &Path) -> Result<Question> {
"{} has no `Submission Time` column, so the rubric columns cannot be located; \ "{} has no `Submission Time` column, so the rubric columns cannot be located; \
is this a Gradescope per-question export?", is this a Gradescope per-question export?",
path.display() path.display()
)])) )]));
} }
}; };
let end = index_of("Adjustment").unwrap_or(header.len()); let end = index_of("Adjustment").unwrap_or(header.len());
+1 -1
View File
@@ -17,8 +17,8 @@ use std::sync::Arc;
use arrow_array::{ArrayRef, BooleanArray, Float64Array, RecordBatch, StringArray, UInt32Array}; use arrow_array::{ArrayRef, BooleanArray, Float64Array, RecordBatch, StringArray, UInt32Array};
use arrow_schema::{DataType, Field, Schema}; use arrow_schema::{DataType, Field, Schema};
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use parquet::arrow::ArrowWriter; use parquet::arrow::ArrowWriter;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use parquet::basic::Compression; use parquet::basic::Compression;
use parquet::file::properties::WriterProperties; use parquet::file::properties::WriterProperties;
+1 -5
View File
@@ -145,11 +145,7 @@ pub fn student(
out.push_str("## What this exam says about each learning objective\n\n"); out.push_str("## What this exam says about each learning objective\n\n");
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n"); out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
for o in &summary.objectives { for o in &summary.objectives {
let you = if o.status == Mastery::NotEnoughEvidence { let you = format!("{:.0}%", o.rate * 100.0);
format!("{:.0}%", o.rate * 100.0)
} else {
format!("{:.0}%", o.rate * 100.0)
};
out.push_str(&format!( out.push_str(&format!(
"| {} | {} | {} | {:.0}% | {} |\n", "| {} | {} | {} | {:.0}% | {} |\n",
o.status.symbol(), o.status.symbol(),
+3 -3
View File
@@ -53,16 +53,16 @@ pub mod value;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub use config::{ pub use config::{
ConfigFile, ContentMode, Fields, LetterStyle, Overrides, RenderConfig, Reveal, StimulusMode, CONFIG_FILE, CONFIG_TEMPLATE, ConfigFile, ContentMode, Fields, LetterStyle, Overrides,
Variant, CONFIG_FILE, CONFIG_TEMPLATE, RenderConfig, Reveal, StimulusMode, Variant,
}; };
pub use payload::Payload; pub use payload::Payload;
pub use template::{Origin, Slot, Template}; pub use template::{Origin, Slot, Template};
pub use value::Value; pub use value::Value;
use crate::Layout;
use crate::assessment::{AssessmentFile, Form}; use crate::assessment::{AssessmentFile, Form};
use crate::catalog::Catalog; use crate::catalog::Catalog;
use crate::course::Layout;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
/// What to render. /// What to render.
+6 -4
View File
@@ -55,7 +55,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::course::Layout; use crate::Layout;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use super::config::Variant; use super::config::Variant;
@@ -702,9 +702,11 @@ mod tests {
#[test] #[test]
fn ordinary_comments_are_left_alone() { fn ordinary_comments_are_left_alone() {
assert!(parse("// nothing to see\n// coursebank\n") assert!(
.unwrap() parse("// nothing to see\n// coursebank\n")
.is_inert()); .unwrap()
.is_inert()
);
// A word that merely starts with `begin` is a slot name, not a keyword. // A word that merely starts with `begin` is a slot name, not a keyword.
let err = parse("// coursebank:beginning\n").unwrap_err(); let err = parse("// coursebank:beginning\n").unwrap_err();
assert!(err.to_string().contains("unknown slot `beginning`")); assert!(err.to_string().contains("unknown slot `beginning`"));
+3 -2
View File
@@ -14,10 +14,11 @@ use std::path::Path;
use coursebank::assessment::{AssessmentFile, Form}; use coursebank::assessment::{AssessmentFile, Form};
use coursebank::catalog::Catalog; use coursebank::catalog::Catalog;
use coursebank::course::{Layout, COURSE_FILE}; use coursebank::course::COURSE_FILE;
use coursebank::date::Date; use coursebank::date::Date;
use coursebank::error::{Error, Result}; use coursebank::error::{Error, Result};
use coursebank::gradescope; use coursebank::gradescope;
use coursebank::layout::Layout;
use coursebank::responses::ResponseSet; use coursebank::responses::ResponseSet;
use coursebank::store::Store; use coursebank::store::Store;
use coursebank::taxonomy::Level; use coursebank::taxonomy::Level;
@@ -223,7 +224,7 @@ pub(crate) fn print_record(catalog: &Catalog, record: &AssessmentFile) {
); );
println!( println!(
"estimated {:.0} minutes of working time", "estimated {:.0} minutes of working time",
record.estimated_minutes(catalog) catalog.estimated_minutes(record)
); );
println!("\nBy level:"); println!("\nBy level:");
+5 -3
View File
@@ -44,7 +44,7 @@
//! //!
//! Assessment records are the single source of truth for reuse history. There //! Assessment records are the single source of truth for reuse history. There
//! is no separate ledger file, because a ledger duplicates what the records must //! is no separate ledger file, because a ledger duplicates what the records must
//! already get right and then drifts from it. [`assessment::History`] derives usage //! already get right and then drifts from it. [`history::History`] derives usage
//! by scanning the records. //! 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
@@ -96,7 +96,7 @@ pub mod util;
pub use util::{date, hash, markup, rng, yaml, zipfile}; pub use util::{date, hash, markup, rng, yaml, zipfile};
pub use model::{assessment, bank, catalog, course, item, taxonomy}; pub use model::{assessment, bank, catalog, course, history, item, layout, taxonomy};
pub use authoring::{jsonschema, lint, select}; pub use authoring::{jsonschema, lint, select};
@@ -109,9 +109,11 @@ pub use analysis::{calibrate, classical, irt, students};
pub use export::{qti, report, typst}; pub use export::{qti, report, typst};
pub use catalog::Catalog; pub use catalog::Catalog;
pub use course::{CourseFile, Layout, SCHEMA_VERSION}; pub use course::{CourseFile, SCHEMA_VERSION};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use history::History;
pub use item::Item; pub use item::Item;
pub use layout::Layout;
pub use taxonomy::{CognitiveProcess, ErrorType, Flag, Format, Level, Status}; pub use taxonomy::{CognitiveProcess, ErrorType, Flag, Format, Level, Status};
/// Version of package. /// Version of package.
+1 -1
View File
@@ -33,7 +33,7 @@ use std::process::ExitCode;
use clap::Parser; use clap::Parser;
use crate::cli::Cli; use crate::cli::Cli;
use crate::commands::{run, Outcome}; use crate::commands::{Outcome, run};
/// Parses the command line, runs the requested command, and maps its result onto /// Parses the command line, runs the requested command, and maps its result onto
/// a process exit code. /// a process exit code.
+2
View File
@@ -24,5 +24,7 @@ pub mod assessment;
pub mod bank; pub mod bank;
pub mod catalog; pub mod catalog;
pub mod course; pub mod course;
pub mod history;
pub mod item; pub mod item;
pub mod layout;
pub mod taxonomy; pub mod taxonomy;
+5 -206
View File
@@ -24,7 +24,6 @@ use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::catalog::Catalog;
use crate::course::SCHEMA_VERSION; use crate::course::SCHEMA_VERSION;
use crate::date::Date; use crate::date::Date;
use crate::error::Result; use crate::error::Result;
@@ -396,7 +395,7 @@ impl AssessmentFile {
/// # Returns /// # Returns
/// ///
/// Every problem found. /// Every problem found.
pub fn validate(&self, catalog: Option<&Catalog>) -> Vec<String> { pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new(); let mut issues = Vec::new();
if self.assessment.id.trim().is_empty() { if self.assessment.id.trim().is_empty() {
@@ -458,60 +457,9 @@ impl AssessmentFile {
form_ids.push(&form.id); form_ids.push(&form.id);
} }
if let Some(cat) = catalog {
for p in &self.items {
match cat.get(&p.item) {
None => {
issues.push(format!("question {}: unknown item `{}`", p.number, p.item))
}
Some(entry) => {
if let Some(fp) = &p.fingerprint {
if *fp != entry.item.fingerprint() {
issues.push(format!(
"question {} ({}): the item has been edited since this \
assessment; statistics from this administration describe \
the older wording",
p.number, p.item
));
}
}
if !p.key.is_empty() && p.key != entry.item.key_letters() {
issues.push(format!(
"question {} ({}): the recorded key {:?} differs from the \
item's current key {:?}",
p.number,
p.item,
p.key,
entry.item.key_letters()
));
}
}
}
}
}
issues issues
} }
/// Estimated time to complete, in minutes.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for per-item time estimates.
///
/// # Returns
///
/// The estimate.
pub fn estimated_minutes(&self, catalog: &Catalog) -> f64 {
let seconds: f64 = self
.items
.iter()
.filter_map(|p| catalog.get(&p.item))
.map(|e| e.item.expected_seconds())
.sum();
seconds / 60.0
}
/// A skeleton record for `coursebank assessment new`. /// A skeleton record for `coursebank assessment new`.
/// ///
/// # Arguments /// # Arguments
@@ -566,113 +514,6 @@ pub struct Usage {
pub fingerprint: Option<String>, pub fingerprint: Option<String>,
} }
/// The usage history of a course, built by scanning assessment records.
///
/// There is deliberately no separate ledger file. A ledger duplicates
/// information that the records already hold and then drifts away from it; this
/// derives the same answers from the one artifact that has to be right anyway.
#[derive(Debug, Clone, Default)]
pub struct History {
/// Every recorded appearance, newest last.
pub usages: Vec<Usage>,
}
impl History {
/// Builds the history from a directory of assessment records.
///
/// # Arguments
///
/// * `dir` - the assessments directory.
///
/// # Returns
///
/// The history.
///
/// # Errors
///
/// Propagates load errors.
pub fn load(dir: &Path) -> Result<History> {
let mut usages = Vec::new();
for file in AssessmentFile::load_all(dir)? {
for p in &file.items {
usages.push(Usage {
item: p.item.clone(),
assessment: file.assessment.id.clone(),
title: file.assessment.title.clone(),
kind: file.assessment.kind,
date: file.assessment.date,
number: p.number,
fingerprint: p.fingerprint.clone(),
});
}
}
usages.sort_by(|a, b| {
a.date
.cmp(&b.date)
.then(a.assessment.cmp(&b.assessment))
.then(a.number.cmp(&b.number))
});
Ok(History { usages })
}
/// Every appearance of one item, oldest first.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// Matching usages.
pub fn for_item(&self, uid: &str) -> Vec<&Usage> {
self.usages.iter().filter(|u| u.item == uid).collect()
}
/// The most recent date an item was used.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// The date, or `None` when never used or never dated.
pub fn last_used(&self, uid: &str) -> Option<Date> {
self.for_item(uid).iter().filter_map(|u| u.date).max()
}
/// How many times an item has been used.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// The count.
pub fn use_count(&self, uid: &str) -> usize {
self.for_item(uid).len()
}
/// Whether an item is still inside its reuse cooldown.
///
/// # Arguments
///
/// * `uid` - the item's global id.
/// * `cooldown_days` - the minimum gap between uses.
/// * `as_of` - the date of the assessment being assembled.
///
/// # Returns
///
/// `true` when the item was used too recently to reuse.
pub fn in_cooldown(&self, uid: &str, cooldown_days: i64, as_of: Date) -> bool {
match self.last_used(uid) {
Some(last) => last.days_until(as_of) < cooldown_days,
None => false,
}
}
}
fn default_version() -> String { fn default_version() -> String {
SCHEMA_VERSION.to_string() SCHEMA_VERSION.to_string()
} }
@@ -716,7 +557,7 @@ items:
assert_eq!(a.items.len(), 3); assert_eq!(a.items.len(), 3);
assert_eq!(a.total_points(1.0), 3.0, "bonus is excluded"); assert_eq!(a.total_points(1.0), 3.0, "bonus is excluded");
assert_eq!(a.placement(2).unwrap().item, "b1::q-a-002"); assert_eq!(a.placement(2).unwrap().item, "b1::q-a-002");
assert!(a.validate(None).is_empty(), "{:?}", a.validate(None)); assert!(a.validate().is_empty(), "{:?}", a.validate());
} }
#[test] #[test]
@@ -737,7 +578,7 @@ items:
- { number: 1, item: "b::q-1" } - { number: 1, item: "b::q-1" }
"#, "#,
); );
let issues = a.validate(None); let issues = a.validate();
assert!(issues.iter().any(|i| i.contains("used 2 times"))); assert!(issues.iter().any(|i| i.contains("used 2 times")));
assert!(issues.iter().any(|i| i.contains("appears twice"))); assert!(issues.iter().any(|i| i.contains("appears twice")));
} }
@@ -752,10 +593,7 @@ items:
- { number: 3, item: "b::q-2" } - { number: 3, item: "b::q-2" }
"#, "#,
); );
assert!(a assert!(a.validate().iter().any(|i| i.contains("not contiguous")));
.validate(None)
.iter()
.any(|i| i.contains("not contiguous")));
} }
#[test] #[test]
@@ -767,46 +605,7 @@ items:
- { number: 1, item: "b::q-1", credit_overrides: { B: 1.5 } } - { number: 1, item: "b::q-1", credit_overrides: { B: 1.5 } }
"#, "#,
); );
assert!(a assert!(a.validate().iter().any(|i| i.contains("must be in [0, 1]")));
.validate(None)
.iter()
.any(|i| i.contains("must be in [0, 1]")));
}
#[test]
fn history_tracks_last_use_and_cooldown() {
let h = History {
usages: vec![
Usage {
item: "b::q-1".into(),
assessment: "exam-1".into(),
title: "Exam 1".into(),
kind: Kind::Exam,
date: Some("2025-09-15".parse().unwrap()),
number: 4,
fingerprint: None,
},
Usage {
item: "b::q-1".into(),
assessment: "exam-3".into(),
title: "Exam 3".into(),
kind: Kind::Exam,
date: Some("2026-02-10".parse().unwrap()),
number: 7,
fingerprint: None,
},
],
};
assert_eq!(h.use_count("b::q-1"), 2);
assert_eq!(h.use_count("b::q-2"), 0);
assert_eq!(h.last_used("b::q-1").unwrap().to_string(), "2026-02-10");
let exam_date: Date = "2026-04-23".parse().unwrap();
// 72 days elapsed, so a 90-day cooldown still blocks it and a 60-day one
// does not.
assert!(h.in_cooldown("b::q-1", 90, exam_date));
assert!(!h.in_cooldown("b::q-1", 60, exam_date));
assert!(!h.in_cooldown("b::never-used", 3650, exam_date));
} }
#[test] #[test]
+11 -7
View File
@@ -491,7 +491,7 @@ fn validate_item(
)); ));
} }
} }
for (letter, _) in &c.option_stats { for letter in c.option_stats.keys() {
if it.option(letter).is_none() { if it.option(letter).is_none() {
issues.push(format!( issues.push(format!(
"calibration.option_stats has `{letter}`, which is not an option of this item" "calibration.option_stats has `{letter}`, which is not an option of this item"
@@ -662,9 +662,11 @@ mod tests {
"#, "#,
); );
let issues = b.validate(None); let issues = b.validate(None);
assert!(issues assert!(
.iter() issues
.any(|i| i.contains("exactly one keyed option"))); .iter()
.any(|i| i.contains("exactly one keyed option"))
);
assert_eq!( assert_eq!(
issues issues
.iter() .iter()
@@ -831,9 +833,11 @@ learning_objectives:
"#, "#,
); );
let issues = b.validate(Some(&course)); let issues = b.validate(Some(&course));
assert!(issues assert!(
.iter() issues
.any(|i| i.contains("unknown learning objective `lo-unknown`"))); .iter()
.any(|i| i.contains("unknown learning objective `lo-unknown`"))
);
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`"))); assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
assert!( assert!(
issues.iter().any(|i| i.contains("exceeds the ceiling")), issues.iter().any(|i| i.contains("exceeds the ceiling")),
+44 -2
View File
@@ -16,10 +16,12 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::assessment::AssessmentFile;
use crate::bank::BankFile; use crate::bank::BankFile;
use crate::course::{CourseFile, Layout}; use crate::course::CourseFile;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::item::Item; use crate::item::Item;
use crate::layout::Layout;
use crate::taxonomy::{Level, Status}; use crate::taxonomy::{Level, Status};
use crate::yaml; use crate::yaml;
@@ -249,6 +251,46 @@ impl Catalog {
Ok(issues) Ok(issues)
} }
/// Validates a record's internal invariants, then its references against this
/// catalog: unknown items, keys that drifted, and fingerprints showing the
/// item was reworded since it was administered.
pub fn validate_record(&self, record: &AssessmentFile) -> Vec<String> {
let mut issues = record.validate();
for p in &record.items {
match self.get(&p.item) {
None => issues.push(format!("question {}: unknown item `{}`", p.number, p.item)),
Some(entry) => {
if let Some(fp) = &p.fingerprint {
if *fp != entry.item.fingerprint() {
issues.push(format!(
"question {} ({}): the item has been edited since this assessment; \
statistics from this administration describe the older wording",
p.number, p.item
));
}
}
if !p.key.is_empty() && p.key != entry.item.key_letters() {
issues.push(format!(
"question {} ({}): the recorded key {:?} differs from the item's current key {:?}",
p.number, p.item, p.key, entry.item.key_letters()));
}
}
}
}
issues
}
/// Estimated working time for a record, in minutes.
pub fn estimated_minutes(&self, record: &AssessmentFile) -> f64 {
let seconds: f64 = record
.items
.iter()
.filter_map(|p| self.get(&p.item))
.map(|e| e.item.expected_seconds())
.sum();
seconds / 60.0
}
/// Counts of assemblable, non-bonus items by level. /// Counts of assemblable, non-bonus items by level.
/// ///
/// # Returns /// # Returns
@@ -391,7 +433,7 @@ impl Catalog {
} }
} }
for (lec_id, _) in &self.course.lectures { for lec_id in self.course.lectures.keys() {
if self if self
.by_lecture(lec_id) .by_lecture(lec_id)
.iter() .iter()
+3 -94
View File
@@ -12,7 +12,7 @@
//! belongs to the course and the administration, never to the item. //! belongs to the course and the administration, never to the item.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -307,7 +307,7 @@ impl CourseFile {
issues.push(format!("lecture `{id}`: empty title")); issues.push(format!("lecture `{id}`: empty title"));
} }
if let Some(u) = &lec.unit { if let Some(u) = &lec.unit {
if !unit_ids.iter().any(|x| *x == u) { if !unit_ids.contains(&u) {
issues.push(format!("lecture `{id}`: unknown unit `{u}`")); issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
} }
} }
@@ -318,7 +318,7 @@ impl CourseFile {
issues.push(format!("objective `{id}`: empty text")); issues.push(format!("objective `{id}`: empty text"));
} }
if let Some(u) = &lo.unit { if let Some(u) = &lo.unit {
if !unit_ids.iter().any(|x| *x == u) { if !unit_ids.contains(&u) {
issues.push(format!("objective `{id}`: unknown unit `{u}`")); issues.push(format!("objective `{id}`: unknown unit `{u}`"));
} }
} }
@@ -550,97 +550,6 @@ impl CourseFile {
} }
} }
/// The standard directory layout of a course, resolved from a root.
///
/// Keeping the layout in one type means every command agrees on where things
/// live, and a future rearrangement touches this struct only.
#[derive(Debug, Clone)]
pub struct Layout {
/// The course root.
pub root: PathBuf,
}
impl Layout {
/// Builds a layout from a course root directory.
///
/// # Arguments
///
/// * `root` - the course directory.
///
/// # Returns
///
/// The layout.
pub fn new(root: impl Into<PathBuf>) -> Layout {
Layout { root: root.into() }
}
/// Path to `course.yaml`.
pub fn course_file(&self) -> PathBuf {
self.root.join(COURSE_FILE)
}
/// Directory holding item bank YAML files.
pub fn banks(&self) -> PathBuf {
self.root.join("banks")
}
/// Directory holding assessment records.
pub fn assessments(&self) -> PathBuf {
self.root.join("assessments")
}
/// Directory holding response tables and derived statistics.
pub fn data(&self) -> PathBuf {
self.root.join("data")
}
/// Directory holding generated reports.
pub fn reports(&self) -> PathBuf {
self.root.join("reports")
}
/// Directory holding generated exports such as QTI packages.
pub fn build(&self) -> PathBuf {
self.root.join("build")
}
/// Directory holding emitted JSON Schema files for editor validation.
pub fn schema(&self) -> PathBuf {
self.root.join("schema")
}
/// Directory holding Typst export templates and their configuration.
///
/// Unlike the other directories, this one is *not* created by
/// [`Layout::create_all`]. Its absence is meaningful: a course with no
/// `templates/` directory uses the templates compiled into the binary, and
/// creating an empty one on `init` would suggest a customization step is
/// required when it is not. `coursebank template dump` creates it on demand.
pub fn templates(&self) -> PathBuf {
self.root.join("templates")
}
/// Creates every directory in the layout.
///
/// # Errors
///
/// Returns [`Error::Io`] if a directory cannot be created.
pub fn create_all(&self) -> Result<()> {
for dir in [
self.root.clone(),
self.banks(),
self.assessments(),
self.data(),
self.reports(),
self.build(),
self.schema(),
] {
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
}
Ok(())
}
}
/// Lowercases and hyphenates a string for use in file names and ids. /// Lowercases and hyphenates a string for use in file names and ids.
/// ///
/// # Arguments /// # Arguments
+179
View File
@@ -0,0 +1,179 @@
//! The usage history of a course, derived by scanning assessment records.
//!
//! There is deliberately no separate ledger file. A ledger duplicates
//! information the records already hold and then drifts away from it; this
//! derives the same answers from the one artifact that has to be right anyway.
//! An item was used exactly when it appears on a record.
//!
//! This is an aggregate over the records, the same kind of thing as
//! [`crate::catalog::Catalog`], which is why it sits beside the schema rather
//! than inside [`crate::assessment`].
use std::path::Path;
use crate::assessment::{AssessmentFile, Kind};
use crate::date::Date;
use crate::error::Result;
/// One recorded appearance of an item, derived from the assessment records.
#[derive(Debug, Clone)]
pub struct Usage {
/// The item's global id.
pub item: String,
/// The assessment id it appeared on.
pub assessment: String,
/// The assessment title.
pub title: String,
/// The kind of assessment.
pub kind: Kind,
/// When it was administered, when recorded.
pub date: Option<Date>,
/// The printed question number.
pub number: u32,
/// The fingerprint as used.
pub fingerprint: Option<String>,
}
/// The usage history of a course, built by scanning assessment records.
#[derive(Debug, Clone, Default)]
pub struct History {
/// Every recorded appearance, newest last.
pub usages: Vec<Usage>,
}
impl History {
/// Builds the history from a directory of assessment records.
///
/// # Arguments
///
/// * `dir` - the assessments directory.
///
/// # Returns
///
/// The history.
///
/// # Errors
///
/// Propagates load errors.
pub fn load(dir: &Path) -> Result<History> {
let mut usages = Vec::new();
for file in AssessmentFile::load_all(dir)? {
for p in &file.items {
usages.push(Usage {
item: p.item.clone(),
assessment: file.assessment.id.clone(),
title: file.assessment.title.clone(),
kind: file.assessment.kind,
date: file.assessment.date,
number: p.number,
fingerprint: p.fingerprint.clone(),
});
}
}
usages.sort_by(|a, b| {
a.date
.cmp(&b.date)
.then(a.assessment.cmp(&b.assessment))
.then(a.number.cmp(&b.number))
});
Ok(History { usages })
}
/// Every appearance of one item, oldest first.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// Matching usages.
pub fn for_item(&self, uid: &str) -> Vec<&Usage> {
self.usages.iter().filter(|u| u.item == uid).collect()
}
/// The most recent date an item was used.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// The date, or `None` when never used or never dated.
pub fn last_used(&self, uid: &str) -> Option<Date> {
self.for_item(uid).iter().filter_map(|u| u.date).max()
}
/// How many times an item has been used.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// The count.
pub fn use_count(&self, uid: &str) -> usize {
self.for_item(uid).len()
}
/// Whether an item is still inside its reuse cooldown.
///
/// # Arguments
///
/// * `uid` - the item's global id.
/// * `cooldown_days` - the minimum gap between uses.
/// * `as_of` - the date of the assessment being assembled.
///
/// # Returns
///
/// `true` when the item was used too recently to reuse.
pub fn in_cooldown(&self, uid: &str, cooldown_days: i64, as_of: Date) -> bool {
match self.last_used(uid) {
Some(last) => last.days_until(as_of) < cooldown_days,
None => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn history_tracks_last_use_and_cooldown() {
let h = History {
usages: vec![
Usage {
item: "b::q-1".into(),
assessment: "exam-1".into(),
title: "Exam 1".into(),
kind: Kind::Exam,
date: Some("2025-09-15".parse().unwrap()),
number: 4,
fingerprint: None,
},
Usage {
item: "b::q-1".into(),
assessment: "exam-3".into(),
title: "Exam 3".into(),
kind: Kind::Exam,
date: Some("2026-02-10".parse().unwrap()),
number: 7,
fingerprint: None,
},
],
};
assert_eq!(h.use_count("b::q-1"), 2);
assert_eq!(h.use_count("b::q-2"), 0);
assert_eq!(h.last_used("b::q-1").unwrap().to_string(), "2026-02-10");
let exam_date: Date = "2026-04-23".parse().unwrap();
// 72 days elapsed, so a 90-day cooldown still blocks it and a 60-day one
// does not.
assert!(h.in_cooldown("b::q-1", 90, exam_date));
assert!(!h.in_cooldown("b::q-1", 60, exam_date));
assert!(!h.in_cooldown("b::never-used", 3650, exam_date));
}
}
+99
View File
@@ -0,0 +1,99 @@
//! The on-disk layout of a course directory.
//!
//! Keeping the layout in one type means every command agrees on where things
//! live, and a future rearrangement touches this file only. It is deliberately
//! separate from the course *schema* in [`crate::course`]: this module is about
//! where files sit on disk, not what they contain.
use std::path::PathBuf;
use crate::course::COURSE_FILE;
use crate::error::{Error, Result};
/// The standard directory layout of a course, resolved from a root.
#[derive(Debug, Clone)]
pub struct Layout {
/// The course root.
pub root: PathBuf,
}
impl Layout {
/// Builds a layout from a course root directory.
///
/// # Arguments
///
/// * `root` - the course directory.
///
/// # Returns
///
/// The layout.
pub fn new(root: impl Into<PathBuf>) -> Layout {
Layout { root: root.into() }
}
/// Path to `course.yaml`.
pub fn course_file(&self) -> PathBuf {
self.root.join(COURSE_FILE)
}
/// Directory holding item bank YAML files.
pub fn banks(&self) -> PathBuf {
self.root.join("banks")
}
/// Directory holding assessment records.
pub fn assessments(&self) -> PathBuf {
self.root.join("assessments")
}
/// Directory holding response tables and derived statistics.
pub fn data(&self) -> PathBuf {
self.root.join("data")
}
/// Directory holding generated reports.
pub fn reports(&self) -> PathBuf {
self.root.join("reports")
}
/// Directory holding generated exports such as QTI packages.
pub fn build(&self) -> PathBuf {
self.root.join("build")
}
/// Directory holding emitted JSON Schema files for editor validation.
pub fn schema(&self) -> PathBuf {
self.root.join("schema")
}
/// Directory holding Typst export templates and their configuration.
///
/// Unlike the other directories, this one is *not* created by
/// [`Layout::create_all`]. Its absence is meaningful: a course with no
/// `templates/` directory uses the templates compiled into the binary, and
/// creating an empty one on `init` would suggest a customization step is
/// required when it is not. `coursebank template dump` creates it on demand.
pub fn templates(&self) -> PathBuf {
self.root.join("templates")
}
/// Creates every directory in the layout.
///
/// # Errors
///
/// Returns [`Error::Io`] if a directory cannot be created.
pub fn create_all(&self) -> Result<()> {
for dir in [
self.root.clone(),
self.banks(),
self.assessments(),
self.data(),
self.reports(),
self.build(),
self.schema(),
] {
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
}
Ok(())
}
}
+2 -2
View File
@@ -137,9 +137,9 @@ pub fn sha256(msg: &[u8]) -> [u8; 32] {
let mut w = [0u32; 64]; let mut w = [0u32; 64];
for chunk in data.chunks(64) { for chunk in data.chunks(64) {
for i in 0..16 { for (i, w_i) in w.iter_mut().enumerate().take(16) {
let j = i * 4; let j = i * 4;
w[i] = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]); *w_i = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
} }
for i in 16..64 { for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
+1 -1
View File
@@ -292,7 +292,7 @@ fn wrap_delimited(s: &str, delim: &str, pre: &str, post: &str) -> String {
out.push_str(rest); out.push_str(rest);
return out; return out;
} }
Some(j) if j == 0 => { Some(0) => {
// Empty span such as `**`; emit literally and move on. // Empty span such as `**`; emit literally and move on.
out.push_str(&rest[..i + delim.len()]); out.push_str(&rest[..i + delim.len()]);
rest = after; rest = after;