diff options
| author | Miquel Sabaté Solà <mikisabate@gmail.com> | 2025-07-29 21:55:31 +0200 |
|---|---|---|
| committer | Miquel Sabaté Solà <mikisabate@gmail.com> | 2025-07-29 21:58:02 +0200 |
| commit | 7b7faf70dbdd83020b4bf6f9c3242a994c97665a (patch) | |
| tree | b4adb273557507ee882c235bddcc11425ebf1e46 | |
| parent | b25f575e41f078be3e47d843fda1827881f23646 (diff) | |
| download | mihi-7b7faf70dbdd83020b4bf6f9c3242a994c97665a.tar.gz mihi-7b7faf70dbdd83020b4bf6f9c3242a994c97665a.zip | |
Add exercises to the practice command
The '-e/--exercises' has also been introduced to instruct the 'practice'
command to only go for exercises, and the '-k/--kind' flag allows the
user to further filter which kind of exercise to practice.
Signed-off-by: Miquel Sabaté Solà <mikisabate@gmail.com>
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | crates/cli/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/cli/src/exercises.rs | 2 | ||||
| -rw-r--r-- | crates/cli/src/run.rs | 202 | ||||
| -rw-r--r-- | lib/mihi/src/lib.rs | 66 |
5 files changed, 259 insertions, 13 deletions
@@ -49,6 +49,7 @@ dependencies = [ "mihi", "serde", "serde_json", + "tempfile", ] [[package]] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ab5a406..cd2a8c9 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -13,3 +13,4 @@ mihi.workspace = true inquire = { version = "0.7.5", features = ["editor"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3.20" diff --git a/crates/cli/src/exercises.rs b/crates/cli/src/exercises.rs index a4bff78..b153aca 100644 --- a/crates/cli/src/exercises.rs +++ b/crates/cli/src/exercises.rs @@ -195,7 +195,7 @@ fn rm(mut args: IntoIter<String>) -> i32 { match ans { Ok(true) => match mihi::delete_exercise(selection) { - Ok(_) => println!("Removed '{}' from the database!", selection), + Ok(_) => println!("Removed '{selection}' from the database!"), Err(e) => { println!("error: words: {e}"); return 1; diff --git a/crates/cli/src/run.rs b/crates/cli/src/run.rs index 31c521d..4e6e707 100644 --- a/crates/cli/src/run.rs +++ b/crates/cli/src/run.rs @@ -1,5 +1,10 @@ -use inquire::Text; -use mihi::{select_random_words, update_success, Category, Word}; +use inquire::{Confirm, Editor, Text}; +use mihi::{select_random_words, update_success, Category, Exercise, ExerciseKind, Word}; +use std::env; +use std::fs; +use std::io::Write; +use std::process::Command; +use tempfile::NamedTempFile; // Maximum number of times a word has to be run in order to increase the number // of successful runs. @@ -11,18 +16,26 @@ fn help(msg: Option<&str>) { } println!("mihi run: Run exercises. Default command if none was given.\n"); - println!("usage: mihi run [OPTIONS]\n"); + println!("usage: mihi practice [OPTIONS]\n"); println!("Options:"); - println!(" -h, --help\t\tPrint this message."); + println!(" -c, --category <CATEGORY>\tOnly ask for words on the given <CATEGORY>."); + println!(" -e, --exercises\t\tOnly practice with exercises."); + println!(" -h, --help\t\t\tPrint this message."); + println!(" -k, --kind <KIND>\t\tOnly ask for exercises for the given <KIND>."); } +// Locale represents the locales accepted for delivering answers on this +// tool. That is, it's not about i18n on the strings for this application. but +// rather the different translations accepted in places like +// `Word.translations`. enum Locale { English, Catalan, } impl Locale { + // Returns the string representation for the locale's code. fn to_code(&self) -> &str { match self { Self::English => "en", @@ -40,7 +53,9 @@ impl std::fmt::Display for Locale { } } -fn run_words(words: Vec<Word>, locale: Locale) -> i32 { +// Run the quiz for all the given `words` while expecting answers to be +// delivered in the given `locale`. +fn run_words(words: Vec<Word>, locale: &Locale) -> i32 { let mut errors = 0; for word in words { @@ -78,6 +93,8 @@ fn run_words(words: Vec<Word>, locale: Locale) -> i32 { errors } +// Returns a vector of words which contain a randomized set of words from +// different categories. fn select_general_words() -> Result<Vec<Word>, String> { let mut res = select_random_words(Category::Noun, 4)?; res.append(&mut select_random_words(Category::Adjective, 2)?); @@ -89,9 +106,125 @@ fn select_general_words() -> Result<Vec<Word>, String> { Ok(res) } +// Assuming that the `given` string is the answer for an exercise enunciate, +// remove the enunciate proper (enveloped via '---' comments) and return only +// what the user typed in. +fn remove_exercise_enunciate(given: String) -> String { + let mut res = vec![]; + let mut found = false; + + for line in given.lines() { + let trimmed = line.trim(); + + if found { + res.push(line); + } + if trimmed.starts_with("---!") { + found = true; + } + } + + res.join("\n").to_string() +} + +// Returns true if the given `bin` exists on the PATH, false otherwise. +fn is_executable(bin: &str) -> bool { + if let Ok(path) = env::var("PATH") { + for p in path.split(":") { + let p_str = format!("{p}/{bin}"); + if fs::metadata(p_str).is_ok() { + return true; + } + } + } + false +} + +// Returns the string for the name of the command that should be used to show +// diffs. +fn diff_tool() -> Option<&'static str> { + ["difft", "vimdiff", "diff"].into_iter().find(|&cmd| is_executable(cmd)) +} + +// Perform a diff with the `given` and the `expected` answers for an exercise +// and interactively ask the user if things are ok. Returns a boolean depending +// on the user's answer to that final question, or false if something went +// wrong. +fn accepted_diff(given: String, expected: String) -> bool { + // If a diff tool could be fetched, then write into temporary files and call + // the diff tool against both temporary files; otherwise just print things + // out into the stdout. + match diff_tool() { + Some(cmd) => { + let Ok(mut given_file) = NamedTempFile::new() else { + return false; + }; + if writeln!(given_file, "{given}").is_err() { + return false; + } + + let Ok(mut expected_file) = NamedTempFile::new() else { + return false; + }; + if writeln!(expected_file, "{expected}").is_err() { + return false; + } + + let mut cmd = Command::new(cmd); + cmd.arg(given_file.path()).arg(expected_file.path()); + cmd.status().expect("process failed to execute"); + println!(); + } + None => { + println!("---Given:\n{given}\n---Expected:\n{expected}"); + } + } + + Confirm::new("Do you think that you did well?") + .with_default(false) + .prompt() + .unwrap_or(false) +} + +// Run the quiz for all the given `exercises`. +fn run_exercises(exercises: Vec<Exercise>) -> i32 { + if exercises.is_empty() { + println!("practice: no exercises!"); + return 0; + } + + let mut errors = 0; + + for exercise in exercises { + let Ok(solution) = Editor::new( + format!("Exercise '{}' (kind: {}):", exercise.title, exercise.kind).as_str(), + ) + .with_predefined_text( + format!( + "---Enunciate: {}\n{}\n---!", + exercise.title, exercise.enunciate + ) + .as_str(), + ) + .with_file_extension(".md") + .prompt() else { + return 1; + }; + let solution = remove_exercise_enunciate(solution); + + if !accepted_diff(solution, exercise.solution) { + errors += 1; + } + } + + errors +} + pub fn run(args: Vec<String>) { let mut it = args.into_iter(); let mut category = None; + let mut kind: Option<ExerciseKind> = None; + let mut exercises_only = false; while let Some(first) = it.next() { match first.as_str() { @@ -101,7 +234,10 @@ pub fn run(args: Vec<String>) { } "-c" | "--category" => { if category.is_some() { - help(Some("error: run: you cannot provide multiple categories")); + help(Some( + "error: practice: you cannot provide multiple categories", + )); + std::process::exit(1); } match it.next() { Some(cat) => { @@ -114,15 +250,43 @@ pub fn run(args: Vec<String>) { "preposition" => Some(Category::Preposition), "conjunction" => Some(Category::Conjunction), "determiner" => Some(Category::Determiner), - _ => return help(Some("error: run: category not allowed")), + _ => return help(Some("error: practice: category not allowed")), + }; + } + None => { + help(Some("error: practice: you have to provide a category")); + std::process::exit(1); + } + } + } + "-e" | "--exercises" => { + exercises_only = true; + } + "-k" | "--kind" => { + if kind.is_some() { + help(Some( + "error: practice: you cannot provide multiple exercise kinds", + )); + std::process::exit(1); + } + match it.next() { + Some(k) => { + kind = match k.trim().to_lowercase().as_str().try_into() { + Ok(k) => Some(k), + Err(e) => { + return help(Some(format!("error: practice: {e}").as_str())) + } }; } - None => help(Some("error: run: you have to provide a category")), + None => { + help(Some("error: practice: you have to provide a category")); + std::process::exit(1); + } } } _ => { help(Some( - format!("error: run: unknown flag or command '{first}'").as_str(), + format!("error: practice: unknown flag or command '{first}'").as_str(), )); std::process::exit(1); } @@ -141,10 +305,26 @@ pub fn run(args: Vec<String>) { None => select_general_words(), }; + let exercises = match mihi::select_relevant_exercises(kind, if exercises_only { 5 } else { 1 }) + { + Ok(exercises) => exercises, + Err(e) => { + println!("error: practice: {e}"); + std::process::exit(1); + } + }; + match words { - Ok(list) => std::process::exit(run_words(list, locale)), + Ok(list) => { + let mut code = 0; + if !exercises_only { + code += run_words(list, &locale); + } + code += run_exercises(exercises); + std::process::exit(code); + } Err(e) => { - println!("error: run: {e}"); + println!("error: practice: {e}"); std::process::exit(1); } }; diff --git a/lib/mihi/src/lib.rs b/lib/mihi/src/lib.rs index 7d5cc8c..7d13226 100644 --- a/lib/mihi/src/lib.rs +++ b/lib/mihi/src/lib.rs @@ -563,7 +563,21 @@ impl TryFrom<usize> for ExerciseKind { 1 => Ok(Self::Translation), 2 => Ok(Self::Transformation), 3 => Ok(Self::Numerical), - _ => Err("unknonwn kind!"), + _ => Err("unknonwn exercise kind"), + } + } +} + +impl TryFrom<&str> for ExerciseKind { + type Error = &'static str; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + match value { + "pensum" => Ok(Self::Pensum), + "translation" => Ok(Self::Translation), + "transformation" => Ok(Self::Transformation), + "numerical" => Ok(Self::Numerical), + _ => Err("unknonwn exercise kind. Available: pensum, translation, transformation and numerical"), } } } @@ -689,3 +703,53 @@ pub fn delete_exercise(title: &str) -> Result<(), String> { Err(e) => Err(format!("could not remove '{title}': {e}")), } } + +// Get a list of exercises sorted by relevance. A maximum of `limit` exercises +// will be returned, and you can also specify to filter the returned exercises +// by `kind`. +pub fn select_relevant_exercises( + kind: Option<ExerciseKind>, + limit: usize, +) -> Result<Vec<Exercise>, String> { + let conn = get_connection()?; + + let mut stmt; + let mut it = match kind { + Some(kind) => { + stmt = conn + .prepare( + "SELECT id, title, enunciate, solution, lessons, kind \ + FROM exercises \ + WHERE kind = ?1 \ + ORDER BY updated_at DESC \ + LIMIT ?2", + ) + .unwrap(); + stmt.query([kind as usize, limit]).unwrap() + } + None => { + stmt = conn + .prepare( + "SELECT id, title, enunciate, solution, lessons, kind \ + FROM exercises \ + ORDER BY updated_at DESC \ + LIMIT ?1", + ) + .unwrap(); + stmt.query([limit]).unwrap() + } + }; + + let mut res = vec![]; + while let Some(row) = it.next().unwrap() { + res.push(Exercise { + id: row.get(0).unwrap(), + title: row.get(1).unwrap(), + enunciate: row.get(2).unwrap(), + solution: row.get(3).unwrap(), + lessons: row.get(4).unwrap(), + kind: row.get::<usize, usize>(5).unwrap().try_into()?, + }); + } + Ok(res) +} |
