mirror of
https://git.aramjonghu.dev/AramJonghu/rustlings.git
synced 2026-09-02 00:13:30 +02:00
Remove "I AM NOT DONE" and the verify mode and add AppState
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
use crate::exercise::Exercise;
|
||||
|
||||
const BAD_INDEX_ERR: &str = "The current exercise index is higher than the number of exercises";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StateFile {
|
||||
current_exercise_ind: usize,
|
||||
progress: Vec<bool>,
|
||||
}
|
||||
|
||||
impl StateFile {
|
||||
fn read(exercises: &[Exercise]) -> Option<Self> {
|
||||
let file_content = fs::read(".rustlings-state.json").ok()?;
|
||||
|
||||
let slf: Self = serde_json::de::from_slice(&file_content).ok()?;
|
||||
|
||||
if slf.progress.len() != exercises.len() || slf.current_exercise_ind >= exercises.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(slf)
|
||||
}
|
||||
|
||||
fn read_or_default(exercises: &[Exercise]) -> Self {
|
||||
Self::read(exercises).unwrap_or_else(|| Self {
|
||||
current_exercise_ind: 0,
|
||||
progress: vec![false; exercises.len()],
|
||||
})
|
||||
}
|
||||
|
||||
fn write(&self) -> Result<()> {
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
serde_json::ser::to_writer(&mut buf, self).context("Failed to serialize the state")?;
|
||||
fs::write(".rustlings-state.json", buf)
|
||||
.context("Failed to write the state file `.rustlings-state.json`")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
state_file: StateFile,
|
||||
exercises: &'static [Exercise],
|
||||
n_done: u16,
|
||||
current_exercise: &'static Exercise,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub enum ExercisesProgress {
|
||||
AllDone,
|
||||
Pending,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(exercises: Vec<Exercise>) -> Self {
|
||||
// Leaking for sending the exercises to the debounce event handler.
|
||||
// Leaking is not a problem since the exercises' slice is used until the end of the program.
|
||||
let exercises = exercises.leak();
|
||||
|
||||
let state_file = StateFile::read_or_default(exercises);
|
||||
let n_done = state_file
|
||||
.progress
|
||||
.iter()
|
||||
.fold(0, |acc, done| acc + u16::from(*done));
|
||||
let current_exercise = &exercises[state_file.current_exercise_ind];
|
||||
|
||||
Self {
|
||||
state_file,
|
||||
exercises,
|
||||
n_done,
|
||||
current_exercise,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn current_exercise_ind(&self) -> usize {
|
||||
self.state_file.current_exercise_ind
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn progress(&self) -> &[bool] {
|
||||
&self.state_file.progress
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn exercises(&self) -> &'static [Exercise] {
|
||||
self.exercises
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn n_done(&self) -> u16 {
|
||||
self.n_done
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn current_exercise(&self) -> &'static Exercise {
|
||||
self.current_exercise
|
||||
}
|
||||
|
||||
pub fn set_current_exercise_ind(&mut self, ind: usize) -> Result<()> {
|
||||
if ind >= self.exercises.len() {
|
||||
bail!(BAD_INDEX_ERR);
|
||||
}
|
||||
|
||||
self.state_file.current_exercise_ind = ind;
|
||||
self.current_exercise = &self.exercises[ind];
|
||||
|
||||
self.state_file.write()
|
||||
}
|
||||
|
||||
pub fn set_current_exercise_by_name(&mut self, name: &str) -> Result<()> {
|
||||
let (ind, exercise) = self
|
||||
.exercises
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, exercise)| exercise.name == name)
|
||||
.with_context(|| format!("No exercise found for '{name}'!"))?;
|
||||
|
||||
self.state_file.current_exercise_ind = ind;
|
||||
self.current_exercise = exercise;
|
||||
|
||||
self.state_file.write()
|
||||
}
|
||||
|
||||
pub fn set_pending(&mut self, ind: usize) -> Result<()> {
|
||||
let done = self
|
||||
.state_file
|
||||
.progress
|
||||
.get_mut(ind)
|
||||
.context(BAD_INDEX_ERR)?;
|
||||
|
||||
if *done {
|
||||
*done = false;
|
||||
self.n_done -= 1;
|
||||
self.state_file.write()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_exercise_ind(&self) -> Option<usize> {
|
||||
let current_ind = self.state_file.current_exercise_ind;
|
||||
|
||||
if current_ind == self.state_file.progress.len() - 1 {
|
||||
// The last exercise is done.
|
||||
// Search for exercises not done from the start.
|
||||
return self.state_file.progress[..current_ind]
|
||||
.iter()
|
||||
.position(|done| !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.state_file.progress[current_ind + 1..]
|
||||
.iter()
|
||||
.position(|done| !done)
|
||||
{
|
||||
Some(ind) => Some(current_ind + 1 + ind),
|
||||
None => self.state_file.progress[..current_ind]
|
||||
.iter()
|
||||
.position(|done| !done),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn done_current_exercise(&mut self) -> Result<ExercisesProgress> {
|
||||
let done = &mut self.state_file.progress[self.state_file.current_exercise_ind];
|
||||
if !*done {
|
||||
*done = true;
|
||||
self.n_done += 1;
|
||||
}
|
||||
|
||||
let Some(ind) = self.next_exercise_ind() else {
|
||||
return Ok(ExercisesProgress::AllDone);
|
||||
};
|
||||
|
||||
self.set_current_exercise_ind(ind)?;
|
||||
|
||||
Ok(ExercisesProgress::Pending)
|
||||
}
|
||||
}
|
||||
+1
-205
@@ -1,38 +1,14 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
array,
|
||||
fmt::{self, Debug, Display, Formatter},
|
||||
fs::{self, File},
|
||||
io::{self, BufRead, BufReader},
|
||||
mem,
|
||||
fs::{self},
|
||||
path::PathBuf,
|
||||
process::{Command, Output},
|
||||
};
|
||||
use winnow::{
|
||||
ascii::{space0, Caseless},
|
||||
combinator::opt,
|
||||
Parser,
|
||||
};
|
||||
|
||||
use crate::embedded::{WriteStrategy, EMBEDDED_FILES};
|
||||
|
||||
// The number of context lines above and below a highlighted line.
|
||||
const CONTEXT: usize = 2;
|
||||
|
||||
// Check if the line contains the "I AM NOT DONE" comment.
|
||||
fn contains_not_done_comment(input: &str) -> bool {
|
||||
(
|
||||
space0::<_, ()>,
|
||||
"//",
|
||||
opt('/'),
|
||||
space0,
|
||||
Caseless("I AM NOT DONE"),
|
||||
)
|
||||
.parse_next(&mut &*input)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
// The mode of the exercise.
|
||||
#[derive(Deserialize, Copy, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -78,13 +54,6 @@ pub struct Exercise {
|
||||
pub hint: String,
|
||||
}
|
||||
|
||||
// The state of an Exercise.
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
pub enum State {
|
||||
Done,
|
||||
Pending(Vec<ContextLine>),
|
||||
}
|
||||
|
||||
// The context information of a pending exercise.
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
pub struct ContextLine {
|
||||
@@ -129,105 +98,6 @@ impl Exercise {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> Result<State> {
|
||||
let source_file = File::open(&self.path)
|
||||
.with_context(|| format!("Failed to open the exercise file {}", self.path.display()))?;
|
||||
let mut source_reader = BufReader::new(source_file);
|
||||
|
||||
// Read the next line into `buf` without the newline at the end.
|
||||
let mut read_line = |buf: &mut String| -> io::Result<_> {
|
||||
let n = source_reader.read_line(buf)?;
|
||||
if buf.ends_with('\n') {
|
||||
buf.pop();
|
||||
if buf.ends_with('\r') {
|
||||
buf.pop();
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
};
|
||||
|
||||
let mut current_line_number: usize = 1;
|
||||
// Keep the last `CONTEXT` lines while iterating over the file lines.
|
||||
let mut prev_lines: [_; CONTEXT] = array::from_fn(|_| String::with_capacity(256));
|
||||
let mut line = String::with_capacity(256);
|
||||
|
||||
loop {
|
||||
let n = read_line(&mut line).with_context(|| {
|
||||
format!("Failed to read the exercise file {}", self.path.display())
|
||||
})?;
|
||||
|
||||
// Reached the end of the file and didn't find the comment.
|
||||
if n == 0 {
|
||||
return Ok(State::Done);
|
||||
}
|
||||
|
||||
if contains_not_done_comment(&line) {
|
||||
let mut context = Vec::with_capacity(2 * CONTEXT + 1);
|
||||
// Previous lines.
|
||||
for (ind, prev_line) in prev_lines
|
||||
.into_iter()
|
||||
.take(current_line_number - 1)
|
||||
.enumerate()
|
||||
.rev()
|
||||
{
|
||||
context.push(ContextLine {
|
||||
line: prev_line,
|
||||
number: current_line_number - 1 - ind,
|
||||
important: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Current line.
|
||||
context.push(ContextLine {
|
||||
line,
|
||||
number: current_line_number,
|
||||
important: true,
|
||||
});
|
||||
|
||||
// Next lines.
|
||||
for ind in 0..CONTEXT {
|
||||
let mut next_line = String::with_capacity(256);
|
||||
let Ok(n) = read_line(&mut next_line) else {
|
||||
// If an error occurs, just ignore the next lines.
|
||||
break;
|
||||
};
|
||||
|
||||
// Reached the end of the file.
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
context.push(ContextLine {
|
||||
line: next_line,
|
||||
number: current_line_number + 1 + ind,
|
||||
important: false,
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(State::Pending(context));
|
||||
}
|
||||
|
||||
current_line_number += 1;
|
||||
// Add the current line as a previous line and shift the older lines by one.
|
||||
for prev_line in &mut prev_lines {
|
||||
mem::swap(&mut line, prev_line);
|
||||
}
|
||||
// The current line now contains the oldest previous line.
|
||||
// Recycle it for reading the next line.
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 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) -> Result<bool> {
|
||||
self.state().map(|state| state == State::Done)
|
||||
}
|
||||
|
||||
pub fn reset(&self) -> Result<()> {
|
||||
EMBEDDED_FILES
|
||||
.write_exercise_to_disk(&self.path, WriteStrategy::Overwrite)
|
||||
@@ -240,77 +110,3 @@ impl Display for Exercise {
|
||||
self.path.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pending_state() {
|
||||
let exercise = Exercise {
|
||||
name: "pending_exercise".into(),
|
||||
path: PathBuf::from("tests/fixture/state/exercises/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.unwrap(), State::Pending(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_finished_exercise() {
|
||||
let exercise = Exercise {
|
||||
name: "finished_exercise".into(),
|
||||
path: PathBuf::from("tests/fixture/state/exercises/finished_exercise.rs"),
|
||||
mode: Mode::Compile,
|
||||
hint: String::new(),
|
||||
};
|
||||
|
||||
assert_eq!(exercise.state().unwrap(), State::Done);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_done() {
|
||||
assert!(contains_not_done_comment("// I AM NOT DONE"));
|
||||
assert!(contains_not_done_comment("/// I AM NOT DONE"));
|
||||
assert!(contains_not_done_comment("// I AM NOT DONE"));
|
||||
assert!(contains_not_done_comment("/// I AM NOT DONE"));
|
||||
assert!(contains_not_done_comment("// I AM NOT DONE "));
|
||||
assert!(contains_not_done_comment("// I AM NOT DONE!"));
|
||||
assert!(contains_not_done_comment("// I am not done"));
|
||||
assert!(contains_not_done_comment("// i am NOT done"));
|
||||
|
||||
assert!(!contains_not_done_comment("I AM NOT DONE"));
|
||||
assert!(!contains_not_done_comment("// NOT DONE"));
|
||||
assert!(!contains_not_done_comment("DONE"));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-12
@@ -9,11 +9,11 @@ use std::{fmt::Write, io};
|
||||
|
||||
mod state;
|
||||
|
||||
use crate::{exercise::Exercise, state_file::StateFile};
|
||||
use crate::app_state::AppState;
|
||||
|
||||
use self::state::{Filter, UiState};
|
||||
|
||||
pub fn list(state_file: &mut StateFile, exercises: &'static [Exercise]) -> Result<()> {
|
||||
pub fn list(app_state: &mut AppState) -> Result<()> {
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout.execute(EnterAlternateScreen)?;
|
||||
enable_raw_mode()?;
|
||||
@@ -21,7 +21,7 @@ pub fn list(state_file: &mut StateFile, exercises: &'static [Exercise]) -> Resul
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(&mut stdout))?;
|
||||
terminal.clear()?;
|
||||
|
||||
let mut ui_state = UiState::new(state_file, exercises);
|
||||
let mut ui_state = UiState::new(app_state);
|
||||
|
||||
'outer: loop {
|
||||
terminal.draw(|frame| ui_state.draw(frame).unwrap())?;
|
||||
@@ -56,7 +56,7 @@ pub fn list(state_file: &mut StateFile, exercises: &'static [Exercise]) -> Resul
|
||||
"Enabled filter DONE │ Press d again to disable the filter"
|
||||
};
|
||||
|
||||
ui_state = ui_state.with_updated_rows(state_file);
|
||||
ui_state = ui_state.with_updated_rows();
|
||||
ui_state.message.push_str(message);
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
@@ -68,23 +68,20 @@ pub fn list(state_file: &mut StateFile, exercises: &'static [Exercise]) -> Resul
|
||||
"Enabled filter PENDING │ Press p again to disable the filter"
|
||||
};
|
||||
|
||||
ui_state = ui_state.with_updated_rows(state_file);
|
||||
ui_state = ui_state.with_updated_rows();
|
||||
ui_state.message.push_str(message);
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
let selected = ui_state.selected();
|
||||
let exercise = &exercises[selected];
|
||||
exercise.reset()?;
|
||||
state_file.reset(selected)?;
|
||||
let exercise = ui_state.reset_selected()?;
|
||||
|
||||
ui_state = ui_state.with_updated_rows(state_file);
|
||||
ui_state = ui_state.with_updated_rows();
|
||||
ui_state
|
||||
.message
|
||||
.write_fmt(format_args!("The exercise {exercise} has been reset!"))?;
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
state_file.set_next_exercise_ind(ui_state.selected())?;
|
||||
ui_state = ui_state.with_updated_rows(state_file);
|
||||
ui_state.selected_to_current_exercise()?;
|
||||
ui_state = ui_state.with_updated_rows();
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
+40
-31
@@ -7,7 +7,7 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::{exercise::Exercise, progress_bar::progress_bar_ratatui, state_file::StateFile};
|
||||
use crate::{app_state::AppState, exercise::Exercise, progress_bar::progress_bar_ratatui};
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
pub enum Filter {
|
||||
@@ -16,30 +16,29 @@ pub enum Filter {
|
||||
None,
|
||||
}
|
||||
|
||||
pub struct UiState {
|
||||
pub struct UiState<'a> {
|
||||
pub table: Table<'static>,
|
||||
pub message: String,
|
||||
pub filter: Filter,
|
||||
exercises: &'static [Exercise],
|
||||
progress: u16,
|
||||
selected: usize,
|
||||
app_state: &'a mut AppState,
|
||||
table_state: TableState,
|
||||
selected: usize,
|
||||
last_ind: usize,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn with_updated_rows(mut self, state_file: &StateFile) -> Self {
|
||||
impl<'a> UiState<'a> {
|
||||
pub fn with_updated_rows(mut self) -> Self {
|
||||
let current_exercise_ind = self.app_state.current_exercise_ind();
|
||||
|
||||
let mut rows_counter: usize = 0;
|
||||
let mut progress: u16 = 0;
|
||||
let rows = self
|
||||
.exercises
|
||||
.app_state
|
||||
.exercises()
|
||||
.iter()
|
||||
.zip(state_file.progress().iter().copied())
|
||||
.zip(self.app_state.progress().iter().copied())
|
||||
.enumerate()
|
||||
.filter_map(|(ind, (exercise, done))| {
|
||||
let exercise_state = if done {
|
||||
progress += 1;
|
||||
|
||||
if self.filter == Filter::Pending {
|
||||
return None;
|
||||
}
|
||||
@@ -55,7 +54,7 @@ impl UiState {
|
||||
|
||||
rows_counter += 1;
|
||||
|
||||
let next = if ind == state_file.next_exercise_ind() {
|
||||
let next = if ind == current_exercise_ind {
|
||||
">>>>".bold().red()
|
||||
} else {
|
||||
Span::default()
|
||||
@@ -74,15 +73,14 @@ impl UiState {
|
||||
self.last_ind = rows_counter.saturating_sub(1);
|
||||
self.select(self.selected.min(self.last_ind));
|
||||
|
||||
self.progress = progress;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn new(state_file: &StateFile, exercises: &'static [Exercise]) -> Self {
|
||||
pub fn new(app_state: &'a mut AppState) -> Self {
|
||||
let header = Row::new(["Next", "State", "Name", "Path"]);
|
||||
|
||||
let max_name_len = exercises
|
||||
let max_name_len = app_state
|
||||
.exercises()
|
||||
.iter()
|
||||
.map(|exercise| exercise.name.len())
|
||||
.max()
|
||||
@@ -104,7 +102,7 @@ impl UiState {
|
||||
.highlight_symbol("🦀")
|
||||
.block(Block::default().borders(Borders::BOTTOM));
|
||||
|
||||
let selected = state_file.next_exercise_ind();
|
||||
let selected = app_state.current_exercise_ind();
|
||||
let table_state = TableState::default()
|
||||
.with_offset(selected.saturating_sub(10))
|
||||
.with_selected(Some(selected));
|
||||
@@ -113,19 +111,13 @@ impl UiState {
|
||||
table,
|
||||
message: String::with_capacity(128),
|
||||
filter: Filter::None,
|
||||
exercises,
|
||||
progress: 0,
|
||||
selected,
|
||||
app_state,
|
||||
table_state,
|
||||
selected,
|
||||
last_ind: 0,
|
||||
};
|
||||
|
||||
slf.with_updated_rows(state_file)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn selected(&self) -> usize {
|
||||
self.selected
|
||||
slf.with_updated_rows()
|
||||
}
|
||||
|
||||
fn select(&mut self, ind: usize) {
|
||||
@@ -134,11 +126,13 @@ impl UiState {
|
||||
}
|
||||
|
||||
pub fn select_next(&mut self) {
|
||||
self.select(self.selected.saturating_add(1).min(self.last_ind));
|
||||
let next = (self.selected + 1).min(self.last_ind);
|
||||
self.select(next);
|
||||
}
|
||||
|
||||
pub fn select_previous(&mut self) {
|
||||
self.select(self.selected.saturating_sub(1));
|
||||
let previous = self.selected.saturating_sub(1);
|
||||
self.select(previous);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -167,8 +161,8 @@ impl UiState {
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(progress_bar_ratatui(
|
||||
self.progress,
|
||||
self.exercises.len() as u16,
|
||||
self.app_state.n_done(),
|
||||
self.app_state.exercises().len() as u16,
|
||||
area.width,
|
||||
)?)
|
||||
.block(Block::default().borders(Borders::BOTTOM)),
|
||||
@@ -200,4 +194,19 @@ impl UiState {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reset_selected(&mut self) -> Result<&'static Exercise> {
|
||||
self.app_state.set_pending(self.selected)?;
|
||||
// TODO: Take care of filters!
|
||||
let exercise = &self.app_state.exercises()[self.selected];
|
||||
exercise.reset()?;
|
||||
|
||||
Ok(exercise)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn selected_to_current_exercise(&mut self) -> Result<()> {
|
||||
// TODO: Take care of filters!
|
||||
self.app_state.set_current_exercise_ind(self.selected)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-46
@@ -1,7 +1,8 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::{path::Path, process::exit};
|
||||
|
||||
mod app_state;
|
||||
mod consts;
|
||||
mod embedded;
|
||||
mod exercise;
|
||||
@@ -9,17 +10,15 @@ mod init;
|
||||
mod list;
|
||||
mod progress_bar;
|
||||
mod run;
|
||||
mod state_file;
|
||||
mod verify;
|
||||
mod watch;
|
||||
|
||||
use self::{
|
||||
app_state::AppState,
|
||||
consts::WELCOME,
|
||||
exercise::{Exercise, InfoFile},
|
||||
exercise::InfoFile,
|
||||
init::init,
|
||||
list::list,
|
||||
run::run,
|
||||
state_file::StateFile,
|
||||
verify::{verify, VerifyState},
|
||||
watch::{watch, WatchExit},
|
||||
};
|
||||
|
||||
@@ -35,14 +34,12 @@ struct Args {
|
||||
enum Subcommands {
|
||||
/// Initialize Rustlings
|
||||
Init,
|
||||
/// Verify all exercises according to the recommended order
|
||||
Verify,
|
||||
/// Same as just running `rustlings` without a subcommand.
|
||||
Watch,
|
||||
/// Run/Test a single exercise
|
||||
/// 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
|
||||
Reset {
|
||||
@@ -56,26 +53,6 @@ enum Subcommands {
|
||||
},
|
||||
}
|
||||
|
||||
fn find_exercise(name: &str, exercises: &'static [Exercise]) -> Result<(usize, &'static Exercise)> {
|
||||
if name == "next" {
|
||||
for (ind, exercise) in exercises.iter().enumerate() {
|
||||
if !exercise.looks_done()? {
|
||||
return Ok((ind, exercise));
|
||||
}
|
||||
}
|
||||
|
||||
println!("🎉 Congratulations! You have done all the exercises!");
|
||||
println!("🔚 There are no more exercises to do next!");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
exercises
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, exercise)| exercise.name == name)
|
||||
.with_context(|| format!("No exercise found for '{name}'!"))
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
@@ -87,11 +64,10 @@ Try running `cargo --version` to diagnose the problem.",
|
||||
|
||||
let mut info_file = InfoFile::parse()?;
|
||||
info_file.exercises.shrink_to_fit();
|
||||
// Leaking is not a problem since the exercises' slice is used until the end of the program.
|
||||
let exercises = info_file.exercises.leak();
|
||||
let exercises = info_file.exercises;
|
||||
|
||||
if matches!(args.command, Some(Subcommands::Init)) {
|
||||
init::init(exercises).context("Initialization failed")?;
|
||||
init(&exercises).context("Initialization failed")?;
|
||||
println!(
|
||||
"\nDone initialization!\n
|
||||
Run `cd rustlings` to go into the generated directory.
|
||||
@@ -109,38 +85,37 @@ If you are just starting with Rustlings, run the command `rustlings init` to ini
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let mut state_file = StateFile::read_or_default(exercises);
|
||||
let mut app_state = AppState::new(exercises);
|
||||
|
||||
match args.command {
|
||||
None | Some(Subcommands::Watch) => loop {
|
||||
match watch(&mut state_file, exercises)? {
|
||||
match watch(&mut app_state)? {
|
||||
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(&mut state_file, exercises)?,
|
||||
WatchExit::List => list(&mut app_state)?,
|
||||
}
|
||||
},
|
||||
// `Init` is handled above.
|
||||
Some(Subcommands::Init) => (),
|
||||
Some(Subcommands::Run { name }) => {
|
||||
let (_, exercise) = find_exercise(&name, exercises)?;
|
||||
run(exercise).unwrap_or_else(|_| exit(1));
|
||||
if let Some(name) = name {
|
||||
app_state.set_current_exercise_by_name(&name)?;
|
||||
}
|
||||
run(&mut app_state)?;
|
||||
}
|
||||
Some(Subcommands::Reset { name }) => {
|
||||
let (ind, exercise) = find_exercise(&name, exercises)?;
|
||||
app_state.set_current_exercise_by_name(&name)?;
|
||||
app_state.set_pending(app_state.current_exercise_ind())?;
|
||||
let exercise = app_state.current_exercise();
|
||||
exercise.reset()?;
|
||||
state_file.reset(ind)?;
|
||||
println!("The exercise {exercise} has been reset!");
|
||||
}
|
||||
Some(Subcommands::Hint { name }) => {
|
||||
let (_, exercise) = find_exercise(&name, exercises)?;
|
||||
println!("{}", exercise.hint);
|
||||
app_state.set_current_exercise_by_name(&name)?;
|
||||
println!("{}", app_state.current_exercise().hint);
|
||||
}
|
||||
Some(Subcommands::Verify) => match verify(exercises, 0)? {
|
||||
VerifyState::AllExercisesDone => println!("All exercises done!"),
|
||||
VerifyState::Failed(exercise) => bail!("Exercise {exercise} failed"),
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+16
-7
@@ -2,13 +2,10 @@ use anyhow::{bail, Result};
|
||||
use crossterm::style::Stylize;
|
||||
use std::io::{stdout, Write};
|
||||
|
||||
use crate::exercise::Exercise;
|
||||
use crate::app_state::{AppState, ExercisesProgress};
|
||||
|
||||
// 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) -> Result<()> {
|
||||
pub fn run(app_state: &mut AppState) -> Result<()> {
|
||||
let exercise = app_state.current_exercise();
|
||||
let output = exercise.run()?;
|
||||
|
||||
{
|
||||
@@ -22,7 +19,19 @@ pub fn run(exercise: &Exercise) -> Result<()> {
|
||||
bail!("Ran {exercise} with errors");
|
||||
}
|
||||
|
||||
println!("{}", "✓ Successfully ran {exercise}".green());
|
||||
println!(
|
||||
"{}{}",
|
||||
"✓ Successfully ran ".green(),
|
||||
exercise.path.to_string_lossy().green(),
|
||||
);
|
||||
|
||||
match app_state.done_current_exercise()? {
|
||||
ExercisesProgress::AllDone => println!(
|
||||
"🎉 Congratulations! You have done all the exercises!
|
||||
🔚 There are no more exercises to do next!"
|
||||
),
|
||||
ExercisesProgress::Pending => println!("Next exercise: {}", app_state.current_exercise()),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
use crate::exercise::Exercise;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StateFile {
|
||||
next_exercise_ind: usize,
|
||||
progress: Vec<bool>,
|
||||
}
|
||||
|
||||
const BAD_INDEX_ERR: &str = "The next exercise index is higher than the number of exercises";
|
||||
|
||||
impl StateFile {
|
||||
fn read(exercises: &[Exercise]) -> Option<Self> {
|
||||
let file_content = fs::read(".rustlings-state.json").ok()?;
|
||||
|
||||
let slf: Self = serde_json::de::from_slice(&file_content).ok()?;
|
||||
|
||||
if slf.progress.len() != exercises.len() || slf.next_exercise_ind >= exercises.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(slf)
|
||||
}
|
||||
|
||||
pub fn read_or_default(exercises: &[Exercise]) -> Self {
|
||||
Self::read(exercises).unwrap_or_else(|| Self {
|
||||
next_exercise_ind: 0,
|
||||
progress: vec![false; exercises.len()],
|
||||
})
|
||||
}
|
||||
|
||||
fn write(&self) -> Result<()> {
|
||||
let mut buf = Vec::with_capacity(1024);
|
||||
serde_json::ser::to_writer(&mut buf, self).context("Failed to serialize the state")?;
|
||||
fs::write(".rustlings-state.json", buf)
|
||||
.context("Failed to write the state file `.rustlings-state.json`")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn next_exercise_ind(&self) -> usize {
|
||||
self.next_exercise_ind
|
||||
}
|
||||
|
||||
pub fn set_next_exercise_ind(&mut self, ind: usize) -> Result<()> {
|
||||
if ind >= self.progress.len() {
|
||||
bail!(BAD_INDEX_ERR);
|
||||
}
|
||||
self.next_exercise_ind = ind;
|
||||
self.write()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn progress(&self) -> &[bool] {
|
||||
&self.progress
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, ind: usize) -> Result<()> {
|
||||
let done = self.progress.get_mut(ind).context(BAD_INDEX_ERR)?;
|
||||
*done = false;
|
||||
self.write()
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::style::{Attribute, ContentStyle, Stylize};
|
||||
use std::io::{stdout, Write};
|
||||
|
||||
use crate::exercise::{Exercise, Mode, State};
|
||||
|
||||
pub enum VerifyState {
|
||||
AllExercisesDone,
|
||||
Failed(&'static Exercise),
|
||||
}
|
||||
|
||||
// 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(
|
||||
exercises: &'static [Exercise],
|
||||
mut current_exercise_ind: usize,
|
||||
) -> Result<VerifyState> {
|
||||
while current_exercise_ind < exercises.len() {
|
||||
let exercise = &exercises[current_exercise_ind];
|
||||
|
||||
println!(
|
||||
"Progress: {current_exercise_ind}/{} ({:.1}%)\n",
|
||||
exercises.len(),
|
||||
current_exercise_ind as f32 / exercises.len() as f32 * 100.0,
|
||||
);
|
||||
|
||||
let output = exercise.run()?;
|
||||
|
||||
{
|
||||
let mut stdout = stdout().lock();
|
||||
stdout.write_all(&output.stdout)?;
|
||||
stdout.write_all(&output.stderr)?;
|
||||
stdout.flush()?;
|
||||
}
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(VerifyState::Failed(exercise));
|
||||
}
|
||||
|
||||
println!();
|
||||
// TODO: Color
|
||||
match exercise.mode {
|
||||
Mode::Compile => println!("Successfully ran {exercise}!"),
|
||||
Mode::Test => println!("Successfully tested {exercise}!"),
|
||||
Mode::Clippy => println!("Successfully checked {exercise}!"),
|
||||
}
|
||||
|
||||
if let State::Pending(context) = exercise.state()? {
|
||||
println!(
|
||||
"\nYou can keep working on this exercise,
|
||||
or jump into the next one by removing the {} comment:\n",
|
||||
"`I AM NOT DONE`".bold()
|
||||
);
|
||||
|
||||
for context_line in context {
|
||||
let formatted_line = if context_line.important {
|
||||
format!("{}", context_line.line.bold())
|
||||
} else {
|
||||
context_line.line
|
||||
};
|
||||
|
||||
println!(
|
||||
"{:>2} {} {}",
|
||||
ContentStyle {
|
||||
foreground_color: Some(crossterm::style::Color::Blue),
|
||||
background_color: None,
|
||||
underline_color: None,
|
||||
attributes: Attribute::Bold.into()
|
||||
}
|
||||
.apply(context_line.number),
|
||||
"|".blue(),
|
||||
formatted_line,
|
||||
);
|
||||
}
|
||||
return Ok(VerifyState::Failed(exercise));
|
||||
}
|
||||
|
||||
current_exercise_ind += 1;
|
||||
}
|
||||
|
||||
Ok(VerifyState::AllExercisesDone)
|
||||
}
|
||||
+5
-5
@@ -15,7 +15,7 @@ mod debounce_event;
|
||||
mod state;
|
||||
mod terminal_event;
|
||||
|
||||
use crate::{exercise::Exercise, state_file::StateFile};
|
||||
use crate::app_state::AppState;
|
||||
|
||||
use self::{
|
||||
debounce_event::DebounceEventHandler,
|
||||
@@ -39,23 +39,23 @@ pub enum WatchExit {
|
||||
List,
|
||||
}
|
||||
|
||||
pub fn watch(state_file: &mut StateFile, exercises: &'static [Exercise]) -> Result<WatchExit> {
|
||||
pub fn watch(app_state: &mut AppState) -> Result<WatchExit> {
|
||||
let (tx, rx) = channel();
|
||||
let mut debouncer = new_debouncer(
|
||||
Duration::from_secs(1),
|
||||
DebounceEventHandler {
|
||||
tx: tx.clone(),
|
||||
exercises,
|
||||
exercises: app_state.exercises(),
|
||||
},
|
||||
)?;
|
||||
debouncer
|
||||
.watcher()
|
||||
.watch(Path::new("exercises"), RecursiveMode::Recursive)?;
|
||||
|
||||
let mut watch_state = WatchState::new(state_file, exercises);
|
||||
let mut watch_state = WatchState::new(app_state);
|
||||
|
||||
// TODO: bool
|
||||
watch_state.run_exercise()?;
|
||||
watch_state.run_current_exercise()?;
|
||||
watch_state.render()?;
|
||||
|
||||
thread::spawn(move || terminal_event_handler(tx));
|
||||
|
||||
+24
-72
@@ -1,26 +1,16 @@
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
style::{Attribute, ContentStyle, Stylize},
|
||||
style::Stylize,
|
||||
terminal::{size, Clear, ClearType},
|
||||
ExecutableCommand,
|
||||
};
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
io::{self, StdoutLock, Write},
|
||||
};
|
||||
use std::io::{self, StdoutLock, Write};
|
||||
|
||||
use crate::{
|
||||
exercise::{Exercise, State},
|
||||
progress_bar::progress_bar,
|
||||
state_file::StateFile,
|
||||
};
|
||||
use crate::{app_state::AppState, progress_bar::progress_bar};
|
||||
|
||||
pub struct WatchState<'a> {
|
||||
writer: StdoutLock<'a>,
|
||||
exercises: &'static [Exercise],
|
||||
exercise: &'static Exercise,
|
||||
current_exercise_ind: usize,
|
||||
progress: u16,
|
||||
app_state: &'a mut AppState,
|
||||
stdout: Option<Vec<u8>>,
|
||||
stderr: Option<Vec<u8>>,
|
||||
message: Option<String>,
|
||||
@@ -28,19 +18,12 @@ pub struct WatchState<'a> {
|
||||
}
|
||||
|
||||
impl<'a> WatchState<'a> {
|
||||
pub fn new(state_file: &StateFile, exercises: &'static [Exercise]) -> Self {
|
||||
let current_exercise_ind = state_file.next_exercise_ind();
|
||||
let progress = state_file.progress().iter().filter(|done| **done).count() as u16;
|
||||
let exercise = &exercises[current_exercise_ind];
|
||||
|
||||
pub fn new(app_state: &'a mut AppState) -> Self {
|
||||
let writer = io::stdout().lock();
|
||||
|
||||
Self {
|
||||
writer,
|
||||
exercises,
|
||||
exercise,
|
||||
current_exercise_ind,
|
||||
progress,
|
||||
app_state,
|
||||
stdout: None,
|
||||
stderr: None,
|
||||
message: None,
|
||||
@@ -53,8 +36,8 @@ impl<'a> WatchState<'a> {
|
||||
self.writer
|
||||
}
|
||||
|
||||
pub fn run_exercise(&mut self) -> Result<bool> {
|
||||
let output = self.exercise.run()?;
|
||||
pub fn run_current_exercise(&mut self) -> Result<bool> {
|
||||
let output = self.app_state.current_exercise().run()?;
|
||||
self.stdout = Some(output.stdout);
|
||||
|
||||
if !output.status.success() {
|
||||
@@ -64,55 +47,15 @@ impl<'a> WatchState<'a> {
|
||||
|
||||
self.stderr = None;
|
||||
|
||||
if let State::Pending(context) = self.exercise.state()? {
|
||||
let mut message = format!(
|
||||
"
|
||||
You can keep working on this exercise or jump into the next one by removing the {} comment:
|
||||
|
||||
",
|
||||
"`I AM NOT DONE`".bold(),
|
||||
);
|
||||
|
||||
for context_line in context {
|
||||
let formatted_line = if context_line.important {
|
||||
context_line.line.bold()
|
||||
} else {
|
||||
context_line.line.stylize()
|
||||
};
|
||||
|
||||
writeln!(
|
||||
message,
|
||||
"{:>2} {} {}",
|
||||
ContentStyle {
|
||||
foreground_color: Some(crossterm::style::Color::Blue),
|
||||
background_color: None,
|
||||
underline_color: None,
|
||||
attributes: Attribute::Bold.into()
|
||||
}
|
||||
.apply(context_line.number),
|
||||
"|".blue(),
|
||||
formatted_line,
|
||||
)?;
|
||||
}
|
||||
|
||||
self.message = Some(message);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn run_exercise_with_ind(&mut self, exercise_ind: usize) -> Result<bool> {
|
||||
self.exercise = self
|
||||
.exercises
|
||||
.get(exercise_ind)
|
||||
.context("Invalid exercise index")?;
|
||||
self.current_exercise_ind = exercise_ind;
|
||||
|
||||
self.run_exercise()
|
||||
self.app_state.set_current_exercise_ind(exercise_ind)?;
|
||||
self.run_current_exercise()
|
||||
}
|
||||
|
||||
pub fn show_prompt(&mut self) -> io::Result<()> {
|
||||
fn show_prompt(&mut self) -> io::Result<()> {
|
||||
self.writer.write_all(b"\n\n")?;
|
||||
|
||||
if !self.hint_displayed {
|
||||
@@ -150,18 +93,27 @@ You can keep working on this exercise or jump into the next one by removing the
|
||||
if self.hint_displayed {
|
||||
self.writer
|
||||
.write_fmt(format_args!("\n{}\n", "Hint".bold().cyan().underlined()))?;
|
||||
self.writer.write_all(self.exercise.hint.as_bytes())?;
|
||||
self.writer
|
||||
.write_all(self.app_state.current_exercise().hint.as_bytes())?;
|
||||
self.writer.write_all(b"\n\n")?;
|
||||
}
|
||||
|
||||
let line_width = size()?.0;
|
||||
let progress_bar = progress_bar(self.progress, self.exercises.len() as u16, line_width)?;
|
||||
let progress_bar = progress_bar(
|
||||
self.app_state.n_done(),
|
||||
self.app_state.exercises().len() as u16,
|
||||
line_width,
|
||||
)?;
|
||||
self.writer.write_all(progress_bar.as_bytes())?;
|
||||
|
||||
self.writer.write_all(b"Current exercise: ")?;
|
||||
self.writer.write_fmt(format_args!(
|
||||
"{}",
|
||||
self.exercise.path.to_string_lossy().bold()
|
||||
self.app_state
|
||||
.current_exercise()
|
||||
.path
|
||||
.to_string_lossy()
|
||||
.bold(),
|
||||
))?;
|
||||
|
||||
self.show_prompt()?;
|
||||
|
||||
Reference in New Issue
Block a user