Allow processing for hints and messages in the info file

This commit is contained in:
mo8it 2026-08-01 13:01:54 +02:00
parent 9065860390
commit 276c59e8bf
8 changed files with 44 additions and 52 deletions

View File

@ -16,16 +16,8 @@ struct InfoFile<'a> {
#[proc_macro] #[proc_macro]
pub fn include_files(_: TokenStream) -> TokenStream { pub fn include_files(_: TokenStream) -> TokenStream {
// Remove `\r` on Windows let info_file = include_str!("../info.toml");
let info_file = String::from_utf8( let exercises = toml::de::from_str::<InfoFile>(info_file)
include_bytes!("../info.toml")
.iter()
.copied()
.filter(|c| *c != b'\r')
.collect(),
)
.expect("Failed to parse `info.toml` as UTF8");
let exercises = toml::de::from_str::<InfoFile>(&info_file)
.expect("Failed to parse `info.toml`") .expect("Failed to parse `info.toml`")
.exercises; .exercises;

View File

@ -54,7 +54,7 @@ pub struct AppState {
exercises: Vec<Exercise>, exercises: Vec<Exercise>,
// Cache the number of done exercises to avoid iterating over all exercises every time. // Cache the number of done exercises to avoid iterating over all exercises every time.
n_done: u32, n_done: u32,
final_message: &'static str, final_message: String,
state_file: File, state_file: File,
// Preallocated buffer for reading and writing the state file. // Preallocated buffer for reading and writing the state file.
file_buf: Vec<u8>, file_buf: Vec<u8>,
@ -66,8 +66,8 @@ pub struct AppState {
impl AppState { impl AppState {
pub fn new( pub fn new(
exercise_infos: Vec<ExerciseInfo>, exercise_infos: Vec<ExerciseInfo<'static>>,
final_message: &'static str, final_message: String,
editor: Option<Editor>, editor: Option<Editor>,
vs_code_term: bool, vs_code_term: bool,
) -> Result<(Self, StateFileStatus)> { ) -> Result<(Self, StateFileStatus)> {
@ -111,13 +111,12 @@ impl AppState {
Exercise { Exercise {
name: exercise_info.name, name: exercise_info.name,
dir: exercise_info.dir, dir: exercise_info.dir,
// Leaking for `Editor::open`. // LEAKING: For `Editor::open`. The app state is used until the end of the program.
// Leaking is fine since the app state exists until the end of the program.
path: exercise_info.path().leak(), path: exercise_info.path().leak(),
canonical_path, canonical_path,
test: exercise_info.test, test: exercise_info.test,
strict_clippy: exercise_info.strict_clippy, strict_clippy: exercise_info.strict_clippy,
hint: exercise_info.hint.trim_ascii(), hint: exercise_info.hint,
// Updated below. // Updated below.
done: false, done: false,
} }
@ -549,9 +548,9 @@ impl AppState {
clear_terminal(stdout)?; clear_terminal(stdout)?;
stdout.write_all(FINISH_LINE.as_bytes())?; stdout.write_all(FINISH_LINE.as_bytes())?;
let final_message = self.final_message.trim_ascii(); let final_message = self.final_message.as_bytes().trim_ascii();
if !final_message.is_empty() { if !final_message.is_empty() {
stdout.write_all(final_message.as_bytes())?; stdout.write_all(final_message)?;
stdout.write_all(b"\n")?; stdout.write_all(b"\n")?;
} }
@ -617,7 +616,7 @@ mod tests {
canonical_path: None, canonical_path: None,
test: false, test: false,
strict_clippy: false, strict_clippy: false,
hint: "", hint: String::new(),
done: false, done: false,
} }
} }
@ -628,7 +627,7 @@ mod tests {
current_exercise_ind: 0, current_exercise_ind: 0,
exercises: vec![dummy_exercise(), dummy_exercise(), dummy_exercise()], exercises: vec![dummy_exercise(), dummy_exercise(), dummy_exercise()],
n_done: 0, n_done: 0,
final_message: "", final_message: String::new(),
state_file: tempfile::tempfile().unwrap(), state_file: tempfile::tempfile().unwrap(),
file_buf: Vec::new(), file_buf: Vec::new(),
official_exercises: true, official_exercises: true,

View File

@ -110,7 +110,7 @@ mod tests {
dir: None, dir: None,
test: true, test: true,
strict_clippy: true, strict_clippy: true,
hint: "", hint: String::new(),
skip_check_unsolved: false, skip_check_unsolved: false,
}, },
ExerciseInfo { ExerciseInfo {
@ -118,7 +118,7 @@ mod tests {
dir: Some("d"), dir: Some("d"),
test: false, test: false,
strict_clippy: false, strict_clippy: false,
hint: "", hint: String::new(),
skip_check_unsolved: false, skip_check_unsolved: false,
}, },
]; ];

View File

@ -383,7 +383,7 @@ pub fn check(require_solutions: bool) -> Result<()> {
check_cargo_toml(&info_file.exercises, "Cargo.toml", b"")?; check_cargo_toml(&info_file.exercises, "Cargo.toml", b"")?;
} }
// Leaking is fine since they are used until the end of the program. // LEAKING: Used until the end of the program.
let cmd_runner = Box::leak(Box::new(CmdRunner::build()?)); let cmd_runner = Box::leak(Box::new(CmdRunner::build()?));
let info_file = Box::leak(Box::new(info_file)); let info_file = Box::leak(Box::new(info_file));

View File

@ -73,7 +73,7 @@ pub struct Exercise {
pub canonical_path: Option<String>, pub canonical_path: Option<String>,
pub test: bool, pub test: bool,
pub strict_clippy: bool, pub strict_clippy: bool,
pub hint: &'static str, pub hint: String,
pub done: bool, pub done: bool,
} }

View File

@ -6,11 +6,11 @@ use crate::{embedded::EMBEDDED_FILES, exercise::RunnableExercise};
/// Deserialized from the `info.toml` file. /// Deserialized from the `info.toml` file.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ExerciseInfo { pub struct ExerciseInfo<'a> {
/// Exercise's unique name. /// Exercise's unique name.
pub name: &'static str, pub name: &'a str,
/// Exercise's directory name inside the `exercises/` directory. /// Exercise's directory name inside the `exercises/` directory.
pub dir: Option<&'static str>, pub dir: Option<&'a str>,
/// Run `cargo test` on the exercise. /// Run `cargo test` on the exercise.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub test: bool, pub test: bool,
@ -18,7 +18,7 @@ pub struct ExerciseInfo {
#[serde(default)] #[serde(default)]
pub strict_clippy: bool, pub strict_clippy: bool,
/// The exercise's hint to be shown to the user on request. /// The exercise's hint to be shown to the user on request.
pub hint: &'static str, pub hint: String,
/// The exercise is already solved. Ignore it when checking that all exercises are unsolved. /// The exercise is already solved. Ignore it when checking that all exercises are unsolved.
#[serde(default)] #[serde(default)]
pub skip_check_unsolved: bool, pub skip_check_unsolved: bool,
@ -27,7 +27,7 @@ const fn default_true() -> bool {
true true
} }
impl ExerciseInfo { impl ExerciseInfo<'_> {
/// Path to the exercise file starting with the `exercises/` directory. /// Path to the exercise file starting with the `exercises/` directory.
pub fn path(&self) -> String { pub fn path(&self) -> String {
let mut path = if let Some(dir) = self.dir { let mut path = if let Some(dir) = self.dir {
@ -53,7 +53,7 @@ impl ExerciseInfo {
} }
} }
impl RunnableExercise for ExerciseInfo { impl RunnableExercise for ExerciseInfo<'_> {
fn name(&self) -> &str { fn name(&self) -> &str {
self.name self.name
} }
@ -77,11 +77,14 @@ pub struct InfoFile {
/// For possible breaking changes in the future for community exercises. /// For possible breaking changes in the future for community exercises.
pub format_version: u8, pub format_version: u8,
/// Shown to users when starting with the exercises. /// Shown to users when starting with the exercises.
pub welcome_message: Option<&'static str>, #[serde(default)]
pub welcome_message: String,
/// Shown to users after finishing all exercises. /// Shown to users after finishing all exercises.
pub final_message: Option<&'static str>, #[serde(default)]
pub final_message: String,
/// List of all exercises. /// List of all exercises.
pub exercises: Vec<ExerciseInfo>, #[serde(borrow)]
pub exercises: Vec<ExerciseInfo<'static>>,
} }
impl InfoFile { impl InfoFile {
@ -89,15 +92,10 @@ impl InfoFile {
/// Community exercises: Parse the `info.toml` file in the current directory. /// Community exercises: Parse the `info.toml` file in the current directory.
pub fn parse() -> Result<Self> { pub fn parse() -> Result<Self> {
// Read a local `info.toml` if it exists. // Read a local `info.toml` if it exists.
let slf = match fs::read("info.toml") { let slf = match fs::read_to_string("info.toml") {
Ok(file_content) => { Ok(file_content) => {
// Remove `\r` on Windows. // LEAKING: The info file is used until the end of the program.
// Leaking is fine since the info file is used until the end of the program. toml::de::from_str::<Self>(file_content.leak())
let file_content =
String::from_utf8(file_content.into_iter().filter(|c| *c != b'\r').collect())
.context("Failed to parse `info.toml` as UTF8")?
.leak();
toml::de::from_str::<Self>(file_content)
.context("Failed to parse the `info.toml` file")? .context("Failed to parse the `info.toml` file")?
} }
Err(e) => { Err(e) => {

View File

@ -70,24 +70,22 @@ fn main() -> Result<ExitCode> {
let (mut app_state, state_file_status) = AppState::new( let (mut app_state, state_file_status) = AppState::new(
info_file.exercises, info_file.exercises,
info_file.final_message.unwrap_or_default(), info_file.final_message,
editor, editor,
vs_code_term, vs_code_term,
)?; )?;
// Show the welcome message if the state file doesn't exist yet. // Show the welcome message if the state file doesn't exist yet.
if let Some(welcome_message) = info_file.welcome_message { let welcome_message = info_file.welcome_message.as_bytes().trim_ascii();
if !welcome_message.is_empty() {
match state_file_status { match state_file_status {
StateFileStatus::NotRead => { StateFileStatus::NotRead => {
let mut stdout = io::stdout().lock(); let mut stdout = io::stdout().lock();
clear_terminal(&mut stdout)?; clear_terminal(&mut stdout)?;
let welcome_message = welcome_message.trim_ascii(); let welcome_message = welcome_message.trim_ascii();
write!( stdout.write_all(welcome_message)?;
stdout, stdout.write_all(b"\n\nPress ENTER to continue ")?;
"{welcome_message}\n\n\
Press ENTER to continue "
)?;
press_enter_prompt(&mut stdout)?; press_enter_prompt(&mut stdout)?;
clear_terminal(&mut stdout)?; clear_terminal(&mut stdout)?;
// Flush to be able to show errors occurring before printing a newline to stdout. // Flush to be able to show errors occurring before printing a newline to stdout.
@ -106,8 +104,7 @@ fn main() -> Result<ExitCode> {
let notify_exercise_names = if args.manual_run { let notify_exercise_names = if args.manual_run {
None None
} else { } else {
// For the notify event handler thread. // LEAKING: For the notify event handler thread. The slice is used until the end of the program.
// Leaking is fine since the slice is used until the end of the program.
Some( Some(
&*app_state &*app_state
.exercises() .exercises()
@ -176,7 +173,7 @@ fn main() -> Result<ExitCode> {
current_exercise.terminal_file_link(&mut stdout, app_state.emit_file_links())?; current_exercise.terminal_file_link(&mut stdout, app_state.emit_file_links())?;
stdout.write_all(b"\n\nHint:\n")?; stdout.write_all(b"\n\nHint:\n")?;
stdout.write_all(current_exercise.hint.as_bytes())?; stdout.write_all(current_exercise.hint.as_bytes().trim_ascii())?;
stdout.write_all(b"\n")?; stdout.write_all(b"\n")?;
} }
// Handled in an earlier match. // Handled in an earlier match.

View File

@ -224,7 +224,13 @@ impl<'a> WatchState<'a> {
stdout.queue(ResetColor)?; stdout.queue(ResetColor)?;
stdout.write_all(b"\n")?; stdout.write_all(b"\n")?;
stdout.write_all(self.app_state.current_exercise().hint.as_bytes())?; stdout.write_all(
self.app_state
.current_exercise()
.hint
.as_bytes()
.trim_ascii(),
)?;
stdout.write_all(b"\n\n")?; stdout.write_all(b"\n\n")?;
} }