moodle_xml/
question.rs

1use crate::{
2    answer::Answer,
3    quiz::{EmptyError, QuizError},
4    xml_util::{write_named_formatted_scope, write_text_tag},
5};
6use std::fs::File;
7use xml::writer::{EventWriter, XmlEvent};
8
9/// Common trait for all question types
10pub trait Question {
11    /// Returns the name of the question>
12    fn get_name(&self) -> &str;
13    /// Returns the description of the question.
14    fn get_description(&self) -> &str;
15    /// Set the text rendering format `TextFormat` for the question.
16    fn set_text_format(&mut self, format: TextFormat);
17    /// Adds all answers from type `Vec<Answer>` to the Question variant type.
18    /// May return an error if there is a problem with the fractions or count of answers.
19    fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError>;
20    /// Writes the question in XML format to the provided file descriptor.
21    fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError>;
22}
23
24/// Represents the formatting options for the question text, feedback text and in other situations where Moodle could render it differently.
25#[derive(Debug, Default, Copy, Clone)]
26pub enum TextFormat {
27    #[default]
28    HTML,
29    Moodle,
30    Markdown,
31    PlainText,
32}
33impl TextFormat {
34    pub fn name(&self) -> &'static str {
35        match self {
36            TextFormat::HTML => "html",
37            TextFormat::Moodle => "moodle_auto_format",
38            TextFormat::Markdown => "markdown",
39            TextFormat::PlainText => "plain_text",
40        }
41    }
42}
43
44/// Represents a base for question in Moodle XML format.
45///
46/// # Fields
47///
48/// - `name`: The name of the question.
49/// - `description`: A description of the question.
50/// - `question_text_format`: The format that Moodle uses to render the question.
51/// - `answers`: A vector of answer objects associated with the question.
52///
53#[derive(Debug, Clone)]
54struct QuestionBase {
55    pub name: String,
56    pub description: String,
57    pub question_text_format: TextFormat,
58    pub answers: Vec<Answer>,
59}
60impl QuestionBase {
61    fn new(name: String, description: String) -> Self {
62        Self {
63            name,
64            description,
65            question_text_format: TextFormat::default(),
66            answers: Vec::new(),
67        }
68    }
69    /// Checks if the answers create the total fraction of 100% at least
70    /// There can be also cases where the total fraction is more than 100% because of multiple correct answers
71    fn check_answer_fraction(&mut self) -> Result<(), QuizError> {
72        let mut total_fraction = 0usize;
73        for answer in &self.answers {
74            total_fraction += answer.fraction as usize;
75        }
76        if total_fraction < 100 {
77            self.answers.clear();
78            return Err(QuizError::AnswerFractionError(
79                "The total fraction of answers must be at least 100".to_string(),
80            ));
81        }
82        Ok(())
83    }
84}
85
86impl Question for QuestionBase {
87    fn get_name(&self) -> &str {
88        self.name.as_str()
89    }
90    fn get_description(&self) -> &str {
91        self.description.as_str()
92    }
93    fn set_text_format(&mut self, format: TextFormat) {
94        self.question_text_format = format;
95    }
96    fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
97        self.answers.extend(answers);
98        self.check_answer_fraction()?;
99        Ok(())
100    }
101    /// Writes the common part between all types of the question for provided XML EventWriter<File>
102    fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
103        writer.write(XmlEvent::start_element("name"))?;
104        write_text_tag(writer, self.name.as_str(), false)?;
105        writer.write(XmlEvent::end_element())?;
106        writer.write(
107            XmlEvent::start_element("questiontext")
108                .attr("format", self.question_text_format.name()),
109        )?;
110        // By default, the text format should be specified on the parent of the <text> element.
111        write_text_tag(writer, self.description.as_str(), true)?;
112        writer.write(XmlEvent::end_element())?;
113        if self.answers.is_empty() {
114            return Err(EmptyError.into());
115        }
116        for answer in &self.answers {
117            answer.to_xml(writer)?;
118        }
119        Ok(())
120    }
121}
122
123/// Multiple choice question type.
124#[derive(Debug, Clone)]
125pub struct MultiChoiceQuestion {
126    base: QuestionBase,
127    pub single: bool,
128    pub shuffleanswers: bool, // Should be casted to u8 for XML
129    pub correctfeedback: String,
130    pub partiallycorrectfeedback: String,
131    pub incorrectfeedback: String,
132    // TODO use constrained type instead of string
133    pub answernumbering: String,
134}
135
136impl MultiChoiceQuestion {
137    /// New must take all the required fields after base wrapped with Option<> so that I can use default when not provided.
138    #[allow(clippy::too_many_arguments)]
139    pub fn new(
140        name: String,
141        description: String,
142        single: Option<bool>,
143        shuffleanswers: Option<bool>,
144        correctfeedback: Option<String>,
145        partiallycorrectfeedback: Option<String>,
146        incorrectfeedback: Option<String>,
147        answernumbering: Option<String>,
148    ) -> Self {
149        Self {
150            base: QuestionBase::new(name, description),
151            single: single.unwrap_or(true),
152            shuffleanswers: shuffleanswers.unwrap_or(true),
153            correctfeedback: correctfeedback.unwrap_or_default(),
154            partiallycorrectfeedback: partiallycorrectfeedback.unwrap_or_default(),
155            incorrectfeedback: incorrectfeedback.unwrap_or_default(),
156            answernumbering: answernumbering.unwrap_or_default(),
157        }
158    }
159}
160
161impl Question for MultiChoiceQuestion {
162    fn get_name(&self) -> &str {
163        self.base.get_name()
164    }
165    fn get_description(&self) -> &str {
166        self.base.get_description()
167    }
168    fn set_text_format(&mut self, format: TextFormat) {
169        self.base.question_text_format = format;
170    }
171    fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
172        self.base.add_answers(answers)
173    }
174    fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
175        // Start question tag
176        writer.write(XmlEvent::start_element("question").attr("type", "multichoice"))?;
177        // Write the common part of the question
178        self.base.to_xml(writer)?;
179
180        write_named_formatted_scope(writer, "single", None, |writer| {
181            writer.write(XmlEvent::characters(&self.single.to_string()))?;
182            Ok(())
183        })?;
184        write_named_formatted_scope(writer, "shuffleanswers", None, |writer| {
185            writer.write(XmlEvent::characters(
186                &(self.shuffleanswers as u8).to_string(),
187            ))?;
188            Ok(())
189        })?;
190        write_named_formatted_scope(
191            writer,
192            "correctfeedback",
193            TextFormat::default().into(),
194            |writer| write_text_tag(writer, &self.correctfeedback, true),
195        )?;
196        write_named_formatted_scope(
197            writer,
198            "partiallycorrectfeedback",
199            TextFormat::default().into(),
200            |writer| write_text_tag(writer, &self.partiallycorrectfeedback, true),
201        )?;
202        write_named_formatted_scope(
203            writer,
204            "incorrectfeedback",
205            TextFormat::default().into(),
206            |writer| write_text_tag(writer, &self.incorrectfeedback, true),
207        )?;
208        write_named_formatted_scope(writer, "answernumbering", None, |writer| {
209            writer.write(XmlEvent::characters(&self.answernumbering.to_string()))?;
210            Ok(())
211        })?;
212        // End question tag
213        writer.write(XmlEvent::end_element())?;
214        Ok(())
215    }
216}
217
218/// True/False question type. The amount of answers is fixed to 2, where one answer must have 100 fraction.
219#[derive(Debug, Clone)]
220pub struct TrueFalseQuestion {
221    base: QuestionBase,
222}
223impl TrueFalseQuestion {
224    pub fn new(name: String, description: String) -> Self {
225        Self {
226            base: QuestionBase::new(name, description),
227        }
228    }
229}
230
231impl Question for TrueFalseQuestion {
232    fn get_name(&self) -> &str {
233        self.base.get_name()
234    }
235    fn get_description(&self) -> &str {
236        self.base.get_description()
237    }
238    fn set_text_format(&mut self, format: TextFormat) {
239        self.base.question_text_format = format;
240    }
241    fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
242        if answers.len() != 2 {
243            return Err(QuizError::AnswerCountError(
244                "True/False questions must have exactly 2 answers".to_string(),
245            ));
246        }
247        if answers[0].fraction == 100 {
248            if answers[1].fraction == 0 {
249                // good
250            } else {
251                return Err(QuizError::AnswerFractionError(
252                    "Only fractions 100 and 0 are allowed in True/False questions".to_string(),
253                ));
254            }
255        } else if answers[1].fraction == 100 {
256            if answers[0].fraction == 0 {
257                // good
258            } else {
259                return Err(QuizError::AnswerFractionError(
260                    "Only fractions 100 and 0 are allowed in True/False questions".to_string(),
261                ));
262            }
263        } else {
264            return Err(QuizError::AnswerFractionError(
265                "Only fractions 100 and 0 are allowed in True/False questions".to_string(),
266            ));
267        }
268        self.base.add_answers(answers)
269    }
270    fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
271        // Start question tag
272        writer.write(XmlEvent::start_element("question").attr("type", "truefalse"))?;
273        // Write the common part of the question
274        self.base.to_xml(writer)?;
275        // End question tag
276        writer.write(XmlEvent::end_element())?;
277        Ok(())
278    }
279}
280
281/// Short answer question type.
282#[derive(Debug, Clone)]
283pub struct ShortAnswerQuestion {
284    base: QuestionBase,
285    // The <usecase> tag toggles case-sensitivity with the values 1/0.
286    pub usecase: bool,
287}
288
289impl ShortAnswerQuestion {
290    pub fn new(name: String, description: String, usecase: Option<bool>) -> Self {
291        Self {
292            base: QuestionBase::new(name, description),
293            usecase: usecase.unwrap_or_default(),
294        }
295    }
296}
297
298impl Question for ShortAnswerQuestion {
299    fn get_name(&self) -> &str {
300        self.base.get_name()
301    }
302    fn get_description(&self) -> &str {
303        self.base.get_description()
304    }
305    fn set_text_format(&mut self, format: TextFormat) {
306        self.base.question_text_format = format;
307    }
308    fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
309        self.base.add_answers(answers)
310    }
311    fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
312        // Start question tag
313        writer.write(XmlEvent::start_element("question").attr("type", "shortanswer"))?;
314        // Write the common part of the question
315        self.base.to_xml(writer)?;
316        write_named_formatted_scope(writer, "usecase", None, |writer| {
317            writer.write(XmlEvent::characters(&(self.usecase as u8).to_string()))?;
318            Ok(())
319        })?;
320        // End question tag
321        writer.write(XmlEvent::end_element())?;
322        Ok(())
323    }
324}
325
326/// Essay question type. There are no answers for this question type.
327#[derive(Debug, Clone)]
328pub struct EssayQuestion {
329    base: QuestionBase,
330}
331
332impl EssayQuestion {
333    pub fn new(name: String, description: String) -> Self {
334        Self {
335            base: QuestionBase::new(name, description),
336        }
337    }
338}
339
340impl Question for EssayQuestion {
341    fn get_name(&self) -> &str {
342        self.base.get_name()
343    }
344    fn get_description(&self) -> &str {
345        self.base.get_description()
346    }
347    fn set_text_format(&mut self, format: TextFormat) {
348        self.base.question_text_format = format;
349    }
350    fn add_answers(&mut self, answers: Vec<Answer>) -> Result<(), QuizError> {
351        if !answers.is_empty() {
352            return Err(QuizError::AnswerCountError(
353                "Essay questions must not have any answers".to_string(),
354            ));
355        }
356        Ok(())
357    }
358    fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
359        // Start question tag
360        writer.write(XmlEvent::start_element("question").attr("type", "essay"))?;
361        // Write the common part of the question
362        self.base.to_xml(writer)?;
363        // End question tag
364        writer.write(XmlEvent::end_element())?;
365        Ok(())
366    }
367}
368
369/// Represents the different types of questions that can be included in a quiz.
370///
371/// - `Multichoice`: A multiple-choice question with several answer options.
372/// - `TrueFalse`: A true/false question.
373/// - `ShortAnswer`: A short-answer question.
374/// - TODO - `Matching`: A matching question where items need to be paired.
375/// - TODO - `Cloze`: A cloze (fill-in-the-blank) question.
376/// - `Essay`: An essay question.
377/// - TODO `Numerical`: A numerical answer question.
378/// - TODO - `Description`: A descriptive question.
379pub enum QuestionType {
380    Multichoice(MultiChoiceQuestion),
381    TrueFalse(TrueFalseQuestion),
382    ShortAnswer(ShortAnswerQuestion),
383    // Matching,
384    // Cloze,
385    Essay(EssayQuestion),
386    // Numerical,
387    // Description,
388}
389impl QuestionType {
390    pub fn to_xml(&self, writer: &mut EventWriter<&File>) -> Result<(), QuizError> {
391        match self {
392            QuestionType::Multichoice(q) => q.to_xml(writer),
393            QuestionType::TrueFalse(q) => q.to_xml(writer),
394            QuestionType::ShortAnswer(q) => q.to_xml(writer),
395            QuestionType::Essay(q) => q.to_xml(writer),
396        }
397    }
398}
399
400// Make conversion from a single question to into a vector of questions easier with `.into()`
401macro_rules! impl_from_question {
402    ($(($question_type:ty, $variant:ident)),+) => {
403        $(
404            impl<Q> From<$question_type> for Vec<Q>
405            where
406                Q: Question,
407                $question_type: Into<Q>,
408            {
409                fn from(question: $question_type) -> Self {
410                    vec![question.into()]
411                }
412            }
413
414            impl From<$question_type> for Vec<Box<dyn Question>>
415            where
416                $question_type: Question + 'static,
417            {
418                fn from(question: $question_type) -> Self {
419                    vec![Box::new(question)]
420                }
421            }
422
423            impl From<$question_type> for QuestionType {
424                fn from(question: $question_type) -> Self {
425                    QuestionType::$variant(question)
426                }
427            }
428
429            impl From<$question_type> for Vec<QuestionType> {
430                fn from(question: $question_type) -> Self {
431                    vec![QuestionType::$variant(question)]
432                }
433            }
434        )+
435    };
436}
437
438impl_from_question!(
439    (MultiChoiceQuestion, Multichoice),
440    (TrueFalseQuestion, TrueFalse),
441    (ShortAnswerQuestion, ShortAnswer),
442    (EssayQuestion, Essay)
443);
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use std::io::{Read, Seek};
449    use xml::writer::EmitterConfig;
450
451    #[test]
452    fn test_multichoice_question_xml() {
453        let mut tmp_file = tempfile::tempfile().unwrap();
454        let mut writer = EmitterConfig::new()
455            .perform_indent(true)
456            .create_writer(&tmp_file);
457        let multichoice_question = MultiChoiceQuestion {
458            base: QuestionBase {
459                name: "Name of question".to_string(),
460                description: "What is the answer to this question?".to_string(),
461                question_text_format: TextFormat::HTML,
462                answers: vec![
463                    Answer {
464                        fraction: 100,
465                        text: "The correct answer".to_string(),
466                        feedback: "Correct!".to_string().into(),
467                        text_format: TextFormat::HTML,
468                    },
469                    Answer {
470                        fraction: 0,
471                        text: "A distractor".to_string(),
472                        feedback: "Ooops!".to_string().into(),
473                        text_format: TextFormat::HTML,
474                    },
475                    Answer {
476                        fraction: 0,
477                        text: "Another distractor".to_string(),
478                        feedback: "Ooops!".to_string().into(),
479                        text_format: TextFormat::HTML,
480                    },
481                ],
482            },
483            single: true,
484            shuffleanswers: true,
485            correctfeedback: "Correct!".to_string(),
486            partiallycorrectfeedback: "Partially correct!".to_string(),
487            incorrectfeedback: "Incorrect!".to_string(),
488            answernumbering: "abc".to_string(),
489        };
490        multichoice_question.to_xml(&mut writer).unwrap();
491
492        let mut buf = String::new();
493        tmp_file.seek(std::io::SeekFrom::Start(0)).unwrap();
494        tmp_file.read_to_string(&mut buf).unwrap();
495        print!("{buf}");
496        let expected = r#"<?xml version="1.0" encoding="utf-8"?>
497<question type="multichoice">
498  <name>
499    <text>Name of question</text>
500  </name>
501  <questiontext format="html">
502    <text><![CDATA[What is the answer to this question?]]></text>
503  </questiontext>
504  <answer fraction="100" format="html">
505    <text>The correct answer</text>
506    <feedback format="html">
507      <text>Correct!</text>
508    </feedback>
509  </answer>
510  <answer fraction="0" format="html">
511    <text>A distractor</text>
512    <feedback format="html">
513      <text>Ooops!</text>
514    </feedback>
515  </answer>
516  <answer fraction="0" format="html">
517    <text>Another distractor</text>
518    <feedback format="html">
519      <text>Ooops!</text>
520    </feedback>
521  </answer>
522  <single>true</single>
523  <shuffleanswers>1</shuffleanswers>
524  <correctfeedback format="html">
525    <text><![CDATA[Correct!]]></text>
526  </correctfeedback>
527  <partiallycorrectfeedback format="html">
528    <text><![CDATA[Partially correct!]]></text>
529  </partiallycorrectfeedback>
530  <incorrectfeedback format="html">
531    <text><![CDATA[Incorrect!]]></text>
532  </incorrectfeedback>
533  <answernumbering>abc</answernumbering>
534</question>"#;
535        assert_eq!(expected, buf);
536    }
537    #[test]
538    fn test_truefalse_question_xml() {
539        let mut tmp_file = tempfile::tempfile().unwrap();
540        let mut writer = EmitterConfig::new()
541            .perform_indent(true)
542            .create_writer(&tmp_file);
543        let truefalse_question = TrueFalseQuestion {
544            base: QuestionBase {
545                name: "Name of question".to_string(),
546                description: "What is the answer to this question?".to_string(),
547                question_text_format: TextFormat::HTML,
548                answers: vec![
549                    Answer {
550                        fraction: 100,
551                        text: "True".to_string(),
552                        feedback: "Correct!".to_string().into(),
553                        text_format: TextFormat::HTML,
554                    },
555                    Answer {
556                        fraction: 0,
557                        text: "False".to_string(),
558                        feedback: "Ooops!".to_string().into(),
559                        text_format: TextFormat::HTML,
560                    },
561                ],
562            },
563        };
564        truefalse_question.to_xml(&mut writer).unwrap();
565
566        let mut buf = String::new();
567        tmp_file.seek(std::io::SeekFrom::Start(0)).unwrap();
568        tmp_file.read_to_string(&mut buf).unwrap();
569        let expected = r#"<?xml version="1.0" encoding="utf-8"?>
570<question type="truefalse">
571  <name>
572    <text>Name of question</text>
573  </name>
574  <questiontext format="html">
575    <text><![CDATA[What is the answer to this question?]]></text>
576  </questiontext>
577  <answer fraction="100" format="html">
578    <text>True</text>
579    <feedback format="html">
580      <text>Correct!</text>
581    </feedback>
582  </answer>
583  <answer fraction="0" format="html">
584    <text>False</text>
585    <feedback format="html">
586      <text>Ooops!</text>
587    </feedback>
588  </answer>
589</question>"#;
590        assert_eq!(expected, buf);
591    }
592}