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}