summaryrefslogtreecommitdiff
path: root/src/dev
diff options
context:
space:
mode:
Diffstat (limited to 'src/dev')
-rw-r--r--src/dev/check.rs214
-rw-r--r--src/dev/init.rs109
-rw-r--r--src/dev/update.rs53
3 files changed, 376 insertions, 0 deletions
diff --git a/src/dev/check.rs b/src/dev/check.rs
new file mode 100644
index 0000000..e95eb3c
--- /dev/null
+++ b/src/dev/check.rs
@@ -0,0 +1,214 @@
+use anyhow::{anyhow, bail, Context, Error, Result};
+use std::{
+ cmp::Ordering,
+ fs::{self, read_dir, OpenOptions},
+ io::Read,
+ path::{Path, PathBuf},
+};
+
+use crate::{
+ info_file::{ExerciseInfo, InfoFile},
+ CURRENT_FORMAT_VERSION, DEVELOPING_OFFICIAL_RUSTLINGS,
+};
+
+fn forbidden_char(input: &str) -> Option<char> {
+ input.chars().find(|c| *c != '_' && !c.is_alphanumeric())
+}
+
+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 {
+ if exercise_info.name.is_empty() {
+ bail!("Found an empty exercise name in `info.toml`");
+ }
+ 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 dir.is_empty() {
+ bail!(
+ "The exercise `{}` has an empty dir name in `info.toml`",
+ exercise_info.name,
+ );
+ }
+ 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 `{}` has an empty hint. Please provide a hint or at least tell the user why a hint isn't needed for this exercise", exercise_info.name);
+ }
+
+ if !names.insert(exercise_info.name.as_str()) {
+ bail!(
+ "The exercise name `{}` is duplicated. Exercise names must all be unique",
+ exercise_info.name,
+ );
+ }
+
+ 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");
+ }
+
+ file_buf.clear();
+
+ paths.insert(PathBuf::from(path));
+ }
+
+ Ok(paths)
+}
+
+fn unexpected_file(path: &Path) -> Error {
+ anyhow!("Found the file `{}`. Only `README.md` and Rust files related to an exercise in `info.toml` are allowed in the `exercises` directory", path.display())
+}
+
+fn check_exercise_dir_files(info_file_paths: &hashbrown::HashSet<PathBuf>) -> Result<()> {
+ 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) {
+ 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 !info_file_paths.contains(&path) {
+ return Err(unexpected_file(&path));
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn check_exercises(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)?;
+ check_exercise_dir_files(&info_file_paths)?;
+
+ Ok(())
+}
+
+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))
+}
+
+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");
+ }
+}
+
+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(1 << 13);
+ append_bins(&mut new_bins, exercise_infos, exercise_path_prefix);
+
+ if old_bins != new_bins {
+ bail!("`Cargo.toml` is outdated. Run `rustlings dev update` to update it");
+ }
+
+ Ok(())
+}
+
+pub fn check() -> Result<()> {
+ let info_file = InfoFile::parse()?;
+ check_exercises(&info_file)?;
+
+ if DEVELOPING_OFFICIAL_RUSTLINGS {
+ check_cargo_toml(
+ &info_file.exercises,
+ include_str!("../../dev/Cargo.toml"),
+ b"../",
+ )
+ .context("The file `dev/Cargo.toml` is outdated. Please run `cargo run -- dev update` to update it")?;
+ } 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"").context(
+ "The file `Cargo.toml` is outdated. Please run `rustlings dev update` to update it",
+ )?;
+ }
+
+ println!("\nEverything looks fine!");
+
+ Ok(())
+}
diff --git a/src/dev/init.rs b/src/dev/init.rs
new file mode 100644
index 0000000..3ce5055
--- /dev/null
+++ b/src/dev/init.rs
@@ -0,0 +1,109 @@
+use anyhow::{Context, Result};
+use std::fs::{self, create_dir};
+
+use crate::CURRENT_FORMAT_VERSION;
+
+pub fn init() -> Result<()> {
+ create_dir("rustlings").context("Failed to create the directory `rustlings`")?;
+
+ create_dir("rustlings/exercises")
+ .context("Failed to create the directory `rustlings/exercises`")?;
+
+ create_dir("rustlings/solutions")
+ .context("Failed to create the directory `rustlings/solutions`")?;
+
+ fs::write(
+ "rustlings/info.toml",
+ format!("{INFO_FILE_BEFORE_FORMAT_VERSION}{CURRENT_FORMAT_VERSION}{INFO_FILE_AFTER_FORMAT_VERSION}"),
+ )
+ .context("Failed to create the file `rustlings/info.toml`")?;
+
+ fs::write("rustlings/Cargo.toml", CARGO_TOML)
+ .context("Failed to create the file `rustlings/Cargo.toml`")?;
+
+ fs::write("rustlings/.gitignore", crate::init::GITIGNORE)
+ .context("Failed to create the file `rustlings/.gitignore`")?;
+
+ fs::write("rustlings/README.md", README)
+ .context("Failed to create the file `rustlings/README.md`")?;
+
+ create_dir("rustlings/.vscode")
+ .context("Failed to create the directory `rustlings/.vscode`")?;
+ fs::write(
+ "rustlings/.vscode/extensions.json",
+ crate::init::VS_CODE_EXTENSIONS_JSON,
+ )
+ .context("Failed to create the file `rustlings/.vscode/extensions.json`")?;
+
+ println!("{INIT_DONE}");
+
+ Ok(())
+}
+
+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 = "???"
+
+# The mode to run the exercise in.
+# The mode "test" (preferred) runs the exercise's tests.
+# The mode "run" only checks if the exercise compiles and runs it.
+mode = "test"
+
+# 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 = "rustlings"
+edition = "2021"
+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 🚀
+";
+
+const INIT_DONE: &str = r#"Initialization done!
+You can start developing third-party Rustlings exercises in the `rustlings` directory :D
+
+If the initialization was done in a Rust project which is a Cargo workspace, you need to add the
+path to the `rustlings` directory to the `workspace.exclude` list in the project's `Cargo.toml`
+file. For example:
+
+[workspace]
+exclude = ["rustlings"]"#;
diff --git a/src/dev/update.rs b/src/dev/update.rs
new file mode 100644
index 0000000..65dcf76
--- /dev/null
+++ b/src/dev/update.rs
@@ -0,0 +1,53 @@
+use std::fs;
+
+use anyhow::{Context, Result};
+
+use crate::{
+ info_file::{ExerciseInfo, InfoFile},
+ DEVELOPING_OFFICIAL_RUSTLINGS,
+};
+
+use super::check::{append_bins, bins_start_end_ind};
+
+fn update_cargo_toml(
+ exercise_infos: &[ExerciseInfo],
+ current_cargo_toml: &str,
+ cargo_toml_path: &str,
+ exercise_path_prefix: &[u8],
+) -> Result<()> {
+ let (bins_start_ind, bins_end_ind) = bins_start_end_ind(current_cargo_toml)?;
+
+ let mut new_cargo_toml = Vec::with_capacity(1 << 13);
+ new_cargo_toml.extend_from_slice(current_cargo_toml[..bins_start_ind].as_bytes());
+ append_bins(&mut new_cargo_toml, exercise_infos, exercise_path_prefix);
+ new_cargo_toml.extend_from_slice(current_cargo_toml[bins_end_ind..].as_bytes());
+
+ fs::write(cargo_toml_path, new_cargo_toml).context("Failed to write the `Cargo.toml` file")?;
+
+ Ok(())
+}
+
+pub fn update() -> Result<()> {
+ let info_file = InfoFile::parse()?;
+
+ if DEVELOPING_OFFICIAL_RUSTLINGS {
+ update_cargo_toml(
+ &info_file.exercises,
+ include_str!("../../dev/Cargo.toml"),
+ "dev/Cargo.toml",
+ b"../",
+ )
+ .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, "Cargo.toml", b"")
+ .context("Failed to update the file `Cargo.toml`")?;
+
+ println!("Updated `Cargo.toml`");
+ }
+
+ Ok(())
+}