wr/
lib.rs

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