use std::path::Path;
use crate::model::Expectation;
use crate::toml_lite::{self, TomlDoc};
pub const DEFAULT_ORDER: i64 = 1000;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Diagnostic {
pub path: String,
pub message: String,
}
impl Diagnostic {
fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
path: path.into(),
message: message.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecipeManifest {
pub id: String,
pub title: String,
pub codec: String,
pub setup: String,
pub purpose: String,
pub order: i64,
pub tags: Vec<String>,
pub requires: Vec<String>,
pub expect: Vec<Expectation>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BookManifest {
pub book: String,
pub title: String,
pub summary: String,
pub order: i64,
pub chapters: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ChapterManifest {
pub title: Option<String>,
pub order: Option<i64>,
pub summary: String,
}
fn required_str(doc: &TomlDoc, key: &str) -> Result<String, String> {
let value = doc
.get(key)
.ok_or_else(|| format!("missing required key `{key}`"))?;
let text = value.as_str().map_err(|e| format!("`{key}`: {e}"))?;
if text.is_empty() {
return Err(format!("`{key}` must not be empty"));
}
Ok(text.to_string())
}
fn optional_order(doc: &TomlDoc) -> Result<i64, String> {
match doc.get("order") {
Some(value) => value.as_int().map_err(|e| format!("`order`: {e}")),
None => Ok(DEFAULT_ORDER),
}
}
fn optional_strings(doc: &TomlDoc, key: &str) -> Result<Vec<String>, String> {
match doc.get(key) {
Some(value) => Ok(value
.as_array()
.map_err(|e| format!("`{key}`: {e}"))?
.to_vec()),
None => Ok(Vec::new()),
}
}
pub fn parse_recipe(text: &str) -> Result<RecipeManifest, String> {
let doc = toml_lite::parse(text)?;
doc.reject_unknown_top(&[
"id", "title", "codec", "setup", "purpose", "order", "tags", "requires",
])?;
doc.reject_unknown_tables(&["expect"])?;
let mut expect = Vec::new();
for table in doc.tables_named("expect") {
let form = table
.iter()
.find(|(k, _)| k == "form")
.ok_or("`[[expect]]` missing `form`")?
.1
.as_int()
.map_err(|e| format!("`[[expect]].form`: {e}"))?;
if form < 0 {
return Err("`[[expect]].form` must be >= 0".to_string());
}
let result = table
.iter()
.find(|(k, _)| k == "result")
.ok_or("`[[expect]]` missing `result`")?
.1
.as_str()
.map_err(|e| format!("`[[expect]].result`: {e}"))?
.to_string();
expect.push(Expectation {
form: form as usize,
result,
});
}
Ok(RecipeManifest {
id: required_str(&doc, "id")?,
title: required_str(&doc, "title")?,
codec: required_str(&doc, "codec")?,
setup: required_str(&doc, "setup")?,
purpose: required_str(&doc, "purpose")?,
order: optional_order(&doc)?,
tags: optional_strings(&doc, "tags")?,
requires: optional_strings(&doc, "requires")?,
expect,
})
}
pub fn parse_book(text: &str) -> Result<BookManifest, String> {
let doc = toml_lite::parse(text)?;
doc.reject_unknown_top(&["book", "title", "summary", "order", "chapters"])?;
doc.reject_unknown_tables(&[])?;
Ok(BookManifest {
book: required_str(&doc, "book")?,
title: required_str(&doc, "title")?,
summary: doc
.get("summary")
.map(|v| v.as_str().map(str::to_string))
.transpose()
.map_err(|e| format!("`summary`: {e}"))?
.unwrap_or_default(),
order: optional_order(&doc)?,
chapters: optional_strings(&doc, "chapters")?,
})
}
pub fn parse_chapter(text: &str) -> Result<ChapterManifest, String> {
let doc = toml_lite::parse(text)?;
doc.reject_unknown_top(&["title", "order", "summary"])?;
doc.reject_unknown_tables(&[])?;
let title = match doc.get("title") {
Some(v) => Some(v.as_str().map_err(|e| format!("`title`: {e}"))?.to_string()),
None => None,
};
let order = match doc.get("order") {
Some(v) => Some(v.as_int().map_err(|e| format!("`order`: {e}"))?),
None => None,
};
let summary = match doc.get("summary") {
Some(v) => v
.as_str()
.map_err(|e| format!("`summary`: {e}"))?
.to_string(),
None => String::new(),
};
Ok(ChapterManifest {
title,
order,
summary,
})
}
pub fn lint_dir(dir: &Path) -> Result<(), Vec<Diagnostic>> {
let mut problems = Vec::new();
let recipe_path = dir.join("recipe.toml");
let text = match std::fs::read_to_string(&recipe_path) {
Ok(text) => text,
Err(err) => {
return Err(vec![Diagnostic::new(
recipe_path.display().to_string(),
format!("cannot read recipe.toml: {err}"),
)]);
}
};
let manifest = match parse_recipe(&text) {
Ok(manifest) => manifest,
Err(err) => {
return Err(vec![Diagnostic::new(
recipe_path.display().to_string(),
err,
)]);
}
};
if !dir.join(&manifest.setup).is_file() {
problems.push(Diagnostic::new(
recipe_path.display().to_string(),
format!("setup file `{}` does not exist", manifest.setup),
));
}
if !dir.join(&manifest.purpose).is_file() {
problems.push(Diagnostic::new(
recipe_path.display().to_string(),
format!("purpose file `{}` does not exist", manifest.purpose),
));
}
if problems.is_empty() {
Ok(())
} else {
Err(problems)
}
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_RECIPE: &str = r#"
id = "add-two-numbers"
title = "Add two numbers"
codec = "lisp"
setup = "setup.siml"
purpose = "purpose.md"
order = 100
tags = ["arithmetic", "intro"]
requires = ["numbers-f64"]
[[expect]]
form = 0
result = "3"
"#;
#[test]
fn parses_valid_recipe() {
let m = parse_recipe(VALID_RECIPE).unwrap();
assert_eq!(m.id, "add-two-numbers");
assert_eq!(m.codec, "lisp");
assert_eq!(m.order, 100);
assert_eq!(m.tags, ["arithmetic", "intro"]);
assert_eq!(
m.expect,
[Expectation {
form: 0,
result: "3".into()
}]
);
}
#[test]
fn recipe_order_defaults() {
let m = parse_recipe(
"id = \"x\"\ntitle = \"X\"\ncodec = \"lisp\"\nsetup = \"s\"\npurpose = \"p\"\n",
)
.unwrap();
assert_eq!(m.order, DEFAULT_ORDER);
assert!(m.tags.is_empty());
assert!(m.requires.is_empty());
}
#[test]
fn missing_required_field_errors_clearly() {
let err = parse_recipe("title = \"X\"\ncodec = \"lisp\"\n").unwrap_err();
assert!(err.contains("missing required key `id`"), "{err}");
}
#[test]
fn empty_required_field_errors() {
let err = parse_recipe(
"id = \"\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n",
)
.unwrap_err();
assert!(err.contains("must not be empty"), "{err}");
}
#[test]
fn unknown_key_rejected() {
let err = parse_recipe(
"id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\nbogus = 1\n",
)
.unwrap_err();
assert!(err.contains("unknown key `bogus`"), "{err}");
}
#[test]
fn parses_book_and_chapter() {
let book = parse_book(
"book = \"numbers-f64\"\ntitle = \"Numbers\"\norder = 200\nchapters = [\"01-basics\"]\n",
)
.unwrap();
assert_eq!(book.book, "numbers-f64");
assert_eq!(book.order, 200);
assert_eq!(book.chapters, ["01-basics"]);
let chapter = parse_chapter("title = \"Basics\"\norder = 10\n").unwrap();
assert_eq!(chapter.title.as_deref(), Some("Basics"));
assert_eq!(chapter.order, Some(10));
}
#[test]
fn expect_missing_result_errors() {
let err = parse_recipe(
"id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\n[[expect]]\nform = 0\n",
)
.unwrap_err();
assert!(err.contains("missing `result`"), "{err}");
}
fn temp_recipe_dir(tag: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("sim-cookbook-lint-{}-{}", std::process::id(), tag));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn lint_dir_accepts_complete_recipe() {
let dir = temp_recipe_dir("ok");
std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
std::fs::write(dir.join("setup.siml"), "(+ 1 2)").unwrap();
std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
assert!(lint_dir(&dir).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lint_dir_reports_missing_setup_file() {
let dir = temp_recipe_dir("missing-setup");
std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
let problems = lint_dir(&dir).unwrap_err();
assert!(
problems.iter().any(|d| d.message.contains("setup.siml")),
"{problems:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}