use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
const API_DIR: &str = "api/agents";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PostEntry {
pub title: String,
pub url: String,
pub date: String,
pub description: String,
pub tags: Vec<String>,
pub locale: String,
pub word_count: u64,
pub author: Option<String>,
pub topic_clusters: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct AgentApiPlugin {
enabled: bool,
}
impl Default for AgentApiPlugin {
fn default() -> Self {
Self { enabled: true }
}
}
impl AgentApiPlugin {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn disabled() -> Self {
Self { enabled: false }
}
}
impl Plugin for AgentApiPlugin {
fn name(&self) -> &'static str {
"agent-api"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
if !self.enabled || ctx.dry_run || !ctx.site_dir.exists() {
return Ok(());
}
let posts = collect_posts(ctx);
let topics = build_topics_map(&posts);
let out_dir = ctx.site_dir.join(API_DIR);
fs::create_dir_all(&out_dir).with_path(&out_dir)?;
let docs: [(&str, Value); 4] = [
(
"index.json",
build_index_json(ctx, posts.len(), topics.len()),
),
("posts.json", build_posts_json(&posts)),
("topics.json", build_topics_json(&topics)),
("person.json", build_person_json(ctx, &posts)),
];
for (name, value) in docs {
let path = out_dir.join(name);
let body = to_pretty(&value, &path)?;
fs::write(&path, body).with_path(&path)?;
}
log::info!(
"[agent-api] Wrote 4 document(s) ({} post(s), {} topic(s)) to {}",
posts.len(),
topics.len(),
out_dir.display()
);
Ok(())
}
}
fn to_pretty(value: &Value, path: &Path) -> Result<String, SsgError> {
fail_point!("agent_api::to-pretty", |_| {
Err(SsgError::Io {
path: path.to_path_buf(),
source: std::io::Error::other("injected: agent_api::to-pretty"),
})
});
let mut body =
serde_json::to_string_pretty(value).map_err(|e| SsgError::Io {
path: path.to_path_buf(),
source: std::io::Error::other(e),
})?;
body.push('\n');
Ok(body)
}
#[must_use]
pub fn collect_posts(ctx: &PluginContext) -> Vec<PostEntry> {
let roots = [ctx.build_dir.join(".meta"), ctx.site_dir.join(".meta")];
let mut posts = Vec::new();
for root in &roots {
if root.is_dir() {
posts = collect_from_sidecar_dir(ctx, root);
if !posts.is_empty() {
break;
}
}
}
if posts.is_empty() {
posts = collect_from_site_dir(ctx);
}
posts.sort_by(|a, b| a.url.cmp(&b.url));
posts
}
fn collect_from_sidecar_dir(
ctx: &PluginContext,
meta_dir: &Path,
) -> Vec<PostEntry> {
let files = crate::walk::walk_files(meta_dir, "json").unwrap_or_default();
let mut posts = Vec::new();
for sidecar in &files {
if !is_meta_sidecar(sidecar) {
continue;
}
let rel_stem = sidecar
.strip_prefix(meta_dir)
.unwrap_or(sidecar)
.with_extension("")
.with_extension("");
if let Some(post) = read_post(ctx, sidecar, &rel_stem) {
posts.push(post);
}
}
posts
}
fn collect_from_site_dir(ctx: &PluginContext) -> Vec<PostEntry> {
let files =
crate::walk::walk_files(&ctx.site_dir, "json").unwrap_or_default();
let mut posts = Vec::new();
for sidecar in &files {
if !is_meta_sidecar(sidecar) {
continue;
}
let rel_stem = sidecar
.strip_prefix(&ctx.site_dir)
.unwrap_or(sidecar)
.with_extension("")
.with_extension("");
if rel_stem.starts_with(".meta") {
continue;
}
if let Some(post) = read_post(ctx, sidecar, &rel_stem) {
posts.push(post);
}
}
posts
}
fn is_meta_sidecar(path: &Path) -> bool {
path.file_name()
.is_some_and(|n| n.to_string_lossy().ends_with(".meta.json"))
}
fn resolve_page(
ctx: &PluginContext,
rel_stem: &Path,
) -> (String, std::path::PathBuf) {
let stem = rel_stem.to_string_lossy().replace('\\', "/");
let pretty = ctx.site_dir.join(rel_stem).join("index.html");
if pretty.exists() {
(format!("{stem}/"), pretty)
} else {
(
format!("{stem}.html"),
ctx.site_dir.join(format!("{stem}.html")),
)
}
}
fn read_post(
ctx: &PluginContext,
sidecar: &Path,
rel_stem: &Path,
) -> Option<PostEntry> {
let content = fs::read_to_string(sidecar).ok()?;
let meta: serde_json::Map<String, Value> =
serde_json::from_str(&content).ok()?;
if is_excluded(rel_stem, &meta) {
return None;
}
let title = str_field(&meta, "title")?;
if title.is_empty() {
return None;
}
let (rel_url, html_path) = resolve_page(ctx, rel_stem);
let base = base_url(ctx);
let url = if base.is_empty() {
format!("/{rel_url}")
} else {
format!("{base}/{rel_url}")
};
let description = str_field(&meta, "description")
.or_else(|| str_field(&meta, "excerpt"))
.or_else(|| str_field(&meta, "subtitle"))
.unwrap_or_default();
let locale = str_field(&meta, "locale")
.or_else(|| str_field(&meta, "language"))
.filter(|l| !l.is_empty())
.unwrap_or_else(|| site_language(ctx));
let word_count = resolve_word_count(&meta, &html_path);
Some(PostEntry {
title,
url,
date: str_field(&meta, "date").unwrap_or_default(),
description,
tags: terms_field(&meta, "tags"),
locale,
word_count,
author: str_field(&meta, "author").filter(|a| !a.is_empty()),
topic_clusters: terms_field(&meta, "topic_clusters"),
})
}
fn is_excluded(rel_stem: &Path, meta: &serde_json::Map<String, Value>) -> bool {
let file_name = rel_stem
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
if file_name == "404" || file_name.starts_with("error") {
return true;
}
truthy(meta.get("draft"))
|| truthy(meta.get("private"))
|| falsy(meta.get("published"))
}
fn truthy(v: Option<&Value>) -> bool {
match v {
Some(Value::Bool(b)) => *b,
Some(Value::String(s)) => {
matches!(s.to_lowercase().as_str(), "true" | "yes" | "1")
}
_ => false,
}
}
fn falsy(v: Option<&Value>) -> bool {
match v {
Some(Value::Bool(b)) => !*b,
Some(Value::String(s)) => {
matches!(s.to_lowercase().as_str(), "false" | "no" | "0")
}
_ => false,
}
}
fn str_field(
meta: &serde_json::Map<String, Value>,
key: &str,
) -> Option<String> {
meta.get(key).and_then(Value::as_str).map(str::to_string)
}
fn terms_field(
meta: &serde_json::Map<String, Value>,
key: &str,
) -> Vec<String> {
let mut terms: Vec<String> = Vec::new();
match meta.get(key) {
Some(Value::Array(arr)) => {
for item in arr {
if let Some(s) = item.as_str() {
push_terms(&mut terms, s);
}
}
}
Some(Value::String(s)) => push_terms(&mut terms, s),
_ => {}
}
terms.sort();
terms.dedup();
terms
}
fn push_terms(terms: &mut Vec<String>, raw: &str) {
for part in raw.split(',') {
let trimmed = part.trim();
if !trimmed.is_empty() {
terms.push(trimmed.to_string());
}
}
}
fn resolve_word_count(
meta: &serde_json::Map<String, Value>,
html_path: &Path,
) -> u64 {
if let Some(n) = meta.get("word_count").and_then(Value::as_u64) {
return n;
}
let Ok(html) = fs::read_to_string(html_path) else {
return 0;
};
if let Some(n) = jsonld_word_count(&html) {
return n;
}
ssg_core::strip_html_tags(&html).split_whitespace().count() as u64
}
#[must_use]
pub fn jsonld_word_count(html: &str) -> Option<u64> {
let mut rest = html;
while let Some(start) = rest.find("application/ld+json") {
let after = &rest[start..];
let open = after.find('>')?;
let body = &after[open + 1..];
let close = body.find("</script>")?;
if let Ok(v) = serde_json::from_str::<Value>(&body[..close]) {
if let Some(n) = v.get("wordCount").and_then(Value::as_u64) {
return Some(n);
}
}
rest = &body[close..];
}
None
}
fn base_url(ctx: &PluginContext) -> String {
ctx.config
.as_ref()
.map(|c| c.base_url.trim_end_matches('/').to_string())
.unwrap_or_default()
}
fn site_language(ctx: &PluginContext) -> String {
ctx.config
.as_ref()
.map(|c| c.language.clone())
.filter(|l| !l.is_empty())
.unwrap_or_else(|| "en".to_string())
}
#[must_use]
fn build_index_json(
ctx: &PluginContext,
post_count: usize,
topic_count: usize,
) -> Value {
let base = base_url(ctx);
let link = |doc: &str| -> String {
if base.is_empty() {
format!("/{API_DIR}/{doc}")
} else {
format!("{base}/{API_DIR}/{doc}")
}
};
let (name, title, description) = ctx.config.as_ref().map_or_else(
|| (String::new(), String::new(), String::new()),
|c| {
(
c.site_name.clone(),
c.site_title.clone(),
c.site_description.clone(),
)
},
);
json!({
"api": "ssg-agent-api",
"version": env!("CARGO_PKG_VERSION"),
"site": {
"name": name,
"title": title,
"description": description,
"language": site_language(ctx),
"url": base,
},
"counts": {
"posts": post_count,
"topics": topic_count,
},
"links": {
"index": link("index.json"),
"posts": link("posts.json"),
"topics": link("topics.json"),
"person": link("person.json"),
},
})
}
#[must_use]
fn build_posts_json(posts: &[PostEntry]) -> Value {
Value::Array(
posts
.iter()
.map(|p| {
json!({
"title": p.title,
"url": p.url,
"date": p.date,
"description": p.description,
"tags": p.tags,
"locale": p.locale,
"wordCount": p.word_count,
})
})
.collect(),
)
}
#[must_use]
fn build_topics_map(posts: &[PostEntry]) -> BTreeMap<String, Vec<String>> {
let mut topics: BTreeMap<String, Vec<String>> = BTreeMap::new();
for post in posts {
for term in post.tags.iter().chain(post.topic_clusters.iter()) {
let urls = topics.entry(term.clone()).or_default();
if !urls.contains(&post.url) {
urls.push(post.url.clone());
}
}
}
for urls in topics.values_mut() {
urls.sort();
}
topics
}
#[must_use]
fn build_topics_json(topics: &BTreeMap<String, Vec<String>>) -> Value {
let mut obj = serde_json::Map::new();
for (term, urls) in topics {
let _ = obj.insert(
term.clone(),
Value::Array(
urls.iter().map(|u| Value::String(u.clone())).collect(),
),
);
}
Value::Object(obj)
}
#[must_use]
fn build_person_json(ctx: &PluginContext, posts: &[PostEntry]) -> Value {
let raw = dominant_author(posts);
let (name, email) = raw.as_deref().map_or((None, None), parse_author);
let fallback_name = ctx
.config
.as_ref()
.map(|c| c.site_name.clone())
.unwrap_or_default();
let name = name.filter(|n| !n.is_empty()).unwrap_or(fallback_name);
let mut obj = serde_json::Map::new();
let _ = obj.insert(
"@context".to_string(),
Value::String("https://schema.org".to_string()),
);
let _ =
obj.insert("@type".to_string(), Value::String("Person".to_string()));
let _ = obj.insert("name".to_string(), Value::String(name));
if let Some(email) = email {
let _ = obj.insert("email".to_string(), Value::String(email));
}
let base = base_url(ctx);
if !base.is_empty() {
let _ = obj.insert("url".to_string(), Value::String(base));
}
Value::Object(obj)
}
fn dominant_author(posts: &[PostEntry]) -> Option<String> {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for post in posts {
if let Some(a) = post.author.as_deref() {
*counts.entry(a).or_insert(0) += 1;
}
}
counts
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(a.0)))
.map(|(a, _)| a.to_string())
}
#[must_use]
pub fn parse_author(raw: &str) -> (Option<String>, Option<String>) {
let raw = raw.trim();
if raw.is_empty() {
return (None, None);
}
if let (Some(open), Some(close)) = (raw.find('('), raw.rfind(')')) {
if open < close {
let name = raw[open + 1..close].trim();
let email = raw[..open].trim();
let email = email.contains('@').then(|| email.to_string());
let name = (!name.is_empty()).then(|| name.to_string());
if name.is_some() || email.is_some() {
return (name, email);
}
}
}
if let (Some(open), Some(close)) = (raw.find('<'), raw.rfind('>')) {
if open < close {
let email = raw[open + 1..close].trim();
let name = raw[..open].trim();
let email = email.contains('@').then(|| email.to_string());
let name = (!name.is_empty()).then(|| name.to_string());
if name.is_some() || email.is_some() {
return (name, email);
}
}
}
if raw.contains('@') && !raw.contains(' ') {
return (None, Some(raw.to_string()));
}
(Some(raw.to_string()), None)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cmd::SsgConfig;
use tempfile::{tempdir, TempDir};
fn make_ctx() -> (TempDir, PluginContext) {
let dir = tempdir().expect("tempdir");
let build = dir.path().join("build");
let site = dir.path().join("site");
fs::create_dir_all(build.join(".meta")).unwrap();
fs::create_dir_all(&site).unwrap();
let cfg = SsgConfig::builder()
.site_name("Example".to_string())
.base_url("https://example.com".to_string())
.build()
.expect("config");
let ctx = PluginContext::with_config(
dir.path(),
&build,
&site,
dir.path(),
cfg,
);
(dir, ctx)
}
fn write_sidecar(ctx: &PluginContext, name: &str, json: &str) {
let p = ctx.build_dir.join(".meta").join(name);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, json).unwrap();
}
fn read_doc(ctx: &PluginContext, name: &str) -> Value {
let body =
fs::read_to_string(ctx.site_dir.join(API_DIR).join(name)).unwrap();
serde_json::from_str(&body).unwrap()
}
#[test]
fn name_is_stable() {
assert_eq!(AgentApiPlugin::default().name(), "agent-api");
assert_eq!(AgentApiPlugin::new().name(), "agent-api");
}
#[test]
fn default_is_enabled_and_copyable() {
let p = AgentApiPlugin::default();
let copy = p;
assert!(copy.enabled);
assert!(format!("{p:?}").contains("AgentApiPlugin"));
}
#[test]
fn disabled_plugin_writes_nothing() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "a.meta.json", r#"{"title":"A"}"#);
AgentApiPlugin::disabled().after_compile(&ctx).unwrap();
assert!(!ctx.site_dir.join(API_DIR).exists());
}
#[test]
fn dry_run_writes_nothing() {
let (_tmp, ctx) = make_ctx();
let ctx = ctx.with_dry_run(true);
write_sidecar(&ctx, "a.meta.json", r#"{"title":"A"}"#);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
assert!(!ctx.site_dir.join(API_DIR).exists());
}
#[test]
fn missing_site_dir_is_noop() {
let dir = tempdir().unwrap();
let missing = dir.path().join("nope");
let ctx =
PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
AgentApiPlugin::default().after_compile(&ctx).unwrap();
assert!(!missing.exists());
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn emits_all_four_documents() {
let (_tmp, ctx) = make_ctx();
write_sidecar(
&ctx,
"hello.meta.json",
r#"{"title":"Hello","tags":["rust"],"word_count":10}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
for doc in ["index.json", "posts.json", "topics.json", "person.json"] {
assert!(
ctx.site_dir.join(API_DIR).join(doc).exists(),
"{doc} missing"
);
}
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn documents_end_with_newline() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "a.meta.json", r#"{"title":"A"}"#);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let body =
fs::read_to_string(ctx.site_dir.join(API_DIR).join("posts.json"))
.unwrap();
assert!(body.ends_with('\n'));
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn output_is_byte_identical_across_runs() {
let (_tmp, ctx) = make_ctx();
write_sidecar(
&ctx,
"a.meta.json",
r#"{"title":"A","tags":["z","a"],"word_count":5}"#,
);
write_sidecar(
&ctx,
"b.meta.json",
r#"{"title":"B","tags":"a, m","word_count":7}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let first =
fs::read_to_string(ctx.site_dir.join(API_DIR).join("topics.json"))
.unwrap();
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let second =
fs::read_to_string(ctx.site_dir.join(API_DIR).join("topics.json"))
.unwrap();
assert_eq!(first, second);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn posts_json_carries_all_tracker_fields() {
let (_tmp, ctx) = make_ctx();
write_sidecar(
&ctx,
"post.meta.json",
r#"{
"title": "Post",
"date": "2026-01-02",
"description": "Desc",
"tags": ["rust", "web"],
"locale": "en_GB",
"word_count": 123
}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
let p = &posts.as_array().unwrap()[0];
assert_eq!(p["title"], "Post");
assert_eq!(p["url"], "https://example.com/post.html");
assert_eq!(p["date"], "2026-01-02");
assert_eq!(p["description"], "Desc");
assert_eq!(p["tags"], json!(["rust", "web"]));
assert_eq!(p["locale"], "en_GB");
assert_eq!(p["wordCount"], 123);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn posts_sorted_by_url() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "zeta.meta.json", r#"{"title":"Z"}"#);
write_sidecar(&ctx, "alpha.meta.json", r#"{"title":"A"}"#);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
let urls: Vec<&str> = posts
.as_array()
.unwrap()
.iter()
.map(|p| p["url"].as_str().unwrap())
.collect();
assert_eq!(
urls,
vec![
"https://example.com/alpha.html",
"https://example.com/zeta.html"
]
);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn nested_sidecars_map_to_nested_urls() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "blog/deep.meta.json", r#"{"title":"Deep"}"#);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
assert_eq!(
posts.as_array().unwrap()[0]["url"],
"https://example.com/blog/deep.html"
);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn drafts_private_unpublished_and_error_pages_excluded() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "ok.meta.json", r#"{"title":"OK"}"#);
write_sidecar(&ctx, "draft.meta.json", r#"{"title":"D","draft":true}"#);
write_sidecar(
&ctx,
"draft2.meta.json",
r#"{"title":"D2","draft":"true"}"#,
);
write_sidecar(
&ctx,
"priv.meta.json",
r#"{"title":"P","private":"yes"}"#,
);
write_sidecar(
&ctx,
"unpub.meta.json",
r#"{"title":"U","published":false}"#,
);
write_sidecar(
&ctx,
"unpub2.meta.json",
r#"{"title":"U2","published":"false"}"#,
);
write_sidecar(&ctx, "404.meta.json", r#"{"title":"Not Found"}"#);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
assert_eq!(posts.as_array().unwrap().len(), 1);
assert_eq!(posts.as_array().unwrap()[0]["title"], "OK");
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn untitled_and_invalid_sidecars_skipped() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "no-title.meta.json", r#"{"date":"2026"}"#);
write_sidecar(&ctx, "empty-title.meta.json", r#"{"title":""}"#);
write_sidecar(&ctx, "broken.meta.json", "{not json");
write_sidecar(&ctx, "good.meta.json", r#"{"title":"G"}"#);
fs::write(ctx.build_dir.join(".meta/plain.json"), r#"{"title":"X"}"#)
.unwrap();
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
assert_eq!(posts.as_array().unwrap().len(), 1);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn comma_separated_string_tags_are_split_sorted_deduped() {
let (_tmp, ctx) = make_ctx();
write_sidecar(
&ctx,
"p.meta.json",
r#"{"title":"P","tags":"web, rust , rust,,"}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
assert_eq!(
posts.as_array().unwrap()[0]["tags"],
json!(["rust", "web"])
);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn locale_falls_back_language_then_site_then_en() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "a.meta.json", r#"{"title":"A","locale":"fr_FR"}"#);
write_sidecar(&ctx, "b.meta.json", r#"{"title":"B","language":"de"}"#);
write_sidecar(&ctx, "c.meta.json", r#"{"title":"C"}"#);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
let arr = posts.as_array().unwrap();
assert_eq!(arr[0]["locale"], "fr_FR");
assert_eq!(arr[1]["locale"], "de");
let site_lang = ctx.config.as_ref().unwrap().language.clone();
assert!(!site_lang.is_empty());
assert_eq!(arr[2]["locale"], *site_lang);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn word_count_prefers_sidecar_field() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "p.meta.json", r#"{"title":"P","word_count":77}"#);
fs::write(ctx.site_dir.join("p.html"), "<p>one two</p>").unwrap();
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
assert_eq!(posts.as_array().unwrap()[0]["wordCount"], 77);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn word_count_lifts_from_blogposting_jsonld() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
fs::write(
ctx.site_dir.join("p.html"),
r#"<html><head><script type="application/ld+json">
{"@type":"BlogPosting","wordCount":555}
</script></head><body><p>a b c</p></body></html>"#,
)
.unwrap();
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
assert_eq!(posts.as_array().unwrap()[0]["wordCount"], 555);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn word_count_falls_back_to_stripped_html() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
fs::write(
ctx.site_dir.join("p.html"),
"<html><body><p>one two three four</p></body></html>",
)
.unwrap();
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
assert_eq!(posts.as_array().unwrap()[0]["wordCount"], 4);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn word_count_zero_when_html_missing() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
assert_eq!(posts.as_array().unwrap()[0]["wordCount"], 0);
}
#[test]
fn jsonld_word_count_scans_past_blocks_without_field() {
let html = r#"
<script type="application/ld+json">{"@type":"WebSite"}</script>
<script type="application/ld+json">{"wordCount": 9}</script>
"#;
assert_eq!(jsonld_word_count(html), Some(9));
}
#[test]
fn jsonld_word_count_handles_malformed_json() {
let html = r#"<script type="application/ld+json">{oops</script>"#;
assert_eq!(jsonld_word_count(html), None);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn topics_map_terms_to_sorted_member_urls() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "z.meta.json", r#"{"title":"Z","tags":["rust"]}"#);
write_sidecar(
&ctx,
"a.meta.json",
r#"{"title":"A","tags":["rust","web"]}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let topics = read_doc(&ctx, "topics.json");
assert_eq!(
topics["rust"],
json!(["https://example.com/a.html", "https://example.com/z.html"])
);
assert_eq!(topics["web"], json!(["https://example.com/a.html"]));
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn topics_include_topic_clusters() {
let (_tmp, ctx) = make_ctx();
write_sidecar(
&ctx,
"p.meta.json",
r#"{"title":"P","topic_clusters":"cloud-native"}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let topics = read_doc(&ctx, "topics.json");
assert!(topics.get("cloud-native").is_some());
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn topics_keys_are_sorted() {
let (_tmp, ctx) = make_ctx();
write_sidecar(
&ctx,
"p.meta.json",
r#"{"title":"P","tags":["zeta","alpha","mid"]}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let body =
fs::read_to_string(ctx.site_dir.join(API_DIR).join("topics.json"))
.unwrap();
let a = body.find("\"alpha\"").unwrap();
let m = body.find("\"mid\"").unwrap();
let z = body.find("\"zeta\"").unwrap();
assert!(a < m && m < z, "keys must serialise sorted:\n{body}");
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn person_parses_email_paren_name_convention() {
let (_tmp, ctx) = make_ctx();
write_sidecar(
&ctx,
"p.meta.json",
r#"{"title":"P","author":"hello@threshold.press (Threshold)"}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let person = read_doc(&ctx, "person.json");
assert_eq!(person["@context"], "https://schema.org");
assert_eq!(person["@type"], "Person");
assert_eq!(person["name"], "Threshold");
assert_eq!(person["email"], "hello@threshold.press");
assert_eq!(person["url"], "https://example.com");
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn person_falls_back_to_site_name_without_authors() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let person = read_doc(&ctx, "person.json");
assert_eq!(person["name"], "Example");
assert!(person.get("email").is_none());
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn person_picks_most_frequent_author() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "a.meta.json", r#"{"title":"A","author":"Bob"}"#);
write_sidecar(&ctx, "b.meta.json", r#"{"title":"B","author":"Alice"}"#);
write_sidecar(&ctx, "c.meta.json", r#"{"title":"C","author":"Alice"}"#);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let person = read_doc(&ctx, "person.json");
assert_eq!(person["name"], "Alice");
}
#[test]
fn dominant_author_tie_breaks_lexicographically() {
let mk = |author: &str| PostEntry {
title: "T".into(),
url: "/t.html".into(),
date: String::new(),
description: String::new(),
tags: vec![],
locale: "en".into(),
word_count: 0,
author: Some(author.to_string()),
topic_clusters: vec![],
};
let posts = vec![mk("Zoe"), mk("Anna")];
assert_eq!(dominant_author(&posts).as_deref(), Some("Anna"));
}
#[test]
fn parse_author_table_driven() {
let cases: &[(&str, Option<&str>, Option<&str>)] = &[
("a@b.c (Name)", Some("Name"), Some("a@b.c")),
("Name <a@b.c>", Some("Name"), Some("a@b.c")),
("a@b.c", None, Some("a@b.c")),
("Just A Name", Some("Just A Name"), None),
("", None, None),
(" ", None, None),
("() ", Some("()"), None),
];
for &(input, name, email) in cases {
let (n, e) = parse_author(input);
assert_eq!(n.as_deref(), name, "name for {input:?}");
assert_eq!(e.as_deref(), email, "email for {input:?}");
}
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn index_carries_counts_and_absolute_links() {
let (_tmp, ctx) = make_ctx();
write_sidecar(
&ctx,
"a.meta.json",
r#"{"title":"A","tags":["rust","web"]}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let index = read_doc(&ctx, "index.json");
assert_eq!(index["api"], "ssg-agent-api");
assert_eq!(index["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(index["counts"]["posts"], 1);
assert_eq!(index["counts"]["topics"], 2);
assert_eq!(
index["links"]["posts"],
"https://example.com/api/agents/posts.json"
);
assert_eq!(
index["links"]["person"],
"https://example.com/api/agents/person.json"
);
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn no_config_uses_relative_links_and_en() {
let dir = tempdir().unwrap();
let build = dir.path().join("build");
let site = dir.path().join("site");
fs::create_dir_all(build.join(".meta")).unwrap();
fs::create_dir_all(&site).unwrap();
let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
fs::write(build.join(".meta/p.meta.json"), r#"{"title":"P"}"#).unwrap();
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let body =
fs::read_to_string(site.join(API_DIR).join("index.json")).unwrap();
let index: Value = serde_json::from_str(&body).unwrap();
assert_eq!(index["links"]["index"], "/api/agents/index.json");
assert_eq!(index["site"]["language"], "en");
let posts: Value = serde_json::from_str(
&fs::read_to_string(site.join(API_DIR).join("posts.json")).unwrap(),
)
.unwrap();
assert_eq!(posts.as_array().unwrap()[0]["url"], "/p.html");
}
#[test]
fn falls_back_to_site_dir_sidecars() {
let dir = tempdir().unwrap();
let site = dir.path().join("site");
fs::create_dir_all(&site).unwrap();
fs::write(site.join("p.meta.json"), r#"{"title":"P"}"#).unwrap();
fs::write(site.join("p.html"), "<p>x y</p>").unwrap();
let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
let posts = collect_posts(&ctx);
assert_eq!(posts.len(), 1);
assert_eq!(posts[0].title, "P");
assert_eq!(posts[0].word_count, 2);
}
#[test]
fn truthy_falsy_value_forms() {
assert!(truthy(Some(&json!(true))));
assert!(truthy(Some(&json!("yes"))));
assert!(truthy(Some(&json!("1"))));
assert!(!truthy(Some(&json!(false))));
assert!(!truthy(Some(&json!(0))));
assert!(!truthy(None));
assert!(falsy(Some(&json!(false))));
assert!(falsy(Some(&json!("no"))));
assert!(falsy(Some(&json!("0"))));
assert!(!falsy(Some(&json!(true))));
assert!(!falsy(None));
}
#[test]
fn after_compile_fails_when_api_dir_squatted_by_file() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
fs::write(ctx.site_dir.join("api"), "not a dir").unwrap();
let err = AgentApiPlugin::default().after_compile(&ctx).unwrap_err();
assert!(!format!("{err}").is_empty());
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn after_compile_fails_when_doc_path_squatted_by_dir() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
fs::create_dir_all(ctx.site_dir.join(API_DIR).join("index.json"))
.unwrap();
let err = AgentApiPlugin::default().after_compile(&ctx).unwrap_err();
assert!(!format!("{err}").is_empty());
}
#[test]
#[cfg(unix)]
fn unreadable_sidecar_is_skipped() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "ok.meta.json", r#"{"title":"OK"}"#);
write_sidecar(&ctx, "locked.meta.json", r#"{"title":"L"}"#);
let locked = ctx.build_dir.join(".meta/locked.meta.json");
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
.unwrap();
let posts = collect_posts(&ctx);
let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o644));
assert!(posts.iter().any(|p| p.title == "OK"));
}
#[test]
fn site_dir_walk_skips_meta_tree_and_drafts() {
let dir = tempdir().unwrap();
let build = dir.path().join("build");
let site = dir.path().join("site");
fs::create_dir_all(&build).unwrap();
fs::create_dir_all(site.join(".meta")).unwrap();
fs::write(
site.join(".meta/draft.meta.json"),
r#"{"title":"D","draft":true}"#,
)
.unwrap();
fs::write(site.join("ok.meta.json"), r#"{"title":"OK"}"#).unwrap();
fs::write(site.join("skip.meta.json"), r#"{"title":"S","draft":true}"#)
.unwrap();
let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
let posts = collect_posts(&ctx);
assert_eq!(posts.len(), 1);
assert_eq!(posts[0].title, "OK");
}
#[test]
fn terms_field_ignores_non_string_array_items() {
let meta: serde_json::Map<String, Value> =
serde_json::from_str(r#"{"tags": ["a", 42, null, "b"]}"#).unwrap();
assert_eq!(terms_field(&meta, "tags"), vec!["a", "b"]);
}
#[test]
fn jsonld_word_count_returns_none_without_tag_close() {
let html = "<script type=\"application/ld+json";
assert!(jsonld_word_count(html).is_none());
}
#[test]
fn jsonld_word_count_returns_none_without_script_close() {
let html = "<script type=\"application/ld+json\">{\"wordCount\": 3}";
assert!(jsonld_word_count(html).is_none());
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn topics_map_dedupes_term_shared_by_tags_and_clusters() {
let (_tmp, ctx) = make_ctx();
write_sidecar(
&ctx,
"p.meta.json",
r#"{"title":"P","tags":"x","topic_clusters":"x"}"#,
);
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let topics = read_doc(&ctx, "topics.json");
let urls = topics["x"].as_array().unwrap();
assert_eq!(urls.len(), 1, "shared term must not duplicate the URL");
}
#[test]
fn parse_author_paren_form_with_empty_parts_falls_through() {
let (name, email) = parse_author("()");
assert_eq!(name.as_deref(), Some("()"));
assert!(email.is_none());
}
#[test]
fn parse_author_angle_form_email_only() {
let (name, email) = parse_author("<jane@example.com>");
assert!(name.is_none());
assert_eq!(email.as_deref(), Some("jane@example.com"));
}
#[test]
fn parse_author_angle_form_with_empty_parts_falls_through() {
let (name, email) = parse_author("<>");
assert_eq!(name.as_deref(), Some("<>"));
assert!(email.is_none());
}
#[test]
#[serial_test::parallel(agent_api_failpoint)]
fn pretty_url_used_when_directory_index_html_exists() {
let (_tmp, ctx) = make_ctx();
write_sidecar(&ctx, "post.meta.json", r#"{"title":"Post"}"#);
fs::create_dir_all(ctx.site_dir.join("post")).unwrap();
fs::write(ctx.site_dir.join("post/index.html"), "<p>hi</p>").unwrap();
AgentApiPlugin::default().after_compile(&ctx).unwrap();
let posts = read_doc(&ctx, "posts.json");
assert_eq!(
posts.as_array().unwrap()[0]["url"],
"https://example.com/post/"
);
}
}
#[cfg(all(test, feature = "test-fault-injection"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod fault_tests {
use super::*;
use crate::cmd::SsgConfig;
use tempfile::tempdir;
struct FailGuard(&'static str);
impl Drop for FailGuard {
fn drop(&mut self) {
let _ = fail::cfg(self.0, "off");
}
}
#[test]
#[serial_test::serial(agent_api_failpoint)]
fn to_pretty_failpoint_propagates() {
let _guard = FailGuard("agent_api::to-pretty");
fail::cfg("agent_api::to-pretty", "return")
.expect("activate failpoint");
let dir = tempdir().unwrap();
let build = dir.path().join("build");
let site = dir.path().join("site");
fs::create_dir_all(build.join(".meta")).unwrap();
fs::create_dir_all(&site).unwrap();
let cfg = SsgConfig::builder()
.site_name("Example".to_string())
.build()
.expect("config");
let ctx = PluginContext::with_config(
dir.path(),
&build,
&site,
dir.path(),
cfg,
);
let err = AgentApiPlugin::default()
.after_compile(&ctx)
.expect_err("injected serialisation failure must propagate");
assert!(format!("{err:?}").contains("injected: agent_api::to-pretty"));
}
}