summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app_state.rs502
-rw-r--r--src/cargo_toml.rs146
-rw-r--r--src/cmd.rs93
-rw-r--r--src/dev.rs48
-rw-r--r--src/dev/check.rs296
-rw-r--r--src/dev/new.rs145
-rw-r--r--src/dev/update.rs50
-rw-r--r--src/embedded.rs168
-rw-r--r--src/exercise.rs485
-rw-r--r--src/info_file.rs138
-rw-r--r--src/init.rs116
-rw-r--r--src/list.rs90
-rw-r--r--src/list/state.rs271
-rw-r--r--src/main.rs573
-rw-r--r--src/progress_bar.rs100
-rw-r--r--src/project.rs99
-rw-r--r--src/run.rs113
-rw-r--r--src/terminal_link.rs26
-rw-r--r--src/ui.rs33
-rw-r--r--src/verify.rs234
-rw-r--r--src/watch.rs128
-rw-r--r--src/watch/notify_event.rs52
-rw-r--r--src/watch/state.rs174
-rw-r--r--src/watch/terminal_event.rs86
24 files changed, 2979 insertions, 1187 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)]);
+ }
+}
diff --git a/src/cargo_toml.rs b/src/cargo_toml.rs
new file mode 100644
index 0000000..445b6b5
--- /dev/null
+++ b/src/cargo_toml.rs
@@ -0,0 +1,146 @@
+use anyhow::{Context, Result};
+use std::path::Path;
+
+use crate::info_file::ExerciseInfo;
+
+/// Initial capacity of the bins buffer.
+pub const BINS_BUFFER_CAPACITY: usize = 1 << 14;
+
+/// Return the start and end index of the content of the list `bin = […]`.
+/// bin = [xxxxxxxxxxxxxxxxx]
+/// |start_ind |
+/// |end_ind
+pub fn bins_start_end_ind(cargo_toml: &str) -> Result<(usize, usize)> {
+ let start_ind = cargo_toml
+ .find("bin = [")
+ .context("Failed to find the start of the `bin` list (`bin = [`)")?
+ + 7;
+ let end_ind = start_ind
+ + cargo_toml
+ .get(start_ind..)
+ .and_then(|slice| slice.as_bytes().iter().position(|c| *c == b']'))
+ .context("Failed to find the end of the `bin` list (`]`)")?;
+
+ Ok((start_ind, end_ind))
+}
+
+/// Generate and append the content of the `bin` list in `Cargo.toml`.
+/// The `exercise_path_prefix` is the prefix of the `path` field of every list entry.
+pub fn append_bins(
+ buf: &mut Vec<u8>,
+ exercise_infos: &[ExerciseInfo],
+ exercise_path_prefix: &[u8],
+) {
+ buf.push(b'\n');
+ for exercise_info in exercise_infos {
+ buf.extend_from_slice(b" { name = \"");
+ buf.extend_from_slice(exercise_info.name.as_bytes());
+ buf.extend_from_slice(b"\", path = \"");
+ buf.extend_from_slice(exercise_path_prefix);
+ buf.extend_from_slice(b"exercises/");
+ if let Some(dir) = &exercise_info.dir {
+ buf.extend_from_slice(dir.as_bytes());
+ buf.push(b'/');
+ }
+ buf.extend_from_slice(exercise_info.name.as_bytes());
+ buf.extend_from_slice(b".rs\" },\n");
+
+ let sol_path = exercise_info.sol_path();
+ if !Path::new(&sol_path).exists() {
+ continue;
+ }
+
+ buf.extend_from_slice(b" { name = \"");
+ buf.extend_from_slice(exercise_info.name.as_bytes());
+ buf.extend_from_slice(b"_sol");
+ buf.extend_from_slice(b"\", path = \"");
+ buf.extend_from_slice(exercise_path_prefix);
+ buf.extend_from_slice(b"solutions/");
+ if let Some(dir) = &exercise_info.dir {
+ buf.extend_from_slice(dir.as_bytes());
+ buf.push(b'/');
+ }
+ buf.extend_from_slice(exercise_info.name.as_bytes());
+ buf.extend_from_slice(b".rs\" },\n");
+ }
+}
+
+/// Update the `bin` list and leave everything else unchanged.
+pub fn updated_cargo_toml(
+ exercise_infos: &[ExerciseInfo],
+ current_cargo_toml: &str,
+ exercise_path_prefix: &[u8],
+) -> Result<Vec<u8>> {
+ let (bins_start_ind, bins_end_ind) = bins_start_end_ind(current_cargo_toml)?;
+
+ let mut updated_cargo_toml = Vec::with_capacity(BINS_BUFFER_CAPACITY);
+ updated_cargo_toml.extend_from_slice(current_cargo_toml[..bins_start_ind].as_bytes());
+ append_bins(
+ &mut updated_cargo_toml,
+ exercise_infos,
+ exercise_path_prefix,
+ );
+ updated_cargo_toml.extend_from_slice(current_cargo_toml[bins_end_ind..].as_bytes());
+
+ Ok(updated_cargo_toml)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_bins_start_end_ind() {
+ assert_eq!(bins_start_end_ind("").ok(), None);
+ assert_eq!(bins_start_end_ind("[]").ok(), None);
+ assert_eq!(bins_start_end_ind("bin = [").ok(), None);
+ assert_eq!(bins_start_end_ind("bin = ]").ok(), None);
+ assert_eq!(bins_start_end_ind("bin = []").ok(), Some((7, 7)));
+ assert_eq!(bins_start_end_ind("bin= []").ok(), None);
+ assert_eq!(bins_start_end_ind("bin =[]").ok(), None);
+ assert_eq!(bins_start_end_ind("bin=[]").ok(), None);
+ assert_eq!(bins_start_end_ind("bin = [\nxxx\n]").ok(), Some((7, 12)));
+ }
+
+ #[test]
+ fn test_bins() {
+ let exercise_infos = [
+ ExerciseInfo {
+ name: String::from("1"),
+ dir: None,
+ test: true,
+ strict_clippy: true,
+ hint: String::new(),
+ skip_check_unsolved: false,
+ },
+ ExerciseInfo {
+ name: String::from("2"),
+ dir: Some(String::from("d")),
+ test: false,
+ strict_clippy: false,
+ hint: String::new(),
+ skip_check_unsolved: false,
+ },
+ ];
+
+ let mut buf = Vec::with_capacity(128);
+ append_bins(&mut buf, &exercise_infos, b"");
+ assert_eq!(
+ buf,
+ br#"
+ { name = "1", path = "exercises/1.rs" },
+ { name = "2", path = "exercises/d/2.rs" },
+"#,
+ );
+
+ assert_eq!(
+ updated_cargo_toml(&exercise_infos, "abc\nbin = [xxx]\n123", b"../").unwrap(),
+ br#"abc
+bin = [
+ { name = "1", path = "../exercises/1.rs" },
+ { name = "2", path = "../exercises/d/2.rs" },
+]
+123"#,
+ );
+ }
+}
diff --git a/src/cmd.rs b/src/cmd.rs
new file mode 100644
index 0000000..6092f53
--- /dev/null
+++ b/src/cmd.rs
@@ -0,0 +1,93 @@
+use anyhow::{Context, Result};
+use std::{io::Read, path::Path, process::Command};
+
+/// Run a command with a description for a possible error and append the merged stdout and stderr.
+/// The boolean in the returned `Result` is true if the command's exit status is success.
+pub fn run_cmd(mut cmd: Command, description: &str, output: &mut Vec<u8>) -> Result<bool> {
+ let (mut reader, writer) = os_pipe::pipe()
+ .with_context(|| format!("Failed to create a pipe to run the command `{description}``"))?;
+
+ let writer_clone = writer.try_clone().with_context(|| {
+ format!("Failed to clone the pipe writer for the command `{description}`")
+ })?;
+
+ let mut handle = cmd
+ .stdout(writer_clone)
+ .stderr(writer)
+ .spawn()
+ .with_context(|| format!("Failed to run the command `{description}`"))?;
+
+ // Prevent pipe deadlock.
+ drop(cmd);
+
+ reader
+ .read_to_end(output)
+ .with_context(|| format!("Failed to read the output of the command `{description}`"))?;
+
+ output.push(b'\n');
+
+ handle
+ .wait()
+ .with_context(|| format!("Failed to wait on the command `{description}` to exit"))
+ .map(|status| status.success())
+}
+
+pub struct CargoCmd<'a> {
+ pub subcommand: &'a str,
+ pub args: &'a [&'a str],
+ pub bin_name: &'a str,
+ pub description: &'a str,
+ /// RUSTFLAGS="-A warnings"
+ pub hide_warnings: bool,
+ /// Added as `--target-dir` if `Self::dev` is true.
+ pub target_dir: &'a Path,
+ /// The output buffer to append the merged stdout and stderr.
+ pub output: &'a mut Vec<u8>,
+ /// true while developing Rustlings.
+ pub dev: bool,
+}
+
+impl<'a> CargoCmd<'a> {
+ /// Run `cargo SUBCOMMAND --bin EXERCISE_NAME … ARGS`.
+ pub fn run(&mut self) -> Result<bool> {
+ let mut cmd = Command::new("cargo");
+ cmd.arg(self.subcommand);
+
+ // A hack to make `cargo run` work when developing Rustlings.
+ if self.dev {
+ cmd.arg("--manifest-path")
+ .arg("dev/Cargo.toml")
+ .arg("--target-dir")
+ .arg(self.target_dir);
+ }
+
+ cmd.arg("--color")
+ .arg("always")
+ .arg("-q")
+ .arg("--bin")
+ .arg(self.bin_name)
+ .args(self.args);
+
+ if self.hide_warnings {
+ cmd.env("RUSTFLAGS", "-A warnings");
+ }
+
+ run_cmd(cmd, self.description, self.output)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_run_cmd() {
+ let mut cmd = Command::new("echo");
+ cmd.arg("Hello");
+
+ let mut output = Vec::with_capacity(8);
+ run_cmd(cmd, "echo …", &mut output).unwrap();
+
+ assert_eq!(output, b"Hello\n\n");
+ }
+}
diff --git a/src/dev.rs b/src/dev.rs
new file mode 100644
index 0000000..5f7e64c
--- /dev/null
+++ b/src/dev.rs
@@ -0,0 +1,48 @@
+use anyhow::{bail, Context, Result};
+use clap::Subcommand;
+use std::path::PathBuf;
+
+use crate::DEBUG_PROFILE;
+
+mod check;
+mod new;
+mod update;
+
+#[derive(Subcommand)]
+pub enum DevCommands {
+ /// Create a new project for third-party Rustlings exercises
+ New {
+ /// The path to create the project in
+ path: PathBuf,
+ /// Don't try to initialize a Git repository in the project directory
+ #[arg(long)]
+ no_git: bool,
+ },
+ /// Run checks on the exercises
+ Check {
+ /// Require that every exercise has a solution
+ #[arg(short, long)]
+ require_solutions: bool,
+ },
+ /// Update the `Cargo.toml` file for the exercises
+ Update,
+}
+
+impl DevCommands {
+ pub fn run(self) -> Result<()> {
+ match self {
+ Self::New { path, no_git } => {
+ if DEBUG_PROFILE {
+ bail!("Disabled in the debug build");
+ }
+
+ new::new(&path, no_git).context(INIT_ERR)
+ }
+ Self::Check { require_solutions } => check::check(require_solutions),
+ Self::Update => update::update(),
+ }
+ }
+}
+
+const INIT_ERR: &str = "Initialization failed.
+After resolving the issue, delete the `rustlings` directory (if it was created) and try again";
diff --git a/src/dev/check.rs b/src/dev/check.rs
new file mode 100644
index 0000000..5c35462
--- /dev/null
+++ b/src/dev/check.rs
@@ -0,0 +1,296 @@
+use anyhow::{anyhow, bail, Context, Result};
+use std::{
+ cmp::Ordering,
+ fs::{self, read_dir, OpenOptions},
+ io::{self, Read, Write},
+ path::{Path, PathBuf},
+ sync::{
+ atomic::{self, AtomicBool},
+ Mutex,
+ },
+ thread,
+};
+
+use crate::{
+ app_state::parse_target_dir,
+ cargo_toml::{append_bins, bins_start_end_ind, BINS_BUFFER_CAPACITY},
+ exercise::{RunnableExercise, OUTPUT_CAPACITY},
+ info_file::{ExerciseInfo, InfoFile},
+ CURRENT_FORMAT_VERSION, DEBUG_PROFILE,
+};
+
+// Find a char that isn't allowed in the exercise's `name` or `dir`.
+fn forbidden_char(input: &str) -> Option<char> {
+ input.chars().find(|c| !c.is_alphanumeric() && *c != '_')
+}
+
+// Check that the Cargo.toml file is up-to-date.
+fn check_cargo_toml(
+ exercise_infos: &[ExerciseInfo],
+ current_cargo_toml: &str,
+ exercise_path_prefix: &[u8],
+) -> Result<()> {
+ let (bins_start_ind, bins_end_ind) = bins_start_end_ind(current_cargo_toml)?;
+
+ let old_bins = &current_cargo_toml.as_bytes()[bins_start_ind..bins_end_ind];
+ let mut new_bins = Vec::with_capacity(BINS_BUFFER_CAPACITY);
+ append_bins(&mut new_bins, exercise_infos, exercise_path_prefix);
+
+ if old_bins != new_bins {
+ if DEBUG_PROFILE {
+ bail!("The file `dev/Cargo.toml` is outdated. Please run `cargo run -- dev update` to update it");
+ }
+
+ bail!("The file `Cargo.toml` is outdated. Please run `rustlings dev update` to update it");
+ }
+
+ Ok(())
+}
+
+// Check the info of all exercises and return their paths in a set.
+fn check_info_file_exercises(info_file: &InfoFile) -> Result<hashbrown::HashSet<PathBuf>> {
+ let mut names = hashbrown::HashSet::with_capacity(info_file.exercises.len());
+ let mut paths = hashbrown::HashSet::with_capacity(info_file.exercises.len());
+
+ let mut file_buf = String::with_capacity(1 << 14);
+ for exercise_info in &info_file.exercises {
+ let name = exercise_info.name.as_str();
+ if name.is_empty() {
+ bail!("Found an empty exercise name in `info.toml`");
+ }
+ if let Some(c) = forbidden_char(name) {
+ bail!("Char `{c}` in the exercise name `{name}` is not allowed");
+ }
+
+ if let Some(dir) = &exercise_info.dir {
+ if dir.is_empty() {
+ bail!("The exercise `{name}` has an empty dir name in `info.toml`");
+ }
+ if let Some(c) = forbidden_char(dir) {
+ bail!("Char `{c}` in the exercise dir `{dir}` is not allowed");
+ }
+ }
+
+ if exercise_info.hint.trim().is_empty() {
+ bail!("The exercise `{name}` has an empty hint. Please provide a hint or at least tell the user why a hint isn't needed for this exercise");
+ }
+
+ if !names.insert(name) {
+ bail!("The exercise name `{name}` is duplicated. Exercise names must all be unique");
+ }
+
+ let path = exercise_info.path();
+
+ OpenOptions::new()
+ .read(true)
+ .open(&path)
+ .with_context(|| format!("Failed to open the file {path}"))?
+ .read_to_string(&mut file_buf)
+ .with_context(|| format!("Failed to read the file {path}"))?;
+
+ if !file_buf.contains("fn main()") {
+ bail!("The `main` function is missing in the file `{path}`.\nCreate at least an empty `main` function to avoid language server errors");
+ }
+
+ if !file_buf.contains("// TODO") {
+ bail!("Didn't find any `// TODO` comment in the file `{path}`.\nYou need to have at least one such comment to guide the user.");
+ }
+
+ if !exercise_info.test && file_buf.contains("#[test]") {
+ bail!("The file `{path}` contains tests annotated with `#[test]` but the exercise `{name}` has `test = false` in the `info.toml` file");
+ }
+
+ file_buf.clear();
+
+ paths.insert(PathBuf::from(path));
+ }
+
+ Ok(paths)
+}
+
+// Check `dir` for unexpected files.
+// Only Rust files in `allowed_rust_files` and `README.md` files are allowed.
+// Only one level of directory nesting is allowed.
+fn check_unexpected_files(
+ dir: &str,
+ allowed_rust_files: &hashbrown::HashSet<PathBuf>,
+) -> Result<()> {
+ let unexpected_file = |path: &Path| {
+ anyhow!("Found the file `{}`. Only `README.md` and Rust files related to an exercise in `info.toml` are allowed in the `{dir}` directory", path.display())
+ };
+
+ for entry in read_dir(dir).with_context(|| format!("Failed to open the `{dir}` directory"))? {
+ let entry = entry.with_context(|| format!("Failed to read the `{dir}` directory"))?;
+
+ if entry.file_type().unwrap().is_file() {
+ let path = entry.path();
+ let file_name = path.file_name().unwrap();
+ if file_name == "README.md" {
+ continue;
+ }
+
+ if !allowed_rust_files.contains(&path) {
+ return Err(unexpected_file(&path));
+ }
+
+ continue;
+ }
+
+ let dir_path = entry.path();
+ for entry in read_dir(&dir_path)
+ .with_context(|| format!("Failed to open the directory {}", dir_path.display()))?
+ {
+ let entry = entry
+ .with_context(|| format!("Failed to read the directory {}", dir_path.display()))?;
+ let path = entry.path();
+
+ if !entry.file_type().unwrap().is_file() {
+ bail!("Found `{}` but expected only files. Only one level of exercise nesting is allowed", path.display());
+ }
+
+ let file_name = path.file_name().unwrap();
+ if file_name == "README.md" {
+ continue;
+ }
+
+ if !allowed_rust_files.contains(&path) {
+ return Err(unexpected_file(&path));
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn check_exercises_unsolved(info_file: &InfoFile, target_dir: &Path) -> Result<()> {
+ let error_occurred = AtomicBool::new(false);
+
+ println!(
+ "Running all exercises to check that they aren't already solved. This may take a while…\n",
+ );
+ thread::scope(|s| {
+ for exercise_info in &info_file.exercises {
+ if exercise_info.skip_check_unsolved {
+ continue;
+ }
+
+ s.spawn(|| {
+ let error = |e| {
+ let mut stderr = io::stderr().lock();
+ stderr.write_all(e).unwrap();
+ stderr.write_all(b"\nProblem with the exercise ").unwrap();
+ stderr.write_all(exercise_info.name.as_bytes()).unwrap();
+ stderr.write_all(SEPARATOR).unwrap();
+ error_occurred.store(true, atomic::Ordering::Relaxed);
+ };
+
+ let mut output = Vec::with_capacity(OUTPUT_CAPACITY);
+ match exercise_info.run_exercise(&mut output, target_dir) {
+ Ok(true) => error(b"Already solved!"),
+ Ok(false) => (),
+ Err(e) => error(e.to_string().as_bytes()),
+ }
+ });
+ }
+ });
+
+ if error_occurred.load(atomic::Ordering::Relaxed) {
+ bail!(CHECK_EXERCISES_UNSOLVED_ERR);
+ }
+
+ Ok(())
+}
+
+fn check_exercises(info_file: &InfoFile, target_dir: &Path) -> Result<()> {
+ match info_file.format_version.cmp(&CURRENT_FORMAT_VERSION) {
+ Ordering::Less => bail!("`format_version` < {CURRENT_FORMAT_VERSION} (supported version)\nPlease migrate to the latest format version"),
+ Ordering::Greater => bail!("`format_version` > {CURRENT_FORMAT_VERSION} (supported version)\nTry updating the Rustlings program"),
+ Ordering::Equal => (),
+ }
+
+ let info_file_paths = check_info_file_exercises(info_file)?;
+ check_unexpected_files("exercises", &info_file_paths)?;
+
+ check_exercises_unsolved(info_file, target_dir)
+}
+
+fn check_solutions(require_solutions: bool, info_file: &InfoFile, target_dir: &Path) -> Result<()> {
+ let paths = Mutex::new(hashbrown::HashSet::with_capacity(info_file.exercises.len()));
+ let error_occurred = AtomicBool::new(false);
+
+ println!("Running all solutions. This may take a while…\n");
+ thread::scope(|s| {
+ for exercise_info in &info_file.exercises {
+ s.spawn(|| {
+ let error = |e| {
+ let mut stderr = io::stderr().lock();
+ stderr.write_all(e).unwrap();
+ stderr
+ .write_all(b"\nFailed to run the solution of the exercise ")
+ .unwrap();
+ stderr.write_all(exercise_info.name.as_bytes()).unwrap();
+ stderr.write_all(SEPARATOR).unwrap();
+ error_occurred.store(true, atomic::Ordering::Relaxed);
+ };
+
+ let path = exercise_info.sol_path();
+ if !Path::new(&path).exists() {
+ if require_solutions {
+ error(b"Solution missing");
+ }
+
+ // No solution to check.
+ return;
+ }
+
+ let mut output = Vec::with_capacity(OUTPUT_CAPACITY);
+ match exercise_info.run_solution(&mut output, target_dir) {
+ Ok(true) => {
+ paths.lock().unwrap().insert(PathBuf::from(path));
+ }
+ Ok(false) => error(&output),
+ Err(e) => error(e.to_string().as_bytes()),
+ }
+ });
+ }
+ });
+
+ if error_occurred.load(atomic::Ordering::Relaxed) {
+ bail!("At least one solution failed. See the output above.");
+ }
+
+ check_unexpected_files("solutions", &paths.into_inner().unwrap())?;
+
+ Ok(())
+}
+
+pub fn check(require_solutions: bool) -> Result<()> {
+ let info_file = InfoFile::parse()?;
+
+ // A hack to make `cargo run -- dev check` work when developing Rustlings.
+ if DEBUG_PROFILE {
+ check_cargo_toml(
+ &info_file.exercises,
+ include_str!("../../dev-Cargo.toml"),
+ b"../",
+ )?;
+ } else {
+ let current_cargo_toml =
+ fs::read_to_string("Cargo.toml").context("Failed to read the file `Cargo.toml`")?;
+ check_cargo_toml(&info_file.exercises, &current_cargo_toml, b"")?;
+ }
+
+ let target_dir = parse_target_dir()?;
+ check_exercises(&info_file, &target_dir)?;
+ check_solutions(require_solutions, &info_file, &target_dir)?;
+
+ println!("\nEverything looks fine!");
+
+ Ok(())
+}
+
+const SEPARATOR: &[u8] =
+ b"\n========================================================================================\n";
+
+const CHECK_EXERCISES_UNSOLVED_ERR: &str = "At least one exercise is already solved or failed to run. See the output above.
+If this is an intro exercise that is intended to be already solved, add `skip_check_unsolved = true` to the exercise's metadata in the `info.toml` file.";
diff --git a/src/dev/new.rs b/src/dev/new.rs
new file mode 100644
index 0000000..fefc4fc
--- /dev/null
+++ b/src/dev/new.rs
@@ -0,0 +1,145 @@
+use anyhow::{bail, Context, Result};
+use std::{
+ env::set_current_dir,
+ fs::{self, create_dir},
+ path::Path,
+ process::Command,
+};
+
+use crate::CURRENT_FORMAT_VERSION;
+
+// Create a directory relative to the current directory and print its path.
+fn create_rel_dir(dir_name: &str, current_dir: &str) -> Result<()> {
+ create_dir(dir_name)
+ .with_context(|| format!("Failed to create the directory {current_dir}/{dir_name}"))?;
+ println!("Created the directory {current_dir}/{dir_name}");
+ Ok(())
+}
+
+// Write a file relative to the current directory and print its path.
+fn write_rel_file<C>(file_name: &str, current_dir: &str, content: C) -> Result<()>
+where
+ C: AsRef<[u8]>,
+{
+ fs::write(file_name, content)
+ .with_context(|| format!("Failed to create the file {current_dir}/{file_name}"))?;
+ // Space to align with `create_rel_dir`.
+ println!("Created the file {current_dir}/{file_name}");
+ Ok(())
+}
+
+pub fn new(path: &Path, no_git: bool) -> Result<()> {
+ let dir_path_str = path.to_string_lossy();
+
+ create_dir(path).with_context(|| format!("Failed to create the directory {dir_path_str}"))?;
+ println!("Created the directory {dir_path_str}");
+
+ set_current_dir(path)
+ .with_context(|| format!("Failed to set {dir_path_str} as the current directory"))?;
+
+ if !no_git
+ && !Command::new("git")
+ .arg("init")
+ .status()
+ .context("Failed to run `git init`")?
+ .success()
+ {
+ bail!("`git init` didn't run successfully. See the possible error message above");
+ }
+
+ write_rel_file(".gitignore", &dir_path_str, GITIGNORE)?;
+
+ create_rel_dir("exercises", &dir_path_str)?;
+ create_rel_dir("solutions", &dir_path_str)?;
+
+ write_rel_file(
+ "info.toml",
+ &dir_path_str,
+ format!("{INFO_FILE_BEFORE_FORMAT_VERSION}{CURRENT_FORMAT_VERSION}{INFO_FILE_AFTER_FORMAT_VERSION}"),
+ )?;
+
+ write_rel_file("Cargo.toml", &dir_path_str, CARGO_TOML)?;
+
+ write_rel_file("README.md", &dir_path_str, README)?;
+
+ create_rel_dir(".vscode", &dir_path_str)?;
+ write_rel_file(
+ ".vscode/extensions.json",
+ &dir_path_str,
+ crate::init::VS_CODE_EXTENSIONS_JSON,
+ )?;
+
+ println!("\nInitialization done ✓");
+
+ Ok(())
+}
+
+pub const GITIGNORE: &[u8] = b".rustlings-state.txt
+Cargo.lock
+target
+.vscode
+!.vscode/extensions.json
+";
+
+const INFO_FILE_BEFORE_FORMAT_VERSION: &str =
+ "# The format version is an indicator of the compatibility of third-party exercises with the
+# Rustlings program.
+# The format version is not the same as the version of the Rustlings program.
+# In case Rustlings makes an unavoidable breaking change to the expected format of third-party
+# exercises, you would need to raise this version and adapt to the new format.
+# Otherwise, the newest version of the Rustlings program won't be able to run these exercises.
+format_version = ";
+
+const INFO_FILE_AFTER_FORMAT_VERSION: &str = r#"
+
+# Optional multi-line message to be shown to users when just starting with the exercises.
+welcome_message = """Welcome to these third-party Rustlings exercises."""
+
+# Optional multi-line message to be shown to users after finishing all exercises.
+final_message = """We hope that you found the exercises helpful :D"""
+
+# Repeat this section for every exercise.
+[[exercises]]
+# Exercise name which is the exercise file name without the `.rs` extension.
+name = "???"
+
+# Optional directory name to be provided if you want to organize exercises in directories.
+# If `dir` is specified, the exercise path is `exercises/DIR/NAME.rs`
+# Otherwise, the path is `exercises/NAME.rs`
+# dir = "???"
+
+# Rustlings expects the exercise to contain tests and run them.
+# You can optionally disable testing by setting `test` to `false` (the default is `true`).
+# In that case, the exercise will be considered done when it just successfully compiles.
+# test = true
+
+# Rustlings will always run Clippy on exercises.
+# You can optionally set `strict_clippy` to `true` (the default is `false`) to only consider
+# the exercise as done when there are no warnings left.
+# strict_clippy = false
+
+# A multi-line hint to be shown to users on request.
+hint = """???"""
+"#;
+
+const CARGO_TOML: &[u8] =
+ br#"# Don't edit the `bin` list manually! It is updated by `rustlings dev update`
+bin = []
+
+[package]
+name = "exercises"
+edition = "2021"
+# Don't publish the exercises on crates.io!
+publish = false
+
+[dependencies]
+"#;
+
+const README: &str = "# Rustlings 🦀
+
+Welcome to these third-party Rustlings exercises 😃
+
+First, [install Rustlings using the official instructions in the README of the Rustlings project](https://github.com/rust-lang/rustlings) ✅
+
+Then, open your terminal in this directory and run `rustlings` to get started with the exercises 🚀
+";
diff --git a/src/dev/update.rs b/src/dev/update.rs
new file mode 100644
index 0000000..66efe3d
--- /dev/null
+++ b/src/dev/update.rs
@@ -0,0 +1,50 @@
+use anyhow::{Context, Result};
+use std::fs;
+
+use crate::{
+ cargo_toml::updated_cargo_toml,
+ info_file::{ExerciseInfo, InfoFile},
+ DEBUG_PROFILE,
+};
+
+// Update the `Cargo.toml` file.
+fn update_cargo_toml(
+ exercise_infos: &[ExerciseInfo],
+ current_cargo_toml: &str,
+ exercise_path_prefix: &[u8],
+ cargo_toml_path: &str,
+) -> Result<()> {
+ let updated_cargo_toml =
+ updated_cargo_toml(exercise_infos, current_cargo_toml, exercise_path_prefix)?;
+
+ fs::write(cargo_toml_path, updated_cargo_toml)
+ .context("Failed to write the `Cargo.toml` file")?;
+
+ Ok(())
+}
+
+pub fn update() -> Result<()> {
+ let info_file = InfoFile::parse()?;
+
+ // A hack to make `cargo run -- dev update` work when developing Rustlings.
+ if DEBUG_PROFILE {
+ update_cargo_toml(
+ &info_file.exercises,
+ include_str!("../../dev-Cargo.toml"),
+ b"../",
+ "dev/Cargo.toml",
+ )
+ .context("Failed to update the file `dev/Cargo.toml`")?;
+
+ println!("Updated `dev/Cargo.toml`");
+ } else {
+ let current_cargo_toml =
+ fs::read_to_string("Cargo.toml").context("Failed to read the file `Cargo.toml`")?;
+ update_cargo_toml(&info_file.exercises, &current_cargo_toml, b"", "Cargo.toml")
+ .context("Failed to update the file `Cargo.toml`")?;
+
+ println!("Updated `Cargo.toml`");
+ }
+
+ Ok(())
+}
diff --git a/src/embedded.rs b/src/embedded.rs
new file mode 100644
index 0000000..1dce46c
--- /dev/null
+++ b/src/embedded.rs
@@ -0,0 +1,168 @@
+use anyhow::{Context, Error, Result};
+use std::{
+ fs::{create_dir, OpenOptions},
+ io::{self, Write},
+};
+
+use crate::info_file::ExerciseInfo;
+
+/// Contains all embedded files.
+pub static EMBEDDED_FILES: EmbeddedFiles = rustlings_macros::include_files!();
+
+#[derive(Clone, Copy)]
+pub enum WriteStrategy {
+ IfNotExists,
+ Overwrite,
+}
+
+impl WriteStrategy {
+ fn write(self, path: &str, content: &[u8]) -> Result<()> {
+ let file = match self {
+ Self::IfNotExists => OpenOptions::new().create_new(true).write(true).open(path),
+ Self::Overwrite => OpenOptions::new()
+ .create(true)
+ .write(true)
+ .truncate(true)
+ .open(path),
+ };
+
+ file.with_context(|| format!("Failed to open the file `{path}` in write mode"))?
+ .write_all(content)
+ .with_context(|| format!("Failed to write the file {path}"))
+ }
+}
+
+// Files related to one exercise.
+struct ExerciseFiles {
+ // The content of the exercise file.
+ exercise: &'static [u8],
+ // The content of the solution file.
+ solution: &'static [u8],
+ // Index of the related `ExerciseDir` in `EmbeddedFiles::exercise_dirs`.
+ dir_ind: usize,
+}
+
+// A directory in the `exercises/` directory.
+pub struct ExerciseDir {
+ pub name: &'static str,
+ readme: &'static [u8],
+}
+
+impl ExerciseDir {
+ fn init_on_disk(&self) -> Result<()> {
+ // 20 = 10 + 10
+ // exercises/ + /README.md
+ let mut dir_path = String::with_capacity(20 + self.name.len());
+ dir_path.push_str("exercises/");
+ dir_path.push_str(self.name);
+
+ if let Err(e) = create_dir(&dir_path) {
+ if e.kind() == io::ErrorKind::AlreadyExists {
+ return Ok(());
+ }
+
+ return Err(
+ Error::from(e).context(format!("Failed to create the directory {dir_path}"))
+ );
+ }
+
+ let mut readme_path = dir_path;
+ readme_path.push_str("/README.md");
+
+ WriteStrategy::Overwrite.write(&readme_path, self.readme)
+ }
+}
+
+/// All embedded files.
+pub struct EmbeddedFiles {
+ /// The content of the `info.toml` file.
+ pub info_file: &'static str,
+ exercise_files: &'static [ExerciseFiles],
+ pub exercise_dirs: &'static [ExerciseDir],
+}
+
+impl EmbeddedFiles {
+ /// Dump all the embedded files of the `exercises/` directory.
+ pub fn init_exercises_dir(&self, exercise_infos: &[ExerciseInfo]) -> Result<()> {
+ create_dir("exercises").context("Failed to create the directory `exercises`")?;
+
+ WriteStrategy::IfNotExists.write(
+ "exercises/README.md",
+ include_bytes!("../exercises/README.md"),
+ )?;
+
+ for dir in self.exercise_dirs {
+ dir.init_on_disk()?;
+ }
+
+ for (exercise_info, exercise_files) in exercise_infos.iter().zip(self.exercise_files) {
+ WriteStrategy::IfNotExists.write(&exercise_info.path(), exercise_files.exercise)?;
+ }
+
+ Ok(())
+ }
+
+ pub fn write_exercise_to_disk(&self, exercise_ind: usize, path: &str) -> Result<()> {
+ let exercise_files = &self.exercise_files[exercise_ind];
+ let dir = &self.exercise_dirs[exercise_files.dir_ind];
+
+ dir.init_on_disk()?;
+ WriteStrategy::Overwrite.write(path, exercise_files.exercise)
+ }
+
+ /// Write the solution file to disk and return its path.
+ pub fn write_solution_to_disk(
+ &self,
+ exercise_ind: usize,
+ exercise_name: &str,
+ ) -> Result<String> {
+ let exercise_files = &self.exercise_files[exercise_ind];
+ let dir = &self.exercise_dirs[exercise_files.dir_ind];
+
+ // 14 = 10 + 1 + 3
+ // solutions/ + / + .rs
+ let mut solution_path = String::with_capacity(14 + dir.name.len() + exercise_name.len());
+ solution_path.push_str("solutions/");
+ solution_path.push_str(dir.name);
+ solution_path.push('/');
+ solution_path.push_str(exercise_name);
+ solution_path.push_str(".rs");
+
+ WriteStrategy::Overwrite.write(&solution_path, exercise_files.solution)?;
+
+ Ok(solution_path)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use serde::Deserialize;
+
+ use super::*;
+
+ #[derive(Deserialize)]
+ struct ExerciseInfo {
+ dir: String,
+ }
+
+ #[derive(Deserialize)]
+ struct InfoFile {
+ exercises: Vec<ExerciseInfo>,
+ }
+
+ #[test]
+ fn dirs() {
+ let exercises = toml_edit::de::from_str::<InfoFile>(EMBEDDED_FILES.info_file)
+ .expect("Failed to parse `info.toml`")
+ .exercises;
+
+ assert_eq!(exercises.len(), EMBEDDED_FILES.exercise_files.len());
+
+ for (exercise, exercise_files) in exercises.iter().zip(EMBEDDED_FILES.exercise_files) {
+ assert_eq!(
+ exercise.dir,
+ EMBEDDED_FILES.exercise_dirs[exercise_files.dir_ind].name,
+ );
+ }
+ }
+}
diff --git a/src/exercise.rs b/src/exercise.rs
index 664b362..b6adc14 100644
--- a/src/exercise.rs
+++ b/src/exercise.rs
@@ -1,378 +1,181 @@
-use regex::Regex;
-use serde::Deserialize;
-use std::env;
-use std::fmt::{self, Display, Formatter};
-use std::fs::{self, remove_file, File};
-use std::io::Read;
-use std::path::PathBuf;
-use std::process::{self, Command};
-
-const RUSTC_COLOR_ARGS: &[&str] = &["--color", "always"];
-const RUSTC_EDITION_ARGS: &[&str] = &["--edition", "2021"];
-const RUSTC_NO_DEBUG_ARGS: &[&str] = &["-C", "strip=debuginfo"];
-const I_AM_DONE_REGEX: &str = r"(?m)^\s*///?\s*I\s+AM\s+NOT\s+DONE";
-const CONTEXT: usize = 2;
-const CLIPPY_CARGO_TOML_PATH: &str = "./exercises/22_clippy/Cargo.toml";
-
-// Get a temporary file name that is hopefully unique
-#[inline]
-fn temp_file() -> String {
- let thread_id: String = format!("{:?}", std::thread::current().id())
- .chars()
- .filter(|c| c.is_alphanumeric())
- .collect();
-
- format!("./temp_{}_{thread_id}", process::id())
-}
-
-// The mode of the exercise.
-#[derive(Deserialize, Copy, Clone, Debug)]
-#[serde(rename_all = "lowercase")]
-pub enum Mode {
- // Indicates that the exercise should be compiled as a binary
- Compile,
- // Indicates that the exercise should be compiled as a test harness
- Test,
- // Indicates that the exercise should be linted with clippy
- Clippy,
-}
+use anyhow::Result;
+use crossterm::style::{style, StyledContent, Stylize};
+use std::{
+ fmt::{self, Display, Formatter},
+ io::Write,
+ path::{Path, PathBuf},
+ process::Command,
+};
+
+use crate::{
+ cmd::{run_cmd, CargoCmd},
+ in_official_repo,
+ terminal_link::TerminalFileLink,
+ DEBUG_PROFILE,
+};
+
+/// The initial capacity of the output buffer.
+pub const OUTPUT_CAPACITY: usize = 1 << 14;
+
+// Run an exercise binary and append its output to the `output` buffer.
+// Compilation must be done before calling this method.
+fn run_bin(bin_name: &str, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> {
+ writeln!(output, "{}", "Output".underlined())?;
+
+ // 7 = "/debug/".len()
+ let mut bin_path = PathBuf::with_capacity(target_dir.as_os_str().len() + 7 + bin_name.len());
+ bin_path.push(target_dir);
+ bin_path.push("debug");
+ bin_path.push(bin_name);
+
+ let success = run_cmd(Command::new(&bin_path), &bin_path.to_string_lossy(), output)?;
+
+ if !success {
+ // This output is important to show the user that something went wrong.
+ // Otherwise, calling something like `exit(1)` in an exercise without further output
+ // leaves the user confused about why the exercise isn't done yet.
+ writeln!(
+ output,
+ "{}",
+ "The exercise didn't run successfully (nonzero exit code)"
+ .bold()
+ .red(),
+ )?;
+ }
-#[derive(Deserialize)]
-pub struct ExerciseList {
- pub exercises: Vec<Exercise>,
+ Ok(success)
}
-// A representation of a rustlings exercise.
-// This is deserialized from the accompanying info.toml file
-#[derive(Deserialize, Debug)]
+/// See `info_file::ExerciseInfo`
pub struct Exercise {
- // Name of the exercise
- pub name: String,
- // The path to the file containing the exercise's source code
- pub path: PathBuf,
- // The mode of the exercise (Test, Compile, or Clippy)
- pub mode: Mode,
- // The hint text associated with the exercise
+ pub dir: Option<&'static str>,
+ pub name: &'static str,
+ /// Path of the exercise file starting with the `exercises/` directory.
+ pub path: &'static str,
+ pub test: bool,
+ pub strict_clippy: bool,
pub hint: String,
+ pub done: bool,
}
-// An enum to track of the state of an Exercise.
-// An Exercise can be either Done or Pending
-#[derive(PartialEq, Debug)]
-pub enum State {
- // The state of the exercise once it's been completed
- Done,
- // The state of the exercise while it's not completed yet
- Pending(Vec<ContextLine>),
-}
-
-// The context information of a pending exercise
-#[derive(PartialEq, Debug)]
-pub struct ContextLine {
- // The source code that is still pending completion
- pub line: String,
- // The line number of the source code still pending completion
- pub number: usize,
- // Whether or not this is important
- pub important: bool,
-}
-
-// The result of compiling an exercise
-pub struct CompiledExercise<'a> {
- exercise: &'a Exercise,
- _handle: FileHandle,
-}
-
-impl<'a> CompiledExercise<'a> {
- // Run the compiled exercise
- pub fn run(&self) -> Result<ExerciseOutput, ExerciseOutput> {
- self.exercise.run()
+impl Exercise {
+ pub fn terminal_link(&self) -> StyledContent<TerminalFileLink<'_>> {
+ style(TerminalFileLink(self.path)).underlined().blue()
}
}
-// A representation of an already executed binary
-#[derive(Debug)]
-pub struct ExerciseOutput {
- // The textual contents of the standard output of the binary
- pub stdout: String,
- // The textual contents of the standard error of the binary
- pub stderr: String,
-}
-
-struct FileHandle;
-
-impl Drop for FileHandle {
- fn drop(&mut self) {
- clean();
+impl Display for Exercise {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ self.path.fmt(f)
}
}
-impl Exercise {
- pub fn compile(&self) -> Result<CompiledExercise, ExerciseOutput> {
- let cmd = match self.mode {
- Mode::Compile => Command::new("rustc")
- .args([self.path.to_str().unwrap(), "-o", &temp_file()])
- .args(RUSTC_COLOR_ARGS)
- .args(RUSTC_EDITION_ARGS)
- .args(RUSTC_NO_DEBUG_ARGS)
- .output(),
- Mode::Test => Command::new("rustc")
- .args(["--test", self.path.to_str().unwrap(), "-o", &temp_file()])
- .args(RUSTC_COLOR_ARGS)
- .args(RUSTC_EDITION_ARGS)
- .args(RUSTC_NO_DEBUG_ARGS)
- .output(),
- Mode::Clippy => {
- let cargo_toml = format!(
- r#"[package]
-name = "{}"
-version = "0.0.1"
-edition = "2021"
-[[bin]]
-name = "{}"
-path = "{}.rs""#,
- self.name, self.name, self.name
- );
- let cargo_toml_error_msg = if env::var("NO_EMOJI").is_ok() {
- "Failed to write Clippy Cargo.toml file."
- } else {
- "Failed to write 📎 Clippy 📎 Cargo.toml file."
- };
- fs::write(CLIPPY_CARGO_TOML_PATH, cargo_toml).expect(cargo_toml_error_msg);
- // To support the ability to run the clippy exercises, build
- // an executable, in addition to running clippy. With a
- // compilation failure, this would silently fail. But we expect
- // clippy to reflect the same failure while compiling later.
- Command::new("rustc")
- .args([self.path.to_str().unwrap(), "-o", &temp_file()])
- .args(RUSTC_COLOR_ARGS)
- .args(RUSTC_EDITION_ARGS)
- .args(RUSTC_NO_DEBUG_ARGS)
- .output()
- .expect("Failed to compile!");
- // Due to an issue with Clippy, a cargo clean is required to catch all lints.
- // See https://github.com/rust-lang/rust-clippy/issues/2604
- // This is already fixed on Clippy's master branch. See this issue to track merging into Cargo:
- // https://github.com/rust-lang/rust-clippy/issues/3837
- Command::new("cargo")
- .args(["clean", "--manifest-path", CLIPPY_CARGO_TOML_PATH])
- .args(RUSTC_COLOR_ARGS)
- .output()
- .expect("Failed to run 'cargo clean'");
- Command::new("cargo")
- .args(["clippy", "--manifest-path", CLIPPY_CARGO_TOML_PATH])
- .args(RUSTC_COLOR_ARGS)
- .args(["--", "-D", "warnings", "-D", "clippy::float_cmp"])
- .output()
- }
+pub trait RunnableExercise {
+ fn name(&self) -> &str;
+ fn strict_clippy(&self) -> bool;
+ fn test(&self) -> bool;
+
+ // Compile, check and run the exercise or its solution (depending on `bin_name´).
+ // The output is written to the `output` buffer after clearing it.
+ fn run(&self, bin_name: &str, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> {
+ output.clear();
+
+ // Developing the official Rustlings.
+ let dev = DEBUG_PROFILE && in_official_repo();
+
+ let build_success = CargoCmd {
+ subcommand: "build",
+ args: &[],
+ bin_name,
+ description: "cargo build …",
+ hide_warnings: false,
+ target_dir,
+ output,
+ dev,
}
- .expect("Failed to run 'compile' command.");
-
- if cmd.status.success() {
- Ok(CompiledExercise {
- exercise: self,
- _handle: FileHandle,
- })
- } else {
- clean();
- Err(ExerciseOutput {
- stdout: String::from_utf8_lossy(&cmd.stdout).to_string(),
- stderr: String::from_utf8_lossy(&cmd.stderr).to_string(),
- })
+ .run()?;
+ if !build_success {
+ return Ok(false);
}
- }
-
- fn run(&self) -> Result<ExerciseOutput, ExerciseOutput> {
- let arg = match self.mode {
- Mode::Test => "--show-output",
- _ => "",
- };
- let cmd = Command::new(temp_file())
- .arg(arg)
- .output()
- .expect("Failed to run 'run' command");
- let output = ExerciseOutput {
- stdout: String::from_utf8_lossy(&cmd.stdout).to_string(),
- stderr: String::from_utf8_lossy(&cmd.stderr).to_string(),
- };
+ // Discard the output of `cargo build` because it will be shown again by Clippy.
+ output.clear();
- if cmd.status.success() {
- Ok(output)
+ // `--profile test` is required to also check code with `[cfg(test)]`.
+ let clippy_args: &[&str] = if self.strict_clippy() {
+ &["--profile", "test", "--", "-D", "warnings"]
} else {
- Err(output)
- }
- }
-
- pub fn state(&self) -> State {
- let mut source_file = File::open(&self.path).unwrap_or_else(|e| {
- panic!(
- "We were unable to open the exercise file {}! {e}",
- self.path.display()
- )
- });
-
- let source = {
- let mut s = String::new();
- source_file.read_to_string(&mut s).unwrap_or_else(|e| {
- panic!(
- "We were unable to read the exercise file {}! {e}",
- self.path.display()
- )
- });
- s
+ &["--profile", "test"]
};
-
- let re = Regex::new(I_AM_DONE_REGEX).unwrap();
-
- if !re.is_match(&source) {
- return State::Done;
+ let clippy_success = CargoCmd {
+ subcommand: "clippy",
+ args: clippy_args,
+ bin_name,
+ description: "cargo clippy …",
+ hide_warnings: false,
+ target_dir,
+ output,
+ dev,
+ }
+ .run()?;
+ if !clippy_success {
+ return Ok(false);
}
- let matched_line_index = source
- .lines()
- .enumerate()
- .find_map(|(i, line)| if re.is_match(line) { Some(i) } else { None })
- .expect("This should not happen at all");
-
- let min_line = ((matched_line_index as i32) - (CONTEXT as i32)).max(0) as usize;
- let max_line = matched_line_index + CONTEXT;
+ if !self.test() {
+ return run_bin(bin_name, output, target_dir);
+ }
- let context = source
- .lines()
- .enumerate()
- .filter(|&(i, _)| i >= min_line && i <= max_line)
- .map(|(i, line)| ContextLine {
- line: line.to_string(),
- number: i + 1,
- important: i == matched_line_index,
- })
- .collect();
+ let test_success = CargoCmd {
+ subcommand: "test",
+ args: &["--", "--color", "always", "--show-output"],
+ bin_name,
+ description: "cargo test …",
+ // Hide warnings because they are shown by Clippy.
+ hide_warnings: true,
+ target_dir,
+ output,
+ dev,
+ }
+ .run()?;
- State::Pending(context)
- }
+ let run_success = run_bin(bin_name, output, target_dir)?;
- // Check that the exercise looks to be solved using self.state()
- // This is not the best way to check since
- // the user can just remove the "I AM NOT DONE" string from the file
- // without actually having solved anything.
- // The only other way to truly check this would to compile and run
- // the exercise; which would be both costly and counterintuitive
- pub fn looks_done(&self) -> bool {
- self.state() == State::Done
+ Ok(test_success && run_success)
}
-}
-impl Display for Exercise {
- fn fmt(&self, f: &mut Formatter) -> fmt::Result {
- write!(f, "{}", self.path.to_str().unwrap())
+ /// Compile, check and run the exercise.
+ /// The output is written to the `output` buffer after clearing it.
+ #[inline]
+ fn run_exercise(&self, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> {
+ self.run(self.name(), output, target_dir)
}
-}
-
-#[inline]
-fn clean() {
- let _ignored = remove_file(temp_file());
-}
-#[cfg(test)]
-mod test {
- use super::*;
- use std::path::Path;
+ /// Compile, check and run the exercise's solution.
+ /// The output is written to the `output` buffer after clearing it.
+ fn run_solution(&self, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> {
+ let name = self.name();
+ let mut bin_name = String::with_capacity(name.len());
+ bin_name.push_str(name);
+ bin_name.push_str("_sol");
- #[test]
- fn test_clean() {
- File::create(temp_file()).unwrap();
- let exercise = Exercise {
- name: String::from("example"),
- path: PathBuf::from("tests/fixture/state/pending_exercise.rs"),
- mode: Mode::Compile,
- hint: String::from(""),
- };
- let compiled = exercise.compile().unwrap();
- drop(compiled);
- assert!(!Path::new(&temp_file()).exists());
+ self.run(&bin_name, output, target_dir)
}
+}
- #[test]
- #[cfg(target_os = "windows")]
- fn test_no_pdb_file() {
- [Mode::Compile, Mode::Test] // Clippy doesn't like to test
- .iter()
- .for_each(|mode| {
- let exercise = Exercise {
- name: String::from("example"),
- // We want a file that does actually compile
- path: PathBuf::from("tests/fixture/state/pending_exercise.rs"),
- mode: *mode,
- hint: String::from(""),
- };
- let _ = exercise.compile().unwrap();
- assert!(!Path::new(&format!("{}.pdb", temp_file())).exists());
- });
- }
-
- #[test]
- fn test_pending_state() {
- let exercise = Exercise {
- name: "pending_exercise".into(),
- path: PathBuf::from("tests/fixture/state/pending_exercise.rs"),
- mode: Mode::Compile,
- hint: String::new(),
- };
-
- let state = exercise.state();
- let expected = vec![
- ContextLine {
- line: "// fake_exercise".to_string(),
- number: 1,
- important: false,
- },
- ContextLine {
- line: "".to_string(),
- number: 2,
- important: false,
- },
- ContextLine {
- line: "// I AM NOT DONE".to_string(),
- number: 3,
- important: true,
- },
- ContextLine {
- line: "".to_string(),
- number: 4,
- important: false,
- },
- ContextLine {
- line: "fn main() {".to_string(),
- number: 5,
- important: false,
- },
- ];
-
- assert_eq!(state, State::Pending(expected));
+impl RunnableExercise for Exercise {
+ #[inline]
+ fn name(&self) -> &str {
+ self.name
}
- #[test]
- fn test_finished_exercise() {
- let exercise = Exercise {
- name: "finished_exercise".into(),
- path: PathBuf::from("tests/fixture/state/finished_exercise.rs"),
- mode: Mode::Compile,
- hint: String::new(),
- };
-
- assert_eq!(exercise.state(), State::Done);
+ #[inline]
+ fn strict_clippy(&self) -> bool {
+ self.strict_clippy
}
- #[test]
- fn test_exercise_with_output() {
- let exercise = Exercise {
- name: "exercise_with_output".into(),
- path: PathBuf::from("tests/fixture/success/testSuccess.rs"),
- mode: Mode::Test,
- hint: String::new(),
- };
- let out = exercise.compile().unwrap().run().unwrap();
- assert!(out.stdout.contains("THIS TEST TOO SHALL PASS"));
+ #[inline]
+ fn test(&self) -> bool {
+ self.test
}
}
diff --git a/src/info_file.rs b/src/info_file.rs
new file mode 100644
index 0000000..f27d018
--- /dev/null
+++ b/src/info_file.rs
@@ -0,0 +1,138 @@
+use anyhow::{bail, Context, Error, Result};
+use serde::Deserialize;
+use std::{fs, io::ErrorKind};
+
+use crate::{embedded::EMBEDDED_FILES, exercise::RunnableExercise};
+
+/// Deserialized from the `info.toml` file.
+#[derive(Deserialize)]
+pub struct ExerciseInfo {
+ /// Exercise's unique name.
+ pub name: String,
+ /// Exercise's directory name inside the `exercises/` directory.
+ pub dir: Option<String>,
+ /// Run `cargo test` on the exercise.
+ #[serde(default = "default_true")]
+ pub test: bool,
+ /// Deny all Clippy warnings.
+ #[serde(default)]
+ pub strict_clippy: bool,
+ /// The exercise's hint to be shown to the user on request.
+ pub hint: String,
+ /// The exercise is already solved. Ignore it when checking that all exercises are unsolved.
+ #[serde(default)]
+ pub skip_check_unsolved: bool,
+}
+#[inline(always)]
+const fn default_true() -> bool {
+ true
+}
+
+impl ExerciseInfo {
+ /// Path to the exercise file starting with the `exercises/` directory.
+ pub fn path(&self) -> String {
+ let mut path = if let Some(dir) = &self.dir {
+ // 14 = 10 + 1 + 3
+ // exercises/ + / + .rs
+ let mut path = String::with_capacity(14 + dir.len() + self.name.len());
+ path.push_str("exercises/");
+ path.push_str(dir);
+ path.push('/');
+ path
+ } else {
+ // 13 = 10 + 3
+ // exercises/ + .rs
+ let mut path = String::with_capacity(13 + self.name.len());
+ path.push_str("exercises/");
+ path
+ };
+
+ path.push_str(&self.name);
+ path.push_str(".rs");
+
+ path
+ }
+
+ /// Path to the solution file starting with the `solutions/` directory.
+ pub fn sol_path(&self) -> String {
+ let mut path = if let Some(dir) = &self.dir {
+ // 14 = 10 + 1 + 3
+ // solutions/ + / + .rs
+ let mut path = String::with_capacity(14 + dir.len() + self.name.len());
+ path.push_str("solutions/");
+ path.push_str(dir);
+ path.push('/');
+ path
+ } else {
+ // 13 = 10 + 3
+ // solutions/ + .rs
+ let mut path = String::with_capacity(13 + self.name.len());
+ path.push_str("solutions/");
+ path
+ };
+
+ path.push_str(&self.name);
+ path.push_str(".rs");
+
+ path
+ }
+}
+
+impl RunnableExercise for ExerciseInfo {
+ #[inline]
+ fn name(&self) -> &str {
+ &self.name
+ }
+
+ #[inline]
+ fn strict_clippy(&self) -> bool {
+ self.strict_clippy
+ }
+
+ #[inline]
+ fn test(&self) -> bool {
+ self.test
+ }
+}
+
+/// The deserialized `info.toml` file.
+#[derive(Deserialize)]
+pub struct InfoFile {
+ /// For possible breaking changes in the future for third-party exercises.
+ pub format_version: u8,
+ /// Shown to users when starting with the exercises.
+ pub welcome_message: Option<String>,
+ /// Shown to users after finishing all exercises.
+ pub final_message: Option<String>,
+ /// List of all exercises.
+ pub exercises: Vec<ExerciseInfo>,
+}
+
+impl InfoFile {
+ /// Official exercises: Parse the embedded `info.toml` file.
+ /// Third-party exercises: Parse the `info.toml` file in the current directory.
+ pub fn parse() -> Result<Self> {
+ // Read a local `info.toml` if it exists.
+ let slf = match fs::read_to_string("info.toml") {
+ Ok(file_content) => toml_edit::de::from_str::<Self>(&file_content)
+ .context("Failed to parse the `info.toml` file")?,
+ Err(e) => {
+ if e.kind() == ErrorKind::NotFound {
+ return toml_edit::de::from_str(EMBEDDED_FILES.info_file)
+ .context("Failed to parse the embedded `info.toml` file");
+ }
+
+ return Err(Error::from(e).context("Failed to read the `info.toml` file"));
+ }
+ };
+
+ if slf.exercises.is_empty() {
+ bail!("{NO_EXERCISES_ERR}");
+ }
+
+ Ok(slf)
+ }
+}
+
+const NO_EXERCISES_ERR: &str = "There are no exercises yet!
+If you are developing third-party exercises, add at least one exercise before testing.";
diff --git a/src/init.rs b/src/init.rs
new file mode 100644
index 0000000..4063ca7
--- /dev/null
+++ b/src/init.rs
@@ -0,0 +1,116 @@
+use anyhow::{bail, Context, Result};
+use crossterm::style::Stylize;
+use std::{
+ env::set_current_dir,
+ fs::{self, create_dir},
+ io::ErrorKind,
+ path::Path,
+ process::{Command, Stdio},
+};
+
+use crate::{cargo_toml::updated_cargo_toml, embedded::EMBEDDED_FILES, info_file::InfoFile};
+
+pub fn init() -> Result<()> {
+ // Prevent initialization in a directory that contains the file `Cargo.toml`.
+ // This can mean that Rustlings was already initialized in this directory.
+ // Otherwise, this can cause problems with Cargo workspaces.
+ if Path::new("Cargo.toml").exists() {
+ bail!(CARGO_TOML_EXISTS_ERR);
+ }
+
+ let rustlings_path = Path::new("rustlings");
+ if let Err(e) = create_dir(rustlings_path) {
+ if e.kind() == ErrorKind::AlreadyExists {
+ bail!(RUSTLINGS_DIR_ALREADY_EXISTS_ERR);
+ }
+ return Err(e.into());
+ }
+
+ set_current_dir("rustlings")
+ .context("Failed to change the current directory to `rustlings/`")?;
+
+ let info_file = InfoFile::parse()?;
+ EMBEDDED_FILES
+ .init_exercises_dir(&info_file.exercises)
+ .context("Failed to initialize the `rustlings/exercises` directory")?;
+
+ create_dir("solutions").context("Failed to create the `solutions/` directory")?;
+ for dir in EMBEDDED_FILES.exercise_dirs {
+ let mut dir_path = String::with_capacity(10 + dir.name.len());
+ dir_path.push_str("solutions/");
+ dir_path.push_str(dir.name);
+ create_dir(&dir_path)
+ .with_context(|| format!("Failed to create the directory {dir_path}"))?;
+ }
+ for exercise_info in &info_file.exercises {
+ let solution_path = exercise_info.sol_path();
+ fs::write(&solution_path, INIT_SOLUTION_FILE)
+ .with_context(|| format!("Failed to create the file {solution_path}"))?;
+ }
+
+ let current_cargo_toml = include_str!("../dev-Cargo.toml");
+ // Skip the first line (comment).
+ let newline_ind = current_cargo_toml
+ .as_bytes()
+ .iter()
+ .position(|c| *c == b'\n')
+ .context("The embedded `Cargo.toml` is empty or contains only one line")?;
+ let current_cargo_toml = current_cargo_toml
+ .get(newline_ind + 1..)
+ .context("The embedded `Cargo.toml` contains only one line")?;
+ let updated_cargo_toml = updated_cargo_toml(&info_file.exercises, current_cargo_toml, b"")
+ .context("Failed to generate `Cargo.toml`")?;
+ fs::write("Cargo.toml", updated_cargo_toml)
+ .context("Failed to create the file `rustlings/Cargo.toml`")?;
+
+ fs::write(".gitignore", GITIGNORE)
+ .context("Failed to create the file `rustlings/.gitignore`")?;
+
+ create_dir(".vscode").context("Failed to create the directory `rustlings/.vscode`")?;
+ fs::write(".vscode/extensions.json", VS_CODE_EXTENSIONS_JSON)
+ .context("Failed to create the file `rustlings/.vscode/extensions.json`")?;
+
+ // Ignore any Git error because Git initialization is not required.
+ let _ = Command::new("git")
+ .arg("init")
+ .stdin(Stdio::null())
+ .stderr(Stdio::null())
+ .status();
+
+ println!(
+ "\n{}\n\n{}",
+ "Initialization done ✓".green(),
+ POST_INIT_MSG.bold(),
+ );
+
+ Ok(())
+}
+
+const INIT_SOLUTION_FILE: &[u8] = b"fn main() {
+ // DON'T EDIT THIS SOLUTION FILE!
+ // It will be automatically filled after you finish the exercise.
+}
+";
+
+const GITIGNORE: &[u8] = b".rustlings-state.txt
+solutions
+Cargo.lock
+target
+.vscode
+";
+
+pub const VS_CODE_EXTENSIONS_JSON: &[u8] = br#"{"recommendations":["rust-lang.rust-analyzer"]}"#;
+
+const CARGO_TOML_EXISTS_ERR: &str = "The current directory contains the file `Cargo.toml`.
+
+If you already initialized Rustlings, run the command `rustlings` for instructions on getting started with the exercises.
+Otherwise, please run `rustlings init` again in another directory.";
+
+const RUSTLINGS_DIR_ALREADY_EXISTS_ERR: &str =
+ "A directory with the name `rustlings` already exists in the current directory.
+You probably already initialized Rustlings.
+Run `cd rustlings`
+Then run `rustlings` again";
+
+const POST_INIT_MSG: &str = "Run `cd rustlings` to go into the generated directory.
+Then run `rustlings` to get started.";
diff --git a/src/list.rs b/src/list.rs
new file mode 100644
index 0000000..790c02f
--- /dev/null
+++ b/src/list.rs
@@ -0,0 +1,90 @@
+use anyhow::Result;
+use crossterm::{
+ event::{self, Event, KeyCode, KeyEventKind},
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+ ExecutableCommand,
+};
+use ratatui::{backend::CrosstermBackend, Terminal};
+use std::io;
+
+use crate::app_state::AppState;
+
+use self::state::{Filter, UiState};
+
+mod state;
+
+pub fn list(app_state: &mut AppState) -> Result<()> {
+ let mut stdout = io::stdout().lock();
+ stdout.execute(EnterAlternateScreen)?;
+ enable_raw_mode()?;
+
+ let mut terminal = Terminal::new(CrosstermBackend::new(&mut stdout))?;
+ terminal.clear()?;
+
+ let mut ui_state = UiState::new(app_state);
+
+ 'outer: loop {
+ terminal.draw(|frame| ui_state.draw(frame).unwrap())?;
+
+ let key = loop {
+ match event::read()? {
+ Event::Key(key) => match key.kind {
+ KeyEventKind::Press | KeyEventKind::Repeat => break key,
+ KeyEventKind::Release => (),
+ },
+ // Redraw
+ Event::Resize(_, _) => continue 'outer,
+ // Ignore
+ Event::FocusGained | Event::FocusLost | Event::Mouse(_) | Event::Paste(_) => (),
+ }
+ };
+
+ ui_state.message.clear();
+
+ match key.code {
+ KeyCode::Char('q') => break,
+ KeyCode::Down | KeyCode::Char('j') => ui_state.select_next(),
+ KeyCode::Up | KeyCode::Char('k') => ui_state.select_previous(),
+ KeyCode::Home | KeyCode::Char('g') => ui_state.select_first(),
+ KeyCode::End | KeyCode::Char('G') => ui_state.select_last(),
+ KeyCode::Char('d') => {
+ let message = if ui_state.filter == Filter::Done {
+ ui_state.filter = Filter::None;
+ "Disabled filter DONE"
+ } else {
+ ui_state.filter = Filter::Done;
+ "Enabled filter DONE │ Press d again to disable the filter"
+ };
+
+ ui_state = ui_state.with_updated_rows();
+ ui_state.message.push_str(message);
+ }
+ KeyCode::Char('p') => {
+ let message = if ui_state.filter == Filter::Pending {
+ ui_state.filter = Filter::None;
+ "Disabled filter PENDING"
+ } else {
+ ui_state.filter = Filter::Pending;
+ "Enabled filter PENDING │ Press p again to disable the filter"
+ };
+
+ ui_state = ui_state.with_updated_rows();
+ ui_state.message.push_str(message);
+ }
+ KeyCode::Char('r') => {
+ ui_state = ui_state.with_reset_selected()?;
+ }
+ KeyCode::Char('c') => {
+ ui_state.selected_to_current_exercise()?;
+ ui_state = ui_state.with_updated_rows();
+ }
+ _ => (),
+ }
+ }
+
+ drop(terminal);
+ stdout.execute(LeaveAlternateScreen)?;
+ disable_raw_mode()?;
+
+ Ok(())
+}
diff --git a/src/list/state.rs b/src/list/state.rs
new file mode 100644
index 0000000..d6df634
--- /dev/null
+++ b/src/list/state.rs
@@ -0,0 +1,271 @@
+use anyhow::{Context, Result};
+use ratatui::{
+ layout::{Constraint, Rect},
+ style::{Style, Stylize},
+ text::{Line, Span},
+ widgets::{Block, Borders, HighlightSpacing, Paragraph, Row, Table, TableState},
+ Frame,
+};
+use std::fmt::Write;
+
+use crate::{app_state::AppState, progress_bar::progress_bar_ratatui};
+
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub enum Filter {
+ Done,
+ Pending,
+ None,
+}
+
+pub struct UiState<'a> {
+ pub table: Table<'static>,
+ pub message: String,
+ pub filter: Filter,
+ app_state: &'a mut AppState,
+ table_state: TableState,
+ n_rows: usize,
+}
+
+impl<'a> UiState<'a> {
+ pub fn with_updated_rows(mut self) -> Self {
+ let current_exercise_ind = self.app_state.current_exercise_ind();
+
+ self.n_rows = 0;
+ let rows = self
+ .app_state
+ .exercises()
+ .iter()
+ .enumerate()
+ .filter_map(|(ind, exercise)| {
+ let exercise_state = if exercise.done {
+ if self.filter == Filter::Pending {
+ return None;
+ }
+
+ "DONE".green()
+ } else {
+ if self.filter == Filter::Done {
+ return None;
+ }
+
+ "PENDING".yellow()
+ };
+
+ self.n_rows += 1;
+
+ let next = if ind == current_exercise_ind {
+ ">>>>".bold().red()
+ } else {
+ Span::default()
+ };
+
+ Some(Row::new([
+ next,
+ exercise_state,
+ Span::raw(exercise.name),
+ Span::raw(exercise.path),
+ ]))
+ });
+
+ self.table = self.table.rows(rows);
+
+ if self.n_rows == 0 {
+ self.table_state.select(None);
+ } else {
+ self.table_state.select(Some(
+ self.table_state
+ .selected()
+ .map_or(0, |selected| selected.min(self.n_rows - 1)),
+ ));
+ }
+
+ self
+ }
+
+ pub fn new(app_state: &'a mut AppState) -> Self {
+ let header = Row::new(["Next", "State", "Name", "Path"]);
+
+ let max_name_len = app_state
+ .exercises()
+ .iter()
+ .map(|exercise| exercise.name.len())
+ .max()
+ .unwrap_or(4) as u16;
+
+ let widths = [
+ Constraint::Length(4),
+ Constraint::Length(7),
+ Constraint::Length(max_name_len),
+ Constraint::Fill(1),
+ ];
+
+ let table = Table::default()
+ .widths(widths)
+ .header(header)
+ .column_spacing(2)
+ .highlight_spacing(HighlightSpacing::Always)
+ .highlight_style(Style::new().bg(ratatui::style::Color::Rgb(50, 50, 50)))
+ .highlight_symbol("🦀")
+ .block(Block::default().borders(Borders::BOTTOM));
+
+ let selected = app_state.current_exercise_ind();
+ let table_state = TableState::default()
+ .with_offset(selected.saturating_sub(10))
+ .with_selected(Some(selected));
+
+ let filter = Filter::None;
+ let n_rows = app_state.exercises().len();
+
+ let slf = Self {
+ table,
+ message: String::with_capacity(128),
+ filter,
+ app_state,
+ table_state,
+ n_rows,
+ };
+
+ slf.with_updated_rows()
+ }
+
+ pub fn select_next(&mut self) {
+ if self.n_rows > 0 {
+ let next = self
+ .table_state
+ .selected()
+ .map_or(0, |selected| (selected + 1).min(self.n_rows - 1));
+ self.table_state.select(Some(next));
+ }
+ }
+
+ pub fn select_previous(&mut self) {
+ if self.n_rows > 0 {
+ let previous = self
+ .table_state
+ .selected()
+ .map_or(0, |selected| selected.saturating_sub(1));
+ self.table_state.select(Some(previous));
+ }
+ }
+
+ pub fn select_first(&mut self) {
+ if self.n_rows > 0 {
+ self.table_state.select(Some(0));
+ }
+ }
+
+ pub fn select_last(&mut self) {
+ if self.n_rows > 0 {
+ self.table_state.select(Some(self.n_rows - 1));
+ }
+ }
+
+ pub fn draw(&mut self, frame: &mut Frame) -> Result<()> {
+ let area = frame.size();
+
+ frame.render_stateful_widget(
+ &self.table,
+ Rect {
+ x: 0,
+ y: 0,
+ width: area.width,
+ height: area.height - 3,
+ },
+ &mut self.table_state,
+ );
+
+ frame.render_widget(
+ Paragraph::new(progress_bar_ratatui(
+ self.app_state.n_done(),
+ self.app_state.exercises().len() as u16,
+ area.width,
+ )?)
+ .block(Block::default().borders(Borders::BOTTOM)),
+ Rect {
+ x: 0,
+ y: area.height - 3,
+ width: area.width,
+ height: 2,
+ },
+ );
+
+ let message = if self.message.is_empty() {
+ // Help footer.
+ let mut spans = Vec::with_capacity(4);
+ spans.push(Span::raw(
+ "↓/j ↑/k home/g end/G │ <c>ontinue at │ <r>eset │ filter ",
+ ));
+ match self.filter {
+ Filter::Done => {
+ spans.push("<d>one".underlined().magenta());
+ spans.push(Span::raw("/<p>ending"));
+ }
+ Filter::Pending => {
+ spans.push(Span::raw("<d>one/"));
+ spans.push("<p>ending".underlined().magenta());
+ }
+ Filter::None => spans.push(Span::raw("<d>one/<p>ending")),
+ }
+ spans.push(Span::raw(" │ <q>uit"));
+ Line::from(spans)
+ } else {
+ Line::from(self.message.as_str().light_blue())
+ };
+ frame.render_widget(
+ message,
+ Rect {
+ x: 0,
+ y: area.height - 1,
+ width: area.width,
+ height: 1,
+ },
+ );
+
+ Ok(())
+ }
+
+ pub fn with_reset_selected(mut self) -> Result<Self> {
+ let Some(selected) = self.table_state.selected() else {
+ return Ok(self);
+ };
+
+ let ind = self
+ .app_state
+ .exercises()
+ .iter()
+ .enumerate()
+ .filter_map(|(ind, exercise)| match self.filter {
+ Filter::Done => exercise.done.then_some(ind),
+ Filter::Pending => (!exercise.done).then_some(ind),
+ Filter::None => Some(ind),
+ })
+ .nth(selected)
+ .context("Invalid selection index")?;
+
+ let exercise_path = self.app_state.reset_exercise_by_ind(ind)?;
+ write!(self.message, "The exercise {exercise_path} has been reset")?;
+
+ Ok(self.with_updated_rows())
+ }
+
+ pub fn selected_to_current_exercise(&mut self) -> Result<()> {
+ let Some(selected) = self.table_state.selected() else {
+ return Ok(());
+ };
+
+ let ind = self
+ .app_state
+ .exercises()
+ .iter()
+ .enumerate()
+ .filter_map(|(ind, exercise)| match self.filter {
+ Filter::Done => exercise.done.then_some(ind),
+ Filter::Pending => (!exercise.done).then_some(ind),
+ Filter::None => Some(ind),
+ })
+ .nth(selected)
+ .context("Invalid selection index")?;
+
+ self.app_state.set_current_exercise_ind(ind)
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index bbff712..2233d8b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,471 +1,220 @@
-use crate::exercise::{Exercise, ExerciseList};
-use crate::project::RustAnalyzerProject;
-use crate::run::{reset, run};
-use crate::verify::verify;
+use anyhow::{bail, Context, Result};
+use app_state::StateFileStatus;
use clap::{Parser, Subcommand};
-use console::Emoji;
-use notify::DebouncedEvent;
-use notify::{RecommendedWatcher, RecursiveMode, Watcher};
-use std::ffi::OsStr;
-use std::fs;
-use std::io::{self, prelude::*};
-use std::path::Path;
-use std::process::{Command, Stdio};
-use std::sync::atomic::{AtomicBool, Ordering};
-use std::sync::mpsc::{channel, RecvTimeoutError};
-use std::sync::{Arc, Mutex};
-use std::thread;
-use std::time::Duration;
-
-#[macro_use]
-mod ui;
-
+use std::{
+ io::{self, BufRead, StdoutLock, Write},
+ path::Path,
+ process::exit,
+};
+
+use self::{app_state::AppState, dev::DevCommands, info_file::InfoFile, watch::WatchExit};
+
+mod app_state;
+mod cargo_toml;
+mod cmd;
+mod dev;
+mod embedded;
mod exercise;
-mod project;
+mod info_file;
+mod init;
+mod list;
+mod progress_bar;
mod run;
-mod verify;
+mod terminal_link;
+mod watch;
+
+const CURRENT_FORMAT_VERSION: u8 = 1;
+const DEBUG_PROFILE: bool = {
+ #[allow(unused_assignments, unused_mut)]
+ let mut debug_profile = false;
+
+ #[cfg(debug_assertions)]
+ {
+ debug_profile = true;
+ }
+
+ debug_profile
+};
+
+// The current directory is the official Rustligns repository.
+fn in_official_repo() -> bool {
+ Path::new("dev/rustlings-repo.txt").exists()
+}
+
+fn clear_terminal(stdout: &mut StdoutLock) -> io::Result<()> {
+ stdout.write_all(b"\x1b[H\x1b[2J\x1b[3J")
+}
+
+fn press_enter_prompt() -> io::Result<()> {
+ io::stdin().lock().read_until(b'\n', &mut Vec::new())?;
+ Ok(())
+}
/// Rustlings is a collection of small exercises to get you used to writing and reading Rust code
#[derive(Parser)]
#[command(version)]
struct Args {
- /// Show outputs from the test exercises
- #[arg(long)]
- nocapture: bool,
#[command(subcommand)]
command: Option<Subcommands>,
+ /// Manually run the current exercise using `r` in the watch mode.
+ /// Only use this if Rustlings fails to detect exercise file changes.
+ #[arg(long)]
+ manual_run: bool,
}
#[derive(Subcommand)]
enum Subcommands {
- /// Verify all exercises according to the recommended order
- Verify,
- /// Rerun `verify` when files were edited
- Watch {
- /// Show hints on success
- #[arg(long)]
- success_hints: bool,
- },
- /// Run/Test a single exercise
+ /// Initialize the official Rustlings exercises
+ Init,
+ /// Run a single exercise. Runs the next pending exercise if the exercise name is not specified
Run {
/// The name of the exercise
- name: String,
+ name: Option<String>,
},
- /// Reset a single exercise using "git stash -- <filename>"
+ /// Reset a single exercise
Reset {
/// The name of the exercise
name: String,
},
- /// Return a hint for the given exercise
+ /// Show a hint. Shows the hint of the next pending exercise if the exercise name is not specified
Hint {
/// The name of the exercise
- name: String,
+ name: Option<String>,
},
- /// List the exercises available in Rustlings
- List {
- /// Show only the paths of the exercises
- #[arg(short, long)]
- paths: bool,
- /// Show only the names of the exercises
- #[arg(short, long)]
- names: bool,
- /// Provide a string to match exercise names.
- /// Comma separated patterns are accepted
- #[arg(short, long)]
- filter: Option<String>,
- /// Display only exercises not yet solved
- #[arg(short, long)]
- unsolved: bool,
- /// Display only exercises that have been solved
- #[arg(short, long)]
- solved: bool,
- },
- /// Enable rust-analyzer for exercises
- Lsp,
+ /// Commands for developing (third-party) Rustlings exercises
+ #[command(subcommand)]
+ Dev(DevCommands),
}
-fn main() {
+fn main() -> Result<()> {
let args = Args::parse();
- if args.command.is_none() {
- println!("\n{WELCOME}\n");
- }
-
- if !Path::new("info.toml").exists() {
- println!(
- "{} must be run from the rustlings directory",
- std::env::current_exe().unwrap().to_str().unwrap()
- );
- println!("Try `cd rustlings/`!");
- std::process::exit(1);
+ if !DEBUG_PROFILE && in_official_repo() {
+ bail!("{OLD_METHOD_ERR}");
}
- if !rustc_exists() {
- println!("We cannot find `rustc`.");
- println!("Try running `rustc --version` to diagnose your problem.");
- println!("For instructions on how to install Rust, check the README.");
- std::process::exit(1);
- }
-
- let toml_str = &fs::read_to_string("info.toml").unwrap();
- let exercises = toml::from_str::<ExerciseList>(toml_str).unwrap().exercises;
- let verbose = args.nocapture;
-
- let command = args.command.unwrap_or_else(|| {
- println!("{DEFAULT_OUT}\n");
- std::process::exit(0);
- });
-
- match command {
- Subcommands::List {
- paths,
- names,
- filter,
- unsolved,
- solved,
- } => {
- if !paths && !names {
- println!("{:<17}\t{:<46}\t{:<7}", "Name", "Path", "Status");
+ match args.command {
+ Some(Subcommands::Init) => {
+ if DEBUG_PROFILE {
+ bail!("Disabled in the debug build");
}
- let mut exercises_done: u16 = 0;
- let filters = filter.clone().unwrap_or_default().to_lowercase();
- exercises.iter().for_each(|e| {
- let fname = format!("{}", e.path.display());
- let filter_cond = filters
- .split(',')
- .filter(|f| !f.trim().is_empty())
- .any(|f| e.name.contains(f) || fname.contains(f));
- let status = if e.looks_done() {
- exercises_done += 1;
- "Done"
- } else {
- "Pending"
- };
- let solve_cond = {
- (e.looks_done() && solved)
- || (!e.looks_done() && unsolved)
- || (!solved && !unsolved)
- };
- if solve_cond && (filter_cond || filter.is_none()) {
- let line = if paths {
- format!("{fname}\n")
- } else if names {
- format!("{}\n", e.name)
- } else {
- format!("{:<17}\t{fname:<46}\t{status:<7}\n", e.name)
- };
- // Somehow using println! leads to the binary panicking
- // when its output is piped.
- // So, we're handling a Broken Pipe error and exiting with 0 anyway
- let stdout = std::io::stdout();
- {
- let mut handle = stdout.lock();
- handle.write_all(line.as_bytes()).unwrap_or_else(|e| {
- match e.kind() {
- std::io::ErrorKind::BrokenPipe => std::process::exit(0),
- _ => std::process::exit(1),
- };
- });
- }
- }
- });
- let percentage_progress = exercises_done as f32 / exercises.len() as f32 * 100.0;
- println!(
- "Progress: You completed {} / {} exercises ({:.1} %).",
- exercises_done,
- exercises.len(),
- percentage_progress
- );
- std::process::exit(0);
- }
-
- Subcommands::Run { name } => {
- let exercise = find_exercise(&name, &exercises);
-
- run(exercise, verbose).unwrap_or_else(|_| std::process::exit(1));
- }
- Subcommands::Reset { name } => {
- let exercise = find_exercise(&name, &exercises);
+ {
+ let mut stdout = io::stdout().lock();
+ stdout.write_all(b"This command will create the directory `rustlings/` which will contain the exercises.\nPress ENTER to continue ")?;
+ stdout.flush()?;
+ press_enter_prompt()?;
+ stdout.write_all(b"\n")?;
+ }
- reset(exercise).unwrap_or_else(|_| std::process::exit(1));
+ return init::init().context("Initialization failed");
}
+ Some(Subcommands::Dev(dev_command)) => return dev_command.run(),
+ _ => (),
+ }
- Subcommands::Hint { name } => {
- let exercise = find_exercise(&name, &exercises);
-
- println!("{}", exercise.hint);
- }
+ if !Path::new("exercises").is_dir() {
+ println!("{PRE_INIT_MSG}");
+ exit(1);
+ }
- Subcommands::Verify => {
- verify(&exercises, (0, exercises.len()), verbose, false)
- .unwrap_or_else(|_| std::process::exit(1));
- }
+ let info_file = InfoFile::parse()?;
- Subcommands::Lsp => {
- let mut project = RustAnalyzerProject::new();
- project
- .get_sysroot_src()
- .expect("Couldn't find toolchain path, do you have `rustc` installed?");
- project
- .exercises_to_json()
- .expect("Couldn't parse rustlings exercises files");
+ if info_file.format_version > CURRENT_FORMAT_VERSION {
+ bail!(FORMAT_VERSION_HIGHER_ERR);
+ }
- if project.crates.is_empty() {
- println!("Failed find any exercises, make sure you're in the `rustlings` folder");
- } else if project.write_to_disk().is_err() {
- println!("Failed to write rust-project.json to disk for rust-analyzer");
- } else {
- println!("Successfully generated rust-project.json");
- println!("rust-analyzer will now parse exercises, restart your language server or editor")
+ let (mut app_state, state_file_status) = AppState::new(
+ info_file.exercises,
+ info_file.final_message.unwrap_or_default(),
+ )?;
+
+ // Show the welcome message if the state file doesn't exist yet.
+ if let Some(welcome_message) = info_file.welcome_message {
+ match state_file_status {
+ StateFileStatus::NotRead => {
+ let mut stdout = io::stdout().lock();
+ clear_terminal(&mut stdout)?;
+
+ let welcome_message = welcome_message.trim();
+ write!(stdout, "{welcome_message}\n\nPress ENTER to continue ")?;
+ stdout.flush()?;
+ press_enter_prompt()?;
+ clear_terminal(&mut stdout)?;
}
+ StateFileStatus::Read => (),
}
-
- Subcommands::Watch { success_hints } => match watch(&exercises, verbose, success_hints) {
- Err(e) => {
- println!(
- "Error: Could not watch your progress. Error message was {:?}.",
- e
- );
- println!("Most likely you've run out of disk space or your 'inotify limit' has been reached.");
- std::process::exit(1);
- }
- Ok(WatchStatus::Finished) => {
- println!(
- "{emoji} All exercises completed! {emoji}",
- emoji = Emoji("🎉", "★")
- );
- println!("\n{FENISH_LINE}\n");
- }
- Ok(WatchStatus::Unfinished) => {
- println!("We hope you're enjoying learning about Rust!");
- println!("If you want to continue working on the exercises at a later point, you can simply run `rustlings watch` again");
- }
- },
}
-}
-fn spawn_watch_shell(
- failed_exercise_hint: &Arc<Mutex<Option<String>>>,
- should_quit: Arc<AtomicBool>,
-) {
- let failed_exercise_hint = Arc::clone(failed_exercise_hint);
- println!("Welcome to watch mode! You can type 'help' to get an overview of the commands you can use here.");
- thread::spawn(move || loop {
- let mut input = String::new();
- match io::stdin().read_line(&mut input) {
- Ok(_) => {
- let input = input.trim();
- if input == "hint" {
- if let Some(hint) = &*failed_exercise_hint.lock().unwrap() {
- println!("{hint}");
- }
- } else if input == "clear" {
- println!("\x1B[2J\x1B[1;1H");
- } else if input.eq("quit") {
- should_quit.store(true, Ordering::SeqCst);
- println!("Bye!");
- } else if input.eq("help") {
- println!("Commands available to you in watch mode:");
- println!(" hint - prints the current exercise's hint");
- println!(" clear - clears the screen");
- println!(" quit - quits watch mode");
- println!(" !<cmd> - executes a command, like `!rustc --explain E0381`");
- println!(" help - displays this help message");
- println!();
- println!("Watch mode automatically re-evaluates the current exercise");
- println!("when you edit a file's contents.")
- } else if let Some(cmd) = input.strip_prefix('!') {
- let parts: Vec<&str> = cmd.split_whitespace().collect();
- if parts.is_empty() {
- println!("no command provided");
- } else if let Err(e) = Command::new(parts[0]).args(&parts[1..]).status() {
- println!("failed to execute command `{}`: {}", cmd, e);
- }
- } else {
- println!("unknown command: {input}");
+ match args.command {
+ None => {
+ let notify_exercise_names = if args.manual_run {
+ None
+ } else {
+ // For the notify event handler thread.
+ // Leaking is not a problem because the slice lives until the end of the program.
+ Some(
+ &*app_state
+ .exercises()
+ .iter()
+ .map(|exercise| exercise.name.as_bytes())
+ .collect::<Vec<_>>()
+ .leak(),
+ )
+ };
+
+ loop {
+ match watch::watch(&mut app_state, notify_exercise_names)? {
+ WatchExit::Shutdown => break,
+ // It is much easier to exit the watch mode, launch the list mode and then restart
+ // the watch mode instead of trying to pause the watch threads and correct the
+ // watch state.
+ WatchExit::List => list::list(&mut app_state)?,
}
}
- Err(error) => println!("error reading command: {error}"),
}
- });
-}
-
-fn find_exercise<'a>(name: &str, exercises: &'a [Exercise]) -> &'a Exercise {
- if name.eq("next") {
- exercises
- .iter()
- .find(|e| !e.looks_done())
- .unwrap_or_else(|| {
- println!("🎉 Congratulations! You have done all the exercises!");
- println!("🔚 There are no more exercises to do next!");
- std::process::exit(1)
- })
- } else {
- exercises
- .iter()
- .find(|e| e.name == name)
- .unwrap_or_else(|| {
- println!("No exercise found for '{name}'!");
- std::process::exit(1)
- })
- }
-}
-
-enum WatchStatus {
- Finished,
- Unfinished,
-}
-
-fn watch(
- exercises: &[Exercise],
- verbose: bool,
- success_hints: bool,
-) -> notify::Result<WatchStatus> {
- /* Clears the terminal with an ANSI escape code.
- Works in UNIX and newer Windows terminals. */
- fn clear_screen() {
- println!("\x1Bc");
- }
-
- let (tx, rx) = channel();
- let should_quit = Arc::new(AtomicBool::new(false));
-
- let mut watcher: RecommendedWatcher = Watcher::new(tx, Duration::from_secs(1))?;
- watcher.watch(Path::new("./exercises"), RecursiveMode::Recursive)?;
-
- clear_screen();
-
- let to_owned_hint = |t: &Exercise| t.hint.to_owned();
- let failed_exercise_hint = match verify(
- exercises.iter(),
- (0, exercises.len()),
- verbose,
- success_hints,
- ) {
- Ok(_) => return Ok(WatchStatus::Finished),
- Err(exercise) => Arc::new(Mutex::new(Some(to_owned_hint(exercise)))),
- };
- spawn_watch_shell(&failed_exercise_hint, Arc::clone(&should_quit));
- loop {
- match rx.recv_timeout(Duration::from_secs(1)) {
- Ok(event) => match event {
- DebouncedEvent::Create(b) | DebouncedEvent::Chmod(b) | DebouncedEvent::Write(b) => {
- if b.extension() == Some(OsStr::new("rs")) && b.exists() {
- let filepath = b.as_path().canonicalize().unwrap();
- let pending_exercises = exercises
- .iter()
- .find(|e| filepath.ends_with(&e.path))
- .into_iter()
- .chain(
- exercises
- .iter()
- .filter(|e| !e.looks_done() && !filepath.ends_with(&e.path)),
- );
- let num_done = exercises
- .iter()
- .filter(|e| e.looks_done() && !filepath.ends_with(&e.path))
- .count();
- clear_screen();
- match verify(
- pending_exercises,
- (num_done, exercises.len()),
- verbose,
- success_hints,
- ) {
- Ok(_) => return Ok(WatchStatus::Finished),
- Err(exercise) => {
- let mut failed_exercise_hint = failed_exercise_hint.lock().unwrap();
- *failed_exercise_hint = Some(to_owned_hint(exercise));
- }
- }
- }
- }
- _ => {}
- },
- Err(RecvTimeoutError::Timeout) => {
- // the timeout expired, just check the `should_quit` variable below then loop again
+ Some(Subcommands::Run { name }) => {
+ if let Some(name) = name {
+ app_state.set_current_exercise_by_name(&name)?;
}
- Err(e) => println!("watch error: {e:?}"),
+ run::run(&mut app_state)?;
+ }
+ Some(Subcommands::Reset { name }) => {
+ app_state.set_current_exercise_by_name(&name)?;
+ let exercise_path = app_state.reset_current_exercise()?;
+ println!("The exercise {exercise_path} has been reset");
}
- // Check if we need to exit
- if should_quit.load(Ordering::SeqCst) {
- return Ok(WatchStatus::Unfinished);
+ Some(Subcommands::Hint { name }) => {
+ if let Some(name) = name {
+ app_state.set_current_exercise_by_name(&name)?;
+ }
+ println!("{}", app_state.current_exercise().hint);
}
+ // Handled in an earlier match.
+ Some(Subcommands::Init | Subcommands::Dev(_)) => (),
}
-}
-fn rustc_exists() -> bool {
- Command::new("rustc")
- .args(["--version"])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .stdin(Stdio::null())
- .spawn()
- .and_then(|mut child| child.wait())
- .map(|status| status.success())
- .unwrap_or(false)
+ Ok(())
}
-const DEFAULT_OUT: &str = r#"Thanks for installing Rustlings!
-
-Is this your first time? Don't worry, Rustlings was made for beginners! We are
-going to teach you a lot of things about Rust, but before we can get
-started, here's a couple of notes about how Rustlings operates:
-
-1. The central concept behind Rustlings is that you solve exercises. These
- exercises usually have some sort of syntax error in them, which will cause
- them to fail compilation or testing. Sometimes there's a logic error instead
- of a syntax error. No matter what error, it's your job to find it and fix it!
- You'll know when you fixed it because then, the exercise will compile and
- Rustlings will be able to move on to the next exercise.
-2. If you run Rustlings in watch mode (which we recommend), it'll automatically
- start with the first exercise. Don't get confused by an error message popping
- up as soon as you run Rustlings! This is part of the exercise that you're
- supposed to solve, so open the exercise file in an editor and start your
- detective work!
-3. If you're stuck on an exercise, there is a helpful hint you can view by typing
- 'hint' (in watch mode), or running `rustlings hint exercise_name`.
-4. If an exercise doesn't make sense to you, feel free to open an issue on GitHub!
- (https://github.com/rust-lang/rustlings/issues/new). We look at every issue,
- and sometimes, other learners do too so you can help each other out!
-5. If you want to use `rust-analyzer` with exercises, which provides features like
- autocompletion, run the command `rustlings lsp`.
+const OLD_METHOD_ERR: &str = "You are trying to run Rustlings using the old method before v6.
+The new method doesn't include cloning the Rustlings' repository.
+Please follow the instructions in the README:
+https://github.com/rust-lang/rustlings#getting-started";
-Got all that? Great! To get started, run `rustlings watch` in order to get the first
-exercise. Make sure to have your editor open!"#;
+const FORMAT_VERSION_HIGHER_ERR: &str =
+ "The format version specified in the `info.toml` file is higher than the last one supported.
+It is possible that you have an outdated version of Rustlings.
+Try to install the latest Rustlings version first.";
-const FENISH_LINE: &str = r"+----------------------------------------------------+
-| You made it to the Fe-nish line! |
-+-------------------------- ------------------------+
- \\/
- ▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒
- ▒▒▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒▒▒
- ▒▒▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒▒▒
- ░░▒▒▒▒░░▒▒ ▒▒ ▒▒ ▒▒ ▒▒░░▒▒▒▒
- ▓▓▓▓▓▓▓▓ ▓▓ ▓▓██ ▓▓ ▓▓██ ▓▓ ▓▓▓▓▓▓▓▓
- ▒▒▒▒ ▒▒ ████ ▒▒ ████ ▒▒░░ ▒▒▒▒
- ▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒
- ▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▒▒▓▓▒▒▒▒▒▒▒▒
- ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
- ▒▒▒▒▒▒▒▒▒▒██▒▒▒▒▒▒██▒▒▒▒▒▒▒▒▒▒
- ▒▒ ▒▒▒▒▒▒▒▒▒▒██████▒▒▒▒▒▒▒▒▒▒ ▒▒
- ▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒
- ▒▒ ▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒ ▒▒
- ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒
- ▒▒ ▒▒ ▒▒ ▒▒
-
-We hope you enjoyed learning about the various aspects of Rust!
-If you noticed any issues, please don't hesitate to report them to our repo.
-You can also contribute your own exercises to help the greater community!
-
-Before reporting an issue or contributing, please read our guidelines:
-https://github.com/rust-lang/rustlings/blob/main/CONTRIBUTING.md";
-
-const WELCOME: &str = r" welcome to...
+const PRE_INIT_MSG: &str = r"
+ Welcome to...
_ _ _
_ __ _ _ ___| |_| (_)_ __ __ _ ___
| '__| | | / __| __| | | '_ \ / _` / __|
| | | |_| \__ \ |_| | | | | | (_| \__ \
|_| \__,_|___/\__|_|_|_| |_|\__, |___/
- |___/";
+ |___/
+
+The `exercises` directory wasn't found in the current directory.
+If you are just starting with Rustlings, run the command `rustlings init` to initialize it.";
diff --git a/src/progress_bar.rs b/src/progress_bar.rs
new file mode 100644
index 0000000..4a54170
--- /dev/null
+++ b/src/progress_bar.rs
@@ -0,0 +1,100 @@
+use anyhow::{bail, Result};
+use ratatui::text::{Line, Span};
+use std::fmt::Write;
+
+const PREFIX: &str = "Progress: [";
+const PREFIX_WIDTH: u16 = PREFIX.len() as u16;
+// Leaving the last char empty (_) for `total` > 99.
+const POSTFIX_WIDTH: u16 = "] xxx/xx exercises_".len() as u16;
+const WRAPPER_WIDTH: u16 = PREFIX_WIDTH + POSTFIX_WIDTH;
+const MIN_LINE_WIDTH: u16 = WRAPPER_WIDTH + 4;
+
+const PROGRESS_EXCEEDS_MAX_ERR: &str =
+ "The progress of the progress bar is higher than the maximum";
+
+/// Terminal progress bar to be used when not using Ratataui.
+pub fn progress_bar(progress: u16, total: u16, line_width: u16) -> Result<String> {
+ use crossterm::style::Stylize;
+
+ if progress > total {
+ bail!(PROGRESS_EXCEEDS_MAX_ERR);
+ }
+
+ if line_width < MIN_LINE_WIDTH {
+ return Ok(format!("Progress: {progress}/{total} exercises"));
+ }
+
+ let mut line = String::with_capacity(usize::from(line_width));
+ line.push_str(PREFIX);
+
+ let width = line_width - WRAPPER_WIDTH;
+ let filled = (width * progress) / total;
+
+ let mut green_part = String::with_capacity(usize::from(filled + 1));
+ for _ in 0..filled {
+ green_part.push('#');
+ }
+
+ if filled < width {
+ green_part.push('>');
+ }
+ write!(line, "{}", green_part.green()).unwrap();
+
+ let width_minus_filled = width - filled;
+ if width_minus_filled > 1 {
+ let red_part_width = width_minus_filled - 1;
+ let mut red_part = String::with_capacity(usize::from(red_part_width));
+ for _ in 0..red_part_width {
+ red_part.push('-');
+ }
+ write!(line, "{}", red_part.red()).unwrap();
+ }
+
+ writeln!(line, "] {progress:>3}/{total} exercises").unwrap();
+
+ Ok(line)
+}
+
+/// Progress bar to be used with Ratataui.
+// Not using Ratatui's Gauge widget to keep the progress bar consistent.
+pub fn progress_bar_ratatui(progress: u16, total: u16, line_width: u16) -> Result<Line<'static>> {
+ use ratatui::style::Stylize;
+
+ if progress > total {
+ bail!(PROGRESS_EXCEEDS_MAX_ERR);
+ }
+
+ if line_width < MIN_LINE_WIDTH {
+ return Ok(Line::raw(format!("Progress: {progress}/{total} exercises")));
+ }
+
+ let mut spans = Vec::with_capacity(4);
+ spans.push(Span::raw(PREFIX));
+
+ let width = line_width - WRAPPER_WIDTH;
+ let filled = (width * progress) / total;
+
+ let mut green_part = String::with_capacity(usize::from(filled + 1));
+ for _ in 0..filled {
+ green_part.push('#');
+ }
+
+ if filled < width {
+ green_part.push('>');
+ }
+ spans.push(green_part.green());
+
+ let width_minus_filled = width - filled;
+ if width_minus_filled > 1 {
+ let red_part_width = width_minus_filled - 1;
+ let mut red_part = String::with_capacity(usize::from(red_part_width));
+ for _ in 0..red_part_width {
+ red_part.push('-');
+ }
+ spans.push(red_part.red());
+ }
+
+ spans.push(Span::raw(format!("] {progress:>3}/{total} exercises")));
+
+ Ok(Line::from(spans))
+}
diff --git a/src/project.rs b/src/project.rs
deleted file mode 100644
index bcbd7ad..0000000
--- a/src/project.rs
+++ /dev/null
@@ -1,99 +0,0 @@
-use glob::glob;
-use serde::{Deserialize, Serialize};
-use std::env;
-use std::error::Error;
-use std::path::PathBuf;
-use std::process::Command;
-
-/// Contains the structure of resulting rust-project.json file
-/// and functions to build the data required to create the file
-#[derive(Serialize, Deserialize)]
-pub struct RustAnalyzerProject {
- sysroot_src: String,
- pub crates: Vec<Crate>,
-}
-
-#[derive(Serialize, Deserialize)]
-pub struct Crate {
- root_module: String,
- edition: String,
- deps: Vec<String>,
- cfg: Vec<String>,
-}
-
-impl RustAnalyzerProject {
- pub fn new() -> RustAnalyzerProject {
- RustAnalyzerProject {
- sysroot_src: String::new(),
- crates: Vec::new(),
- }
- }
-
- /// Write rust-project.json to disk
- pub fn write_to_disk(&self) -> Result<(), std::io::Error> {
- std::fs::write(
- "./rust-project.json",
- serde_json::to_vec(&self).expect("Failed to serialize to JSON"),
- )?;
- Ok(())
- }
-
- /// If path contains .rs extension, add a crate to `rust-project.json`
- fn path_to_json(&mut self, path: PathBuf) -> Result<(), Box<dyn Error>> {
- if let Some(ext) = path.extension() {
- if ext == "rs" {
- self.crates.push(Crate {
- root_module: path.display().to_string(),
- edition: "2021".to_string(),
- deps: Vec::new(),
- // This allows rust_analyzer to work inside #[test] blocks
- cfg: vec!["test".to_string()],
- })
- }
- }
-
- Ok(())
- }
-
- /// Parse the exercises folder for .rs files, any matches will create
- /// a new `crate` in rust-project.json which allows rust-analyzer to
- /// treat it like a normal binary
- pub fn exercises_to_json(&mut self) -> Result<(), Box<dyn Error>> {
- for path in glob("./exercises/**/*")? {
- self.path_to_json(path?)?;
- }
- Ok(())
- }
-
- /// Use `rustc` to determine the default toolchain
- pub fn get_sysroot_src(&mut self) -> Result<(), Box<dyn Error>> {
- // check if RUST_SRC_PATH is set
- if let Ok(path) = env::var("RUST_SRC_PATH") {
- self.sysroot_src = path;
- return Ok(());
- }
-
- let toolchain = Command::new("rustc")
- .arg("--print")
- .arg("sysroot")
- .output()?
- .stdout;
-
- let toolchain = String::from_utf8_lossy(&toolchain);
- let mut whitespace_iter = toolchain.split_whitespace();
-
- let toolchain = whitespace_iter.next().unwrap_or(&toolchain);
-
- println!("Determined toolchain: {}\n", &toolchain);
-
- self.sysroot_src = (std::path::Path::new(toolchain)
- .join("lib")
- .join("rustlib")
- .join("src")
- .join("rust")
- .join("library")
- .to_string_lossy())
- .to_string();
- Ok(())
- }
-}
diff --git a/src/run.rs b/src/run.rs
index e0ada4c..899d0a9 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -1,74 +1,51 @@
-use std::process::Command;
-use std::time::Duration;
-
-use crate::exercise::{Exercise, Mode};
-use crate::verify::test;
-use indicatif::ProgressBar;
-
-// Invoke the rust compiler on the path of the given exercise,
-// and run the ensuing binary.
-// The verbose argument helps determine whether or not to show
-// the output from the test harnesses (if the mode of the exercise is test)
-pub fn run(exercise: &Exercise, verbose: bool) -> Result<(), ()> {
- match exercise.mode {
- Mode::Test => test(exercise, verbose)?,
- Mode::Compile => compile_and_run(exercise)?,
- Mode::Clippy => compile_and_run(exercise)?,
+use anyhow::{bail, Result};
+use crossterm::style::{style, Stylize};
+use std::io::{self, Write};
+
+use crate::{
+ app_state::{AppState, ExercisesProgress},
+ exercise::{RunnableExercise, OUTPUT_CAPACITY},
+ terminal_link::TerminalFileLink,
+};
+
+pub fn run(app_state: &mut AppState) -> Result<()> {
+ let exercise = app_state.current_exercise();
+ let mut output = Vec::with_capacity(OUTPUT_CAPACITY);
+ let success = exercise.run_exercise(&mut output, app_state.target_dir())?;
+
+ let mut stdout = io::stdout().lock();
+ stdout.write_all(&output)?;
+
+ if !success {
+ app_state.set_pending(app_state.current_exercise_ind())?;
+
+ bail!(
+ "Ran {} with errors",
+ app_state.current_exercise().terminal_link(),
+ );
}
- Ok(())
-}
-
-// Resets the exercise by stashing the changes.
-pub fn reset(exercise: &Exercise) -> Result<(), ()> {
- let command = Command::new("git")
- .args(["stash", "--"])
- .arg(&exercise.path)
- .spawn();
- match command {
- Ok(_) => Ok(()),
- Err(_) => Err(()),
+ writeln!(
+ stdout,
+ "{}{}",
+ "✓ Successfully ran ".green(),
+ exercise.path.green(),
+ )?;
+
+ if let Some(solution_path) = app_state.current_solution_path()? {
+ println!(
+ "\nA solution file can be found at {}\n",
+ style(TerminalFileLink(&solution_path)).underlined().green(),
+ );
}
-}
-// Invoke the rust compiler on the path of the given exercise
-// and run the ensuing binary.
-// This is strictly for non-test binaries, so output is displayed
-fn compile_and_run(exercise: &Exercise) -> Result<(), ()> {
- let progress_bar = ProgressBar::new_spinner();
- progress_bar.set_message(format!("Compiling {exercise}..."));
- progress_bar.enable_steady_tick(Duration::from_millis(100));
-
- let compilation_result = exercise.compile();
- let compilation = match compilation_result {
- Ok(compilation) => compilation,
- Err(output) => {
- progress_bar.finish_and_clear();
- warn!(
- "Compilation of {} failed!, Compiler error message:\n",
- exercise
- );
- println!("{}", output.stderr);
- return Err(());
- }
- };
-
- progress_bar.set_message(format!("Running {exercise}..."));
- let result = compilation.run();
- progress_bar.finish_and_clear();
-
- match result {
- Ok(output) => {
- println!("{}", output.stdout);
- success!("Successfully ran {}", exercise);
- Ok(())
- }
- Err(output) => {
- println!("{}", output.stdout);
- println!("{}", output.stderr);
-
- warn!("Ran {} with errors", exercise);
- Err(())
- }
+ match app_state.done_current_exercise(&mut stdout)? {
+ ExercisesProgress::AllDone => (),
+ ExercisesProgress::CurrentPending | ExercisesProgress::NewPending => println!(
+ "Next exercise: {}",
+ app_state.current_exercise().terminal_link(),
+ ),
}
+
+ Ok(())
}
diff --git a/src/terminal_link.rs b/src/terminal_link.rs
new file mode 100644
index 0000000..9bea07d
--- /dev/null
+++ b/src/terminal_link.rs
@@ -0,0 +1,26 @@
+use std::{
+ fmt::{self, Display, Formatter},
+ fs,
+};
+
+pub struct TerminalFileLink<'a>(pub &'a str);
+
+impl<'a> Display for TerminalFileLink<'a> {
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ let path = fs::canonicalize(self.0);
+
+ if let Some(path) = path.as_deref().ok().and_then(|path| path.to_str()) {
+ // Windows itself can't handle its verbatim paths.
+ #[cfg(windows)]
+ let path = if path.len() > 5 && &path[0..4] == r"\\?\" {
+ &path[4..]
+ } else {
+ path
+ };
+
+ write!(f, "\x1b]8;;file://{path}\x1b\\{}\x1b]8;;\x1b\\", self.0)
+ } else {
+ write!(f, "{}", self.0)
+ }
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
deleted file mode 100644
index 1ee4631..0000000
--- a/src/ui.rs
+++ /dev/null
@@ -1,33 +0,0 @@
-macro_rules! warn {
- ($fmt:literal, $ex:expr) => {{
- use console::{style, Emoji};
- use std::env;
- let formatstr = format!($fmt, $ex);
- if env::var("NO_EMOJI").is_ok() {
- println!("{} {}", style("!").red(), style(formatstr).red());
- } else {
- println!(
- "{} {}",
- style(Emoji("⚠️ ", "!")).red(),
- style(formatstr).red()
- );
- }
- }};
-}
-
-macro_rules! success {
- ($fmt:literal, $ex:expr) => {{
- use console::{style, Emoji};
- use std::env;
- let formatstr = format!($fmt, $ex);
- if env::var("NO_EMOJI").is_ok() {
- println!("{} {}", style("✓").green(), style(formatstr).green());
- } else {
- println!(
- "{} {}",
- style(Emoji("✅", "✓")).green(),
- style(formatstr).green()
- );
- }
- }};
-}
diff --git a/src/verify.rs b/src/verify.rs
deleted file mode 100644
index 8a2ad49..0000000
--- a/src/verify.rs
+++ /dev/null
@@ -1,234 +0,0 @@
-use crate::exercise::{CompiledExercise, Exercise, Mode, State};
-use console::style;
-use indicatif::{ProgressBar, ProgressStyle};
-use std::{env, time::Duration};
-
-// Verify that the provided container of Exercise objects
-// can be compiled and run without any failures.
-// Any such failures will be reported to the end user.
-// If the Exercise being verified is a test, the verbose boolean
-// determines whether or not the test harness outputs are displayed.
-pub fn verify<'a>(
- exercises: impl IntoIterator<Item = &'a Exercise>,
- progress: (usize, usize),
- verbose: bool,
- success_hints: bool,
-) -> Result<(), &'a Exercise> {
- let (num_done, total) = progress;
- let bar = ProgressBar::new(total as u64);
- let mut percentage = num_done as f32 / total as f32 * 100.0;
- bar.set_style(
- ProgressStyle::default_bar()
- .template("Progress: [{bar:60.green/red}] {pos}/{len} {msg}")
- .expect("Progressbar template should be valid!")
- .progress_chars("#>-"),
- );
- bar.set_position(num_done as u64);
- bar.set_message(format!("({:.1} %)", percentage));
-
- for exercise in exercises {
- let compile_result = match exercise.mode {
- Mode::Test => compile_and_test(exercise, RunMode::Interactive, verbose, success_hints),
- Mode::Compile => compile_and_run_interactively(exercise, success_hints),
- Mode::Clippy => compile_only(exercise, success_hints),
- };
- if !compile_result.unwrap_or(false) {
- return Err(exercise);
- }
- percentage += 100.0 / total as f32;
- bar.inc(1);
- bar.set_message(format!("({:.1} %)", percentage));
- }
- Ok(())
-}
-
-enum RunMode {
- Interactive,
- NonInteractive,
-}
-
-// Compile and run the resulting test harness of the given Exercise
-pub fn test(exercise: &Exercise, verbose: bool) -> Result<(), ()> {
- compile_and_test(exercise, RunMode::NonInteractive, verbose, false)?;
- Ok(())
-}
-
-// Invoke the rust compiler without running the resulting binary
-fn compile_only(exercise: &Exercise, success_hints: bool) -> Result<bool, ()> {
- let progress_bar = ProgressBar::new_spinner();
- progress_bar.set_message(format!("Compiling {exercise}..."));
- progress_bar.enable_steady_tick(Duration::from_millis(100));
-
- let _ = compile(exercise, &progress_bar)?;
- progress_bar.finish_and_clear();
-
- Ok(prompt_for_completion(exercise, None, success_hints))
-}
-
-// Compile the given Exercise and run the resulting binary in an interactive mode
-fn compile_and_run_interactively(exercise: &Exercise, success_hints: bool) -> Result<bool, ()> {
- let progress_bar = ProgressBar::new_spinner();
- progress_bar.set_message(format!("Compiling {exercise}..."));
- progress_bar.enable_steady_tick(Duration::from_millis(100));
-
- let compilation = compile(exercise, &progress_bar)?;
-
- progress_bar.set_message(format!("Running {exercise}..."));
- let result = compilation.run();
- progress_bar.finish_and_clear();
-
- let output = match result {
- Ok(output) => output,
- Err(output) => {
- warn!("Ran {} with errors", exercise);
- println!("{}", output.stdout);
- println!("{}", output.stderr);
- return Err(());
- }
- };
-
- Ok(prompt_for_completion(
- exercise,
- Some(output.stdout),
- success_hints,
- ))
-}
-
-// Compile the given Exercise as a test harness and display
-// the output if verbose is set to true
-fn compile_and_test(
- exercise: &Exercise,
- run_mode: RunMode,
- verbose: bool,
- success_hints: bool,
-) -> Result<bool, ()> {
- let progress_bar = ProgressBar::new_spinner();
- progress_bar.set_message(format!("Testing {exercise}..."));
- progress_bar.enable_steady_tick(Duration::from_millis(100));
-
- let compilation = compile(exercise, &progress_bar)?;
- let result = compilation.run();
- progress_bar.finish_and_clear();
-
- match result {
- Ok(output) => {
- if verbose {
- println!("{}", output.stdout);
- }
- if let RunMode::Interactive = run_mode {
- Ok(prompt_for_completion(exercise, None, success_hints))
- } else {
- Ok(true)
- }
- }
- Err(output) => {
- warn!(
- "Testing of {} failed! Please try again. Here's the output:",
- exercise
- );
- println!("{}", output.stdout);
- Err(())
- }
- }
-}
-
-// Compile the given Exercise and return an object with information
-// about the state of the compilation
-fn compile<'a>(
- exercise: &'a Exercise,
- progress_bar: &ProgressBar,
-) -> Result<CompiledExercise<'a>, ()> {
- let compilation_result = exercise.compile();
-
- match compilation_result {
- Ok(compilation) => Ok(compilation),
- Err(output) => {
- progress_bar.finish_and_clear();
- warn!(
- "Compiling of {} failed! Please try again. Here's the output:",
- exercise
- );
- println!("{}", output.stderr);
- Err(())
- }
- }
-}
-
-fn prompt_for_completion(
- exercise: &Exercise,
- prompt_output: Option<String>,
- success_hints: bool,
-) -> bool {
- let context = match exercise.state() {
- State::Done => return true,
- State::Pending(context) => context,
- };
- match exercise.mode {
- Mode::Compile => success!("Successfully ran {}!", exercise),
- Mode::Test => success!("Successfully tested {}!", exercise),
- Mode::Clippy => success!("Successfully compiled {}!", exercise),
- }
-
- let no_emoji = env::var("NO_EMOJI").is_ok();
-
- let clippy_success_msg = if no_emoji {
- "The code is compiling, and Clippy is happy!"
- } else {
- "The code is compiling, and 📎 Clippy 📎 is happy!"
- };
-
- let success_msg = match exercise.mode {
- Mode::Compile => "The code is compiling!",
- Mode::Test => "The code is compiling, and the tests pass!",
- Mode::Clippy => clippy_success_msg,
- };
- println!();
- if no_emoji {
- println!("~*~ {success_msg} ~*~")
- } else {
- println!("🎉 🎉 {success_msg} 🎉 🎉")
- }
- println!();
-
- if let Some(output) = prompt_output {
- println!("Output:");
- println!("{}", separator());
- println!("{output}");
- println!("{}", separator());
- println!();
- }
- if success_hints {
- println!("Hints:");
- println!("{}", separator());
- println!("{}", exercise.hint);
- println!("{}", separator());
- println!();
- }
-
- println!("You can keep working on this exercise,");
- println!(
- "or jump into the next one by removing the {} comment:",
- style("`I AM NOT DONE`").bold()
- );
- println!();
- for context_line in context {
- let formatted_line = if context_line.important {
- format!("{}", style(context_line.line).bold())
- } else {
- context_line.line.to_string()
- };
-
- println!(
- "{:>2} {} {}",
- style(context_line.number).blue().bold(),
- style("|").blue(),
- formatted_line
- );
- }
-
- false
-}
-
-fn separator() -> console::StyledObject<&'static str> {
- style("====================").bold()
-}
diff --git a/src/watch.rs b/src/watch.rs
new file mode 100644
index 0000000..88a1230
--- /dev/null
+++ b/src/watch.rs
@@ -0,0 +1,128 @@
+use anyhow::{Error, Result};
+use notify_debouncer_mini::{
+ new_debouncer,
+ notify::{self, RecursiveMode},
+};
+use std::{
+ io::{self, Write},
+ path::Path,
+ sync::mpsc::channel,
+ thread,
+ time::Duration,
+};
+
+use crate::app_state::{AppState, ExercisesProgress};
+
+use self::{
+ notify_event::NotifyEventHandler,
+ state::WatchState,
+ terminal_event::{terminal_event_handler, InputEvent},
+};
+
+mod notify_event;
+mod state;
+mod terminal_event;
+
+enum WatchEvent {
+ Input(InputEvent),
+ FileChange { exercise_ind: usize },
+ TerminalResize,
+ NotifyErr(notify::Error),
+ TerminalEventErr(io::Error),
+}
+
+/// Returned by the watch mode to indicate what to do afterwards.
+#[must_use]
+pub enum WatchExit {
+ /// Exit the program.
+ Shutdown,
+ /// Enter the list mode and restart the watch mode afterwards.
+ List,
+}
+
+/// `notify_exercise_names` as None activates the manual run mode.
+pub fn watch(
+ app_state: &mut AppState,
+ notify_exercise_names: Option<&'static [&'static [u8]]>,
+) -> Result<WatchExit> {
+ let (tx, rx) = channel();
+
+ let mut manual_run = false;
+ // Prevent dropping the guard until the end of the function.
+ // Otherwise, the file watcher exits.
+ let _debouncer_guard = if let Some(exercise_names) = notify_exercise_names {
+ let mut debouncer = new_debouncer(
+ Duration::from_millis(200),
+ NotifyEventHandler {
+ tx: tx.clone(),
+ exercise_names,
+ },
+ )
+ .inspect_err(|_| eprintln!("{NOTIFY_ERR}"))?;
+ debouncer
+ .watcher()
+ .watch(Path::new("exercises"), RecursiveMode::Recursive)
+ .inspect_err(|_| eprintln!("{NOTIFY_ERR}"))?;
+
+ Some(debouncer)
+ } else {
+ manual_run = true;
+ None
+ };
+
+ let mut watch_state = WatchState::new(app_state, manual_run);
+
+ watch_state.run_current_exercise()?;
+
+ thread::spawn(move || terminal_event_handler(tx, manual_run));
+
+ while let Ok(event) = rx.recv() {
+ match event {
+ WatchEvent::Input(InputEvent::Next) => match watch_state.next_exercise()? {
+ ExercisesProgress::AllDone => break,
+ ExercisesProgress::CurrentPending => watch_state.render()?,
+ ExercisesProgress::NewPending => watch_state.run_current_exercise()?,
+ },
+ WatchEvent::Input(InputEvent::Hint) => {
+ watch_state.show_hint()?;
+ }
+ WatchEvent::Input(InputEvent::List) => {
+ return Ok(WatchExit::List);
+ }
+ WatchEvent::Input(InputEvent::Quit) => {
+ watch_state.into_writer().write_all(QUIT_MSG)?;
+ break;
+ }
+ WatchEvent::Input(InputEvent::Run) => watch_state.run_current_exercise()?,
+ WatchEvent::Input(InputEvent::Unrecognized) => watch_state.render()?,
+ WatchEvent::FileChange { exercise_ind } => {
+ watch_state.handle_file_change(exercise_ind)?;
+ }
+ WatchEvent::TerminalResize => {
+ watch_state.render()?;
+ }
+ WatchEvent::NotifyErr(e) => {
+ watch_state.into_writer().write_all(NOTIFY_ERR.as_bytes())?;
+ return Err(Error::from(e));
+ }
+ WatchEvent::TerminalEventErr(e) => {
+ return Err(Error::from(e).context("Terminal event listener failed"));
+ }
+ }
+ }
+
+ Ok(WatchExit::Shutdown)
+}
+
+const QUIT_MSG: &[u8] = b"
+We hope you're enjoying learning Rust!
+If you want to continue working on the exercises at a later point, you can simply run `rustlings` again.
+";
+
+const NOTIFY_ERR: &str = "
+The automatic detection of exercise file changes failed :(
+Please try running `rustlings` again.
+
+If you keep getting this error, run `rustlings --manual-run` to deactivate the file watcher.
+You need to manually trigger running the current exercise using `r` then.
+";
diff --git a/src/watch/notify_event.rs b/src/watch/notify_event.rs
new file mode 100644
index 0000000..7471640
--- /dev/null
+++ b/src/watch/notify_event.rs
@@ -0,0 +1,52 @@
+use notify_debouncer_mini::{DebounceEventResult, DebouncedEventKind};
+use std::sync::mpsc::Sender;
+
+use super::WatchEvent;
+
+pub struct NotifyEventHandler {
+ pub tx: Sender<WatchEvent>,
+ /// Used to report which exercise was modified.
+ pub exercise_names: &'static [&'static [u8]],
+}
+
+impl notify_debouncer_mini::DebounceEventHandler for NotifyEventHandler {
+ fn handle_event(&mut self, input_event: DebounceEventResult) {
+ let output_event = match input_event {
+ Ok(input_event) => {
+ let Some(exercise_ind) = input_event
+ .iter()
+ .filter_map(|input_event| {
+ if input_event.kind != DebouncedEventKind::Any {
+ return None;
+ }
+
+ let file_name = input_event.path.file_name()?.to_str()?.as_bytes();
+
+ if file_name.len() < 4 {
+ return None;
+ }
+ let (file_name_without_ext, ext) = file_name.split_at(file_name.len() - 3);
+
+ if ext != b".rs" {
+ return None;
+ }
+
+ self.exercise_names
+ .iter()
+ .position(|exercise_name| *exercise_name == file_name_without_ext)
+ })
+ .min()
+ else {
+ return;
+ };
+
+ WatchEvent::FileChange { exercise_ind }
+ }
+ Err(e) => WatchEvent::NotifyErr(e),
+ };
+
+ // An error occurs when the receiver is dropped.
+ // After dropping the receiver, the debouncer guard should also be dropped.
+ let _ = self.tx.send(output_event);
+ }
+}
diff --git a/src/watch/state.rs b/src/watch/state.rs
new file mode 100644
index 0000000..78af30a
--- /dev/null
+++ b/src/watch/state.rs
@@ -0,0 +1,174 @@
+use anyhow::Result;
+use crossterm::{
+ style::{style, Stylize},
+ terminal,
+};
+use std::io::{self, StdoutLock, Write};
+
+use crate::{
+ app_state::{AppState, ExercisesProgress},
+ clear_terminal,
+ exercise::{RunnableExercise, OUTPUT_CAPACITY},
+ progress_bar::progress_bar,
+ terminal_link::TerminalFileLink,
+};
+
+#[derive(PartialEq, Eq)]
+enum DoneStatus {
+ DoneWithSolution(String),
+ DoneWithoutSolution,
+ Pending,
+}
+
+pub struct WatchState<'a> {
+ writer: StdoutLock<'a>,
+ app_state: &'a mut AppState,
+ output: Vec<u8>,
+ show_hint: bool,
+ done_status: DoneStatus,
+ manual_run: bool,
+}
+
+impl<'a> WatchState<'a> {
+ pub fn new(app_state: &'a mut AppState, manual_run: bool) -> Self {
+ let writer = io::stdout().lock();
+
+ Self {
+ writer,
+ app_state,
+ output: Vec::with_capacity(OUTPUT_CAPACITY),
+ show_hint: false,
+ done_status: DoneStatus::Pending,
+ manual_run,
+ }
+ }
+
+ #[inline]
+ pub fn into_writer(self) -> StdoutLock<'a> {
+ self.writer
+ }
+
+ pub fn run_current_exercise(&mut self) -> Result<()> {
+ self.show_hint = false;
+
+ let success = self
+ .app_state
+ .current_exercise()
+ .run_exercise(&mut self.output, self.app_state.target_dir())?;
+ if success {
+ self.done_status =
+ if let Some(solution_path) = self.app_state.current_solution_path()? {
+ DoneStatus::DoneWithSolution(solution_path)
+ } else {
+ DoneStatus::DoneWithoutSolution
+ };
+ } else {
+ self.app_state
+ .set_pending(self.app_state.current_exercise_ind())?;
+
+ self.done_status = DoneStatus::Pending;
+ }
+
+ self.render()
+ }
+
+ pub fn handle_file_change(&mut self, exercise_ind: usize) -> Result<()> {
+ // Don't skip exercises on file changes to avoid confusion from missing exercises.
+ // Skipping exercises must be explicit in the interactive list.
+ // But going back to an earlier exercise on file change is fine.
+ if self.app_state.current_exercise_ind() < exercise_ind {
+ return Ok(());
+ }
+
+ self.app_state.set_current_exercise_ind(exercise_ind)?;
+ self.run_current_exercise()
+ }
+
+ /// Move on to the next exercise if the current one is done.
+ pub fn next_exercise(&mut self) -> Result<ExercisesProgress> {
+ if self.done_status == DoneStatus::Pending {
+ return Ok(ExercisesProgress::CurrentPending);
+ }
+
+ self.app_state.done_current_exercise(&mut self.writer)
+ }
+
+ fn show_prompt(&mut self) -> io::Result<()> {
+ self.writer.write_all(b"\n")?;
+
+ if self.manual_run {
+ write!(self.writer, "{}:run / ", 'r'.bold())?;
+ }
+
+ if self.done_status != DoneStatus::Pending {
+ write!(self.writer, "{}:{} / ", 'n'.bold(), "next".underlined())?;
+ }
+
+ if !self.show_hint {
+ write!(self.writer, "{}:hint / ", 'h'.bold())?;
+ }
+
+ write!(self.writer, "{}:list / {}:quit ? ", 'l'.bold(), 'q'.bold())?;
+
+ self.writer.flush()
+ }
+
+ pub fn render(&mut self) -> Result<()> {
+ // Prevent having the first line shifted if clearing wasn't successful.
+ self.writer.write_all(b"\n")?;
+
+ clear_terminal(&mut self.writer)?;
+
+ self.writer.write_all(&self.output)?;
+ self.writer.write_all(b"\n")?;
+
+ if self.show_hint {
+ writeln!(
+ self.writer,
+ "{}\n{}\n",
+ "Hint".bold().cyan().underlined(),
+ self.app_state.current_exercise().hint,
+ )?;
+ }
+
+ if self.done_status != DoneStatus::Pending {
+ writeln!(
+ self.writer,
+ "{}\n",
+ "Exercise done ✓
+When you are done experimenting, enter `n` to move on to the next exercise 🦀"
+ .bold()
+ .green(),
+ )?;
+ }
+
+ if let DoneStatus::DoneWithSolution(solution_path) = &self.done_status {
+ writeln!(
+ self.writer,
+ "A solution file can be found at {}\n",
+ style(TerminalFileLink(solution_path)).underlined().green(),
+ )?;
+ }
+
+ let line_width = terminal::size()?.0;
+ let progress_bar = progress_bar(
+ self.app_state.n_done(),
+ self.app_state.exercises().len() as u16,
+ line_width,
+ )?;
+ writeln!(
+ self.writer,
+ "{progress_bar}Current exercise: {}",
+ self.app_state.current_exercise().terminal_link(),
+ )?;
+
+ self.show_prompt()?;
+
+ Ok(())
+ }
+
+ pub fn show_hint(&mut self) -> Result<()> {
+ self.show_hint = true;
+ self.render()
+ }
+}
diff --git a/src/watch/terminal_event.rs b/src/watch/terminal_event.rs
new file mode 100644
index 0000000..f54af17
--- /dev/null
+++ b/src/watch/terminal_event.rs
@@ -0,0 +1,86 @@
+use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
+use std::sync::mpsc::Sender;
+
+use super::WatchEvent;
+
+pub enum InputEvent {
+ Run,
+ Next,
+ Hint,
+ List,
+ Quit,
+ Unrecognized,
+}
+
+pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) {
+ // Only send `Unrecognized` on ENTER if the last input wasn't valid.
+ let mut last_input_valid = false;
+
+ let last_input_event = loop {
+ let terminal_event = match event::read() {
+ Ok(v) => v,
+ Err(e) => {
+ // If `send` returns an error, then the receiver is dropped and
+ // a shutdown has been already initialized.
+ let _ = tx.send(WatchEvent::TerminalEventErr(e));
+ return;
+ }
+ };
+
+ match terminal_event {
+ Event::Key(key) => {
+ match key.kind {
+ KeyEventKind::Release | KeyEventKind::Repeat => continue,
+ KeyEventKind::Press => (),
+ }
+
+ if key.modifiers != KeyModifiers::NONE {
+ last_input_valid = false;
+ continue;
+ }
+
+ let input_event = match key.code {
+ KeyCode::Enter => {
+ if last_input_valid {
+ continue;
+ }
+
+ InputEvent::Unrecognized
+ }
+ KeyCode::Char(c) => {
+ let input_event = match c {
+ 'n' => InputEvent::Next,
+ 'h' => InputEvent::Hint,
+ 'l' => break InputEvent::List,
+ 'q' => break InputEvent::Quit,
+ 'r' if manual_run => InputEvent::Run,
+ _ => {
+ last_input_valid = false;
+ continue;
+ }
+ };
+
+ last_input_valid = true;
+ input_event
+ }
+ _ => {
+ last_input_valid = false;
+ continue;
+ }
+ };
+
+ if tx.send(WatchEvent::Input(input_event)).is_err() {
+ return;
+ }
+ }
+ Event::Resize(_, _) => {
+ if tx.send(WatchEvent::TerminalResize).is_err() {
+ return;
+ }
+ }
+ Event::FocusGained | Event::FocusLost | Event::Mouse(_) | Event::Paste(_) => continue,
+ }
+ };
+
+ let _ = tx.send(WatchEvent::Input(last_input_event));
+}