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)]
13pub struct ExercisesConfig {
15 #[serde(default = "default_exercise_dir")]
18 exercises_dir: PathBuf,
19 #[serde(default)]
21 verification: Vec<Verification>,
22 #[serde(default)]
24 pub skip_build: bool,
25}
26
27#[derive(serde::Deserialize, Debug)]
28pub struct ExerciseConfig {
30 #[serde(default)]
33 pub verification: Vec<Verification>,
34}
35
36#[derive(Debug, serde::Deserialize)]
37pub struct Verification {
38 pub command: String,
40 #[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 exercises_config.exercises_dir = root_path.join(&exercises_config.exercises_dir);
65 Ok(exercises_config)
66 }
67
68 pub fn exercises_dir(&self) -> &Path {
71 &self.exercises_dir
72 }
73
74 pub fn verification(&self) -> &[Verification] {
77 &self.verification
78 }
79}
80
81pub 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 let connection = Connection::open(db_path)
135 .context("Failed to create a SQLite database to track your progress")?;
136 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 pub fn opened(&self) -> Result<BTreeSet<OpenedExercise>, anyhow::Error> {
167 opened_exercises(&self.connection)
168 }
169
170 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 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 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 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 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 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 pub fn exercises_dir(&self) -> &Path {
250 &self.exercises_dir
251 }
252
253 pub fn iter(&self) -> impl Iterator<Item = &ExerciseDefinition> {
256 self.exercises.iter()
257 }
258}
259
260fn 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 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 pub fn manifest_path(&self, exercises_dir: &Path) -> PathBuf {
370 self.manifest_folder_path(exercises_dir).join("Cargo.toml")
371 }
372
373 pub fn manifest_folder_path(&self, exercises_dir: &Path) -> PathBuf {
375 exercises_dir.join(self.chapter()).join(self.exercise())
376 }
377
378 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 pub fn chapter(&self) -> String {
400 format!("{:02}_{}", self.chapter_number, self.chapter_name)
401 }
402
403 pub fn exercise(&self) -> String {
405 format!("{:02}_{}", self.number, self.name)
406 }
407
408 pub fn exercise_number(&self) -> u16 {
410 self.number
411 }
412
413 pub fn chapter_number(&self) -> u16 {
415 self.chapter_number
416 }
417
418 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}