wr/
lib.rs

1use anyhow::{anyhow, bail, Context};
2use fs_err::read_dir;
3use regex::Regex;
4use rusqlite::{params, Connection};
5use std::cmp::Ordering;
6use std::collections::BTreeSet;
7use std::ffi::OsStr;
8use std::fmt::Formatter;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12#[derive(serde::Deserialize, Debug)]
13/// The configuration for the current collection of exercises.
14pub struct ExercisesConfig {
15    /// The path to the directory containing the exercises, relative
16    /// to the root of the repository.
17    #[serde(default = "default_exercise_dir")]
18    exercises_dir: PathBuf,
19    /// The command that should be run to verify that the workshop-runner is working as expected.
20    #[serde(default)]
21    verification: Vec<Verification>,
22    /// Don't try to build the project before running the verification command.
23    #[serde(default)]
24    pub skip_build: bool,
25}
26
27#[derive(serde::Deserialize, Debug)]
28/// The configuration for a specific exercise.
29pub struct ExerciseConfig {
30    /// The commands that should be run to verify this exercise.
31    /// It overrides the verification command specified in the collection configuration, if any.
32    #[serde(default)]
33    pub verification: Vec<Verification>,
34}
35
36#[derive(Debug, serde::Deserialize)]
37pub struct Verification {
38    /// The command that should be run to verify that the workshop-runner is working as expected.
39    pub command: String,
40    /// The arguments that should be passed to the verification command.
41    #[serde(default)]
42    pub args: Vec<String>,
43}
44
45fn default_exercise_dir() -> PathBuf {
46    PathBuf::from("exercises")
47}
48
49impl ExercisesConfig {
50    pub fn load() -> Result<Self, anyhow::Error> {
51        let root_path = get_git_repository_root_dir()
52            .context("Failed to determine the root path of the current `git` repository")?;
53        let exercises_config_path = root_path.join(".wr.toml");
54        let exercises_config = fs_err::read_to_string(&exercises_config_path).context(
55            "Failed to read the configuration for the current collection of workshop-runner",
56        )?;
57        let mut exercises_config: ExercisesConfig = toml::from_str(&exercises_config).with_context(|| {
58            format!(
59                "Failed to parse the configuration at `{}` for the current collection of workshop-runner",
60                exercises_config_path.to_string_lossy()
61            )
62        })?;
63        // The path to the exercises directory is relative to the root of the repository.
64        exercises_config.exercises_dir = root_path.join(&exercises_config.exercises_dir);
65        Ok(exercises_config)
66    }
67
68    /// The path to the directory containing the exercises
69    /// for the current collection of workshop-runner.
70    pub fn exercises_dir(&self) -> &Path {
71        &self.exercises_dir
72    }
73
74    /// The command(s) that should be run to verify that exercises are correct.
75    /// If empty, workshop-runner will use `cargo test` as default.
76    pub fn verification(&self) -> &[Verification] {
77        &self.verification
78    }
79}
80
81/// Retrieve the path to the root directory of the current `git` repository.
82pub fn get_git_repository_root_dir() -> Result<PathBuf, anyhow::Error> {
83    let cmd = Command::new("git")
84        .args(["rev-parse", "--show-cdup"])
85        .output()
86        .context("Failed to run a `git` command (`git rev-parse --show-cdup`) to determine the root path of the current `git` repository")?;
87    if cmd.status.success() {
88        let path = String::from_utf8(cmd.stdout)
89            .context("The root path of the current `git` repository is not valid UTF-8")?;
90        Ok(path.trim().into())
91    } else {
92        Err(anyhow!(
93            "Failed to determine the root path of the current `git` repository"
94        ))
95    }
96}
97
98pub struct ExerciseCollection {
99    exercises_dir: PathBuf,
100    connection: Connection,
101    exercises: BTreeSet<ExerciseDefinition>,
102}
103
104impl ExerciseCollection {
105    pub fn new(exercises_dir: PathBuf) -> Result<Self, anyhow::Error> {
106        let chapters = read_dir(&exercises_dir)
107            .context("Failed to read the exercises directory")?
108            .filter_map(|entry| {
109                let Ok(entry) = entry else {
110                    return None;
111                };
112                let Ok(file_type) = entry.file_type() else {
113                    return None;
114                };
115                if file_type.is_dir() {
116                    Some(entry)
117                } else {
118                    None
119                }
120            });
121        let exercises: BTreeSet<ExerciseDefinition> = chapters
122            .flat_map(|entry| {
123                let chapter_name = entry.file_name();
124                read_dir(entry.path()).unwrap().map(move |f| {
125                    let exercise = f.unwrap();
126                    (chapter_name.to_owned(), exercise.file_name())
127                })
128            })
129            .filter_map(|(c, k)| ExerciseDefinition::new(&c, &k).ok())
130            .collect();
131
132        let db_path = exercises_dir.join("progress.db");
133        // Open the database (or create it, if it doesn't exist yet).
134        let connection = Connection::open(db_path)
135            .context("Failed to create a SQLite database to track your progress")?;
136        // Make sure all tables are initialised
137        connection
138            .execute(
139                "CREATE TABLE IF NOT EXISTS open_exercises (
140                chapter TEXT NOT NULL,
141                exercise TEXT NOT NULL,
142                solved INTEGER NOT NULL,
143                PRIMARY KEY (chapter, exercise)
144            )",
145                [],
146            )
147            .context("Failed to initialise our SQLite database to track your progress")?;
148
149        Ok(Self {
150            connection,
151            exercises_dir,
152            exercises,
153        })
154    }
155
156    pub fn n_opened(&self) -> Result<usize, anyhow::Error> {
157        let err_msg = "Failed to determine how many workshop-runner have been opened";
158        let mut stmt = self
159            .connection
160            .prepare("SELECT COUNT(*) FROM open_exercises")
161            .context(err_msg)?;
162        stmt.query_row([], |row| row.get(0)).context(err_msg)
163    }
164
165    /// Return an iterator over all the workshop-runner that have been opened.
166    pub fn opened(&self) -> Result<BTreeSet<OpenedExercise>, anyhow::Error> {
167        opened_exercises(&self.connection)
168    }
169
170    /// Return the next exercise that should be opened, if we are going through the workshop-runner
171    /// in the expected order.
172    pub fn next(&mut self) -> Result<Option<ExerciseDefinition>, anyhow::Error> {
173        let opened = opened_exercises(&self.connection)?
174            .into_iter()
175            .map(|e| e.definition)
176            .collect();
177        let unsolved = self
178            .exercises
179            .difference(&opened)
180            .cloned()
181            .collect::<BTreeSet<_>>();
182        for next in unsolved {
183            if next.exists(&self.exercises_dir) {
184                return Ok(Some(next));
185            } else {
186                self.close(&next)?;
187            }
188        }
189        Ok(None)
190    }
191
192    /// Record in the database that an exercise was solved, so that it can be skipped next time.
193    pub fn mark_as_solved(&self, exercise: &ExerciseDefinition) -> Result<(), anyhow::Error> {
194        self.connection
195            .execute(
196                "UPDATE open_exercises SET solved = 1 WHERE chapter = ?1 AND exercise = ?2",
197                params![exercise.chapter(), exercise.exercise(),],
198            )
199            .context("Failed to mark exercise as solved")?;
200        Ok(())
201    }
202
203    /// Record in the database that an exercise was not solved, so that it won't be skipped next time.
204    pub fn mark_as_unsolved(&self, exercise: &ExerciseDefinition) -> Result<(), anyhow::Error> {
205        self.connection
206            .execute(
207                "UPDATE open_exercises SET solved = 0 WHERE chapter = ?1 AND exercise = ?2",
208                params![exercise.chapter(), exercise.exercise(),],
209            )
210            .context("Failed to mark exercise as unsolved")?;
211        Ok(())
212    }
213
214    /// Open a specific exercise.
215    pub fn open(&mut self, exercise: &ExerciseDefinition) -> Result<(), anyhow::Error> {
216        if !self.exercises.contains(exercise) {
217            bail!("The exercise you are trying to open doesn't exist")
218        }
219        self.connection
220            .execute(
221                "INSERT OR IGNORE INTO open_exercises (chapter, exercise, solved) VALUES (?1, ?2, 0)",
222                params![exercise.chapter(), exercise.exercise(),],
223            )
224            .context("Failed to open the next exercise")?;
225        Ok(())
226    }
227
228    /// Close a specific exercise.
229    pub fn close(&mut self, exercise: &ExerciseDefinition) -> Result<(), anyhow::Error> {
230        self.connection
231            .execute(
232                "DELETE FROM open_exercises WHERE chapter = ?1 AND exercise = ?2",
233                params![exercise.chapter(), exercise.exercise(),],
234            )
235            .context("Failed to close an exercise")?;
236        Ok(())
237    }
238
239    /// Open the next exercise, assuming we are going through the workshop-runner in order.
240    pub fn open_next(&mut self) -> Result<ExerciseDefinition, anyhow::Error> {
241        let Some(next) = self.next()? else {
242            bail!("There are no more exercises to open")
243        };
244        self.open(&next)?;
245        Ok(next)
246    }
247
248    /// The directory containing all the workshop chapters and workshop-runner.
249    pub fn exercises_dir(&self) -> &Path {
250        &self.exercises_dir
251    }
252
253    /// Iterate over the workshop-runner in the collection, in the order we expect them to be completed.
254    /// It returns both opened and unopened workshop-runner.
255    pub fn iter(&self) -> impl Iterator<Item = &ExerciseDefinition> {
256        self.exercises.iter()
257    }
258}
259
260/// Return the set of all workshop-runner that have been opened.
261fn opened_exercises(connection: &Connection) -> Result<BTreeSet<OpenedExercise>, anyhow::Error> {
262    let err_msg = "Failed to retrieve the list of exercises that you have already started";
263    let mut stmt = connection
264        .prepare("SELECT chapter, exercise, solved FROM open_exercises")
265        .context(err_msg)?;
266    let opened_exercises = stmt
267        .query_map([], |row| {
268            let chapter = row.get_ref_unwrap(0).as_str().unwrap();
269            let exercise = row.get_ref_unwrap(1).as_str().unwrap();
270            let solved = row.get_ref_unwrap(2).as_i64().unwrap();
271            let solved = if solved == 0 { false } else { true };
272            let definition = ExerciseDefinition::new(chapter.as_ref(), exercise.as_ref())
273                .expect("An invalid exercise has been stored in the database");
274            Ok(OpenedExercise { definition, solved })
275        })
276        .context(err_msg)?
277        .collect::<Result<BTreeSet<_>, _>>()?;
278    Ok(opened_exercises)
279}
280
281#[derive(Clone, PartialEq, Eq)]
282pub struct ExerciseDefinition {
283    chapter_name: String,
284    chapter_number: u16,
285    name: String,
286    number: u16,
287}
288
289#[derive(Clone, PartialEq, Eq)]
290pub struct OpenedExercise {
291    pub definition: ExerciseDefinition,
292    pub solved: bool,
293}
294
295impl PartialOrd for OpenedExercise {
296    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
297        self.definition.partial_cmp(&other.definition)
298    }
299}
300
301impl Ord for OpenedExercise {
302    fn cmp(&self, other: &Self) -> Ordering {
303        self.definition.cmp(&other.definition)
304    }
305}
306
307impl PartialOrd for ExerciseDefinition {
308    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
309        let ord = self
310            .chapter_number
311            .cmp(&other.chapter_number)
312            .then(self.number.cmp(&other.number));
313        Some(ord)
314    }
315}
316
317impl Ord for ExerciseDefinition {
318    fn cmp(&self, other: &Self) -> Ordering {
319        self.partial_cmp(other).unwrap()
320    }
321}
322
323impl PartialEq<OpenedExercise> for ExerciseDefinition {
324    fn eq(&self, other: &OpenedExercise) -> bool {
325        self == &other.definition
326    }
327}
328
329impl PartialOrd<OpenedExercise> for ExerciseDefinition {
330    fn partial_cmp(&self, other: &OpenedExercise) -> Option<Ordering> {
331        self.partial_cmp(&other.definition)
332    }
333}
334
335impl ExerciseDefinition {
336    pub fn new(chapter_dir_name: &OsStr, exercise_dir_name: &OsStr) -> Result<Self, anyhow::Error> {
337        fn parse(dir_name: &OsStr, type_: &str) -> Result<(String, u16), anyhow::Error> {
338            // TODO: compile the regex only once.
339            let re = Regex::new(r"(?P<number>\d{2})_(?P<name>\w+)").unwrap();
340
341            let dir_name = dir_name.to_str().ok_or_else(|| {
342                anyhow!(
343                    "The name of a {type_} must be valid UTF-8 text, but {:?} isn't",
344                    dir_name
345                )
346            })?;
347            match re.captures(&dir_name) {
348                None => bail!("Failed to parse `{dir_name:?}` as a {type_} (<NN>_<name>).",),
349                Some(s) => {
350                    let name = s["name"].into();
351                    let number = s["number"].parse().unwrap();
352                    Ok((name, number))
353                }
354            }
355        }
356
357        let (name, number) = parse(exercise_dir_name, "exercise")?;
358        let (chapter_name, chapter_number) = parse(chapter_dir_name, "chapter")?;
359
360        Ok(ExerciseDefinition {
361            chapter_name,
362            chapter_number,
363            name,
364            number,
365        })
366    }
367
368    /// The path to the `Cargo.toml` file of the current exercise.
369    pub fn manifest_path(&self, exercises_dir: &Path) -> PathBuf {
370        self.manifest_folder_path(exercises_dir).join("Cargo.toml")
371    }
372
373    /// The path to the folder containing the `Cargo.toml` file for the current exercise.
374    pub fn manifest_folder_path(&self, exercises_dir: &Path) -> PathBuf {
375        exercises_dir.join(self.chapter()).join(self.exercise())
376    }
377
378    /// The configuration for the current exercise, if any.
379    pub fn config(&self, exercises_dir: &Path) -> Result<Option<ExerciseConfig>, anyhow::Error> {
380        let exercise_config = self.manifest_folder_path(exercises_dir).join(".wr.toml");
381        if !exercise_config.exists() {
382            return Ok(None);
383        }
384        let exercise_config = fs_err::read_to_string(&exercise_config).context(format!(
385            "Failed to read the configuration for the exercise `{}`",
386            self.exercise()
387        ))?;
388        let exercise_config: ExerciseConfig =
389            toml::from_str(&exercise_config).with_context(|| {
390                format!(
391                    "Failed to parse the configuration for the exercise `{}`",
392                    self.exercise()
393                )
394            })?;
395        Ok(Some(exercise_config))
396    }
397
398    /// The number+name of the chapter that contains this exercise.
399    pub fn chapter(&self) -> String {
400        format!("{:02}_{}", self.chapter_number, self.chapter_name)
401    }
402
403    /// The number+name of this exercise.
404    pub fn exercise(&self) -> String {
405        format!("{:02}_{}", self.number, self.name)
406    }
407
408    /// The number of this exercise.
409    pub fn exercise_number(&self) -> u16 {
410        self.number
411    }
412
413    /// The number of the chapter that contains this exercise.
414    pub fn chapter_number(&self) -> u16 {
415        self.chapter_number
416    }
417
418    /// Verify that the exercise exists.
419    /// It may have been removed from the repository after an update to the current course.
420    pub fn exists(&self, exercises_dir: &Path) -> bool {
421        self.manifest_path(exercises_dir).exists()
422    }
423}
424
425impl std::fmt::Display for ExerciseDefinition {
426    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
427        write!(
428            f,
429            "({:02}) {} - ({:02}) {}",
430            self.chapter_number, self.chapter_name, self.number, self.name
431        )
432    }
433}