mapm/
problem.rs

1//! Fetching and filtering functions for problems
2
3use crate::result::MapmErr;
4use crate::result::MapmErr::*;
5use crate::result::MapmResult;
6
7use crate::template::Template;
8use Filter::*;
9use FilterAction::*;
10use Views::*;
11
12use std::fmt::Write;
13use std::fs;
14use std::path::Path;
15
16use std::collections::HashMap;
17
18use serde::{Deserialize, Serialize};
19
20use serde_yaml::Value;
21
22#[derive(Debug, Serialize, Deserialize)]
23struct SerializedProblem {
24    pub choices: Option<Vec<String>>,
25    pub solutions: Option<Solutions>,
26    #[serde(flatten)]
27    pub vars: HashMap<String, Value>,
28}
29
30pub type Vars = HashMap<String, String>;
31pub type Solutions = Vec<HashMap<String, String>>;
32
33#[derive(Debug, PartialEq, Clone)]
34pub struct Problem {
35    pub name: String,
36    pub vars: Vars,
37    pub choices: Option<Vec<String>>,
38    pub solutions: Option<Solutions>,
39}
40
41/// Defines types of filters for problems
42///
43/// Only the "Exists" variant may be used for the special key "solutions".
44///
45/// For the Gt, Lt, Ge, Le conditions, whoever is working with the problem files (either through CLI utils or a GUI) is responsible for ensuring that any key being compared with Gt, Lt, Ge, Le is a non-negative integer
46
47#[derive(Clone)]
48pub enum Filter {
49    Exists { key: String },
50    Eq { key: String, val: String },
51    Gt { key: String, val: usize },
52    Lt { key: String, val: usize },
53    Ge { key: String, val: usize },
54    Le { key: String, val: usize },
55}
56
57/// Defines the actions for filtering problems
58///
59/// Positive means "keep if this condition is satisfied", negative means "keep if this condition is **not** satisfied"
60#[derive(Clone)]
61pub enum FilterAction {
62    Positive(Filter),
63    Negative(Filter),
64}
65
66/// Views *only* shown strings or views everything *but* hidden strings
67#[derive(Clone)]
68pub enum Views {
69    Show(Vec<String>),
70    Hide(Vec<String>),
71}
72
73/// Fetches problem given the name (which corresponds to the filename) and the directory to find it
74/// in
75
76pub fn fetch_problem<T: AsRef<Path>>(problem_name: &str, problem_dir: T) -> MapmResult<Problem> {
77    let problem_dir: &Path = problem_dir.as_ref();
78    let problem_path = &problem_dir.join(&format!("{}.yml", problem_name));
79    match fs::read_to_string(problem_path) {
80        Ok(problem_yaml) => parse_problem_yaml(problem_name, &problem_yaml),
81        Err(_) => Err(ProblemErr(format!(
82            "Could not read problem `{}` from {:?}",
83            problem_name, problem_path
84        ))),
85    }
86}
87
88impl Problem {
89    /// Checks if a problem passes or fails a certain filter
90    ///
91    /// For Gt, it checks if the value of the problem is greater than the value passed in the filter (not the other way around). The same is true of Lt, Ge, Le.
92    ///
93    /// # Usage
94    ///
95    /// ```
96    /// use mapm::problem::Problem;
97    /// use mapm::problem::Filter;
98    /// use std::collections::HashMap;
99    ///
100    /// let mut vars: HashMap<String, String> = HashMap::new();
101    /// vars.insert(String::from("problem"), String::from("What is $1+1$?"));
102    /// vars.insert(String::from("author"), String::from("Dennis Chen"));
103    /// vars.insert(String::from("difficulty"), String::from("5"));
104    ///
105    /// let mut solution_one: HashMap<String, String> = HashMap::new();
106    /// solution_one.insert(String::from("text"), String::from("It's probably $2$."));
107    /// solution_one.insert(String::from("author"), String::from("Dennis Chen"));
108    /// let mut solution_two = HashMap::new();
109    /// solution_two.insert(String::from("text"), String::from("The answer is $2$, but my proof is too small to fit into the margin."));
110    /// solution_two.insert(String::from("author"), String::from("Pierre de Fermat"));
111    ///
112    /// let mut solutions: Option<Vec<HashMap<String, String>>> = Some(vec![solution_one, solution_two]);
113    ///
114    /// let problem = Problem { name: String::from("problem"), vars, choices: None, solutions };
115    ///
116    /// assert_eq!(problem.try_filter(&Filter::Exists{key: String::from("subject")}), false);
117    /// assert_eq!(problem.try_filter(&Filter::Exists{key: String::from("solutions")}), true);
118    ///
119    /// assert_eq!(problem.try_filter(&Filter::Gt{key: String::from("difficulty"), val: 5}), false);
120    /// assert_eq!(problem.try_filter(&Filter::Ge{key: String::from("difficulty"), val: 5}), true);
121    /// assert_eq!(problem.try_filter(&Filter::Lt{key: String::from("difficulty"), val: 5}), false);
122    /// assert_eq!(problem.try_filter(&Filter::Le{key: String::from("difficulty"), val: 5}), true);
123    ///
124    /// ```
125
126    pub fn try_filter(&self, filter: &Filter) -> bool {
127        match filter {
128            Exists { key } => {
129                if key == "solutions" {
130                    !(self.solutions == None)
131                } else {
132                    self.vars.contains_key(key)
133                }
134            }
135            Eq { key, val } => match self.vars.get(key) {
136                Some(problem_value) => problem_value == val,
137                None => false,
138            },
139            Gt { key, val } => match self.vars.get(key) {
140                Some(problem_value) => match problem_value.parse::<usize>() {
141                    Ok(problem_value_usize) => problem_value_usize > *val,
142                    Err(_) => false,
143                },
144                None => false,
145            },
146            Lt { key, val } => match self.vars.get(key) {
147                Some(problem_value) => match problem_value.parse::<usize>() {
148                    Ok(problem_value_usize) => problem_value_usize < *val,
149                    Err(_) => false,
150                },
151                None => false,
152            },
153            Ge { key, val } => match self.vars.get(key) {
154                Some(problem_value) => match problem_value.parse::<usize>() {
155                    Ok(problem_value_usize) => problem_value_usize >= *val,
156                    Err(_) => false,
157                },
158                None => false,
159            },
160            Le { key, val } => match self.vars.get(key) {
161                Some(problem_value) => match problem_value.parse::<usize>() {
162                    Ok(problem_value_usize) => problem_value_usize <= *val,
163                    Err(_) => false,
164                },
165                None => false,
166            },
167        }
168    }
169
170    /// Checks whether a problem satisfies a set of filters, and if it does, return it; otherwise return `None`
171    ///
172    /// If `filters` is empty, then every problem will pass the filter.
173
174    pub fn filter(&self, filters: &[FilterAction]) -> Option<Self> {
175        for filter_action in filters {
176            match filter_action {
177                Positive(filter) => {
178                    if !self.try_filter(filter) {
179                        return None;
180                    }
181                }
182                Negative(filter) => {
183                    if self.try_filter(filter) {
184                        return None;
185                    }
186                }
187            }
188        }
189        Some(self.clone())
190    }
191
192    /// Show only the keys passed in or everything but the keys passed in to a problem
193    ///
194    /// # Usage
195    ///
196    /// ```
197    /// use mapm::problem::Views;
198    /// use mapm::problem::Views::{Show, Hide};
199    /// use mapm::problem::Problem;
200    /// use mapm::problem::Filter;
201    /// use std::collections::HashMap;
202    ///
203    /// let problem = Problem {
204    ///     name: String::from("problem"),
205    ///     vars: HashMap::from([
206    ///         (String::from("problem"), String::from("What is $1+1$?")),
207    ///         (String::from("author"), String::from("Dennis Chen")),
208    ///         (String::from("answer"), String::from("2"))
209    ///     ]),
210    ///     choices: None,
211    ///     solutions: Some(vec![HashMap::from([
212    ///         (String::from("text"), String::from("It's $2$.")),
213    ///         (String::from("author"), String::from("Alexander")),
214    ///     ])])
215    /// };
216    ///
217    /// let show: Views = Show(vec![String::from("author"), String::from("answer"), String::from("solutions")]);
218    /// let show_filtered_problem = problem.clone().filter_keys(&show);
219    /// assert_eq!(show_filtered_problem.vars, HashMap::from([
220    ///     (String::from("author"), String::from("Dennis Chen")),
221    ///     (String::from("answer"), String::from("2"))
222    /// ]));
223    /// assert_eq!(show_filtered_problem.solutions, Some(vec![
224    ///     HashMap::from([
225    ///         (String::from("text"), String::from("It's $2$.")),
226    ///         (String::from("author"), String::from("Alexander"))
227    ///     ])
228    /// ]));
229    ///
230    /// let hide: Views = Hide(vec![String::from("solutions"), String::from("author")]);
231    /// let hide_filtered_problem = problem.clone().filter_keys(&hide);
232    /// assert_eq!(hide_filtered_problem.vars, HashMap::from([
233    ///     (String::from("problem"), String::from("What is $1+1$?")),
234    ///     (String::from("answer"), String::from("2")),
235    /// ]));
236    /// assert_eq!(hide_filtered_problem.solutions, None);
237    /// ```
238
239    pub fn filter_keys(&mut self, views: &Views) -> Self {
240        match views {
241            Show(tags) => {
242                let mut vars: Vars = HashMap::new();
243                for (key, val) in &self.vars {
244                    if key != "solutions" && tags.contains(key) {
245                        vars.insert(key.to_string(), val.to_string());
246                    }
247                }
248                if tags.contains(&"solutions".to_string()) {
249                    Problem {
250                        name: self.name.clone(),
251                        vars,
252                        choices: self.choices.clone(),
253                        solutions: self.solutions.clone(),
254                    }
255                } else {
256                    Problem {
257                        name: self.name.clone(),
258                        vars,
259                        choices: self.choices.clone(),
260                        solutions: None,
261                    }
262                }
263            }
264            Hide(tags) => {
265                let mut vars: Vars = HashMap::new();
266                for (key, val) in &self.vars {
267                    if key != "solutions" && !tags.contains(key) {
268                        vars.insert(key.to_string(), val.to_string());
269                    }
270                }
271                if !tags.contains(&"solutions".to_string()) {
272                    Problem {
273                        name: self.name.clone(),
274                        vars,
275                        choices: self.choices.clone(),
276                        solutions: self.solutions.clone(),
277                    }
278                } else {
279                    Problem {
280                        name: self.name.clone(),
281                        vars,
282                        choices: self.choices.clone(),
283                        solutions: None,
284                    }
285                }
286            }
287        }
288    }
289
290    /// Checks if a problem contains all the variables in a template
291    ///
292    /// Returns None if the problem successfully passes the test, returns Some(MapmErr) otherwise
293    ///
294    /// # Usage
295    ///
296    /// ## Expected success
297    ///
298    /// ```
299    /// use mapm::problem::Problem;
300    /// use mapm::template::Template;
301    /// use std::collections::HashMap;
302    ///
303    /// let problem = Problem {
304    ///     name: String::from("problem"),
305    ///     vars: HashMap::from([
306    ///         (String::from("problem"), String::from("What is $1+1?$"))
307    ///     ]),
308    ///     choices: None,
309    ///     solutions: Some(vec![HashMap::from([
310    ///         (String::from("text"), String::from("Some say the answer is $2$.")),
311    ///         (String::from("author"), String::from("Dennis Chen")),
312    ///     ])])
313    /// };
314    ///
315    /// let template = Template {
316    ///     name: String::from("template"),
317    ///     engine: String::from("pdflatex"),
318    ///     problem_count: Some(1),
319    ///     texfiles: HashMap::from([
320    ///         (String::from("problems.tex"), String::from("problems.pdf"))
321    ///     ]),
322    ///     choices: None,
323    ///     vars: vec![String::from("title"), String::from("year")],
324    ///     problemvars: vec![String::from("problem")],
325    ///     solutionvars: vec![String::from("text"), String::from("author")],
326    /// };
327    ///
328    /// assert!(problem.check_template(&template).is_ok());
329    /// ```
330    ///
331    /// ## Expected failure
332    ///
333    /// ```
334    /// use mapm::problem::Problem;
335    /// use mapm::template::Template;
336    /// use mapm::result::MapmErr;
337    /// use mapm::result::MapmErr::*;
338    /// use std::collections::HashMap;
339    ///
340    /// let problem = Problem {
341    ///     name: String::from("problem"),
342    ///     vars: HashMap::from([(String::from("problem"), String::from("What is $1+1?$"))]),
343    ///     choices: None,
344    ///     solutions: Some(vec![HashMap::from([
345    ///         (String::from("text"), String::from("Some say the answer is $2$.")),
346    ///         (String::from("author"), String::from("Dennis Chen")),
347    ///     ])])
348    /// };
349    ///
350    /// let template = Template {
351    ///     name: String::from("template"),
352    ///     engine: String::from("pdflatex"),
353    ///     problem_count: Some(1),
354    ///     texfiles: HashMap::from([(
355    ///         String::from("problems.tex"), String::from("problems.pdf")
356    ///     )]),
357    ///     choices: None,
358    ///     vars: vec!(String::from("title"), String::from("year")) ,
359    ///     problemvars: vec!(String::from("problem"), String::from("author")),
360    ///     solutionvars: vec!(String::from("text"), String::from("author")),
361    /// };
362    /// let template_check = problem.check_template(&template).unwrap_err();
363    ///
364    /// assert_eq!(template_check.len(), 1);
365    /// match &template_check[0] {
366    ///     ProblemErr(err) => {
367    ///         assert_eq!(err, "Does not contain key `author`");
368    ///     }
369    ///     _ => {
370    ///         panic!("MapmErr type is not ProblemErr");
371    ///     }
372    /// }
373    /// ```
374
375    pub fn check_template(&self, template: &Template) -> Result<(), Vec<MapmErr>> {
376        let mut mapm_errs: Vec<MapmErr> = Vec::new();
377
378        for problemvar in &template.problemvars {
379            if !self.vars.contains_key(problemvar) {
380                mapm_errs.push(ProblemErr(format!("Does not contain key `{}`", problemvar)));
381            }
382        }
383
384        if let Some(solutions) = &self.solutions {
385            for (idx, map) in solutions.iter().enumerate() {
386                for solutionvar in &template.solutionvars {
387                    if !map.contains_key(solutionvar) {
388                        mapm_errs.push(SolutionErr(format!(
389                            "Solution `{}` does not contain key `{}`",
390                            idx, solutionvar,
391                        )));
392                    }
393                }
394            }
395        }
396
397        // We don't check that problem.choices == template.choices if template.choices = None
398        // because we want to enable flexibility between switching a problem between an MC exam
399        // (say AMCs) and a short-answer exam (say AIME).
400        if let Some(tc) = template.choices {
401            if let Some(pc_vec) = &self.choices {
402                let pc = pc_vec.len();
403                if pc != tc {
404                    mapm_errs.push(ProblemErr(format!(
405                        "Problem has {} answer choices and should have {}",
406                        pc, tc
407                    )));
408                }
409            } else {
410                mapm_errs.push(ProblemErr(format!(
411                    "Problem has no answer choices and should have {}",
412                    tc
413                )));
414            }
415        }
416
417        if mapm_errs.is_empty() {
418            Ok(())
419        } else {
420            Err(mapm_errs)
421        }
422    }
423}
424
425fn parse_problem_yaml(name: &str, yaml: &str) -> MapmResult<Problem> {
426    match serde_yaml::from_str::<SerializedProblem>(yaml) {
427        Ok(problem) => {
428            let mut vars: Vars = HashMap::new();
429            for (key, val) in problem.vars {
430                match val {
431                    Value::String(val) => {
432                        vars.insert(key, val);
433                    }
434                    Value::Number(val) => {
435                        vars.insert(key, val.to_string());
436                    }
437                    Value::Bool(val) => {
438                        match val {
439                            true => vars.insert(key, String::from("true")),
440                            false => vars.insert(key, String::from("false")),
441                        };
442                    }
443                    _ => {
444                        return Err(ProblemErr(format!("Could not parse key `{}`", key)));
445                    }
446                }
447            }
448            Ok(Problem {
449                name: String::from(name),
450                vars,
451                choices: problem.choices,
452                solutions: problem.solutions,
453            })
454        }
455        Err(err) => Err(ProblemErr(err.to_string())),
456    }
457}
458
459/// Converts a vector of problems into TeX files and writes them in the current working directory
460///
461/// Internal function used for compiling vectors of problems and for compiling contests
462///
463/// Returns a header TeX string as well (for contest compilation if necessary)
464
465pub(crate) fn write_as_tex(problem_vec: &[Problem]) -> String {
466    let mut problem_number = 0;
467    let mut headers = String::new();
468
469    let _ = write!(
470        headers,
471        "\\expandafter\\def\\csname mapm@probcount\\endcsname{{{}}}",
472        problem_vec.len()
473    );
474
475    for problem in problem_vec {
476        problem_number += 1;
477        let _ = write!(
478            headers,
479            "\\expandafter\\def\\csname mapm@solcount@{}\\endcsname{{{}}}",
480            problem_number,
481            match &problem.solutions {
482                Some(solutions) => solutions.len(),
483                None => 0,
484            }
485        );
486        let _ = write!(
487            headers,
488            "\\expandafter\\def\\csname mapm@probname@{}\\endcsname{{{}}}",
489            problem_number, problem.name
490        );
491        for (key, val) in &problem.vars {
492            let filename = &format!("mapm-prob-{}-{}.tex", problem_number, key);
493            fs::write(filename, val)
494                .unwrap_or_else(|_| panic!("Could not write to `{}`", filename));
495        }
496        if let Some(solutions) = &problem.solutions {
497            for (pos, map) in solutions.iter().enumerate() {
498                for (key, val) in map {
499                    let solution_number = pos + 1;
500                    let filename = &format!(
501                        "mapm-sol-{}-{}-{}.tex",
502                        problem_number, solution_number, key,
503                    );
504                    fs::write(filename, val)
505                        .unwrap_or_else(|_| panic!("Could not write to `{}`", filename));
506                }
507            }
508        }
509    }
510    headers
511}
512
513#[cfg(test)]
514mod tests {
515    #[test]
516    fn test_parse() {
517        use super::parse_problem_yaml;
518        use std::collections::HashMap;
519
520        let yaml = "problem: What is $1+1$?
521author: Dennis Chen
522solutions:
523  - text: It's probably $2$.
524    author: Dennis Chen
525  - text: The answer is $2$, but my proof is too small to fit into the margin.
526    author: Pierre de Fermat
527";
528        let problem = parse_problem_yaml("problem", yaml).unwrap();
529
530        let mut vars = HashMap::new();
531        vars.insert(String::from("problem"), String::from("What is $1+1$?"));
532        vars.insert(String::from("author"), String::from("Dennis Chen"));
533
534        let mut solution_one = HashMap::new();
535        solution_one.insert(String::from("text"), String::from("It's probably $2$."));
536        solution_one.insert(String::from("author"), String::from("Dennis Chen"));
537        let mut solution_two = HashMap::new();
538        solution_two.insert(
539            String::from("text"),
540            String::from("The answer is $2$, but my proof is too small to fit into the margin."),
541        );
542        solution_two.insert(String::from("author"), String::from("Pierre de Fermat"));
543
544        let solutions = Some(vec![solution_one, solution_two]);
545
546        assert_eq!(problem.name, "problem");
547        assert_eq!(problem.vars, vars);
548        assert_eq!(problem.solutions, solutions);
549    }
550}