use std::path::{Path, PathBuf};
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>,
}
impl RecipeManifest {
pub fn validate_for_dir(&self) -> Result<(), Vec<String>> {
let mut problems = Vec::new();
if let Err(err) = validate_recipe_rel_path("setup", &self.setup) {
problems.push(err);
}
if let Err(err) = validate_recipe_rel_path("purpose", &self.purpose) {
problems.push(err);
}
if problems.is_empty() {
Ok(())
} else {
Err(problems)
}
}
}
#[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()),
}
}
fn validate_recipe_rel_path(field: &str, value: &str) -> Result<(), String> {
if value.starts_with('/') || value.starts_with('\\') {
return Err(format!("`{field}` must be a relative slash path"));
}
if value.len() >= 2 && value.as_bytes()[1] == b':' && value.as_bytes()[0].is_ascii_alphabetic()
{
return Err(format!("`{field}` must be a relative slash path"));
}
if value.contains('\\') {
return Err(format!("`{field}` must use `/` separators only"));
}
for component in value.split('/') {
if component.is_empty() {
return Err(format!("`{field}` must not contain empty path components"));
}
if component == "." {
return Err(format!("`{field}` must not contain `.` path components"));
}
if component == ".." {
return Err(format!("`{field}` must not contain `..` path components"));
}
}
Ok(())
}
fn resolve_recipe_rel_path(base: &Path, value: &str) -> PathBuf {
let mut path = base.to_path_buf();
for component in value.split('/') {
path.push(component);
}
path
}
pub fn parse_recipe(text: &str) -> Result<RecipeManifest, String> {
let doc = toml_lite::parse(text)?;
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)?;
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>> {
lint_dir_impl(dir, false)
}
pub fn lint_dir_strict_no_quote(dir: &Path) -> Result<(), Vec<Diagnostic>> {
lint_dir_impl(dir, true)
}
fn lint_dir_impl(dir: &Path, strict_no_quote: bool) -> 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,
)]);
}
};
let validated = match manifest.validate_for_dir() {
Ok(()) => true,
Err(errors) => {
for err in errors {
problems.push(Diagnostic::new(recipe_path.display().to_string(), err));
}
false
}
};
let setup_path = if validated {
Some(resolve_recipe_rel_path(dir, &manifest.setup))
} else {
None
};
if let Some(setup_path) = setup_path.as_ref() {
if !setup_path.is_file() {
problems.push(Diagnostic::new(
recipe_path.display().to_string(),
format!("setup file `{}` does not exist", manifest.setup),
));
} else if strict_no_quote {
match std::fs::read_to_string(setup_path) {
Ok(setup) => {
if setup_is_bare_quote(&setup) {
problems.push(Diagnostic::new(
setup_path.display().to_string(),
"recipe setup must not be a bare quote; use an operation, codec form, or read-construct",
));
}
}
Err(err) => problems.push(Diagnostic::new(
setup_path.display().to_string(),
format!("cannot read setup file `{}`: {err}", manifest.setup),
)),
}
}
}
if validated {
let purpose_path = resolve_recipe_rel_path(dir, &manifest.purpose);
if !purpose_path.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)
}
}
fn setup_is_bare_quote(source: &str) -> bool {
let trimmed = source.trim_start();
trimmed.starts_with("(quote") || trimmed.starts_with("( quote")
}
#[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_ignored_not_rejected() {
let m = parse_recipe(
"id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"s\"\npurpose = \"p\"\nbogus = 1\n",
)
.unwrap();
assert_eq!(m.id, "x");
}
#[test]
fn manifest_validate_for_dir_rejects_unsafe_paths() {
let mut manifest = parse_recipe(
"id = \"x\"\ntitle = \"X\"\ncodec = \"l\"\nsetup = \"../setup.siml\"\npurpose = \"/tmp/purpose.md\"\n",
)
.unwrap();
let err = manifest.validate_for_dir().unwrap_err();
assert!(
err.iter()
.any(|msg| msg.contains("`setup` must not contain `..`")),
"{err:?}"
);
assert!(
err.iter()
.any(|msg| msg.contains("`purpose` must be a relative slash path")),
"{err:?}"
);
manifest.setup = "nested\\setup.siml".to_string();
manifest.purpose = "notes//purpose.md".to_string();
let err = manifest.validate_for_dir().unwrap_err();
assert!(
err.iter()
.any(|msg| msg.contains("`setup` must use `/` separators only")),
"{err:?}"
);
assert!(
err.iter()
.any(|msg| msg.contains("`purpose` must not contain empty path components")),
"{err:?}"
);
}
#[test]
fn parses_rich_descriptor_keys() {
let rich = r#"
id = "a30-009-agentic-workflow"
title = "Agentic workflow"
codec = "lisp"
setup = "setup.siml"
purpose = "purpose.md"
order = 9
tags = ["30-agents", "sandbox-descriptor"]
requires = ["agent", "codec/lisp"]
recipe_number = 9
source_chapter = 7
architecture_family = "agentic-workflow"
runner_mode = "fake"
safety_posture = "offline"
capabilities = ["read-eval", "workflow-state"]
descriptor_shape = "agentic-workflow-trace"
assert_tags = ["30-agents", "chapter-07"]
assert_capabilities = ["read-eval"]
assert_setup_codec = "lisp"
expected = "expected.txt"
[[expect]]
form = 0
result = "(agentic-workflow-trace)"
"#;
let m = parse_recipe(rich).unwrap();
assert_eq!(m.id, "a30-009-agentic-workflow");
assert_eq!(m.codec, "lisp");
assert_eq!(m.order, 9);
assert_eq!(m.requires, ["agent", "codec/lisp"]);
assert_eq!(m.expect[0].result, "(agentic-workflow-trace)");
}
#[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 chapter_unknown_key_ignored_not_rejected() {
let chapter = parse_chapter(
"title = \"30 Agents\"\norder = 20\nsummary = \"s\"\ntags = [\"30-agents\"]\n",
)
.unwrap();
assert_eq!(chapter.title.as_deref(), Some("30 Agents"));
assert_eq!(chapter.order, Some(20));
}
#[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);
}
#[test]
fn lint_dir_allows_manifest_id_that_differs_from_the_directory_name() {
let dir = temp_recipe_dir("browser-facade");
std::fs::write(
dir.join("recipe.toml"),
VALID_RECIPE.replace("add-two-numbers", "frame-facade"),
)
.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_parent_path_components() {
let dir = temp_recipe_dir("parent-path");
std::fs::write(
dir.join("recipe.toml"),
VALID_RECIPE.replace("setup.siml", "../setup.siml"),
)
.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` must not contain `..`")),
"{problems:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lint_dir_reports_absolute_and_backslash_paths() {
let dir = temp_recipe_dir("absolute-path");
std::fs::write(
dir.join("recipe.toml"),
VALID_RECIPE
.replace("setup.siml", "nested\\\\setup.siml")
.replace("purpose.md", "/tmp/purpose.md"),
)
.unwrap();
let problems = lint_dir(&dir).unwrap_err();
assert!(
problems
.iter()
.any(|d| d.message.contains("`setup` must use `/` separators only")),
"{problems:?}"
);
assert!(
problems.iter().any(|d| d
.message
.contains("`purpose` must be a relative slash path")),
"{problems:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn write_recipe_with_setup(dir: &Path, setup: &str) {
std::fs::write(dir.join("recipe.toml"), VALID_RECIPE).unwrap();
std::fs::write(dir.join("setup.siml"), setup).unwrap();
std::fs::write(dir.join("purpose.md"), "Add.").unwrap();
}
#[test]
fn default_lint_allows_bare_quote_until_strict_gate_is_requested() {
let dir = temp_recipe_dir("default-quote");
write_recipe_with_setup(&dir, "(quote add-two-numbers)");
assert!(lint_dir(&dir).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn strict_lint_reports_bare_quote_setups() {
for (tag, setup) in [
("single-line-quote", "(quote add-two-numbers)"),
("spaced-quote", " ( quote add-two-numbers)"),
("multi-line-quote", "\n(quote\n add-two-numbers\n)"),
] {
let dir = temp_recipe_dir(tag);
write_recipe_with_setup(&dir, setup);
let problems = lint_dir_strict_no_quote(&dir).unwrap_err();
assert!(
problems.iter().any(|d| {
d.path.ends_with("setup.siml") && d.message.contains("must not be a bare quote")
}),
"{tag}: {problems:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
}