use anyhow::{anyhow, bail, Context};
use fs_err::read_dir;
use regex::Regex;
use rusqlite::{params, Connection};
use std::cmp::Ordering;
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fmt::Formatter;
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn get_exercises_dir() -> Result<PathBuf, anyhow::Error> {
#[derive(serde::Deserialize, Debug)]
struct ExercisesConfig {
exercises_dir: PathBuf,
}
let git_root_dir = get_git_repository_root_dir()
.context("Failed to determine the root path of the current `git` repository")?;
let exercises_config_path = git_root_dir.join(".wr.toml");
let exercises_config = fs_err::read_to_string(&exercises_config_path).context(
"Failed to read the configuration for the current collection of workshop-runner",
)?;
let exercises_config: ExercisesConfig = toml::from_str(&exercises_config).with_context(|| {
format!(
"Failed to parse the configuration at `{}` for the current collection of workshop-runner",
exercises_config_path.to_string_lossy()
)
})?;
if exercises_config.exercises_dir.is_absolute() {
Ok(exercises_config.exercises_dir)
} else {
Ok(git_root_dir.join(exercises_config.exercises_dir))
}
}
pub fn get_git_repository_root_dir() -> Result<PathBuf, anyhow::Error> {
let cmd = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to run a `git` command (`git rev-parse --show-toplevel`) to determine the root path of the current `git` repository")?;
if cmd.status.success() {
let path = String::from_utf8(cmd.stdout)
.context("The root path of the current `git` repository is not valid UTF-8")?;
Ok(path.into())
} else {
Err(anyhow!(
"Failed to determine the root path of the current `git` repository"
))
}
}
pub struct ExerciseCollection {
exercises_dir: PathBuf,
connection: Connection,
exercises: BTreeSet<ExerciseDefinition>,
}
impl ExerciseCollection {
pub fn new(exercises_dir: PathBuf) -> Result<Self, anyhow::Error> {
let chapters = read_dir(&exercises_dir)
.context("Failed to read the workshop-runner directory")?
.filter_map(|entry| {
let Ok(entry) = entry else {
return None;
};
let Ok(file_type) = entry.file_type() else {
return None;
};
if file_type.is_dir() {
Some(entry)
} else {
None
}
});
let exercises: BTreeSet<ExerciseDefinition> = chapters
.flat_map(|entry| {
let chapter_name = entry.file_name();
read_dir(entry.path()).unwrap().map(move |f| {
let exercise = f.unwrap();
(chapter_name.to_owned(), exercise.file_name())
})
})
.map(|(c, k)| ExerciseDefinition::new(&c, &k))
.collect::<Result<_, _>>()?;
let db_path = exercises_dir.join("progress.db");
let connection = Connection::open(db_path)
.context("Failed to create a SQLite database to track your progress")?;
connection
.execute(
"CREATE TABLE IF NOT EXISTS open_exercises (
chapter TEXT NOT NULL,
exercise TEXT NOT NULL,
solved INTEGER NOT NULL,
PRIMARY KEY (chapter, exercise)
)",
[],
)
.context("Failed to initialise our SQLite database to track your progress")?;
Ok(Self {
connection,
exercises_dir,
exercises,
})
}
pub fn n_opened(&self) -> Result<usize, anyhow::Error> {
let err_msg = "Failed to determine how many workshop-runner have been opened";
let mut stmt = self
.connection
.prepare("SELECT COUNT(*) FROM open_exercises")
.context(err_msg)?;
stmt.query_row([], |row| row.get(0)).context(err_msg)
}
pub fn opened(&self) -> Result<BTreeSet<OpenedExercise>, anyhow::Error> {
opened_exercises(&self.connection)
}
pub fn next(&self) -> Result<Option<ExerciseDefinition>, anyhow::Error> {
let opened = opened_exercises(&self.connection)?
.into_iter()
.map(|e| e.definition)
.collect();
Ok(self.exercises.difference(&opened).next().cloned())
}
pub fn mark_as_solved(&self, exercise: &OpenedExercise) -> Result<(), anyhow::Error> {
self.connection
.execute(
"UPDATE open_exercises SET solved = 1 WHERE chapter = ?1 AND exercise = ?2",
params![
exercise.definition.chapter(),
exercise.definition.exercise(),
],
)
.context("Failed to mark exercise as solved")?;
Ok(())
}
pub fn mark_as_unsolved(&self, exercise: &OpenedExercise) -> Result<(), anyhow::Error> {
self.connection
.execute(
"UPDATE open_exercises SET solved = 0 WHERE chapter = ?1 AND exercise = ?2",
params![
exercise.definition.chapter(),
exercise.definition.exercise(),
],
)
.context("Failed to mark exercise as unsolved")?;
Ok(())
}
pub fn open(&mut self, exercise: &ExerciseDefinition) -> Result<(), anyhow::Error> {
if !self.exercises.contains(exercise) {
bail!("The exercise you are trying to open doesn't exist")
}
self.connection
.execute(
"INSERT OR IGNORE INTO open_exercises (chapter, exercise, solved) VALUES (?1, ?2, 0)",
params![exercise.chapter(), exercise.exercise(),],
)
.context("Failed to open the next exercise")?;
Ok(())
}
pub fn open_next(&mut self) -> Result<ExerciseDefinition, anyhow::Error> {
let Some(next) = self.next()? else {
bail!("There are no more workshop-runner to open")
};
self.open(&next)?;
Ok(next)
}
pub fn exercises_dir(&self) -> &Path {
&self.exercises_dir
}
pub fn iter(&self) -> impl Iterator<Item = &ExerciseDefinition> {
self.exercises.iter()
}
}
fn opened_exercises(connection: &Connection) -> Result<BTreeSet<OpenedExercise>, anyhow::Error> {
let err_msg = "Failed to retrieve the list of exercises that you have already started";
let mut stmt = connection
.prepare("SELECT chapter, exercise, solved FROM open_exercises")
.context(err_msg)?;
let opened_exercises = stmt
.query_map([], |row| {
let chapter = row.get_ref_unwrap(0).as_str().unwrap();
let exercise = row.get_ref_unwrap(1).as_str().unwrap();
let solved = row.get_ref_unwrap(2).as_i64().unwrap();
let solved = if solved == 0 { false } else { true };
let definition = ExerciseDefinition::new(chapter.as_ref(), exercise.as_ref())
.expect("An invalid exercise has been stored in the database");
Ok(OpenedExercise { definition, solved })
})
.context(err_msg)?
.collect::<Result<BTreeSet<_>, _>>()?;
Ok(opened_exercises)
}
#[derive(Clone, PartialEq, Eq)]
pub struct ExerciseDefinition {
chapter_name: String,
chapter_number: u16,
name: String,
number: u16,
}
#[derive(Clone, PartialEq, Eq)]
pub struct OpenedExercise {
pub definition: ExerciseDefinition,
pub solved: bool,
}
impl PartialOrd for OpenedExercise {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.definition.partial_cmp(&other.definition)
}
}
impl Ord for OpenedExercise {
fn cmp(&self, other: &Self) -> Ordering {
self.definition.cmp(&other.definition)
}
}
impl PartialOrd for ExerciseDefinition {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
let ord = self
.chapter_number
.cmp(&other.chapter_number)
.then(self.number.cmp(&other.number));
Some(ord)
}
}
impl Ord for ExerciseDefinition {
fn cmp(&self, other: &Self) -> Ordering {
self.partial_cmp(other).unwrap()
}
}
impl PartialEq<OpenedExercise> for ExerciseDefinition {
fn eq(&self, other: &OpenedExercise) -> bool {
self == &other.definition
}
}
impl PartialOrd<OpenedExercise> for ExerciseDefinition {
fn partial_cmp(&self, other: &OpenedExercise) -> Option<Ordering> {
self.partial_cmp(&other.definition)
}
}
impl ExerciseDefinition {
pub fn new(chapter_dir_name: &OsStr, exercise_dir_name: &OsStr) -> Result<Self, anyhow::Error> {
fn parse(dir_name: &OsStr, type_: &str) -> Result<(String, u16), anyhow::Error> {
let re = Regex::new(r"(?P<number>\d{2})_(?P<name>\w+)").unwrap();
let dir_name = dir_name.to_str().ok_or_else(|| {
anyhow!(
"The name of a {type_} must be valid UTF-8 text, but {:?} isn't",
dir_name
)
})?;
match re.captures(&dir_name) {
None => bail!("Failed to parse `{dir_name:?}` as a {type_} (<NN>_<name>).",),
Some(s) => {
let name = s["name"].into();
let number = s["number"].parse().unwrap();
Ok((name, number))
}
}
}
let (name, number) = parse(exercise_dir_name, "exercise")?;
let (chapter_name, chapter_number) = parse(chapter_dir_name, "chapter")?;
Ok(ExerciseDefinition {
chapter_name,
chapter_number,
name,
number,
})
}
pub fn manifest_path(&self, exercises_dir: &Path) -> PathBuf {
self.manifest_folder_path(exercises_dir).join("Cargo.toml")
}
pub fn manifest_folder_path(&self, exercises_dir: &Path) -> PathBuf {
exercises_dir.join(self.chapter()).join(self.exercise())
}
pub fn chapter(&self) -> String {
format!("{:02}_{}", self.chapter_number, self.chapter_name)
}
pub fn exercise(&self) -> String {
format!("{:02}_{}", self.number, self.name)
}
pub fn exercise_number(&self) -> u16 {
self.number
}
pub fn chapter_number(&self) -> u16 {
self.chapter_number
}
}
impl std::fmt::Display for ExerciseDefinition {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"({:02}) {} - ({:02}) {}",
self.chapter_number, self.chapter_name, self.number, self.name
)
}
}