diff --git a/src/dev/check.rs b/src/dev/check.rs index 9002eaff..ac2c603d 100644 --- a/src/dev/check.rs +++ b/src/dev/check.rs @@ -1,11 +1,128 @@ use anyhow::{bail, Context, Result}; -use std::{cmp::Ordering, fs}; +use std::{ + cmp::Ordering, + fs::{self, read_dir}, + path::PathBuf, +}; use crate::{ info_file::{ExerciseInfo, InfoFile}, CURRENT_FORMAT_VERSION, DEVELOPING_OFFICIAL_RUSTLINGS, }; +fn forbidden_char(input: &str) -> Option { + input.chars().find(|c| *c != '_' && !c.is_alphanumeric()) +} + +fn check_info_file_exercises(info_file: &InfoFile) -> Result> { + let mut names = hashbrown::HashSet::with_capacity(info_file.exercises.len()); + let mut paths = hashbrown::HashSet::with_capacity(info_file.exercises.len()); + for exercise_info in &info_file.exercises { + if let Some(c) = forbidden_char(&exercise_info.name) { + bail!( + "Char `{c}` in the exercise name `{}` is not allowed", + exercise_info.name, + ); + } + + if let Some(dir) = &exercise_info.dir { + if let Some(c) = forbidden_char(dir) { + bail!("Char `{c}` in the exercise dir `{dir}` is not allowed"); + } + } + + if !names.insert(exercise_info.name.as_str()) { + bail!( + "The exercise name {} is duplicated. Exercise names must all be unique", + exercise_info.name, + ); + } + + paths.insert(PathBuf::from(exercise_info.path())); + } + + Ok(paths) +} + +fn check_exercise_dir_files( + info_file: &InfoFile, + info_file_paths: hashbrown::HashSet, +) -> Result> { + let mut names = hashbrown::HashSet::with_capacity(info_file.exercises.len()); + for entry in read_dir("exercises").context("Failed to open the `exercises` directory")? { + let entry = entry.context("Failed to read the `exercises` 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 !info_file_paths.contains(&path) { + bail!("`{}` is expected to be an exercise file corresponding to some exercise in `info.toml`", path.display()); + } + + let file_name = file_name.to_string_lossy(); + names.insert(file_name[..file_name.len() - 3].to_string()); + 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", path.display()); + } + + let file_name = path.file_name().unwrap(); + if file_name == "README.md" { + continue; + } + + if !info_file_paths.contains(&path) { + bail!("`{}` is expected to be an exercise file corresponding to some exercise in `info.toml`", path.display()); + } + + let file_name = file_name.to_string_lossy(); + names.insert(file_name[..file_name.len() - 3].to_string()); + } + } + + Ok(names) +} + +fn check_info_file(info_file: &InfoFile) -> 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)?; + let names_in_exercises_dir = check_exercise_dir_files(info_file, info_file_paths)?; + + // Now, we know that every file has an exercise in `info.toml`. + // But we need to check that every exercise in `info.toml` has a file. + if names_in_exercises_dir.len() != info_file.exercises.len() { + for exercise_info in &info_file.exercises { + if !names_in_exercises_dir.contains(&exercise_info.name) { + bail!( + "No exercise file found for the exercise `{}`", + exercise_info.name, + ); + } + } + } + + Ok(()) +} + pub fn bins_start_end_ind(cargo_toml: &str) -> Result<(usize, usize)> { let start_ind = cargo_toml .find("bin = [") @@ -62,11 +179,7 @@ fn check_cargo_toml( pub fn check() -> Result<()> { let info_file = InfoFile::parse()?; - 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 => (), - } + check_info_file(&info_file)?; if DEVELOPING_OFFICIAL_RUSTLINGS { check_cargo_toml(