aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorMiquel Sabaté Solà <mikisabate@gmail.com>2025-07-29 21:55:31 +0200
committerMiquel Sabaté Solà <mikisabate@gmail.com>2025-07-29 21:58:02 +0200
commit7b7faf70dbdd83020b4bf6f9c3242a994c97665a (patch)
treeb4adb273557507ee882c235bddcc11425ebf1e46 /crates
parentb25f575e41f078be3e47d843fda1827881f23646 (diff)
downloadmihi-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>
Diffstat (limited to 'crates')
-rw-r--r--crates/cli/Cargo.toml1
-rw-r--r--crates/cli/src/exercises.rs2
-rw-r--r--crates/cli/src/run.rs202
3 files changed, 193 insertions, 12 deletions
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);
}
};