diff options
Diffstat (limited to 'src/app_state.rs')
| -rw-r--r-- | src/app_state.rs | 502 |
1 files changed, 502 insertions, 0 deletions
diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..e9a5b10 --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,502 @@ +use anyhow::{bail, Context, Result}; +use crossterm::style::Stylize; +use serde::Deserialize; +use std::{ + fs::{self, File}, + io::{Read, StdoutLock, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use crate::{ + clear_terminal, + embedded::EMBEDDED_FILES, + exercise::{Exercise, RunnableExercise, OUTPUT_CAPACITY}, + info_file::ExerciseInfo, + DEBUG_PROFILE, +}; + +const STATE_FILE_NAME: &str = ".rustlings-state.txt"; +const BAD_INDEX_ERR: &str = "The current exercise index is higher than the number of exercises"; + +#[must_use] +pub enum ExercisesProgress { + // All exercises are done. + AllDone, + // The current exercise failed and is still pending. + CurrentPending, + // A new exercise is now pending. + NewPending, +} + +pub enum StateFileStatus { + Read, + NotRead, +} + +// Parses parts of the output of `cargo metadata`. +#[derive(Deserialize)] +struct CargoMetadata { + target_directory: PathBuf, +} + +pub fn parse_target_dir() -> Result<PathBuf> { + // Get the target directory from Cargo. + let metadata_output = Command::new("cargo") + .arg("metadata") + .arg("-q") + .arg("--format-version") + .arg("1") + .arg("--no-deps") + .stdin(Stdio::null()) + .stderr(Stdio::inherit()) + .output() + .context(CARGO_METADATA_ERR)? + .stdout; + + serde_json::de::from_slice::<CargoMetadata>(&metadata_output) + .context("Failed to read the field `target_directory` from the `cargo metadata` output") + .map(|metadata| metadata.target_directory) +} + +pub struct AppState { + current_exercise_ind: usize, + exercises: Vec<Exercise>, + // Caches the number of done exercises to avoid iterating over all exercises every time. + n_done: u16, + final_message: String, + // Preallocated buffer for reading and writing the state file. + file_buf: Vec<u8>, + official_exercises: bool, + // Cargo's target directory. + target_dir: PathBuf, +} + +impl AppState { + // Update the app state from the state file. + fn update_from_file(&mut self) -> StateFileStatus { + self.file_buf.clear(); + self.n_done = 0; + + if File::open(STATE_FILE_NAME) + .and_then(|mut file| file.read_to_end(&mut self.file_buf)) + .is_err() + { + return StateFileStatus::NotRead; + } + + // See `Self::write` for more information about the file format. + let mut lines = self.file_buf.split(|c| *c == b'\n').skip(2); + + let Some(current_exercise_name) = lines.next() else { + return StateFileStatus::NotRead; + }; + + if current_exercise_name.is_empty() || lines.next().is_none() { + return StateFileStatus::NotRead; + } + + let mut done_exercises = hashbrown::HashSet::with_capacity(self.exercises.len()); + + for done_exerise_name in lines { + if done_exerise_name.is_empty() { + break; + } + done_exercises.insert(done_exerise_name); + } + + for (ind, exercise) in self.exercises.iter_mut().enumerate() { + if done_exercises.contains(exercise.name.as_bytes()) { + exercise.done = true; + self.n_done += 1; + } + + if exercise.name.as_bytes() == current_exercise_name { + self.current_exercise_ind = ind; + } + } + + StateFileStatus::Read + } + + pub fn new( + exercise_infos: Vec<ExerciseInfo>, + final_message: String, + ) -> Result<(Self, StateFileStatus)> { + let target_dir = parse_target_dir()?; + + let exercises = exercise_infos + .into_iter() + .map(|exercise_info| { + // Leaking to be able to borrow in the watch mode `Table`. + // Leaking is not a problem because the `AppState` instance lives until + // the end of the program. + let path = exercise_info.path().leak(); + let name = exercise_info.name.leak(); + let dir = exercise_info.dir.map(|dir| &*dir.leak()); + + let hint = exercise_info.hint.trim().to_owned(); + + Exercise { + dir, + name, + path, + test: exercise_info.test, + strict_clippy: exercise_info.strict_clippy, + hint, + // Updated in `Self::update_from_file`. + done: false, + } + }) + .collect::<Vec<_>>(); + + let mut slf = Self { + current_exercise_ind: 0, + exercises, + n_done: 0, + final_message, + file_buf: Vec::with_capacity(2048), + official_exercises: !Path::new("info.toml").exists(), + target_dir, + }; + + let state_file_status = slf.update_from_file(); + + Ok((slf, state_file_status)) + } + + #[inline] + pub fn current_exercise_ind(&self) -> usize { + self.current_exercise_ind + } + + #[inline] + pub fn exercises(&self) -> &[Exercise] { + &self.exercises + } + + #[inline] + pub fn n_done(&self) -> u16 { + self.n_done + } + + #[inline] + pub fn current_exercise(&self) -> &Exercise { + &self.exercises[self.current_exercise_ind] + } + + #[inline] + pub fn target_dir(&self) -> &Path { + &self.target_dir + } + + // Write the state file. + // The file's format is very simple: + // - The first line is a comment. + // - The second line is an empty line. + // - The third line is the name of the current exercise. It must end with `\n` even if there + // are no done exercises. + // - The fourth line is an empty line. + // - All remaining lines are the names of done exercises. + fn write(&mut self) -> Result<()> { + self.file_buf.clear(); + + self.file_buf + .extend_from_slice(b"DON'T EDIT THIS FILE!\n\n"); + self.file_buf + .extend_from_slice(self.current_exercise().name.as_bytes()); + self.file_buf.push(b'\n'); + + for exercise in &self.exercises { + if exercise.done { + self.file_buf.push(b'\n'); + self.file_buf.extend_from_slice(exercise.name.as_bytes()); + } + } + + fs::write(STATE_FILE_NAME, &self.file_buf) + .with_context(|| format!("Failed to write the state file {STATE_FILE_NAME}"))?; + + Ok(()) + } + + pub fn set_current_exercise_ind(&mut self, exercise_ind: usize) -> Result<()> { + if exercise_ind == self.current_exercise_ind { + return Ok(()); + } + + if exercise_ind >= self.exercises.len() { + bail!(BAD_INDEX_ERR); + } + + self.current_exercise_ind = exercise_ind; + + self.write() + } + + pub fn set_current_exercise_by_name(&mut self, name: &str) -> Result<()> { + // O(N) is fine since this method is used only once until the program exits. + // Building a hashmap would have more overhead. + self.current_exercise_ind = self + .exercises + .iter() + .position(|exercise| exercise.name == name) + .with_context(|| format!("No exercise found for '{name}'!"))?; + + self.write() + } + + pub fn set_pending(&mut self, exercise_ind: usize) -> Result<()> { + let exercise = self + .exercises + .get_mut(exercise_ind) + .context(BAD_INDEX_ERR)?; + + if exercise.done { + exercise.done = false; + self.n_done -= 1; + self.write()?; + } + + Ok(()) + } + + // Official exercises: Dump the original file from the binary. + // Third-party exercises: Reset the exercise file with `git stash`. + fn reset(&self, exercise_ind: usize, path: &str) -> Result<()> { + if self.official_exercises { + return EMBEDDED_FILES + .write_exercise_to_disk(exercise_ind, path) + .with_context(|| format!("Failed to reset the exercise {path}")); + } + + let output = Command::new("git") + .arg("stash") + .arg("push") + .arg("--") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .output() + .with_context(|| format!("Failed to run `git stash push -- {path}`"))?; + + if !output.status.success() { + bail!( + "`git stash push -- {path}` didn't run successfully: {}", + String::from_utf8_lossy(&output.stderr), + ); + } + + Ok(()) + } + + pub fn reset_current_exercise(&mut self) -> Result<&'static str> { + self.set_pending(self.current_exercise_ind)?; + let exercise = self.current_exercise(); + self.reset(self.current_exercise_ind, exercise.path)?; + + Ok(exercise.path) + } + + pub fn reset_exercise_by_ind(&mut self, exercise_ind: usize) -> Result<&'static str> { + if exercise_ind >= self.exercises.len() { + bail!(BAD_INDEX_ERR); + } + + self.set_pending(exercise_ind)?; + let exercise = &self.exercises[exercise_ind]; + self.reset(exercise_ind, exercise.path)?; + + Ok(exercise.path) + } + + // Return the index of the next pending exercise or `None` if all exercises are done. + fn next_pending_exercise_ind(&self) -> Option<usize> { + if self.current_exercise_ind == self.exercises.len() - 1 { + // The last exercise is done. + // Search for exercises not done from the start. + return self.exercises[..self.current_exercise_ind] + .iter() + .position(|exercise| !exercise.done); + } + + // The done exercise isn't the last one. + // Search for a pending exercise after the current one and then from the start. + match self.exercises[self.current_exercise_ind + 1..] + .iter() + .position(|exercise| !exercise.done) + { + Some(ind) => Some(self.current_exercise_ind + 1 + ind), + None => self.exercises[..self.current_exercise_ind] + .iter() + .position(|exercise| !exercise.done), + } + } + + /// Official exercises: Dump the solution file form the binary and return its path. + /// Third-party exercises: Check if a solution file exists and return its path in that case. + pub fn current_solution_path(&self) -> Result<Option<String>> { + if DEBUG_PROFILE { + return Ok(None); + } + + let current_exercise = self.current_exercise(); + + if self.official_exercises { + EMBEDDED_FILES + .write_solution_to_disk(self.current_exercise_ind, current_exercise.name) + .map(Some) + } else { + let solution_path = if let Some(dir) = current_exercise.dir { + format!("solutions/{dir}/{}.rs", current_exercise.name) + } else { + format!("solutions/{}.rs", current_exercise.name) + }; + + if Path::new(&solution_path).exists() { + return Ok(Some(solution_path)); + } + + Ok(None) + } + } + + /// Mark the current exercise as done and move on to the next pending exercise if one exists. + /// If all exercises are marked as done, run all of them to make sure that they are actually + /// done. If an exercise which is marked as done fails, mark it as pending and continue on it. + pub fn done_current_exercise(&mut self, writer: &mut StdoutLock) -> Result<ExercisesProgress> { + let exercise = &mut self.exercises[self.current_exercise_ind]; + if !exercise.done { + exercise.done = true; + self.n_done += 1; + } + + if let Some(ind) = self.next_pending_exercise_ind() { + self.set_current_exercise_ind(ind)?; + + return Ok(ExercisesProgress::NewPending); + } + + writer.write_all(RERUNNING_ALL_EXERCISES_MSG)?; + + let mut output = Vec::with_capacity(OUTPUT_CAPACITY); + for (exercise_ind, exercise) in self.exercises().iter().enumerate() { + write!(writer, "Running {exercise} ... ")?; + writer.flush()?; + + let success = exercise.run_exercise(&mut output, &self.target_dir)?; + if !success { + writeln!(writer, "{}\n", "FAILED".red())?; + + self.current_exercise_ind = exercise_ind; + + // No check if the exercise is done before setting it to pending + // because no pending exercise was found. + self.exercises[exercise_ind].done = false; + self.n_done -= 1; + + self.write()?; + + return Ok(ExercisesProgress::NewPending); + } + + writeln!(writer, "{}", "ok".green())?; + } + + clear_terminal(writer)?; + writer.write_all(FENISH_LINE.as_bytes())?; + + let final_message = self.final_message.trim(); + if !final_message.is_empty() { + writer.write_all(final_message.as_bytes())?; + writer.write_all(b"\n")?; + } + + Ok(ExercisesProgress::AllDone) + } +} + +const CARGO_METADATA_ERR: &str = "Failed to run the command `cargo metadata …` +Did you already install Rust? +Try running `cargo --version` to diagnose the problem."; + +const RERUNNING_ALL_EXERCISES_MSG: &[u8] = b" +All exercises seem to be done. +Recompiling and running all exercises to make sure that all of them are actually done. + +"; + +const FENISH_LINE: &str = "+----------------------------------------------------+ +| You made it to the Fe-nish line! | ++-------------------------- ------------------------+ + \\/\x1b[31m + ▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒ + ▒▒▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒▒▒ + ▒▒▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒▒▒ + ░░▒▒▒▒░░▒▒ ▒▒ ▒▒ ▒▒ ▒▒░░▒▒▒▒ + ▓▓▓▓▓▓▓▓ ▓▓ ▓▓██ ▓▓ ▓▓██ ▓▓ ▓▓▓▓▓▓▓▓ + ▒▒▒▒ ▒▒ ████ ▒▒ ████ ▒▒░░ ▒▒▒▒ + ▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒ + ▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▒▒▒▒▒▒▒▒▒▒██▒▒▒▒▒▒██▒▒▒▒▒▒▒▒▒▒ + ▒▒ ▒▒▒▒▒▒▒▒▒▒██████▒▒▒▒▒▒▒▒▒▒ ▒▒ + ▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒ + ▒▒ ▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒ ▒▒ + ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ + ▒▒ ▒▒ ▒▒ ▒▒\x1b[0m + +"; + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_exercise() -> Exercise { + Exercise { + dir: None, + name: "0", + path: "exercises/0.rs", + test: false, + strict_clippy: false, + hint: String::new(), + done: false, + } + } + + #[test] + fn next_pending_exercise() { + let mut app_state = AppState { + current_exercise_ind: 0, + exercises: vec![dummy_exercise(), dummy_exercise(), dummy_exercise()], + n_done: 0, + final_message: String::new(), + file_buf: Vec::new(), + official_exercises: true, + target_dir: PathBuf::new(), + }; + + let mut assert = |done: [bool; 3], expected: [Option<usize>; 3]| { + for (exercise, done) in app_state.exercises.iter_mut().zip(done) { + exercise.done = done; + } + for (ind, expected) in expected.into_iter().enumerate() { + app_state.current_exercise_ind = ind; + assert_eq!( + app_state.next_pending_exercise_ind(), + expected, + "done={done:?}, ind={ind}", + ); + } + }; + + assert([true, true, true], [None, None, None]); + assert([false, false, false], [Some(1), Some(2), Some(0)]); + assert([false, true, true], [None, Some(0), Some(0)]); + assert([true, false, true], [Some(1), None, Some(1)]); + assert([true, true, false], [Some(2), Some(2), None]); + assert([true, false, false], [Some(1), Some(2), Some(1)]); + assert([false, true, false], [Some(2), Some(2), Some(0)]); + assert([false, false, true], [Some(1), Some(0), Some(0)]); + } +} |
