Skip to content

Commit

Permalink
generator: improve error handling
Browse files Browse the repository at this point in the history
  • Loading branch information
senekor committed Apr 1, 2024
1 parent 075e4c8 commit 47301cd
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 77 deletions.
7 changes: 7 additions & 0 deletions rust-tooling/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions rust-tooling/generate/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ description = "Generates exercise boilerplate, especially test cases"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
panic = "warn"

[dependencies]
anyhow = "1.0.81"
clap = { version = "4.4.8", features = ["derive"] }
convert_case = "0.6.0"
glob = "0.3.1"
Expand Down
60 changes: 37 additions & 23 deletions rust-tooling/generate/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use convert_case::{Case, Casing};
use glob::glob;
Expand Down Expand Up @@ -45,15 +46,25 @@ pub struct FullAddArgs {
}

impl AddArgs {
pub fn unwrap_args_or_prompt(self) -> FullAddArgs {
let slug = self.slug.unwrap_or_else(prompt_for_add_slug);
let name = self.name.unwrap_or_else(|| prompt_for_exercise_name(&slug));
let difficulty = self.difficulty.unwrap_or_else(prompt_for_difficulty).into();
FullAddArgs {
pub fn unwrap_args_or_prompt(self) -> Result<FullAddArgs> {
let slug = match self.slug {
Some(slug) => slug,
_ => prompt_for_add_slug()?,
};
let name = match self.name {
Some(name) => name,
None => prompt_for_exercise_name(&slug)?,
};
let difficulty = match self.difficulty {
Some(diff) => diff,
None => prompt_for_difficulty()?,
}
.into();
Ok(FullAddArgs {
slug,
name,
difficulty,
}
})
}
}

Expand All @@ -65,38 +76,41 @@ pub struct UpdateArgs {
}

impl UpdateArgs {
pub fn unwrap_slug_or_prompt(self) -> String {
self.slug.unwrap_or_else(prompt_for_update_slug)
pub fn unwrap_slug_or_prompt(self) -> Result<String> {
match self.slug {
Some(slug) => Ok(slug),
_ => prompt_for_update_slug(),
}
}
}

pub fn prompt_for_update_slug() -> String {
pub fn prompt_for_update_slug() -> Result<String> {
let implemented_exercises = glob("exercises/practice/*")
.unwrap()
.map_err(anyhow::Error::from)?
.filter_map(Result::ok)
.map(|path| path.file_name().unwrap().to_str().unwrap().to_string())
.flat_map(|path| path.file_name()?.to_str().map(|s| s.to_owned()))
.collect::<Vec<_>>();

Select::new(
"Which exercise would you like to update?",
implemented_exercises,
)
.prompt()
.unwrap()
.context("failed to select slug")
}

pub fn prompt_for_add_slug() -> String {
pub fn prompt_for_add_slug() -> Result<String> {
let implemented_exercises = glob("exercises/concept/*")
.unwrap()
.chain(glob("exercises/practice/*").unwrap())
.map_err(anyhow::Error::from)?
.chain(glob("exercises/practice/*").map_err(anyhow::Error::from)?)
.filter_map(Result::ok)
.map(|path| path.file_name().unwrap().to_str().unwrap().to_string())
.flat_map(|path| path.file_name()?.to_str().map(|s| s.to_owned()))
.collect::<Vec<_>>();

let todo_with_spec = glob("problem-specifications/exercises/*")
.unwrap()
.map_err(anyhow::Error::from)?
.filter_map(Result::ok)
.map(|path| path.file_name().unwrap().to_str().unwrap().to_string())
.flat_map(|path| path.file_name()?.to_str().map(|s| s.to_owned()))
.filter(|e| !implemented_exercises.contains(e))
.collect::<Vec<_>>();

Expand Down Expand Up @@ -128,14 +142,14 @@ pub fn prompt_for_add_slug() -> String {
}
})
.prompt()
.unwrap()
.context("failed to prompt for slug")
}

pub fn prompt_for_exercise_name(slug: &str) -> String {
pub fn prompt_for_exercise_name(slug: &str) -> Result<String> {
Text::new("What's the name of your exercise?")
.with_initial_value(&slug.to_case(Case::Title))
.prompt()
.unwrap()
.context("failed to prompt for exercise name")
}

/// Mostly a clone of the `Difficulty` enum from `models::track_config`.
Expand Down Expand Up @@ -172,7 +186,7 @@ impl std::fmt::Display for Difficulty {
}
}

pub fn prompt_for_difficulty() -> Difficulty {
pub fn prompt_for_difficulty() -> Result<Difficulty> {
Select::new(
"What's the difficulty of your exercise?",
vec![
Expand All @@ -183,5 +197,5 @@ pub fn prompt_for_difficulty() -> Difficulty {
],
)
.prompt()
.unwrap()
.context("failed to select difficulty")
}
21 changes: 15 additions & 6 deletions rust-tooling/generate/src/custom_filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,25 @@ type Filter = fn(&Value, &HashMap<String, Value>) -> Result<Value>;

pub static CUSTOM_FILTERS: &[(&str, Filter)] = &[("to_hex", to_hex), ("snake_case", snake_case)];

pub fn to_hex(value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(serde_json::Value::String(format!(
"{:x}",
value.as_u64().unwrap()
)))
pub fn to_hex(value: &Value, _args: &HashMap<String, Value>) -> Result<Value> {
let Some(value) = value.as_u64() else {
return Err(tera::Error::call_filter(
"to_hex filter expects an unsigned integer",
"serde_json::value::Value::as_u64",
));
};
Ok(serde_json::Value::String(format!("{:x}", value)))
}

pub fn snake_case(value: &Value, _args: &HashMap<String, Value>) -> Result<Value> {
let Some(value) = value.as_str() else {
return Err(tera::Error::call_filter(
"snake_case filter expects a string",
"serde_json::value::Value::as_str",
));
};
Ok(serde_json::Value::String(
// slug is the same dependency tera uses for its builtin 'slugify'
slug::slugify(value.as_str().unwrap()).replace('-', "_"),
slug::slugify(value).replace('-', "_"),
))
}
50 changes: 25 additions & 25 deletions rust-tooling/generate/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ use std::{
process::{Command, Stdio},
};

use tera::{Context, Tera};
use anyhow::{Context, Result};
use tera::Tera;

use custom_filters::CUSTOM_FILTERS;
use models::{
Expand All @@ -22,17 +23,17 @@ pub struct GeneratedExercise {
pub tests: String,
}

pub fn new(slug: &str) -> GeneratedExercise {
pub fn new(slug: &str) -> Result<GeneratedExercise> {
let crate_name = slug.replace('-', "_");

GeneratedExercise {
Ok(GeneratedExercise {
gitignore: GITIGNORE.into(),
manifest: generate_manifest(&crate_name),
lib_rs: LIB_RS.into(),
example: EXAMPLE_RS.into(),
test_template: TEST_TEMPLATE.into(),
tests: generate_tests(slug),
}
tests: generate_tests(slug)?,
})
}

static GITIGNORE: &str = "\
Expand Down Expand Up @@ -77,7 +78,7 @@ fn extend_single_cases(single_cases: &mut Vec<SingleTestCase>, cases: Vec<TestCa
}
}

fn generate_tests(slug: &str) -> String {
fn generate_tests(slug: &str) -> Result<String> {
let cases = {
let mut cases = get_canonical_data(slug)
.map(|data| data.cases)
Expand All @@ -86,11 +87,9 @@ fn generate_tests(slug: &str) -> String {
cases
};
let excluded_tests = get_excluded_tests(slug);
let mut template = get_test_template(slug).unwrap();
if template.get_template_names().next().is_none() {
template
.add_raw_template("test_template.tera", TEST_TEMPLATE)
.unwrap();
let mut template = get_test_template(slug).context("failed to get test template")?;
if template.get_template_names().next() != Some("test_template.tera") {
anyhow::bail!("'test_template.tera' not found");
}
for (name, filter) in CUSTOM_FILTERS {
template.register_filter(name, filter);
Expand All @@ -100,12 +99,12 @@ fn generate_tests(slug: &str) -> String {
extend_single_cases(&mut single_cases, cases);
single_cases.retain(|case| !excluded_tests.contains(&case.uuid));

let mut context = Context::new();
let mut context = tera::Context::new();
context.insert("cases", &single_cases);

let rendered = template
.render("test_template.tera", &context)
.unwrap_or_else(|_| panic!("failed to render template of '{slug}'"));
.with_context(|| format!("failed to render template of '{slug}'"))?;

// Remove ignore-annotation on first test.
// This could be done in the template itself,
Expand All @@ -120,36 +119,37 @@ fn generate_tests(slug: &str) -> String {
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn process");
.context("failed to spawn rustfmt process")?;

child
.stdin
.as_mut()
.unwrap()
.context("failed to get rustfmt's stdin")?
.write_all(rendered.as_bytes())
.unwrap();
let rustfmt_out = child.wait_with_output().unwrap();
.context("failed to write to rustfmt's stdin")?;
let rustfmt_out = child
.wait_with_output()
.context("failed to get rustfmt's output")?;

if rustfmt_out.status.success() {
String::from_utf8(rustfmt_out.stdout).unwrap()
} else {
let rustfmt_error = String::from_utf8(rustfmt_out.stderr).unwrap();
if !rustfmt_out.status.success() {
let rustfmt_error = String::from_utf8_lossy(&rustfmt_out.stderr);
let mut last_16_error_lines = rustfmt_error.lines().rev().take(16).collect::<Vec<_>>();
last_16_error_lines.reverse();
let last_16_error_lines = last_16_error_lines.join("\n");

println!(
eprintln!(
"{last_16_error_lines}\
^ last 16 lines of errors from rustfmt
Check the test template (.meta/test_template.tera)
It probably generates invalid Rust code."
);

// still return the unformatted content to be written to the file
rendered
return Ok(rendered);
}
Ok(String::from_utf8_lossy(&rustfmt_out.stdout).into_owned())
}

pub fn get_test_template(slug: &str) -> Option<Tera> {
Some(Tera::new(format!("exercises/practice/{slug}/.meta/*.tera").as_str()).unwrap())
pub fn get_test_template(slug: &str) -> Result<Tera> {
Tera::new(format!("exercises/practice/{slug}/.meta/*.tera").as_str()).map_err(Into::into)
}
Loading

0 comments on commit 47301cd

Please sign in to comment.