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)]
17pub struct ExercisesConfig {
19 #[serde(default = "default_exercise_dir")]
22 exercises_dir: PathBuf,
23 #[serde(default)]
25 verification: Vec<Verification>,
26 #[serde(default)]
28 pub skip_build: bool,
29}
30
31#[derive(serde::Deserialize, Debug)]
32pub struct ExerciseConfig {
34 #[serde(default)]
37 pub verification: Vec<Verification>,
38}
39
40#[derive(Debug, serde::Deserialize)]
41pub struct Verification {
42 pub command: String,
44 #[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 exercises_config.exercises_dir = root_path.join(&exercises_config.exercises_dir);
69 Ok(exercises_config)
70 }
71
72 pub fn exercises_dir(&self) -> &Path {
75 &self.exercises_dir
76 }
77
78 pub fn verification(&self) -> &[Verification] {
81 &self.verification
82 }
83}
84
85pub 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 let connection = Connection::open(db_path)
139 .context("Failed to create a SQLite database to track your progress")?;
140 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 pub fn opened(&self) -> Result<BTreeSet<OpenedExercise>, anyhow::Error> {
171 opened_exercises(&self.connection)
172 }
173
174 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 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 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 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 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 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 pub fn exercises_dir(&self) -> &Path {
254 &self.exercises_dir
255 }
256
257 pub fn iter(&self) -> impl Iterator<Item = &ExerciseDefinition> {
260 self.exercises.iter()
261 }
262}
263
264fn 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 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 pub fn manifest_path(&self, exercises_dir: &Path) -> PathBuf {
374 self.manifest_folder_path(exercises_dir).join("Cargo.toml")
375 }
376
377 pub fn manifest_folder_path(&self, exercises_dir: &Path) -> PathBuf {
379 exercises_dir.join(self.chapter()).join(self.exercise())
380 }
381
382 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 pub fn chapter(&self) -> String {
404 format!("{:02}_{}", self.chapter_number, self.chapter_name)
405 }
406
407 pub fn exercise(&self) -> String {
409 format!("{:02}_{}", self.number, self.name)
410 }
411
412 pub fn exercise_number(&self) -> u16 {
414 self.number
415 }
416
417 pub fn chapter_number(&self) -> u16 {
419 self.chapter_number
420 }
421
422 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}