use crate::core::ir::{EnumDef, ErrorDef, FunctionDef, TypeDef};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
pub const DOCS_ONLY_FIXTURE_KIND: &str = "docs_only";
pub const DOCS_ONLY_OUTPUT_SLUG: &str = "docs-only";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DocsOnlyKind {
DocsOnly,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ApiReference {
Type { path: String },
Function { path: String },
Method { path: String },
Field { path: String },
Variant { path: String },
}
impl ApiReference {
fn describe(&self) -> String {
match self {
Self::Type { path } => format!("type `{path}`"),
Self::Function { path } => format!("function `{path}`"),
Self::Method { path } => format!("method `{path}`"),
Self::Field { path } => format!("field `{path}`"),
Self::Variant { path } => format!("variant `{path}`"),
}
}
fn path(&self) -> &str {
match self {
Self::Type { path }
| Self::Function { path }
| Self::Method { path }
| Self::Field { path }
| Self::Variant { path } => path,
}
}
fn owner_and_member(&self) -> Result<(&str, &str)> {
let path = self.path();
let (owner, member) = path
.split_once('.')
.ok_or_else(|| anyhow::anyhow!("{} must be written as `Owner.member`", self.describe()))?;
if owner.is_empty() || member.is_empty() || member.contains('.') {
bail!("{} must be written as exactly one `Owner.member`", self.describe());
}
Ok((owner, member))
}
fn resolve(&self, surface: &ApiSurfaceView<'_>) -> Result<()> {
match self {
Self::Type { path } => {
if surface.has_type(path) {
Ok(())
} else {
bail!("no type named `{path}` exists in the API surface")
}
}
Self::Function { path } => {
if surface.functions.iter().any(|function| &function.name == path) {
Ok(())
} else {
bail!("no function named `{path}` exists in the API surface")
}
}
Self::Method { .. } => {
let (owner, member) = self.owner_and_member()?;
if surface.has_method(owner, member) {
Ok(())
} else {
bail!("no method `{member}` exists on `{owner}` in the API surface")
}
}
Self::Field { .. } => {
let (owner, member) = self.owner_and_member()?;
if surface.has_field(owner, member) {
Ok(())
} else {
bail!("no field `{member}` exists on `{owner}` in the API surface")
}
}
Self::Variant { .. } => {
let (owner, member) = self.owner_and_member()?;
if surface.has_variant(owner, member) {
Ok(())
} else {
bail!("no variant `{member}` exists on `{owner}` in the API surface")
}
}
}
}
}
struct ApiSurfaceView<'a> {
type_defs: &'a [TypeDef],
enums: &'a [EnumDef],
errors: &'a [ErrorDef],
functions: &'a [FunctionDef],
}
impl<'a> ApiSurfaceView<'a> {
fn has_type(&self, name: &str) -> bool {
self.type_defs.iter().any(|type_def| type_def.name == name)
|| self.enums.iter().any(|enum_def| enum_def.name == name)
|| self.errors.iter().any(|error_def| error_def.name == name)
}
fn has_method(&self, owner: &str, method: &str) -> bool {
self.type_defs
.iter()
.find(|type_def| type_def.name == owner)
.map(|type_def| type_def.methods.iter().any(|m| m.name == method))
.or_else(|| {
self.enums
.iter()
.find(|enum_def| enum_def.name == owner)
.map(|enum_def| enum_def.methods.iter().any(|m| m.name == method))
})
.or_else(|| {
self.errors
.iter()
.find(|error_def| error_def.name == owner)
.map(|error_def| error_def.methods.iter().any(|m| m.name == method))
})
.unwrap_or(false)
}
fn has_field(&self, owner: &str, field: &str) -> bool {
self.type_defs
.iter()
.find(|type_def| type_def.name == owner)
.is_some_and(|type_def| type_def.fields.iter().any(|f| f.name == field))
}
fn has_variant(&self, owner: &str, variant: &str) -> bool {
self.enums
.iter()
.find(|enum_def| enum_def.name == owner)
.map(|enum_def| enum_def.variants.iter().any(|v| v.name == variant))
.or_else(|| {
self.errors
.iter()
.find(|error_def| error_def.name == owner)
.map(|error_def| error_def.variants.iter().any(|v| v.name == variant))
})
.unwrap_or(false)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DocsOnlyFixture {
pub kind: DocsOnlyKind,
pub id: String,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub description: String,
pub topic: String,
#[serde(default)]
pub stem: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub references: Vec<ApiReference>,
pub content: String,
#[serde(skip)]
pub source: String,
}
pub fn is_docs_only_marker(value: &serde_json::Value) -> bool {
value.get("kind").and_then(serde_json::Value::as_str) == Some(DOCS_ONLY_FIXTURE_KIND)
}
fn validate_path_component(value: &str, label: &str) -> Result<()> {
if value.is_empty() || Path::new(value).components().count() != 1 || matches!(value, "." | "..") {
bail!("unsafe {label} `{value}`");
}
Ok(())
}
fn validate_docs_only_fixture_shape(fixture: &DocsOnlyFixture) -> Result<()> {
if fixture.id.trim().is_empty() {
bail!("docs-only fixture has an empty id");
}
if fixture.content.trim().is_empty() {
bail!("docs-only fixture `{}` has empty content", fixture.id);
}
validate_path_component(&fixture.topic, "docs-only topic").with_context(|| fixture.id.clone())?;
let stem = fixture.stem.as_deref().unwrap_or(&fixture.id);
validate_path_component(stem, "docs-only stem").with_context(|| fixture.id.clone())?;
for reference in &fixture.references {
if !matches!(reference, ApiReference::Type { .. } | ApiReference::Function { .. }) {
reference.owner_and_member().with_context(|| {
format!(
"docs-only fixture `{}` has an invalid reference {}",
fixture.id,
reference.describe()
)
})?;
} else if reference.path().trim().is_empty() {
bail!(
"docs-only fixture `{}` has an empty path for {}",
fixture.id,
reference.describe()
);
}
}
Ok(())
}
pub fn load_docs_only_fixtures(dir: &Path) -> Result<Vec<DocsOnlyFixture>> {
let mut fixtures = Vec::new();
load_docs_only_recursive(dir, dir, &mut fixtures)?;
let mut seen: HashMap<String, String> = HashMap::new();
for fixture in &fixtures {
if let Some(previous_source) = seen.get(&fixture.id) {
bail!(
"duplicate docs-only fixture ID '{}': found in '{}' and '{}'",
fixture.id,
previous_source,
fixture.source
);
}
seen.insert(fixture.id.clone(), fixture.source.clone());
}
fixtures.sort_by(|a, b| a.topic.cmp(&b.topic).then_with(|| a.id.cmp(&b.id)));
Ok(fixtures)
}
fn load_docs_only_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<DocsOnlyFixture>) -> Result<()> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(());
};
let mut paths: Vec<_> = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.collect();
paths.sort();
for path in paths {
if path.is_dir() {
load_docs_only_recursive(base, &path, fixtures)?;
continue;
}
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let filename = path.file_name().and_then(|name| name.to_str()).unwrap_or("");
if filename == "schema.json" || filename.starts_with('_') {
continue;
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read docs-only fixture: {}", path.display()))?;
let value: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("failed to parse docs-only fixture candidate: {}", path.display()))?;
if !is_docs_only_marker(&value) {
continue;
}
let mut fixture: DocsOnlyFixture = serde_json::from_value(value)
.with_context(|| format!("failed to parse docs-only fixture: {}", path.display()))?;
fixture.source = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
validate_docs_only_fixture_shape(&fixture)
.with_context(|| format!("invalid docs-only fixture: {}", path.display()))?;
fixtures.push(fixture);
}
Ok(())
}
pub fn validate_api_references(
fixture: &DocsOnlyFixture,
type_defs: &[TypeDef],
enums: &[EnumDef],
errors: &[ErrorDef],
functions: &[FunctionDef],
) -> Result<()> {
let surface = ApiSurfaceView {
type_defs,
enums,
errors,
functions,
};
for reference in &fixture.references {
reference.resolve(&surface).with_context(|| {
format!(
"docs-only fixture `{}` ({}) references {} that does not exist",
fixture.id,
fixture.source,
reference.describe()
)
})?;
}
Ok(())
}
fn validate_relative_output(output: &str) -> Result<()> {
let path = Path::new(output);
if path.as_os_str().is_empty()
|| path.is_absolute()
|| path.components().any(|part| !matches!(part, Component::Normal(_)))
{
bail!("docs-only output root must be a safe relative path: {output}");
}
Ok(())
}
pub fn docs_only_output_path(output: &str, fixture: &DocsOnlyFixture) -> Result<PathBuf> {
validate_relative_output(output)?;
let stem = fixture.stem.as_deref().unwrap_or(&fixture.id);
Ok(Path::new(output)
.join(DOCS_ONLY_OUTPUT_SLUG)
.join(&fixture.topic)
.join(format!("{stem}.md")))
}
const DOCS_ONLY_REGENERATE_COMMAND: &str = "alef e2e generate";
fn yaml_quoted(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
fn render_docs_only_markdown(fixture: &DocsOnlyFixture) -> String {
let rendered = crate::e2e::template_env::render(
"snippets/docs_only_file.md.jinja",
minijinja::context! {
id => format!("docs_only_{}", fixture.id),
topic => &fixture.topic,
title_yaml => fixture.title.as_deref().map(yaml_quoted),
content => fixture.content.trim_end(),
},
);
crate::docs::with_html_header(rendered, DOCS_ONLY_REGENERATE_COMMAND)
}
pub fn render_docs_only_fixture(
fixture: &DocsOnlyFixture,
output: &str,
) -> Result<crate::core::backend::GeneratedFile> {
let path = docs_only_output_path(output, fixture)?;
Ok(crate::core::backend::GeneratedFile {
path,
content: render_docs_only_markdown(fixture),
generated_header: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn type_def(name: &str, fields: &[&str], methods: &[&str]) -> TypeDef {
let mut type_def: TypeDef = serde_json::from_value(serde_json::json!({
"name": name,
"rust_path": format!("crate::{name}"),
"fields": [],
"methods": [],
"is_opaque": false,
"is_clone": false,
"doc": "",
}))
.unwrap();
type_def.fields = fields
.iter()
.map(|field| {
serde_json::from_value(serde_json::json!({
"name": field,
"ty": "String",
"optional": false,
"default": null,
"doc": "",
}))
.unwrap()
})
.collect();
type_def.methods = methods
.iter()
.map(|method| {
serde_json::from_value(serde_json::json!({
"name": method,
"params": [],
"return_type": "String",
"is_async": false,
"is_static": false,
"error_type": null,
"doc": "",
"receiver": null,
}))
.unwrap()
})
.collect();
type_def
}
fn function_def(name: &str) -> FunctionDef {
serde_json::from_value(serde_json::json!({
"name": name,
"rust_path": format!("crate::{name}"),
"params": [],
"return_type": "String",
"is_async": false,
"error_type": null,
"doc": "",
}))
.unwrap()
}
fn docs_only_json(references: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"kind": "docs_only",
"id": "config_discovery",
"topic": "guides",
"content": "Configuration is discovered by walking up from the working directory.",
"references": references,
})
}
#[test]
fn kind_field_is_required_and_must_be_the_exact_literal() {
let missing = serde_json::json!({
"id": "config_discovery",
"topic": "guides",
"content": "text",
});
assert!(
serde_json::from_value::<DocsOnlyFixture>(missing).is_err(),
"omitting `kind` must fail to parse as DocsOnlyFixture"
);
let wrong_value = serde_json::json!({
"kind": "docs-only",
"id": "config_discovery",
"topic": "guides",
"content": "text",
});
assert!(
serde_json::from_value::<DocsOnlyFixture>(wrong_value).is_err(),
"a near-miss spelling must not be accepted"
);
}
#[test]
fn deny_unknown_fields_rejects_a_runtime_field_on_a_docs_only_fixture() {
let mixed = serde_json::json!({
"kind": "docs_only",
"id": "config_discovery",
"topic": "guides",
"content": "text",
"call": "discover_config",
});
let error = serde_json::from_value::<DocsOnlyFixture>(mixed)
.expect_err("a docs-only fixture carrying a runtime `call` field must be rejected");
assert!(error.to_string().contains("call"), "{error}");
}
#[test]
fn load_fixtures_skips_a_docs_only_marked_file() {
let dir = tempfile_dir();
std::fs::write(
dir.join("docs_only_example.json"),
docs_only_json(serde_json::json!([])).to_string(),
)
.unwrap();
let runtime_fixtures = super::super::load_fixtures(&dir).expect("runtime loader must not error");
assert!(
runtime_fixtures.is_empty(),
"a docs-only file must never be parsed as a runtime Fixture: got {runtime_fixtures:?}"
);
let docs_only_fixtures = load_docs_only_fixtures(&dir).expect("docs-only loader must find the file");
assert_eq!(docs_only_fixtures.len(), 1);
assert_eq!(docs_only_fixtures[0].id, "config_discovery");
}
#[test]
fn a_reference_to_a_real_type_and_field_validates_clean() {
let fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([
{"kind": "type", "path": "ConfigSource"},
{"kind": "field", "path": "ConfigSource.priority"},
{"kind": "function", "path": "discover_config"},
])))
.unwrap();
let type_defs = vec![type_def("ConfigSource", &["priority"], &[])];
let functions = vec![function_def("discover_config")];
validate_api_references(&fixture, &type_defs, &[], &[], &functions)
.expect("every reference names a real API surface item");
}
#[test]
fn a_reference_to_a_field_that_does_not_exist_fails_validation() {
let fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([
{"kind": "field", "path": "ConfigSource.does_not_exist"},
])))
.unwrap();
let type_defs = vec![type_def("ConfigSource", &["priority"], &[])];
let error = validate_api_references(&fixture, &type_defs, &[], &[], &[])
.expect_err("a field that does not exist must fail validation");
assert!(error.to_string().contains("does_not_exist"), "{error}");
assert!(error.to_string().contains("config_discovery"), "{error}");
}
#[test]
fn a_reference_to_a_method_that_does_not_exist_fails_validation() {
let fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([
{"kind": "method", "path": "ConfigSource.reload"},
])))
.unwrap();
let type_defs = vec![type_def("ConfigSource", &[], &["load"])];
let error = validate_api_references(&fixture, &type_defs, &[], &[], &[])
.expect_err("a method that does not exist must fail validation");
assert!(error.to_string().contains("reload"), "{error}");
}
#[test]
fn a_reference_to_a_type_that_does_not_exist_fails_validation() {
let fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([
{"kind": "type", "path": "NoSuchType"},
])))
.unwrap();
let error =
validate_api_references(&fixture, &[], &[], &[], &[]).expect_err("an unknown type must fail validation");
assert!(error.to_string().contains("NoSuchType"), "{error}");
}
#[test]
fn method_and_field_paths_must_be_owner_dot_member() {
let fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([
{"kind": "field", "path": "no_dot_here"},
])))
.unwrap();
let error = validate_docs_only_fixture_shape(&fixture).expect_err("a bare path must be rejected at load time");
let full = format!("{error:#}");
assert!(full.contains("Owner.member"), "{full}");
}
#[test]
fn empty_content_is_rejected_at_load_time() {
let mut fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([]))).unwrap();
fixture.content = " ".to_string();
let error = validate_docs_only_fixture_shape(&fixture).expect_err("empty content must be rejected");
assert!(error.to_string().contains("empty content"), "{error}");
}
#[test]
fn output_path_uses_the_dedicated_slug_and_stem_default() {
let fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([]))).unwrap();
let path = docs_only_output_path("docs/snippets-generated", &fixture).unwrap();
assert_eq!(
path,
Path::new("docs/snippets-generated/docs-only/guides/config_discovery.md")
);
}
#[test]
fn stem_override_wins_over_id() {
let mut fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([]))).unwrap();
fixture.stem = Some("discovery-order".to_string());
let path = docs_only_output_path("out", &fixture).unwrap();
assert_eq!(path, Path::new("out/docs-only/guides/discovery-order.md"));
}
#[test]
fn rendered_output_carries_the_alef_provenance_marker() {
let fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([]))).unwrap();
let file = render_docs_only_fixture(&fixture, "docs/snippets-generated").unwrap();
assert!(
crate::core::hash::content_has_alef_marker(&file.content),
"rendered docs-only output must be recognized as alef-owned: {}",
file.content
);
assert!(file.content.contains("kind: docs_only"));
assert!(file.content.contains("Configuration is discovered"));
}
#[test]
fn title_is_yaml_escaped_in_front_matter() {
let mut fixture: DocsOnlyFixture = serde_json::from_value(docs_only_json(serde_json::json!([]))).unwrap();
fixture.title = Some("Config: the \"discovery\" order".to_string());
let file = render_docs_only_fixture(&fixture, "out").unwrap();
assert!(
file.content.contains("title: \"Config: the \\\"discovery\\\" order\""),
"{}",
file.content
);
}
fn tempfile_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!("alef-docs-only-test-{}", uuid_like()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn uuid_like() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
format!(
"{}-{:?}",
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(),
std::thread::current().id()
)
}
}