From 62c90241ac210f8a72d8e77f8248f7e4cf65fb3d Mon Sep 17 00:00:00 2001 From: mo8it Date: Sat, 1 Aug 2026 23:30:40 +0200 Subject: [PATCH 1/5] New check progress visualization --- src/app_state.rs | 78 ++++++++++++++-------------------- src/term.rs | 107 ++++++++++++++--------------------------------- 2 files changed, 64 insertions(+), 121 deletions(-) diff --git a/src/app_state.rs b/src/app_state.rs index e1d0e0b868..789f30434c 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Error, Result, bail}; -use crossterm::{QueueableCommand, cursor, terminal}; +use crossterm::{QueueableCommand, cursor}; use std::{ collections::HashSet, fs::{File, OpenOptions}, @@ -43,8 +43,6 @@ pub enum StateFileStatus { #[derive(Clone, Copy)] pub enum CheckProgress { - None, - Checking, Done, Pending, } @@ -398,22 +396,18 @@ impl AppState { } fn check_all_exercises_impl(&mut self, stdout: &mut StdoutLock) -> Result> { - let term_width = terminal::size() - .context("Failed to get the terminal size")? - .0; - let mut progress_visualizer = CheckProgressVisualizer::build(stdout, term_width)?; + let mut progress_visualizer = CheckProgressVisualizer::build(stdout, self.exercises.len())?; - let next_exercise_ind = AtomicUsize::new(0); - let mut progresses = vec![CheckProgress::None; self.exercises.len()]; + let next_exercise_ind = &AtomicUsize::new(0); + let mut progresses = vec![None; self.exercises.len()]; thread::scope(|s| { - let (exercise_progress_sender, exercise_progress_receiver) = mpsc::channel(); + let (progress_sender, progress_receiver) = mpsc::channel(); let n_threads = thread::available_parallelism() .map_or(DEFAULT_CHECK_PARALLELISM, |count| count.get()); for _ in 0..n_threads { - let exercise_progress_sender = exercise_progress_sender.clone(); - let next_exercise_ind = &next_exercise_ind; + let progress_sender = progress_sender.clone(); let slf = &self; thread::Builder::new() .spawn_scoped(s, move || { @@ -424,25 +418,16 @@ impl AppState { break; }; - if exercise_progress_sender - .send((exercise_ind, CheckProgress::Checking)) - .is_err() - { - break; - } - - let success = exercise.run_exercise(None, &slf.cmd_runner); - let progress = match success { - Ok(true) => CheckProgress::Done, - Ok(false) => CheckProgress::Pending, - Err(_) => CheckProgress::None, - }; + if let Ok(success) = exercise.run_exercise(None, &slf.cmd_runner) { + let progress = if success { + CheckProgress::Done + } else { + CheckProgress::Pending + }; - if exercise_progress_sender - .send((exercise_ind, progress)) - .is_err() - { - break; + if progress_sender.send((exercise_ind, progress)).is_err() { + break; + } } } }) @@ -450,47 +435,48 @@ impl AppState { } // Drop this sender to detect when the last thread is done. - drop(exercise_progress_sender); - - while let Ok((exercise_ind, progress)) = exercise_progress_receiver.recv() { - progresses[exercise_ind] = progress; - progress_visualizer.update(&progresses)?; + drop(progress_sender); + + // TODO: Timeout + while let Ok((exercise_ind, progress)) = progress_receiver.recv() { + let name = self.exercises[exercise_ind].name; + match progress { + CheckProgress::Done => progress_visualizer.done(name)?, + CheckProgress::Pending => progress_visualizer.pending(name)?, + } + progresses[exercise_ind] = Some(progress); } Ok::<_, Error>(()) })?; let mut first_pending_exercise_ind = None; - for exercise_ind in 0..progresses.len() { - match progresses[exercise_ind] { - CheckProgress::Done => { + for (exercise_ind, progress) in progresses.into_iter().enumerate() { + match progress { + Some(CheckProgress::Done) => { self.set_status(exercise_ind, true)?; } - CheckProgress::Pending => { + Some(CheckProgress::Pending) => { self.set_status(exercise_ind, false)?; if first_pending_exercise_ind.is_none() { first_pending_exercise_ind = Some(exercise_ind); } } - CheckProgress::None | CheckProgress::Checking => { + None => { // If we got an error while checking all exercises in parallel, // it could be because we exceeded the limit of open file descriptors. // Therefore, try running exercises with errors sequentially. - progresses[exercise_ind] = CheckProgress::Checking; - progress_visualizer.update(&progresses)?; - let exercise = &self.exercises[exercise_ind]; let success = exercise.run_exercise(None, &self.cmd_runner)?; if success { - progresses[exercise_ind] = CheckProgress::Done; + progress_visualizer.done(exercise.name)?; } else { - progresses[exercise_ind] = CheckProgress::Pending; + progress_visualizer.pending(exercise.name)?; if first_pending_exercise_ind.is_none() { first_pending_exercise_ind = Some(exercise_ind); } } self.set_status(exercise_ind, success)?; - progress_visualizer.update(&progresses)?; } } } diff --git a/src/term.rs b/src/term.rs index 2467b45036..455b860eff 100644 --- a/src/term.rs +++ b/src/term.rs @@ -9,8 +9,6 @@ use std::{ io::{self, BufRead, StdoutLock, Write}, }; -use crate::app_state::CheckProgress; - pub struct MaxLenWriter<'a, 'lock> { pub stdout: &'a mut StdoutLock<'lock>, len: usize, @@ -81,79 +79,6 @@ impl<'a> CountedWrite<'a> for StdoutLock<'a> { } } -pub struct CheckProgressVisualizer<'a, 'lock> { - stdout: &'a mut StdoutLock<'lock>, - n_cols: usize, -} - -impl<'a, 'lock> CheckProgressVisualizer<'a, 'lock> { - const CHECKING_COLOR: Color = Color::Blue; - const DONE_COLOR: Color = Color::Green; - const PENDING_COLOR: Color = Color::Red; - - pub fn build(stdout: &'a mut StdoutLock<'lock>, term_width: u16) -> io::Result { - clear_terminal(stdout)?; - stdout.write_all("Checking all exercises…\n".as_bytes())?; - - // Legend - stdout.write_all(b"Color of exercise number: ")?; - stdout.queue(SetForegroundColor(Self::CHECKING_COLOR))?; - stdout.write_all(b"Checking")?; - stdout.queue(ResetColor)?; - stdout.write_all(b" - ")?; - stdout.queue(SetForegroundColor(Self::DONE_COLOR))?; - stdout.write_all(b"Done")?; - stdout.queue(ResetColor)?; - stdout.write_all(b" - ")?; - stdout.queue(SetForegroundColor(Self::PENDING_COLOR))?; - stdout.write_all(b"Pending")?; - stdout.queue(ResetColor)?; - stdout.write_all(b"\n")?; - - // Exercise numbers with up to 3 digits. - // +1 because the last column doesn't end with a whitespace. - let n_cols = usize::from(term_width + 1) / 4; - - Ok(Self { stdout, n_cols }) - } - - pub fn update(&mut self, progresses: &[CheckProgress]) -> io::Result<()> { - self.stdout.queue(MoveTo(0, 2))?; - - let mut exercise_num = 1; - for exercise_progress in progresses { - match exercise_progress { - CheckProgress::None => (), - CheckProgress::Checking => { - self.stdout - .queue(SetForegroundColor(Self::CHECKING_COLOR))?; - } - CheckProgress::Done => { - self.stdout.queue(SetForegroundColor(Self::DONE_COLOR))?; - } - CheckProgress::Pending => { - self.stdout.queue(SetForegroundColor(Self::PENDING_COLOR))?; - } - } - - write!(self.stdout, "{exercise_num:<3}")?; - self.stdout.queue(ResetColor)?; - - if exercise_num != progresses.len() { - if exercise_num % self.n_cols == 0 { - self.stdout.write_all(b"\n")?; - } else { - self.stdout.write_all(b" ")?; - } - - exercise_num += 1; - } - } - - self.stdout.flush() - } -} - pub struct ProgressCounter<'a, 'lock> { stdout: &'a mut StdoutLock<'lock>, total: usize, @@ -185,6 +110,38 @@ impl Drop for ProgressCounter<'_, '_> { } } +pub struct CheckProgressVisualizer<'a, 'lock>(ProgressCounter<'a, 'lock>); + +impl<'a, 'lock> CheckProgressVisualizer<'a, 'lock> { + pub fn build(stdout: &'a mut StdoutLock<'lock>, total: usize) -> io::Result { + clear_terminal(stdout)?; + stdout.write_all("Checking all exercises…\n".as_bytes())?; + + Ok(Self(ProgressCounter::new(stdout, total)?)) + } + + fn checked(&mut self, exercise_name: &str) -> io::Result<()> { + self.0.stdout.queue(ResetColor)?; + self.0.stdout.write_all(exercise_name.as_bytes())?; + self.0.stdout.queue(Clear(ClearType::UntilNewLine))?; + + self.0.stdout.write_all(b"\n")?; + self.0.increment() + } + + pub fn done(&mut self, exercise_name: &str) -> io::Result<()> { + self.0.stdout.queue(SetForegroundColor(Color::Green))?; + self.0.stdout.write_all(b"\r DONE ")?; + self.checked(exercise_name) + } + + pub fn pending(&mut self, exercise_name: &str) -> io::Result<()> { + self.0.stdout.queue(SetForegroundColor(Color::Red))?; + self.0.stdout.write_all(b"\rPENDING ")?; + self.checked(exercise_name) + } +} + pub fn progress_bar<'a>( writer: &mut impl CountedWrite<'a>, progress: u32, From 283a508ad9fdfbbcd156b8987af16333e78c3728 Mon Sep 17 00:00:00 2001 From: mo8it Date: Sun, 2 Aug 2026 11:18:34 +0200 Subject: [PATCH 2/5] Add command timeout --- Cargo.lock | 10 ++++++ Cargo.toml | 1 + src/app_state.rs | 1 - src/cmd.rs | 79 +++++++++++++++++++++++++++++++----------------- src/init.rs | 4 +-- src/watch.rs | 2 +- 6 files changed, 66 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3a03d32ea..9a2975f053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -441,6 +441,7 @@ dependencies = [ "shlex", "tempfile", "toml", + "wait-timeout", ] [[package]] @@ -643,6 +644,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 192eeb616d..c9affed18d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ serde_json = "1" serde.workspace = true shlex = "1" toml.workspace = true +wait-timeout = "0.2" [target.'cfg(not(windows))'.dependencies] rustix = { version = "1.0", default-features = false, features = ["std", "stdio", "termios"] } diff --git a/src/app_state.rs b/src/app_state.rs index 789f30434c..541534a2a1 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -437,7 +437,6 @@ impl AppState { // Drop this sender to detect when the last thread is done. drop(progress_sender); - // TODO: Timeout while let Ok((exercise_ind, progress)) = progress_receiver.recv() { let name = self.exercises[exercise_ind].name; match progress { diff --git a/src/cmd.rs b/src/cmd.rs index 6442e449a3..094db5a05b 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -3,47 +3,73 @@ use serde::Deserialize; use std::{ io::{Read, pipe}, path::PathBuf, - process::{Command, Stdio}, + process::{Child, Command, Stdio}, + thread, + time::Duration, }; +use wait_timeout::ChildExt; + +const TIMEOUT_SECS: u64 = 30; /// 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. fn run_cmd(mut cmd: Command, description: &str, output: Option<&mut Vec>) -> Result { let spawn = |mut cmd: Command| { - // NOTE: The closure drops `cmd` which prevents a pipe deadlock. + // The closure drops `cmd` which prevents a pipe deadlock. cmd.stdin(Stdio::null()) .spawn() - .with_context(|| format!("Failed to run the command `{description}`")) + .with_context(|| format!("Failed to run `{description}`")) + }; + let wait = |handle: &mut Child| { + handle + .wait_timeout(Duration::from_secs(TIMEOUT_SECS)) + .with_context(|| format!("Failed to wait on `{description}` to exit")) }; let mut handle = if let Some(output) = output { - let (mut reader, writer) = pipe().with_context(|| { - format!("Failed to create a pipe to run the command `{description}``") - })?; + let (mut reader, writer) = + pipe().with_context(|| format!("Failed to create a pipe to run `{description}``"))?; - let writer_clone = writer.try_clone().with_context(|| { - format!("Failed to clone the pipe writer for the command `{description}`") - })?; + let writer_clone = writer + .try_clone() + .with_context(|| format!("Failed to clone the pipe writer for `{description}`"))?; cmd.stdout(writer_clone).stderr(writer); - let handle = spawn(cmd)?; - - reader - .read_to_end(output) - .with_context(|| format!("Failed to read the output of the command `{description}`"))?; - - output.push(b'\n'); + let mut handle = spawn(cmd)?; + + let thread_handle = thread::Builder::new() + .spawn(move || { + let mut out = Vec::with_capacity(128); + reader.read_to_end(&mut out).map(|_| out) + }) + .context("Failed to spawn a thread to collect a command's output")?; + + if let Some(status) = wait(&mut handle)? { + let out = thread_handle + .join() + .unwrap() + .with_context(|| format!("Failed to read the output of `{description}`"))?; + output.extend_from_slice(&out); + output.push(b'\n'); + return Ok(status.success()); + } handle } else { cmd.stdout(Stdio::null()).stderr(Stdio::null()); - spawn(cmd)? + let mut handle = spawn(cmd)?; + + if let Some(status) = wait(&mut handle)? { + return Ok(status.success()); + } + + handle }; handle - .wait() - .with_context(|| format!("Failed to wait on the command `{description}` to exit")) - .map(|status| status.success()) + .kill() + .with_context(|| format!("Failed to kill `{description}` after timeout"))?; + bail!("`{description}` timed out after {TIMEOUT_SECS} seconds"); } // Parses parts of the output of `cargo metadata`. @@ -71,13 +97,12 @@ impl CmdRunner { .context(CARGO_METADATA_ERR)?; if !metadata_output.status.success() { - bail!("The command `cargo metadata …` failed. Are you in the `rustlings/` directory?"); + bail!("`cargo metadata …` failed. Are you in the `rustlings/` directory?"); } - let metadata: CargoMetadata = serde_json::de::from_slice(&metadata_output.stdout) - .context( - "Failed to read the field `target_directory` from the output of the command `cargo metadata …`", - )?; + let metadata: CargoMetadata = serde_json::de::from_slice(&metadata_output.stdout).context( + "Failed to read the field `target_directory` from the output of `cargo metadata …`", + )?; Ok(Self { target_dir: metadata.target_directory, @@ -116,7 +141,7 @@ impl CmdRunner { bin_path.push("debug"); bin_path.push(bin_name); - run_cmd(Command::new(&bin_path), &bin_path.to_string_lossy(), output) + run_cmd(Command::new(&bin_path), bin_name, output) } } @@ -140,7 +165,7 @@ impl CargoSubcommand<'_> { } } -const CARGO_METADATA_ERR: &str = "Failed to run the command `cargo metadata …` +const CARGO_METADATA_ERR: &str = "Failed to run `cargo metadata …` Did you already install Rust? Try running `cargo --version` to diagnose the problem."; diff --git a/src/init.rs b/src/init.rs index f043bd48f2..1210e9bc40 100644 --- a/src/init.rs +++ b/src/init.rs @@ -37,7 +37,7 @@ pub fn init() -> Result<()> { .stderr(Stdio::null()) .output() .context( - "Failed to run the command `cargo locate-project …`\n\ + "Failed to run `cargo locate-project …`\n\ Did you already install Rust?\n\ Try running `cargo --version` to diagnose the problem.", )?; @@ -49,7 +49,7 @@ pub fn init() -> Result<()> { .stdout(Stdio::null()) .stderr(Stdio::null()) .status() - .context("Failed to run the command `cargo clippy --version`")? + .context("Failed to run `cargo clippy --version`")? .success() { bail!( diff --git a/src/watch.rs b/src/watch.rs index f3804a4021..22817f2acf 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -150,7 +150,7 @@ pub fn watch( app_state: &mut AppState, notify_exercise_names: Option<&'static [&'static [u8]]>, ) -> Result<()> { - // TODO: Use cfg_select! after bumping MSRV to at least 1.95 + // TODO: Use cfg_select! after MSRV 1.95 #[cfg(not(windows))] { let stdin_fd = rustix::stdio::stdin(); From 9d6388387f06f7fe07ca6a85c4199c7fcfe35942 Mon Sep 17 00:00:00 2001 From: mo8it Date: Sun, 2 Aug 2026 14:16:12 +0200 Subject: [PATCH 3/5] Remove check all input --- src/watch.rs | 13 ++++++------- src/watch/state.rs | 1 - src/watch/terminal_event.rs | 2 -- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/watch.rs b/src/watch.rs index 22817f2acf..3bc56ce8c3 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -43,6 +43,7 @@ enum WatchEvent { Input(InputEvent), FileChange { exercise_ind: usize }, TerminalResize { width: u16 }, + CheckAll, NotifyErr(notify::Error), TerminalEventErr(io::Error), } @@ -102,13 +103,6 @@ fn run_watch( WatchEvent::Input(InputEvent::Run) => watch_state.run_current_exercise(&mut stdout)?, WatchEvent::Input(InputEvent::Hint) => watch_state.show_hint(&mut stdout)?, WatchEvent::Input(InputEvent::List) => return Ok(WatchExit::List), - WatchEvent::Input(InputEvent::CheckAll) => match watch_state - .check_all_exercises(&mut stdout)? - { - ExercisesProgress::AllDone => break, - ExercisesProgress::NewPending => watch_state.run_current_exercise(&mut stdout)?, - ExercisesProgress::CurrentPending => watch_state.render(&mut stdout)?, - }, WatchEvent::Input(InputEvent::Reset) => watch_state.reset_exercise(&mut stdout)?, WatchEvent::Input(InputEvent::Quit) => { stdout.write_all(QUIT_MSG)?; @@ -120,6 +114,11 @@ fn run_watch( WatchEvent::TerminalResize { width } => { watch_state.update_term_width(width, &mut stdout)?; } + WatchEvent::CheckAll => match watch_state.check_all_exercises(&mut stdout)? { + ExercisesProgress::AllDone => break, + ExercisesProgress::NewPending => watch_state.run_current_exercise(&mut stdout)?, + ExercisesProgress::CurrentPending => watch_state.render(&mut stdout)?, + }, WatchEvent::NotifyErr(e) => return Err(Error::from(e).context(NOTIFY_ERR)), WatchEvent::TerminalEventErr(e) => { return Err(Error::from(e).context("Terminal event listener failed")); diff --git a/src/watch/state.rs b/src/watch/state.rs index e47b73f816..8af5fed04a 100644 --- a/src/watch/state.rs +++ b/src/watch/state.rs @@ -202,7 +202,6 @@ impl<'a> WatchState<'a> { } show_key(b'l', b":list / ")?; - show_key(b'c', b":check all / ")?; show_key(b'x', b":reset / ")?; show_key(b'q', b":quit ? ")?; diff --git a/src/watch/terminal_event.rs b/src/watch/terminal_event.rs index 4f0685b6e0..4e1158a4c6 100644 --- a/src/watch/terminal_event.rs +++ b/src/watch/terminal_event.rs @@ -11,7 +11,6 @@ pub enum InputEvent { Run, Hint, List, - CheckAll, Reset, Quit, } @@ -38,7 +37,6 @@ pub fn terminal_event_handler( KeyCode::Char('r') if manual_run => InputEvent::Run, KeyCode::Char('h') => InputEvent::Hint, KeyCode::Char('l') => break WatchEvent::Input(InputEvent::List), - KeyCode::Char('c') => InputEvent::CheckAll, KeyCode::Char('x') => { if sender.send(WatchEvent::Input(InputEvent::Reset)).is_err() { return; From 30edc559b29fe9b4b524b3b669916132af2f2e28 Mon Sep 17 00:00:00 2001 From: mo8it Date: Sun, 2 Aug 2026 14:18:11 +0200 Subject: [PATCH 4/5] Trigger check all when an exercise is done but not marked as such --- src/watch.rs | 21 ++++++++++++++------- src/watch/state.rs | 37 +++++++++++++++++++++---------------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/watch.rs b/src/watch.rs index 3bc56ce8c3..522e961c7e 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -43,7 +43,6 @@ enum WatchEvent { Input(InputEvent), FileChange { exercise_ind: usize }, TerminalResize { width: u16 }, - CheckAll, NotifyErr(notify::Error), TerminalEventErr(io::Error), } @@ -97,7 +96,20 @@ fn run_watch( match event { WatchEvent::Input(InputEvent::Next) => match watch_state.next_exercise(&mut stdout)? { ExercisesProgress::AllDone => break, - ExercisesProgress::NewPending => watch_state.run_current_exercise(&mut stdout)?, + ExercisesProgress::NewPending => { + watch_state.run_current_exercise(&mut stdout)?; + if watch_state.done() { + // An exercise is done although it was not marked as such. + // Trigger check all to fix the state file. + match watch_state.check_all_exercises(&mut stdout)? { + ExercisesProgress::AllDone => break, + ExercisesProgress::NewPending => { + watch_state.run_current_exercise(&mut stdout)?; + } + ExercisesProgress::CurrentPending => watch_state.render(&mut stdout)?, + } + } + } ExercisesProgress::CurrentPending => (), }, WatchEvent::Input(InputEvent::Run) => watch_state.run_current_exercise(&mut stdout)?, @@ -114,11 +126,6 @@ fn run_watch( WatchEvent::TerminalResize { width } => { watch_state.update_term_width(width, &mut stdout)?; } - WatchEvent::CheckAll => match watch_state.check_all_exercises(&mut stdout)? { - ExercisesProgress::AllDone => break, - ExercisesProgress::NewPending => watch_state.run_current_exercise(&mut stdout)?, - ExercisesProgress::CurrentPending => watch_state.render(&mut stdout)?, - }, WatchEvent::NotifyErr(e) => return Err(Error::from(e).context(NOTIFY_ERR)), WatchEvent::TerminalEventErr(e) => { return Err(Error::from(e).context("Terminal event listener failed")); diff --git a/src/watch/state.rs b/src/watch/state.rs index 8af5fed04a..c8724e7f2e 100644 --- a/src/watch/state.rs +++ b/src/watch/state.rs @@ -24,7 +24,6 @@ const HEADING_ATTRIBUTES: Attributes = Attributes::none() .with(Attribute::Bold) .with(Attribute::Underlined); -#[derive(PartialEq, Eq)] enum DoneStatus { DoneWithSolution(String), DoneWithoutSolution, @@ -93,19 +92,19 @@ impl<'a> WatchState<'a> { .current_exercise() .run_exercise(Some(&mut self.output), self.app_state.cmd_runner())?; self.output.push(b'\n'); - if success { - self.done_status = - if let Some(solution_path) = self.app_state.current_solution_path()? { - DoneStatus::DoneWithSolution(solution_path) - } else { - DoneStatus::DoneWithoutSolution - }; + + self.done_status = if success { + 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; - } + DoneStatus::Pending + }; self.app_state.join_editor_handle(editor_handle)?; self.render(stdout)?; @@ -164,18 +163,24 @@ impl<'a> WatchState<'a> { self.run_current_exercise(stdout) } + pub fn done(&self) -> bool { + match self.done_status { + DoneStatus::DoneWithSolution(_) | DoneStatus::DoneWithoutSolution => true, + DoneStatus::Pending => false, + } + } + /// Move on to the next exercise if the current one is done. pub fn next_exercise(&mut self, stdout: &mut StdoutLock) -> Result { - match self.done_status { - DoneStatus::DoneWithSolution(_) | DoneStatus::DoneWithoutSolution => (), - DoneStatus::Pending => return Ok(ExercisesProgress::CurrentPending), + if self.done() { + return self.app_state.done_current_exercise::(stdout); } - self.app_state.done_current_exercise::(stdout) + Ok(ExercisesProgress::CurrentPending) } fn show_prompt(&self, stdout: &mut StdoutLock) -> io::Result<()> { - if self.done_status != DoneStatus::Pending { + if self.done() { stdout.queue(SetAttribute(Attribute::Bold))?; stdout.write_all(b"n")?; stdout.queue(ResetColor)?; @@ -233,7 +238,7 @@ impl<'a> WatchState<'a> { stdout.write_all(b"\n\n")?; } - if self.done_status != DoneStatus::Pending { + if self.done() { stdout .queue(SetAttribute(Attribute::Bold))? .queue(SetForegroundColor(Color::Green))?; From 1b4e590e2991ab8698692d29a48390a8f247bb2c Mon Sep 17 00:00:00 2001 From: Remo Senekowitsch Date: Sun, 3 May 2026 14:41:56 +0200 Subject: [PATCH 5/5] Add readme to generated rustlings directory This can remind users how to use rustlings, in case they come back after a lengthy break. --- src/init.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/init.rs b/src/init.rs index 1210e9bc40..3ccef756d8 100644 --- a/src/init.rs +++ b/src/init.rs @@ -165,6 +165,8 @@ pub fn init() -> Result<()> { fs::write(".gitignore", GITIGNORE) .context("Failed to create the file `rustlings/.gitignore`")?; + fs::write("README.md", README).context("Failed to create the file `rustlings/README.md`")?; + 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`")?; @@ -220,6 +222,14 @@ target/ .vscode/ "; +const README: &[u8] = b"# Rustlings + +This is your space to solve Rustlings exercises. +Simply run `rustlings` in this directory to get started! +Learn more about using Rustlings here: + +"; + pub const VS_CODE_EXTENSIONS_JSON: &[u8] = br#"{"recommendations":["rust-lang.rust-analyzer"]}"#; const IN_INITIALIZED_DIR_ERR: &str = "It looks like Rustlings is already initialized in this directory.