//! What gets emitted, and in what shape. //! //! This module holds the knobs that used to be `format!` calls. The split is //! deliberate: a template decides how a question *looks*, and this config decides //! what the template is *told*. Anything you can express by rearranging boxes //! belongs in the template, not here. //! //! The one setting that is not cosmetic is [`Reveal`]. It controls whether the //! payload contains the answer at all, and the reason it is a config value rather //! than a template concern is that a template cannot be trusted with it. If the //! exam paper's payload carries `correct: true`, then every future edit to that //! template is one `if` statement away from printing the key, and the failure mode //! is discovered by a room full of students. Withholding the field is the only //! version of this that stays correct under editing. //! //! Config is resolved in three layers, each overriding the last: the built-in //! defaults for the variant, the `defaults:` block of `templates/typst.yaml`, and //! that file's `variants:` block. `coursebank template config` writes the whole //! resolved thing out so there is no guessing about what applied. use std::collections::BTreeMap; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::error::{Error, Result}; /// Which document is being produced. /// /// A variant is not a style. It is a different set of facts: the paper withholds /// the key, the key withholds the questions, and the answer sheet needs only the /// option counts. Each has its own template and its own default [`Reveal`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Variant { /// The question paper a student writes on. Exam, /// The grader's answer key. Key, /// A bubble sheet matching the form. AnswerSheet, } impl Variant { /// Every variant, in the order `export` writes them. pub const ALL: [Variant; 3] = [Variant::Exam, Variant::Key, Variant::AnswerSheet]; /// The token used on the command line, in config keys, and in file names. pub fn as_str(self) -> &'static str { match self { Variant::Exam => "exam", Variant::Key => "key", Variant::AnswerSheet => "answer-sheet", } } /// The variant for a token. /// /// # Arguments /// /// * `s` - the token, e.g. `"answer-sheet"`. /// /// # Returns /// /// The variant. /// /// # Errors /// /// Returns [`Error::Usage`] naming the valid tokens. pub fn parse(s: &str) -> Result { let normalized = s.trim().to_ascii_lowercase().replace('_', "-"); Variant::ALL .iter() .copied() .find(|v| v.as_str() == normalized) .ok_or_else(|| { Error::usage(format!( "unknown template variant `{s}`; expected one of {}", Variant::ALL .iter() .map(|v| v.as_str()) .collect::>() .join(", ") )) }) } /// The file name this variant's template is looked up under. pub fn template_file(self) -> String { format!("{}.typ", self.as_str()) } /// The suffix appended to an exported file's stem. /// /// The paper gets no suffix because it is the thing you print most often and /// `exam-2-A.typ` reads better than `exam-2-A-exam.typ`. pub fn suffix(self) -> &'static str { match self { Variant::Exam => "", Variant::Key => "-key", Variant::AnswerSheet => "-answer-sheet", } } } /// How much of the answer side of an item reaches the payload. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Reveal { /// Nothing. No `correct`, no `credit`, no keyed letters, no rationale. What a /// student form must use. Nothing, /// Which options are correct, what credit each earns, and any recorded /// partial-credit overrides. Enough to grade with. Key, /// Everything, including instructor rationale, targeted misconceptions, and /// the record's private notes. For a review copy that never leaves your desk. Everything, } impl Reveal { /// Whether keyed letters, `correct`, and `credit` are emitted. pub fn shows_key(self) -> bool { self != Reveal::Nothing } /// Whether rationale, misconceptions, and private notes are emitted. pub fn shows_rationale(self) -> bool { self == Reveal::Everything } } /// How option labels are generated. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum LetterStyle { /// `A`, `B`, `C`. Upper, /// `a`, `b`, `c`. Lower, /// `1`, `2`, `3`. Numeric, /// `i`, `ii`, `iii`. Roman, /// No label; the template supplies its own, usually via `numbering`. Nothing, } impl LetterStyle { /// The label for a zero-based printed position. /// /// # Arguments /// /// * `position` - the printed position, counting from zero. /// /// # Returns /// /// The label, or an empty string for [`LetterStyle::Nothing`]. pub fn label(self, position: usize) -> String { match self { LetterStyle::Upper => alphabetic(position, true), LetterStyle::Lower => alphabetic(position, false), LetterStyle::Numeric => (position + 1).to_string(), LetterStyle::Roman => roman(position + 1), LetterStyle::Nothing => String::new(), } } } /// A spreadsheet-style label: `A`..`Z`, then `AA`. /// /// Eight options is the schema's ceiling, so the second character is unreachable /// in practice. It is here so that raising that ceiling does not silently produce /// `[` as an option label, which is what `b'A' + 26` gives you. fn alphabetic(position: usize, upper: bool) -> String { let base = if upper { b'A' } else { b'a' }; let mut n = position; let mut letters = Vec::new(); loop { letters.push((base + (n % 26) as u8) as char); if n < 26 { break; } n = n / 26 - 1; } letters.iter().rev().collect() } /// A lowercase Roman numeral for a one-based position. fn roman(mut n: usize) -> String { const TABLE: [(usize, &str); 13] = [ (1000, "m"), (900, "cm"), (500, "d"), (400, "cd"), (100, "c"), (90, "xc"), (50, "l"), (40, "xl"), (10, "x"), (9, "ix"), (5, "v"), (4, "iv"), (1, "i"), ]; let mut out = String::new(); for (value, numeral) in TABLE { while n >= value { out.push_str(numeral); n -= value; } } out } /// How markup-bearing fields are emitted. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ContentMode { /// As content blocks, `[...]`, which Typst parses at compile time. Errors /// point at the generated file, and no `eval` is needed. Content, /// As quoted strings, which a template evaluates with /// `eval(q.stem, mode: "markup")`. Required if the same payload is also /// consumed as JSON, since JSON has no content type. Str, } impl ContentMode { /// Whether markup becomes a content block rather than a string. pub fn is_content(self) -> bool { self == ContentMode::Content } } /// Whether a shared stimulus travels with each item that uses it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum StimulusMode { /// Repeat the body with every item that references it. Wasteful on paper, but /// a student should never have to turn a page to find the passage a question /// is about. Inline, /// Emit only the id on the item, and the bodies once in the metadata under /// `stimuli`. For a template that prints a testlet header above its group. Shared, /// Leave stimuli out. Omit, } /// Which optional per-question fields are emitted. /// /// Everything here defaults on except the two heavy ones. Turning a field off is /// about payload noise, not secrecy — [`Reveal`] is what governs secrecy. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Fields { /// The item's global id, `bank::item`. Useful printed in small grey type on a /// review copy; never wanted on a student form. #[serde(default = "yes")] pub uid: bool, /// The item's short title. #[serde(default = "yes")] pub title: bool, /// Point value. #[serde(default = "yes")] pub points: bool, /// Cognitive level, as both a number and a name. #[serde(default = "yes")] pub level: bool, /// Learning objective ids. #[serde(default = "yes")] pub objectives: bool, /// Topic tags. #[serde(default = "yes")] pub topics: bool, /// Figures and data files attached to the item. #[serde(default = "yes")] pub assets: bool, /// The letter the option carries in the bank, before shuffling. On a key this /// is what lets you find the option in the YAML. #[serde(default = "yes")] pub source_letters: bool, /// The authored predictions in the item's `design` block. #[serde(default = "no")] pub design: bool, /// Pooled statistics from previous administrations. #[serde(default = "no")] pub calibration: bool, } impl Default for Fields { fn default() -> Fields { Fields { uid: true, title: true, points: true, level: true, objectives: true, topics: true, assets: true, source_letters: true, design: false, calibration: false, } } } /// The resolved configuration for rendering one variant. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RenderConfig { /// A template path that overrides the usual lookup. #[serde(default, skip_serializing_if = "Option::is_none")] pub template: Option, /// The Typst function the `questions` slot calls, once per item, with a /// single dictionary argument. The template defines it. #[serde(default = "default_question_fn")] pub question_fn: String, /// The binding the `meta` slot declares. #[serde(default = "default_meta_binding")] pub meta_binding: String, /// The binding the `data` slot declares. #[serde(default = "default_data_binding")] pub data_binding: String, /// How much of the answer side to include. #[serde(default = "default_reveal")] pub reveal: Reveal, /// How option labels are generated. #[serde(default = "default_letters")] pub letters: LetterStyle, /// How markup is emitted. #[serde(default = "default_content")] pub content: ContentMode, /// How shared stimuli are handled. #[serde(default = "default_stimulus")] pub stimulus: StimulusMode, /// Which optional fields to include. #[serde(default)] pub fields: Fields, /// Whether questions carry the number recorded in the assessment record /// rather than their printed position. /// /// Keep this on. The recorded number is the join key to every grading export /// and response row; renumbering after a drop breaks that join silently, and /// the symptom is item statistics attributed to the wrong question. #[serde(default = "yes")] pub number_from_record: bool, /// Anything else you want the template to see, carried through untouched. /// /// This is the escape hatch that keeps the crate out of your layout /// decisions. Tier colours, a `show-solutions` flag, a font stack, a watermark /// string: put it here and read it from `extra` in the template. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extra: BTreeMap, } impl RenderConfig { /// The built-in configuration for a variant. /// /// # Arguments /// /// * `variant` - which document. /// /// # Returns /// /// A configuration matching what the bundled template for that variant /// expects. pub fn for_variant(variant: Variant) -> RenderConfig { let mut config = RenderConfig { template: None, question_fn: default_question_fn(), meta_binding: default_meta_binding(), data_binding: default_data_binding(), reveal: Reveal::Nothing, letters: LetterStyle::Upper, content: ContentMode::Content, stimulus: StimulusMode::Inline, fields: Fields::default(), number_from_record: true, extra: BTreeMap::new(), }; match variant { Variant::Exam => { // The paper must not carry the key in any form. config.reveal = Reveal::Nothing; config.fields.uid = false; config.fields.source_letters = false; config.fields.objectives = false; } Variant::Key => { config.reveal = Reveal::Everything; config.stimulus = StimulusMode::Omit; } Variant::AnswerSheet => { config.reveal = Reveal::Nothing; config.stimulus = StimulusMode::Omit; config.fields = Fields { uid: false, title: false, points: true, level: false, objectives: false, topics: false, assets: false, source_letters: false, design: false, calibration: false, }; } } config } /// Renders the configuration as YAML. /// /// For `coursebank template config --resolved`, which is the answer to "which /// layer won?" — a question that is otherwise answered by reading three files /// and guessing. /// /// # Returns /// /// YAML text. /// /// # Errors /// /// Returns [`Error::Other`] if serialization /// fails. pub fn to_yaml(&self) -> Result { serde_yaml_ng::to_string(self).map_err(Error::other) } } /// The file a course's Typst configuration lives in, under `templates/`. pub const CONFIG_FILE: &str = "typst.yaml"; /// A course's Typst export configuration. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ConfigFile { /// Schema version this file targets. #[serde(default, skip_serializing_if = "Option::is_none")] pub schema_version: Option, /// Overrides applied to every variant. #[serde(default, skip_serializing_if = "Option::is_none")] pub defaults: Option, /// Overrides applied to one variant, on top of `defaults`. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub variants: BTreeMap, } impl ConfigFile { /// Resolves the configuration for one variant. /// /// # Arguments /// /// * `variant` - which document. /// /// # Returns /// /// The built-in defaults with the file's `defaults` and then its /// variant-specific block applied. pub fn resolve(&self, variant: Variant) -> RenderConfig { let mut config = RenderConfig::for_variant(variant); if let Some(defaults) = &self.defaults { defaults.apply(&mut config); } if let Some(specific) = self.variants.get(&variant) { specific.apply(&mut config); } config } } /// A partial [`RenderConfig`]: every field optional, so a config file can say one /// thing without restating the rest. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Overrides { /// See [`RenderConfig::template`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub template: Option, /// See [`RenderConfig::question_fn`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub question_fn: Option, /// See [`RenderConfig::meta_binding`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub meta_binding: Option, /// See [`RenderConfig::data_binding`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub data_binding: Option, /// See [`RenderConfig::reveal`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub reveal: Option, /// See [`RenderConfig::letters`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub letters: Option, /// See [`RenderConfig::content`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub content: Option, /// See [`RenderConfig::stimulus`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub stimulus: Option, /// See [`RenderConfig::fields`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub fields: Option, /// See [`RenderConfig::number_from_record`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub number_from_record: Option, /// Merged key by key into [`RenderConfig::extra`] rather than replacing it, so /// a variant can add one flag without repeating the shared block. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extra: BTreeMap, } impl Overrides { /// Applies these overrides in place. /// /// # Arguments /// /// * `config` - the configuration to modify. pub fn apply(&self, config: &mut RenderConfig) { if let Some(v) = &self.template { config.template = Some(v.clone()); } if let Some(v) = &self.question_fn { config.question_fn = v.clone(); } if let Some(v) = &self.meta_binding { config.meta_binding = v.clone(); } if let Some(v) = &self.data_binding { config.data_binding = v.clone(); } if let Some(v) = self.reveal { config.reveal = v; } if let Some(v) = self.letters { config.letters = v; } if let Some(v) = self.content { config.content = v; } if let Some(v) = self.stimulus { config.stimulus = v; } if let Some(v) = &self.fields { config.fields = v.clone(); } if let Some(v) = self.number_from_record { config.number_from_record = v; } for (key, value) in &self.extra { config.extra.insert(key.clone(), value.clone()); } } } /// The commented starter config written by `coursebank template config`. /// /// Written as text rather than serialized from [`ConfigFile`] because the comments /// are the useful part, and a serializer drops them. pub const CONFIG_TEMPLATE: &str = r#"# Typst export configuration. # # Layers, each overriding the last: # 1. the built-in defaults for the variant # 2. `defaults:` below # 3. `variants:` below # # `coursebank template config --resolved` prints what a variant actually ends up # with, which is the quickest way to check whether a key landed where you meant. schema_version: "1.0" defaults: # The Typst function the `questions` slot calls once per item, with one # dictionary argument. Your template defines it. question_fn: render-question # `content` emits stems as `[...]`, which Typst parses directly. # `str` emits them as strings for a template that calls `eval`, and is what you # want if the same payload is also read as JSON. content: content # upper | lower | numeric | roman | nothing letters: upper # Anything here is passed through untouched and shows up as `extra` in the # payload. This is where layout decisions belong. # # Note the spelling shift: keys above are coursebank's and are snake_case like # every other coursebank YAML file, but keys under `extra` are yours and reach # Typst verbatim, so they use Typst's hyphens. extra: accent: '#017ab9' font: 'Libertinus Serif' variants: exam: # Leave this alone unless you are certain. `nothing` is what keeps the answer # out of the student's copy; a template cannot leak a field it was never given. reveal: nothing extra: show-solutions: false key: # nothing | key | everything reveal: everything extra: show-solutions: true answer-sheet: reveal: nothing "#; /// Serde default: `true`. fn yes() -> bool { true } /// Serde default: `false`. fn no() -> bool { false } /// Serde default for [`RenderConfig::question_fn`]. fn default_question_fn() -> String { "render-question".to_string() } /// Serde default for [`RenderConfig::meta_binding`]. fn default_meta_binding() -> String { "cb-meta".to_string() } /// Serde default for [`RenderConfig::data_binding`]. fn default_data_binding() -> String { "cb-data".to_string() } /// Serde default for [`RenderConfig::reveal`]. fn default_reveal() -> Reveal { Reveal::Nothing } /// Serde default for [`RenderConfig::letters`]. fn default_letters() -> LetterStyle { LetterStyle::Upper } /// Serde default for [`RenderConfig::content`]. fn default_content() -> ContentMode { ContentMode::Content } /// Serde default for [`RenderConfig::stimulus`]. fn default_stimulus() -> StimulusMode { StimulusMode::Inline } #[cfg(test)] mod tests { use super::*; #[test] fn variant_tokens_round_trip() { for variant in Variant::ALL { assert_eq!(Variant::parse(variant.as_str()).unwrap(), variant); } // Underscores are tolerated because people type them. assert_eq!( Variant::parse("answer_sheet").unwrap(), Variant::AnswerSheet ); assert!(Variant::parse("bubbles").is_err()); } #[test] fn the_exam_variant_never_reveals_the_key_by_default() { // This is the one default in this module that is a correctness property // rather than a preference. let config = RenderConfig::for_variant(Variant::Exam); assert_eq!(config.reveal, Reveal::Nothing); assert!(!config.reveal.shows_key()); } #[test] fn letters_are_generated_per_style() { assert_eq!(LetterStyle::Upper.label(0), "A"); assert_eq!(LetterStyle::Upper.label(3), "D"); assert_eq!(LetterStyle::Lower.label(1), "b"); assert_eq!(LetterStyle::Numeric.label(4), "5"); assert_eq!(LetterStyle::Roman.label(3), "iv"); assert_eq!(LetterStyle::Nothing.label(2), ""); } #[test] fn letters_past_z_do_not_run_off_the_alphabet() { // `b'A' + 26` is `[`. Anything that produced that would be a silent // corruption of an option label rather than an error. assert_eq!(LetterStyle::Upper.label(26), "AA"); assert_eq!(LetterStyle::Upper.label(25), "Z"); } #[test] fn config_layers_override_in_order() { let file: ConfigFile = serde_yaml_ng::from_str( "defaults:\n letters: lower\n extra:\n a: 1\nvariants:\n key:\n letters: \ numeric\n extra:\n b: 2\n", ) .unwrap(); let exam = file.resolve(Variant::Exam); assert_eq!(exam.letters, LetterStyle::Lower); let key = file.resolve(Variant::Key); assert_eq!(key.letters, LetterStyle::Numeric); // `extra` merges rather than replacing, so `a` survives. assert!(key.extra.contains_key("a")); assert!(key.extra.contains_key("b")); } #[test] fn an_empty_config_file_changes_nothing() { let file = ConfigFile::default(); for variant in Variant::ALL { assert_eq!(file.resolve(variant), RenderConfig::for_variant(variant)); } } #[test] fn the_starter_config_parses_and_keeps_the_exam_closed() { let file: ConfigFile = serde_yaml_ng::from_str(CONFIG_TEMPLATE).unwrap(); assert_eq!(file.resolve(Variant::Exam).reveal, Reveal::Nothing); assert_eq!(file.resolve(Variant::Key).reveal, Reveal::Everything); } }