Skip to main content

ssg/plugins/
agent_api.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Agent JSON API emitter (issue #586, port 3 of 5).
5//!
6//! Port of the site's Python-pipeline `AgentApiPlugin` (~382 LOC): a
7//! stable, machine-readable JSON API for AI crawlers and agent
8//! toolchains. Complements [`crate::ai::AiPlugin`] (llms.txt /
9//! llms-full.txt) and the agentic-discovery emitters
10//! (`agents.txt` + `ai-plugin.json` + `mcp.json`, issue #552) with a
11//! *queryable* content surface rather than a prose one.
12//!
13//! ## Files emitted (under `<site>/api/agents/`)
14//!
15//! | File | Shape |
16//! | --- | --- |
17//! | `index.json`  | API descriptor: site identity, entry counts, absolute links to the other three documents |
18//! | `posts.json`  | Array of `{title, url, date, description, tags, locale, wordCount}` — one entry per public content page |
19//! | `topics.json` | Object mapping each tag / topic-cluster term to the sorted member post URLs |
20//! | `person.json` | JSON-LD (`schema.org`) `Person` entity for the site author |
21//!
22//! ## Data sources
23//!
24//! Frontmatter is read from the `.meta.json` sidecars the compiler
25//! emits under `<build>/.meta/` (the same convention consumed by
26//! [`crate::taxonomy::TaxonomyPlugin`] and the MCP `auto_resources`
27//! walker). When the build-dir sidecars are absent (ad-hoc pipelines),
28//! `*.meta.json` files next to the HTML in `site_dir` are used as a
29//! fallback.
30//!
31//! `wordCount` resolution order (per the tracker):
32//! 1. the sidecar's `word_count` field (stamped by
33//!    `frontmatter::emit_sidecars`),
34//! 2. a `wordCount` field lifted from a rendered
35//!    `BlogPosting`/`Article` JSON-LD block in the page HTML,
36//! 3. a manual word count over [`ssg_core::strip_html_tags`] output.
37//!
38//! ## Default-on and lifecycle
39//!
40//! Registered unconditionally by
41//! `pipeline::register_default_plugins` — the same *default-on, no
42//! `ssg.toml` knob* convention [`crate::ai::AiPlugin`] follows (the
43//! repo's other opt-in plugins gate by **not being registered**, e.g.
44//! [`crate::search_index::VectorSearchPlugin`]). Programmatic opt-out
45//! is available via [`AgentApiPlugin::disabled`]. Runs in
46//! `after_compile` and honours `ctx.dry_run`.
47//!
48//! ## Determinism
49//!
50//! Output must be byte-identical across rebuilds (`determinism.yml`
51//! hashes the site): posts are sorted by URL, tag lists are sorted +
52//! deduplicated, topics use a [`BTreeMap`], and `serde_json`'s default
53//! `BTreeMap`-backed object keeps key order canonical. No timestamps
54//! are embedded.
55
56use crate::error::{PathErrorExt, SsgError};
57use crate::plugin::{Plugin, PluginContext};
58use serde_json::{json, Value};
59use std::collections::BTreeMap;
60use std::fs;
61use std::path::Path;
62
63/// Directory (relative to the site root) the API documents land in.
64const API_DIR: &str = "api/agents";
65
66/// One public content page, normalised from its `.meta.json` sidecar.
67///
68/// # Examples
69///
70/// ```
71/// use ssg::agent_api::PostEntry;
72/// let p = PostEntry {
73///     title: "Hello".into(),
74///     url: "https://example.com/hello.html".into(),
75///     date: "2026-01-01".into(),
76///     description: "Greeting".into(),
77///     tags: vec!["rust".into()],
78///     locale: "en".into(),
79///     word_count: 42,
80///     author: None,
81///     topic_clusters: Vec::new(),
82/// };
83/// assert_eq!(p.word_count, 42);
84/// ```
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct PostEntry {
87    /// Page title (sidecar `title` — pages without one are skipped).
88    pub title: String,
89    /// Absolute URL (base URL + `/{rel}.html`).
90    pub url: String,
91    /// Publication date string, verbatim from frontmatter (may be empty).
92    pub date: String,
93    /// Meta description / excerpt / subtitle (may be empty).
94    pub description: String,
95    /// Sorted, deduplicated tag terms.
96    pub tags: Vec<String>,
97    /// BCP-47-ish locale (`locale` → `language` → site language → `en`).
98    pub locale: String,
99    /// Resolved word count (see module docs for the fallback chain).
100    pub word_count: u64,
101    /// Raw author string, used only to derive `person.json`.
102    pub author: Option<String>,
103    /// Sorted `topic_clusters` terms (merged into `topics.json`).
104    pub topic_clusters: Vec<String>,
105}
106
107/// Plugin that emits `/api/agents/{index,posts,topics,person}.json`.
108///
109/// # Examples
110///
111/// ```
112/// use ssg::agent_api::AgentApiPlugin;
113/// use ssg::plugin::Plugin;
114/// assert_eq!(AgentApiPlugin::default().name(), "agent-api");
115/// ```
116#[derive(Debug, Clone, Copy)]
117pub struct AgentApiPlugin {
118    enabled: bool,
119}
120
121impl Default for AgentApiPlugin {
122    /// Default-on, per the #586 tracker.
123    fn default() -> Self {
124        Self { enabled: true }
125    }
126}
127
128impl AgentApiPlugin {
129    /// Creates the default (enabled) plugin.
130    ///
131    /// # Examples
132    ///
133    /// ```
134    /// use ssg::agent_api::AgentApiPlugin;
135    /// let _p = AgentApiPlugin::new();
136    /// ```
137    #[must_use]
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Creates a disabled instance — the programmatic opt-out for
143    /// pipelines that must not emit the API surface.
144    ///
145    /// # Examples
146    ///
147    /// ```
148    /// use ssg::agent_api::AgentApiPlugin;
149    /// let _p = AgentApiPlugin::disabled();
150    /// ```
151    #[must_use]
152    pub const fn disabled() -> Self {
153        Self { enabled: false }
154    }
155}
156
157impl Plugin for AgentApiPlugin {
158    fn name(&self) -> &'static str {
159        "agent-api"
160    }
161
162    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
163        if !self.enabled || ctx.dry_run || !ctx.site_dir.exists() {
164            return Ok(());
165        }
166
167        let posts = collect_posts(ctx);
168        let topics = build_topics_map(&posts);
169
170        let out_dir = ctx.site_dir.join(API_DIR);
171        fs::create_dir_all(&out_dir).with_path(&out_dir)?;
172
173        let docs: [(&str, Value); 4] = [
174            (
175                "index.json",
176                build_index_json(ctx, posts.len(), topics.len()),
177            ),
178            ("posts.json", build_posts_json(&posts)),
179            ("topics.json", build_topics_json(&topics)),
180            ("person.json", build_person_json(ctx, &posts)),
181        ];
182
183        for (name, value) in docs {
184            let path = out_dir.join(name);
185            let body = to_pretty(&value, &path)?;
186            fs::write(&path, body).with_path(&path)?;
187        }
188
189        log::info!(
190            "[agent-api] Wrote 4 document(s) ({} post(s), {} topic(s)) to {}",
191            posts.len(),
192            topics.len(),
193            out_dir.display()
194        );
195        Ok(())
196    }
197}
198
199/// Serialises pretty JSON with a trailing newline (Git/editor friendly,
200/// byte-stable across rebuilds).
201fn to_pretty(value: &Value, path: &Path) -> Result<String, SsgError> {
202    fail_point!("agent_api::to-pretty", |_| {
203        Err(SsgError::Io {
204            path: path.to_path_buf(),
205            source: std::io::Error::other("injected: agent_api::to-pretty"),
206        })
207    });
208    let mut body =
209        serde_json::to_string_pretty(value).map_err(|e| SsgError::Io {
210            path: path.to_path_buf(),
211            source: std::io::Error::other(e),
212        })?;
213    body.push('\n');
214    Ok(body)
215}
216
217// =====================================================================
218// Collection — sidecar walk + word-count fallback chain
219// =====================================================================
220
221/// Collects public posts from `.meta.json` sidecars.
222///
223/// Sidecar roots are tried in priority order: `<build>/.meta/` (the
224/// `emit_sidecars` convention), `<site>/.meta/` (the staged copy the
225/// real pipeline leaves in the output — the audited demo site's
226/// layout), then `*.meta.json` files sitting next to their HTML in
227/// `site_dir` (the layout `ai.rs` consumes). Results are sorted by
228/// URL for deterministic output.
229///
230/// # Examples
231///
232/// ```
233/// use ssg::agent_api::collect_posts;
234/// use ssg::plugin::PluginContext;
235/// let tmp = tempfile::tempdir().unwrap();
236/// let ctx = PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
237/// assert!(collect_posts(&ctx).is_empty());
238/// ```
239#[must_use]
240pub fn collect_posts(ctx: &PluginContext) -> Vec<PostEntry> {
241    let roots = [ctx.build_dir.join(".meta"), ctx.site_dir.join(".meta")];
242    let mut posts = Vec::new();
243    for root in &roots {
244        if root.is_dir() {
245            posts = collect_from_sidecar_dir(ctx, root);
246            if !posts.is_empty() {
247                break;
248            }
249        }
250    }
251    if posts.is_empty() {
252        posts = collect_from_site_dir(ctx);
253    }
254    posts.sort_by(|a, b| a.url.cmp(&b.url));
255    posts
256}
257
258/// Walks `<root>/**.meta.json` sidecars (a dedicated sidecar tree).
259fn collect_from_sidecar_dir(
260    ctx: &PluginContext,
261    meta_dir: &Path,
262) -> Vec<PostEntry> {
263    let files = crate::walk::walk_files(meta_dir, "json").unwrap_or_default();
264    let mut posts = Vec::new();
265    for sidecar in &files {
266        if !is_meta_sidecar(sidecar) {
267            continue;
268        }
269        let rel_stem = sidecar
270            .strip_prefix(meta_dir)
271            .unwrap_or(sidecar)
272            .with_extension("")
273            .with_extension("");
274        if let Some(post) = read_post(ctx, sidecar, &rel_stem) {
275            posts.push(post);
276        }
277    }
278    posts
279}
280
281/// Walks `site_dir` for `*.meta.json` files sitting next to their
282/// HTML. Skips the `.meta/` staging tree — that layout is handled by
283/// [`collect_from_sidecar_dir`].
284fn collect_from_site_dir(ctx: &PluginContext) -> Vec<PostEntry> {
285    let files =
286        crate::walk::walk_files(&ctx.site_dir, "json").unwrap_or_default();
287    let mut posts = Vec::new();
288    for sidecar in &files {
289        if !is_meta_sidecar(sidecar) {
290            continue;
291        }
292        let rel_stem = sidecar
293            .strip_prefix(&ctx.site_dir)
294            .unwrap_or(sidecar)
295            .with_extension("")
296            .with_extension("");
297        if rel_stem.starts_with(".meta") {
298            continue;
299        }
300        if let Some(post) = read_post(ctx, sidecar, &rel_stem) {
301            posts.push(post);
302        }
303    }
304    posts
305}
306
307/// Returns `true` for `<stem>.meta.json` file names.
308fn is_meta_sidecar(path: &Path) -> bool {
309    path.file_name()
310        .is_some_and(|n| n.to_string_lossy().ends_with(".meta.json"))
311}
312
313/// Resolves a sidecar stem (`blog/post`) to its site-relative URL
314/// path and on-disk HTML file. Pretty URLs win when the compiled
315/// output is directory-shaped (`<stem>/index.html` → `/<stem>/`);
316/// otherwise the flat `/<stem>.html` form is used.
317fn resolve_page(
318    ctx: &PluginContext,
319    rel_stem: &Path,
320) -> (String, std::path::PathBuf) {
321    let stem = rel_stem.to_string_lossy().replace('\\', "/");
322    let pretty = ctx.site_dir.join(rel_stem).join("index.html");
323    if pretty.exists() {
324        (format!("{stem}/"), pretty)
325    } else {
326        (
327            format!("{stem}.html"),
328            ctx.site_dir.join(format!("{stem}.html")),
329        )
330    }
331}
332
333/// Parses one sidecar into a [`PostEntry`], or `None` when the page is
334/// not public (draft/private/unpublished/error page/untitled).
335fn read_post(
336    ctx: &PluginContext,
337    sidecar: &Path,
338    rel_stem: &Path,
339) -> Option<PostEntry> {
340    let content = fs::read_to_string(sidecar).ok()?;
341    let meta: serde_json::Map<String, Value> =
342        serde_json::from_str(&content).ok()?;
343
344    if is_excluded(rel_stem, &meta) {
345        return None;
346    }
347
348    let title = str_field(&meta, "title")?;
349    if title.is_empty() {
350        return None;
351    }
352
353    let (rel_url, html_path) = resolve_page(ctx, rel_stem);
354    let base = base_url(ctx);
355    let url = if base.is_empty() {
356        format!("/{rel_url}")
357    } else {
358        format!("{base}/{rel_url}")
359    };
360
361    let description = str_field(&meta, "description")
362        .or_else(|| str_field(&meta, "excerpt"))
363        .or_else(|| str_field(&meta, "subtitle"))
364        .unwrap_or_default();
365
366    let locale = str_field(&meta, "locale")
367        .or_else(|| str_field(&meta, "language"))
368        .filter(|l| !l.is_empty())
369        .unwrap_or_else(|| site_language(ctx));
370
371    let word_count = resolve_word_count(&meta, &html_path);
372
373    Some(PostEntry {
374        title,
375        url,
376        date: str_field(&meta, "date").unwrap_or_default(),
377        description,
378        tags: terms_field(&meta, "tags"),
379        locale,
380        word_count,
381        author: str_field(&meta, "author").filter(|a| !a.is_empty()),
382        topic_clusters: terms_field(&meta, "topic_clusters"),
383    })
384}
385
386/// Mirrors the exclusion rules `ai.rs` applies to `llms.txt`: error
387/// pages, drafts, private and unpublished pages never surface.
388fn is_excluded(rel_stem: &Path, meta: &serde_json::Map<String, Value>) -> bool {
389    let file_name = rel_stem
390        .file_name()
391        .map(|n| n.to_string_lossy().to_lowercase())
392        .unwrap_or_default();
393    if file_name == "404" || file_name.starts_with("error") {
394        return true;
395    }
396    truthy(meta.get("draft"))
397        || truthy(meta.get("private"))
398        || falsy(meta.get("published"))
399}
400
401/// `true` / `"true"` / `"yes"` / `"1"` (bool or string form).
402fn truthy(v: Option<&Value>) -> bool {
403    match v {
404        Some(Value::Bool(b)) => *b,
405        Some(Value::String(s)) => {
406            matches!(s.to_lowercase().as_str(), "true" | "yes" | "1")
407        }
408        _ => false,
409    }
410}
411
412/// Explicit `published: false` (bool or string form).
413fn falsy(v: Option<&Value>) -> bool {
414    match v {
415        Some(Value::Bool(b)) => !*b,
416        Some(Value::String(s)) => {
417            matches!(s.to_lowercase().as_str(), "false" | "no" | "0")
418        }
419        _ => false,
420    }
421}
422
423/// Extracts a string field from the sidecar map.
424fn str_field(
425    meta: &serde_json::Map<String, Value>,
426    key: &str,
427) -> Option<String> {
428    meta.get(key).and_then(Value::as_str).map(str::to_string)
429}
430
431/// Extracts taxonomy terms from either an array of strings or a
432/// comma-separated string — the two frontmatter shapes in the wild
433/// (the bundled examples use `tags: "a, b, c"`). Sorted + deduped.
434fn terms_field(
435    meta: &serde_json::Map<String, Value>,
436    key: &str,
437) -> Vec<String> {
438    let mut terms: Vec<String> = Vec::new();
439    match meta.get(key) {
440        Some(Value::Array(arr)) => {
441            for item in arr {
442                if let Some(s) = item.as_str() {
443                    push_terms(&mut terms, s);
444                }
445            }
446        }
447        Some(Value::String(s)) => push_terms(&mut terms, s),
448        _ => {}
449    }
450    terms.sort();
451    terms.dedup();
452    terms
453}
454
455/// Splits on commas, trims, drops empties.
456fn push_terms(terms: &mut Vec<String>, raw: &str) {
457    for part in raw.split(',') {
458        let trimmed = part.trim();
459        if !trimmed.is_empty() {
460            terms.push(trimmed.to_string());
461        }
462    }
463}
464
465/// Resolves the word count via the fallback chain documented on the
466/// module: sidecar `word_count` → rendered JSON-LD `wordCount` →
467/// manual count over the stripped HTML.
468fn resolve_word_count(
469    meta: &serde_json::Map<String, Value>,
470    html_path: &Path,
471) -> u64 {
472    if let Some(n) = meta.get("word_count").and_then(Value::as_u64) {
473        return n;
474    }
475    let Ok(html) = fs::read_to_string(html_path) else {
476        return 0;
477    };
478    if let Some(n) = jsonld_word_count(&html) {
479        return n;
480    }
481    ssg_core::strip_html_tags(&html).split_whitespace().count() as u64
482}
483
484/// Lifts `wordCount` from any `<script type="application/ld+json">`
485/// block in the page (`BlogPosting` / `Article` emit it in the Python
486/// original this port mirrors).
487///
488/// # Examples
489///
490/// ```
491/// use ssg::agent_api::jsonld_word_count;
492/// let html = r#"<script type="application/ld+json">
493///   {"@type":"BlogPosting","wordCount":321}
494/// </script>"#;
495/// assert_eq!(jsonld_word_count(html), Some(321));
496/// assert_eq!(jsonld_word_count("<p>no jsonld</p>"), None);
497/// ```
498#[must_use]
499pub fn jsonld_word_count(html: &str) -> Option<u64> {
500    let mut rest = html;
501    while let Some(start) = rest.find("application/ld+json") {
502        let after = &rest[start..];
503        let open = after.find('>')?;
504        let body = &after[open + 1..];
505        let close = body.find("</script>")?;
506        if let Ok(v) = serde_json::from_str::<Value>(&body[..close]) {
507            if let Some(n) = v.get("wordCount").and_then(Value::as_u64) {
508                return Some(n);
509            }
510        }
511        rest = &body[close..];
512    }
513    None
514}
515
516// =====================================================================
517// Document builders — pure functions, unit-testable without I/O
518// =====================================================================
519
520/// Trimmed base URL from the context config (empty when no config).
521fn base_url(ctx: &PluginContext) -> String {
522    ctx.config
523        .as_ref()
524        .map(|c| c.base_url.trim_end_matches('/').to_string())
525        .unwrap_or_default()
526}
527
528/// Site language, defaulting to `en`.
529fn site_language(ctx: &PluginContext) -> String {
530    ctx.config
531        .as_ref()
532        .map(|c| c.language.clone())
533        .filter(|l| !l.is_empty())
534        .unwrap_or_else(|| "en".to_string())
535}
536
537/// Builds `index.json` — the API descriptor with counts and absolute
538/// links to the other three documents.
539#[must_use]
540fn build_index_json(
541    ctx: &PluginContext,
542    post_count: usize,
543    topic_count: usize,
544) -> Value {
545    let base = base_url(ctx);
546    let link = |doc: &str| -> String {
547        if base.is_empty() {
548            format!("/{API_DIR}/{doc}")
549        } else {
550            format!("{base}/{API_DIR}/{doc}")
551        }
552    };
553    let (name, title, description) = ctx.config.as_ref().map_or_else(
554        || (String::new(), String::new(), String::new()),
555        |c| {
556            (
557                c.site_name.clone(),
558                c.site_title.clone(),
559                c.site_description.clone(),
560            )
561        },
562    );
563
564    json!({
565        "api": "ssg-agent-api",
566        "version": env!("CARGO_PKG_VERSION"),
567        "site": {
568            "name": name,
569            "title": title,
570            "description": description,
571            "language": site_language(ctx),
572            "url": base,
573        },
574        "counts": {
575            "posts": post_count,
576            "topics": topic_count,
577        },
578        "links": {
579            "index": link("index.json"),
580            "posts": link("posts.json"),
581            "topics": link("topics.json"),
582            "person": link("person.json"),
583        },
584    })
585}
586
587/// Builds `posts.json` — a bare array (the tracker-specified shape).
588#[must_use]
589fn build_posts_json(posts: &[PostEntry]) -> Value {
590    Value::Array(
591        posts
592            .iter()
593            .map(|p| {
594                json!({
595                    "title": p.title,
596                    "url": p.url,
597                    "date": p.date,
598                    "description": p.description,
599                    "tags": p.tags,
600                    "locale": p.locale,
601                    "wordCount": p.word_count,
602                })
603            })
604            .collect(),
605    )
606}
607
608/// Builds the term → member-URL map from tags and topic clusters.
609/// `BTreeMap` keeps term iteration (and therefore output) sorted.
610#[must_use]
611fn build_topics_map(posts: &[PostEntry]) -> BTreeMap<String, Vec<String>> {
612    let mut topics: BTreeMap<String, Vec<String>> = BTreeMap::new();
613    for post in posts {
614        for term in post.tags.iter().chain(post.topic_clusters.iter()) {
615            let urls = topics.entry(term.clone()).or_default();
616            if !urls.contains(&post.url) {
617                urls.push(post.url.clone());
618            }
619        }
620    }
621    for urls in topics.values_mut() {
622        urls.sort();
623    }
624    topics
625}
626
627/// Builds `topics.json` — object mapping each term to its member URLs.
628#[must_use]
629fn build_topics_json(topics: &BTreeMap<String, Vec<String>>) -> Value {
630    let mut obj = serde_json::Map::new();
631    for (term, urls) in topics {
632        let _ = obj.insert(
633            term.clone(),
634            Value::Array(
635                urls.iter().map(|u| Value::String(u.clone())).collect(),
636            ),
637        );
638    }
639    Value::Object(obj)
640}
641
642/// Builds `person.json` — a JSON-LD `schema.org/Person` entity for the
643/// site author.
644///
645/// The author string is taken from the most frequent `author`
646/// frontmatter value across public posts (ties broken
647/// lexicographically for determinism), parsed from either
648/// `email (Name)` or `Name <email>` form. When no post declares an
649/// author the site name stands in.
650#[must_use]
651fn build_person_json(ctx: &PluginContext, posts: &[PostEntry]) -> Value {
652    let raw = dominant_author(posts);
653    let (name, email) = raw.as_deref().map_or((None, None), parse_author);
654
655    let fallback_name = ctx
656        .config
657        .as_ref()
658        .map(|c| c.site_name.clone())
659        .unwrap_or_default();
660    let name = name.filter(|n| !n.is_empty()).unwrap_or(fallback_name);
661
662    let mut obj = serde_json::Map::new();
663    let _ = obj.insert(
664        "@context".to_string(),
665        Value::String("https://schema.org".to_string()),
666    );
667    let _ =
668        obj.insert("@type".to_string(), Value::String("Person".to_string()));
669    let _ = obj.insert("name".to_string(), Value::String(name));
670    if let Some(email) = email {
671        let _ = obj.insert("email".to_string(), Value::String(email));
672    }
673    let base = base_url(ctx);
674    if !base.is_empty() {
675        let _ = obj.insert("url".to_string(), Value::String(base));
676    }
677    Value::Object(obj)
678}
679
680/// Most frequent author string across posts (ties: lexicographically
681/// smallest — deterministic across filesystem walk orders).
682fn dominant_author(posts: &[PostEntry]) -> Option<String> {
683    let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
684    for post in posts {
685        if let Some(a) = post.author.as_deref() {
686            *counts.entry(a).or_insert(0) += 1;
687        }
688    }
689    counts
690        .into_iter()
691        .max_by(|a, b| a.1.cmp(&b.1).then(b.0.cmp(a.0)))
692        .map(|(a, _)| a.to_string())
693}
694
695/// Parses `email (Name)` and `Name <email>` author conventions.
696///
697/// # Examples
698///
699/// ```
700/// use ssg::agent_api::parse_author;
701/// let (name, email) = parse_author("[email protected] (Jane Doe)");
702/// assert_eq!(name.as_deref(), Some("Jane Doe"));
703/// assert_eq!(email.as_deref(), Some("[email protected]"));
704///
705/// let (name, email) = parse_author("Jane Doe <[email protected]>");
706/// assert_eq!(name.as_deref(), Some("Jane Doe"));
707/// assert_eq!(email.as_deref(), Some("[email protected]"));
708///
709/// let (name, email) = parse_author("Jane Doe");
710/// assert_eq!(name.as_deref(), Some("Jane Doe"));
711/// assert!(email.is_none());
712/// ```
713#[must_use]
714pub fn parse_author(raw: &str) -> (Option<String>, Option<String>) {
715    let raw = raw.trim();
716    if raw.is_empty() {
717        return (None, None);
718    }
719    // `email (Name)` — the convention the bundled examples use.
720    if let (Some(open), Some(close)) = (raw.find('('), raw.rfind(')')) {
721        if open < close {
722            let name = raw[open + 1..close].trim();
723            let email = raw[..open].trim();
724            let email = email.contains('@').then(|| email.to_string());
725            let name = (!name.is_empty()).then(|| name.to_string());
726            if name.is_some() || email.is_some() {
727                return (name, email);
728            }
729        }
730    }
731    // `Name <email>` — the RFC 5322-ish convention.
732    if let (Some(open), Some(close)) = (raw.find('<'), raw.rfind('>')) {
733        if open < close {
734            let email = raw[open + 1..close].trim();
735            let name = raw[..open].trim();
736            let email = email.contains('@').then(|| email.to_string());
737            let name = (!name.is_empty()).then(|| name.to_string());
738            if name.is_some() || email.is_some() {
739                return (name, email);
740            }
741        }
742    }
743    if raw.contains('@') && !raw.contains(' ') {
744        return (None, Some(raw.to_string()));
745    }
746    (Some(raw.to_string()), None)
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752    use crate::cmd::SsgConfig;
753    use tempfile::{tempdir, TempDir};
754
755    // -----------------------------------------------------------------
756    // Fixtures
757    // -----------------------------------------------------------------
758
759    fn make_ctx() -> (TempDir, PluginContext) {
760        let dir = tempdir().expect("tempdir");
761        let build = dir.path().join("build");
762        let site = dir.path().join("site");
763        fs::create_dir_all(build.join(".meta")).unwrap();
764        fs::create_dir_all(&site).unwrap();
765        let cfg = SsgConfig::builder()
766            .site_name("Example".to_string())
767            .base_url("https://example.com".to_string())
768            .build()
769            .expect("config");
770        let ctx = PluginContext::with_config(
771            dir.path(),
772            &build,
773            &site,
774            dir.path(),
775            cfg,
776        );
777        (dir, ctx)
778    }
779
780    fn write_sidecar(ctx: &PluginContext, name: &str, json: &str) {
781        let p = ctx.build_dir.join(".meta").join(name);
782        // `p` always has a parent (it was built via join).
783        fs::create_dir_all(p.parent().unwrap()).unwrap();
784        fs::write(p, json).unwrap();
785    }
786
787    fn read_doc(ctx: &PluginContext, name: &str) -> Value {
788        let body =
789            fs::read_to_string(ctx.site_dir.join(API_DIR).join(name)).unwrap();
790        serde_json::from_str(&body).unwrap()
791    }
792
793    // -----------------------------------------------------------------
794    // Plugin surface
795    // -----------------------------------------------------------------
796
797    #[test]
798    fn name_is_stable() {
799        assert_eq!(AgentApiPlugin::default().name(), "agent-api");
800        assert_eq!(AgentApiPlugin::new().name(), "agent-api");
801    }
802
803    #[test]
804    fn default_is_enabled_and_copyable() {
805        let p = AgentApiPlugin::default();
806        let copy = p;
807        assert!(copy.enabled);
808        assert!(format!("{p:?}").contains("AgentApiPlugin"));
809    }
810
811    #[test]
812    fn disabled_plugin_writes_nothing() {
813        let (_tmp, ctx) = make_ctx();
814        write_sidecar(&ctx, "a.meta.json", r#"{"title":"A"}"#);
815        AgentApiPlugin::disabled().after_compile(&ctx).unwrap();
816        assert!(!ctx.site_dir.join(API_DIR).exists());
817    }
818
819    #[test]
820    fn dry_run_writes_nothing() {
821        let (_tmp, ctx) = make_ctx();
822        let ctx = ctx.with_dry_run(true);
823        write_sidecar(&ctx, "a.meta.json", r#"{"title":"A"}"#);
824        AgentApiPlugin::default().after_compile(&ctx).unwrap();
825        assert!(!ctx.site_dir.join(API_DIR).exists());
826    }
827
828    #[test]
829    fn missing_site_dir_is_noop() {
830        let dir = tempdir().unwrap();
831        let missing = dir.path().join("nope");
832        let ctx =
833            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
834        AgentApiPlugin::default().after_compile(&ctx).unwrap();
835        assert!(!missing.exists());
836    }
837
838    #[test]
839    #[serial_test::parallel(agent_api_failpoint)]
840    fn emits_all_four_documents() {
841        let (_tmp, ctx) = make_ctx();
842        write_sidecar(
843            &ctx,
844            "hello.meta.json",
845            r#"{"title":"Hello","tags":["rust"],"word_count":10}"#,
846        );
847        AgentApiPlugin::default().after_compile(&ctx).unwrap();
848        for doc in ["index.json", "posts.json", "topics.json", "person.json"] {
849            assert!(
850                ctx.site_dir.join(API_DIR).join(doc).exists(),
851                "{doc} missing"
852            );
853        }
854    }
855
856    #[test]
857    #[serial_test::parallel(agent_api_failpoint)]
858    fn documents_end_with_newline() {
859        let (_tmp, ctx) = make_ctx();
860        write_sidecar(&ctx, "a.meta.json", r#"{"title":"A"}"#);
861        AgentApiPlugin::default().after_compile(&ctx).unwrap();
862        let body =
863            fs::read_to_string(ctx.site_dir.join(API_DIR).join("posts.json"))
864                .unwrap();
865        assert!(body.ends_with('\n'));
866    }
867
868    #[test]
869    #[serial_test::parallel(agent_api_failpoint)]
870    fn output_is_byte_identical_across_runs() {
871        let (_tmp, ctx) = make_ctx();
872        write_sidecar(
873            &ctx,
874            "a.meta.json",
875            r#"{"title":"A","tags":["z","a"],"word_count":5}"#,
876        );
877        write_sidecar(
878            &ctx,
879            "b.meta.json",
880            r#"{"title":"B","tags":"a, m","word_count":7}"#,
881        );
882        AgentApiPlugin::default().after_compile(&ctx).unwrap();
883        let first =
884            fs::read_to_string(ctx.site_dir.join(API_DIR).join("topics.json"))
885                .unwrap();
886        AgentApiPlugin::default().after_compile(&ctx).unwrap();
887        let second =
888            fs::read_to_string(ctx.site_dir.join(API_DIR).join("topics.json"))
889                .unwrap();
890        assert_eq!(first, second);
891    }
892
893    // -----------------------------------------------------------------
894    // posts.json shape
895    // -----------------------------------------------------------------
896
897    #[test]
898    #[serial_test::parallel(agent_api_failpoint)]
899    fn posts_json_carries_all_tracker_fields() {
900        let (_tmp, ctx) = make_ctx();
901        write_sidecar(
902            &ctx,
903            "post.meta.json",
904            r#"{
905                "title": "Post",
906                "date": "2026-01-02",
907                "description": "Desc",
908                "tags": ["rust", "web"],
909                "locale": "en_GB",
910                "word_count": 123
911            }"#,
912        );
913        AgentApiPlugin::default().after_compile(&ctx).unwrap();
914        let posts = read_doc(&ctx, "posts.json");
915        let p = &posts.as_array().unwrap()[0];
916        assert_eq!(p["title"], "Post");
917        assert_eq!(p["url"], "https://example.com/post.html");
918        assert_eq!(p["date"], "2026-01-02");
919        assert_eq!(p["description"], "Desc");
920        assert_eq!(p["tags"], json!(["rust", "web"]));
921        assert_eq!(p["locale"], "en_GB");
922        assert_eq!(p["wordCount"], 123);
923    }
924
925    #[test]
926    #[serial_test::parallel(agent_api_failpoint)]
927    fn posts_sorted_by_url() {
928        let (_tmp, ctx) = make_ctx();
929        write_sidecar(&ctx, "zeta.meta.json", r#"{"title":"Z"}"#);
930        write_sidecar(&ctx, "alpha.meta.json", r#"{"title":"A"}"#);
931        AgentApiPlugin::default().after_compile(&ctx).unwrap();
932        let posts = read_doc(&ctx, "posts.json");
933        let urls: Vec<&str> = posts
934            .as_array()
935            .unwrap()
936            .iter()
937            .map(|p| p["url"].as_str().unwrap())
938            .collect();
939        assert_eq!(
940            urls,
941            vec![
942                "https://example.com/alpha.html",
943                "https://example.com/zeta.html"
944            ]
945        );
946    }
947
948    #[test]
949    #[serial_test::parallel(agent_api_failpoint)]
950    fn nested_sidecars_map_to_nested_urls() {
951        let (_tmp, ctx) = make_ctx();
952        write_sidecar(&ctx, "blog/deep.meta.json", r#"{"title":"Deep"}"#);
953        AgentApiPlugin::default().after_compile(&ctx).unwrap();
954        let posts = read_doc(&ctx, "posts.json");
955        assert_eq!(
956            posts.as_array().unwrap()[0]["url"],
957            "https://example.com/blog/deep.html"
958        );
959    }
960
961    #[test]
962    #[serial_test::parallel(agent_api_failpoint)]
963    fn drafts_private_unpublished_and_error_pages_excluded() {
964        let (_tmp, ctx) = make_ctx();
965        write_sidecar(&ctx, "ok.meta.json", r#"{"title":"OK"}"#);
966        write_sidecar(&ctx, "draft.meta.json", r#"{"title":"D","draft":true}"#);
967        write_sidecar(
968            &ctx,
969            "draft2.meta.json",
970            r#"{"title":"D2","draft":"true"}"#,
971        );
972        write_sidecar(
973            &ctx,
974            "priv.meta.json",
975            r#"{"title":"P","private":"yes"}"#,
976        );
977        write_sidecar(
978            &ctx,
979            "unpub.meta.json",
980            r#"{"title":"U","published":false}"#,
981        );
982        write_sidecar(
983            &ctx,
984            "unpub2.meta.json",
985            r#"{"title":"U2","published":"false"}"#,
986        );
987        write_sidecar(&ctx, "404.meta.json", r#"{"title":"Not Found"}"#);
988        AgentApiPlugin::default().after_compile(&ctx).unwrap();
989        let posts = read_doc(&ctx, "posts.json");
990        assert_eq!(posts.as_array().unwrap().len(), 1);
991        assert_eq!(posts.as_array().unwrap()[0]["title"], "OK");
992    }
993
994    #[test]
995    #[serial_test::parallel(agent_api_failpoint)]
996    fn untitled_and_invalid_sidecars_skipped() {
997        let (_tmp, ctx) = make_ctx();
998        write_sidecar(&ctx, "no-title.meta.json", r#"{"date":"2026"}"#);
999        write_sidecar(&ctx, "empty-title.meta.json", r#"{"title":""}"#);
1000        write_sidecar(&ctx, "broken.meta.json", "{not json");
1001        write_sidecar(&ctx, "good.meta.json", r#"{"title":"G"}"#);
1002        // Non-sidecar JSON must be ignored.
1003        fs::write(ctx.build_dir.join(".meta/plain.json"), r#"{"title":"X"}"#)
1004            .unwrap();
1005        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1006        let posts = read_doc(&ctx, "posts.json");
1007        assert_eq!(posts.as_array().unwrap().len(), 1);
1008    }
1009
1010    #[test]
1011    #[serial_test::parallel(agent_api_failpoint)]
1012    fn comma_separated_string_tags_are_split_sorted_deduped() {
1013        let (_tmp, ctx) = make_ctx();
1014        write_sidecar(
1015            &ctx,
1016            "p.meta.json",
1017            r#"{"title":"P","tags":"web, rust , rust,,"}"#,
1018        );
1019        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1020        let posts = read_doc(&ctx, "posts.json");
1021        assert_eq!(
1022            posts.as_array().unwrap()[0]["tags"],
1023            json!(["rust", "web"])
1024        );
1025    }
1026
1027    #[test]
1028    #[serial_test::parallel(agent_api_failpoint)]
1029    fn locale_falls_back_language_then_site_then_en() {
1030        let (_tmp, ctx) = make_ctx();
1031        write_sidecar(&ctx, "a.meta.json", r#"{"title":"A","locale":"fr_FR"}"#);
1032        write_sidecar(&ctx, "b.meta.json", r#"{"title":"B","language":"de"}"#);
1033        write_sidecar(&ctx, "c.meta.json", r#"{"title":"C"}"#);
1034        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1035        let posts = read_doc(&ctx, "posts.json");
1036        let arr = posts.as_array().unwrap();
1037        assert_eq!(arr[0]["locale"], "fr_FR");
1038        assert_eq!(arr[1]["locale"], "de");
1039        // SsgConfig::builder always supplies a non-empty default
1040        // language, so the site language is the expected fallback.
1041        let site_lang = ctx.config.as_ref().unwrap().language.clone();
1042        assert!(!site_lang.is_empty());
1043        assert_eq!(arr[2]["locale"], *site_lang);
1044    }
1045
1046    // -----------------------------------------------------------------
1047    // wordCount fallback chain
1048    // -----------------------------------------------------------------
1049
1050    #[test]
1051    #[serial_test::parallel(agent_api_failpoint)]
1052    fn word_count_prefers_sidecar_field() {
1053        let (_tmp, ctx) = make_ctx();
1054        write_sidecar(&ctx, "p.meta.json", r#"{"title":"P","word_count":77}"#);
1055        fs::write(ctx.site_dir.join("p.html"), "<p>one two</p>").unwrap();
1056        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1057        let posts = read_doc(&ctx, "posts.json");
1058        assert_eq!(posts.as_array().unwrap()[0]["wordCount"], 77);
1059    }
1060
1061    #[test]
1062    #[serial_test::parallel(agent_api_failpoint)]
1063    fn word_count_lifts_from_blogposting_jsonld() {
1064        let (_tmp, ctx) = make_ctx();
1065        write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
1066        fs::write(
1067            ctx.site_dir.join("p.html"),
1068            r#"<html><head><script type="application/ld+json">
1069               {"@type":"BlogPosting","wordCount":555}
1070               </script></head><body><p>a b c</p></body></html>"#,
1071        )
1072        .unwrap();
1073        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1074        let posts = read_doc(&ctx, "posts.json");
1075        assert_eq!(posts.as_array().unwrap()[0]["wordCount"], 555);
1076    }
1077
1078    #[test]
1079    #[serial_test::parallel(agent_api_failpoint)]
1080    fn word_count_falls_back_to_stripped_html() {
1081        let (_tmp, ctx) = make_ctx();
1082        write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
1083        fs::write(
1084            ctx.site_dir.join("p.html"),
1085            "<html><body><p>one two three four</p></body></html>",
1086        )
1087        .unwrap();
1088        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1089        let posts = read_doc(&ctx, "posts.json");
1090        assert_eq!(posts.as_array().unwrap()[0]["wordCount"], 4);
1091    }
1092
1093    #[test]
1094    #[serial_test::parallel(agent_api_failpoint)]
1095    fn word_count_zero_when_html_missing() {
1096        let (_tmp, ctx) = make_ctx();
1097        write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
1098        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1099        let posts = read_doc(&ctx, "posts.json");
1100        assert_eq!(posts.as_array().unwrap()[0]["wordCount"], 0);
1101    }
1102
1103    #[test]
1104    fn jsonld_word_count_scans_past_blocks_without_field() {
1105        let html = r#"
1106            <script type="application/ld+json">{"@type":"WebSite"}</script>
1107            <script type="application/ld+json">{"wordCount": 9}</script>
1108        "#;
1109        assert_eq!(jsonld_word_count(html), Some(9));
1110    }
1111
1112    #[test]
1113    fn jsonld_word_count_handles_malformed_json() {
1114        let html = r#"<script type="application/ld+json">{oops</script>"#;
1115        assert_eq!(jsonld_word_count(html), None);
1116    }
1117
1118    // -----------------------------------------------------------------
1119    // topics.json
1120    // -----------------------------------------------------------------
1121
1122    #[test]
1123    #[serial_test::parallel(agent_api_failpoint)]
1124    fn topics_map_terms_to_sorted_member_urls() {
1125        let (_tmp, ctx) = make_ctx();
1126        write_sidecar(&ctx, "z.meta.json", r#"{"title":"Z","tags":["rust"]}"#);
1127        write_sidecar(
1128            &ctx,
1129            "a.meta.json",
1130            r#"{"title":"A","tags":["rust","web"]}"#,
1131        );
1132        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1133        let topics = read_doc(&ctx, "topics.json");
1134        assert_eq!(
1135            topics["rust"],
1136            json!(["https://example.com/a.html", "https://example.com/z.html"])
1137        );
1138        assert_eq!(topics["web"], json!(["https://example.com/a.html"]));
1139    }
1140
1141    #[test]
1142    #[serial_test::parallel(agent_api_failpoint)]
1143    fn topics_include_topic_clusters() {
1144        let (_tmp, ctx) = make_ctx();
1145        write_sidecar(
1146            &ctx,
1147            "p.meta.json",
1148            r#"{"title":"P","topic_clusters":"cloud-native"}"#,
1149        );
1150        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1151        let topics = read_doc(&ctx, "topics.json");
1152        assert!(topics.get("cloud-native").is_some());
1153    }
1154
1155    #[test]
1156    #[serial_test::parallel(agent_api_failpoint)]
1157    fn topics_keys_are_sorted() {
1158        let (_tmp, ctx) = make_ctx();
1159        write_sidecar(
1160            &ctx,
1161            "p.meta.json",
1162            r#"{"title":"P","tags":["zeta","alpha","mid"]}"#,
1163        );
1164        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1165        let body =
1166            fs::read_to_string(ctx.site_dir.join(API_DIR).join("topics.json"))
1167                .unwrap();
1168        let a = body.find("\"alpha\"").unwrap();
1169        let m = body.find("\"mid\"").unwrap();
1170        let z = body.find("\"zeta\"").unwrap();
1171        assert!(a < m && m < z, "keys must serialise sorted:\n{body}");
1172    }
1173
1174    // -----------------------------------------------------------------
1175    // person.json
1176    // -----------------------------------------------------------------
1177
1178    #[test]
1179    #[serial_test::parallel(agent_api_failpoint)]
1180    fn person_parses_email_paren_name_convention() {
1181        let (_tmp, ctx) = make_ctx();
1182        write_sidecar(
1183            &ctx,
1184            "p.meta.json",
1185            r#"{"title":"P","author":"[email protected] (Threshold)"}"#,
1186        );
1187        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1188        let person = read_doc(&ctx, "person.json");
1189        assert_eq!(person["@context"], "https://schema.org");
1190        assert_eq!(person["@type"], "Person");
1191        assert_eq!(person["name"], "Threshold");
1192        assert_eq!(person["email"], "[email protected]");
1193        assert_eq!(person["url"], "https://example.com");
1194    }
1195
1196    #[test]
1197    #[serial_test::parallel(agent_api_failpoint)]
1198    fn person_falls_back_to_site_name_without_authors() {
1199        let (_tmp, ctx) = make_ctx();
1200        write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
1201        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1202        let person = read_doc(&ctx, "person.json");
1203        assert_eq!(person["name"], "Example");
1204        assert!(person.get("email").is_none());
1205    }
1206
1207    #[test]
1208    #[serial_test::parallel(agent_api_failpoint)]
1209    fn person_picks_most_frequent_author() {
1210        let (_tmp, ctx) = make_ctx();
1211        write_sidecar(&ctx, "a.meta.json", r#"{"title":"A","author":"Bob"}"#);
1212        write_sidecar(&ctx, "b.meta.json", r#"{"title":"B","author":"Alice"}"#);
1213        write_sidecar(&ctx, "c.meta.json", r#"{"title":"C","author":"Alice"}"#);
1214        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1215        let person = read_doc(&ctx, "person.json");
1216        assert_eq!(person["name"], "Alice");
1217    }
1218
1219    #[test]
1220    fn dominant_author_tie_breaks_lexicographically() {
1221        let mk = |author: &str| PostEntry {
1222            title: "T".into(),
1223            url: "/t.html".into(),
1224            date: String::new(),
1225            description: String::new(),
1226            tags: vec![],
1227            locale: "en".into(),
1228            word_count: 0,
1229            author: Some(author.to_string()),
1230            topic_clusters: vec![],
1231        };
1232        let posts = vec![mk("Zoe"), mk("Anna")];
1233        assert_eq!(dominant_author(&posts).as_deref(), Some("Anna"));
1234    }
1235
1236    #[test]
1237    fn parse_author_table_driven() {
1238        let cases: &[(&str, Option<&str>, Option<&str>)] = &[
1239            ("[email protected] (Name)", Some("Name"), Some("[email protected]")),
1240            ("Name <[email protected]>", Some("Name"), Some("[email protected]")),
1241            ("[email protected]", None, Some("[email protected]")),
1242            ("Just A Name", Some("Just A Name"), None),
1243            ("", None, None),
1244            ("   ", None, None),
1245            ("() ", Some("()"), None),
1246        ];
1247        for &(input, name, email) in cases {
1248            let (n, e) = parse_author(input);
1249            assert_eq!(n.as_deref(), name, "name for {input:?}");
1250            assert_eq!(e.as_deref(), email, "email for {input:?}");
1251        }
1252    }
1253
1254    // -----------------------------------------------------------------
1255    // index.json
1256    // -----------------------------------------------------------------
1257
1258    #[test]
1259    #[serial_test::parallel(agent_api_failpoint)]
1260    fn index_carries_counts_and_absolute_links() {
1261        let (_tmp, ctx) = make_ctx();
1262        write_sidecar(
1263            &ctx,
1264            "a.meta.json",
1265            r#"{"title":"A","tags":["rust","web"]}"#,
1266        );
1267        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1268        let index = read_doc(&ctx, "index.json");
1269        assert_eq!(index["api"], "ssg-agent-api");
1270        assert_eq!(index["version"], env!("CARGO_PKG_VERSION"));
1271        assert_eq!(index["counts"]["posts"], 1);
1272        assert_eq!(index["counts"]["topics"], 2);
1273        assert_eq!(
1274            index["links"]["posts"],
1275            "https://example.com/api/agents/posts.json"
1276        );
1277        assert_eq!(
1278            index["links"]["person"],
1279            "https://example.com/api/agents/person.json"
1280        );
1281    }
1282
1283    #[test]
1284    #[serial_test::parallel(agent_api_failpoint)]
1285    fn no_config_uses_relative_links_and_en() {
1286        let dir = tempdir().unwrap();
1287        let build = dir.path().join("build");
1288        let site = dir.path().join("site");
1289        fs::create_dir_all(build.join(".meta")).unwrap();
1290        fs::create_dir_all(&site).unwrap();
1291        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
1292        fs::write(build.join(".meta/p.meta.json"), r#"{"title":"P"}"#).unwrap();
1293        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1294        let body =
1295            fs::read_to_string(site.join(API_DIR).join("index.json")).unwrap();
1296        let index: Value = serde_json::from_str(&body).unwrap();
1297        assert_eq!(index["links"]["index"], "/api/agents/index.json");
1298        assert_eq!(index["site"]["language"], "en");
1299        let posts: Value = serde_json::from_str(
1300            &fs::read_to_string(site.join(API_DIR).join("posts.json")).unwrap(),
1301        )
1302        .unwrap();
1303        assert_eq!(posts.as_array().unwrap()[0]["url"], "/p.html");
1304    }
1305
1306    // -----------------------------------------------------------------
1307    // site-dir sidecar fallback
1308    // -----------------------------------------------------------------
1309
1310    #[test]
1311    fn falls_back_to_site_dir_sidecars() {
1312        let dir = tempdir().unwrap();
1313        let site = dir.path().join("site");
1314        fs::create_dir_all(&site).unwrap();
1315        // No build/.meta at all — sidecars live next to the HTML.
1316        fs::write(site.join("p.meta.json"), r#"{"title":"P"}"#).unwrap();
1317        fs::write(site.join("p.html"), "<p>x y</p>").unwrap();
1318        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1319        let posts = collect_posts(&ctx);
1320        assert_eq!(posts.len(), 1);
1321        assert_eq!(posts[0].title, "P");
1322        assert_eq!(posts[0].word_count, 2);
1323    }
1324
1325    #[test]
1326    fn truthy_falsy_value_forms() {
1327        assert!(truthy(Some(&json!(true))));
1328        assert!(truthy(Some(&json!("yes"))));
1329        assert!(truthy(Some(&json!("1"))));
1330        assert!(!truthy(Some(&json!(false))));
1331        assert!(!truthy(Some(&json!(0))));
1332        assert!(!truthy(None));
1333        assert!(falsy(Some(&json!(false))));
1334        assert!(falsy(Some(&json!("no"))));
1335        assert!(falsy(Some(&json!("0"))));
1336        assert!(!falsy(Some(&json!(true))));
1337        assert!(!falsy(None));
1338    }
1339
1340    // -----------------------------------------------------------------
1341    // Remaining branches — IO errors, parser edges, term shapes
1342    // -----------------------------------------------------------------
1343
1344    #[test]
1345    fn after_compile_fails_when_api_dir_squatted_by_file() {
1346        let (_tmp, ctx) = make_ctx();
1347        write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
1348        // `site/api` is a file, so create_dir_all(site/api/agents)
1349        // fails.
1350        fs::write(ctx.site_dir.join("api"), "not a dir").unwrap();
1351        let err = AgentApiPlugin::default().after_compile(&ctx).unwrap_err();
1352        assert!(!format!("{err}").is_empty());
1353    }
1354
1355    #[test]
1356    #[serial_test::parallel(agent_api_failpoint)]
1357    fn after_compile_fails_when_doc_path_squatted_by_dir() {
1358        let (_tmp, ctx) = make_ctx();
1359        write_sidecar(&ctx, "p.meta.json", r#"{"title":"P"}"#);
1360        // A directory squats `index.json`, so fs::write fails.
1361        fs::create_dir_all(ctx.site_dir.join(API_DIR).join("index.json"))
1362            .unwrap();
1363        let err = AgentApiPlugin::default().after_compile(&ctx).unwrap_err();
1364        assert!(!format!("{err}").is_empty());
1365    }
1366
1367    #[test]
1368    #[cfg(unix)]
1369    fn unreadable_sidecar_is_skipped() {
1370        use std::os::unix::fs::PermissionsExt;
1371        let (_tmp, ctx) = make_ctx();
1372        write_sidecar(&ctx, "ok.meta.json", r#"{"title":"OK"}"#);
1373        write_sidecar(&ctx, "locked.meta.json", r#"{"title":"L"}"#);
1374        let locked = ctx.build_dir.join(".meta/locked.meta.json");
1375        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
1376            .unwrap();
1377
1378        let posts = collect_posts(&ctx);
1379
1380        let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o644));
1381        // Root CI runners bypass perms; the readable sidecar always
1382        // survives either way.
1383        assert!(posts.iter().any(|p| p.title == "OK"));
1384    }
1385
1386    #[test]
1387    fn site_dir_walk_skips_meta_tree_and_drafts() {
1388        // Both sidecar roots yield zero posts (site/.meta only has a
1389        // draft), so the site-dir walk runs: it must skip the `.meta`
1390        // staging tree and non-public sidecars while keeping the good
1391        // one.
1392        let dir = tempdir().unwrap();
1393        let build = dir.path().join("build");
1394        let site = dir.path().join("site");
1395        fs::create_dir_all(&build).unwrap();
1396        fs::create_dir_all(site.join(".meta")).unwrap();
1397        fs::write(
1398            site.join(".meta/draft.meta.json"),
1399            r#"{"title":"D","draft":true}"#,
1400        )
1401        .unwrap();
1402        fs::write(site.join("ok.meta.json"), r#"{"title":"OK"}"#).unwrap();
1403        fs::write(site.join("skip.meta.json"), r#"{"title":"S","draft":true}"#)
1404            .unwrap();
1405        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
1406
1407        let posts = collect_posts(&ctx);
1408        assert_eq!(posts.len(), 1);
1409        assert_eq!(posts[0].title, "OK");
1410    }
1411
1412    #[test]
1413    fn terms_field_ignores_non_string_array_items() {
1414        let meta: serde_json::Map<String, Value> =
1415            serde_json::from_str(r#"{"tags": ["a", 42, null, "b"]}"#).unwrap();
1416        assert_eq!(terms_field(&meta, "tags"), vec!["a", "b"]);
1417    }
1418
1419    #[test]
1420    fn jsonld_word_count_returns_none_without_tag_close() {
1421        // `application/ld+json` marker but no `>` afterwards.
1422        let html = "<script type=\"application/ld+json";
1423        assert!(jsonld_word_count(html).is_none());
1424    }
1425
1426    #[test]
1427    fn jsonld_word_count_returns_none_without_script_close() {
1428        let html = "<script type=\"application/ld+json\">{\"wordCount\": 3}";
1429        assert!(jsonld_word_count(html).is_none());
1430    }
1431
1432    #[test]
1433    #[serial_test::parallel(agent_api_failpoint)]
1434    fn topics_map_dedupes_term_shared_by_tags_and_clusters() {
1435        let (_tmp, ctx) = make_ctx();
1436        write_sidecar(
1437            &ctx,
1438            "p.meta.json",
1439            r#"{"title":"P","tags":"x","topic_clusters":"x"}"#,
1440        );
1441        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1442        let topics = read_doc(&ctx, "topics.json");
1443        let urls = topics["x"].as_array().unwrap();
1444        assert_eq!(urls.len(), 1, "shared term must not duplicate the URL");
1445    }
1446
1447    #[test]
1448    fn parse_author_paren_form_with_empty_parts_falls_through() {
1449        // `()` — both name and email empty, so the paren branch does
1450        // not return and the raw string becomes the name.
1451        let (name, email) = parse_author("()");
1452        assert_eq!(name.as_deref(), Some("()"));
1453        assert!(email.is_none());
1454    }
1455
1456    #[test]
1457    fn parse_author_angle_form_email_only() {
1458        // Empty display name: the `email.is_some()` side of the `||`
1459        // decides.
1460        let (name, email) = parse_author("<[email protected]>");
1461        assert!(name.is_none());
1462        assert_eq!(email.as_deref(), Some("[email protected]"));
1463    }
1464
1465    #[test]
1466    fn parse_author_angle_form_with_empty_parts_falls_through() {
1467        let (name, email) = parse_author("<>");
1468        assert_eq!(name.as_deref(), Some("<>"));
1469        assert!(email.is_none());
1470    }
1471
1472    // -------------------------------------------------------------------
1473    // resolve_page — pretty-URL branch (`<stem>/index.html` on disk)
1474    // -------------------------------------------------------------------
1475
1476    #[test]
1477    #[serial_test::parallel(agent_api_failpoint)]
1478    fn pretty_url_used_when_directory_index_html_exists() {
1479        // When the compiled output is directory-shaped
1480        // (`post/index.html`), the URL must be the pretty `/post/`
1481        // form rather than the flat `/post.html` fallback.
1482        let (_tmp, ctx) = make_ctx();
1483        write_sidecar(&ctx, "post.meta.json", r#"{"title":"Post"}"#);
1484        fs::create_dir_all(ctx.site_dir.join("post")).unwrap();
1485        fs::write(ctx.site_dir.join("post/index.html"), "<p>hi</p>").unwrap();
1486
1487        AgentApiPlugin::default().after_compile(&ctx).unwrap();
1488        let posts = read_doc(&ctx, "posts.json");
1489        assert_eq!(
1490            posts.as_array().unwrap()[0]["url"],
1491            "https://example.com/post/"
1492        );
1493    }
1494}
1495
1496// =========================================================================
1497// Fault injection — `agent_api::to-pretty` covers the serde_json
1498// serialisation error arm of `to_pretty`, which cannot fail via normal
1499// data (every `Value` built by this module comes from finite numbers
1500// and UTF-8 strings) and so is otherwise unreachable without fault
1501// injection.
1502// =========================================================================
1503#[cfg(all(test, feature = "test-fault-injection"))]
1504mod fault_tests {
1505    use super::*;
1506    use crate::cmd::SsgConfig;
1507    use tempfile::tempdir;
1508
1509    /// RAII guard that disables a failpoint on drop.
1510    struct FailGuard(&'static str);
1511
1512    impl Drop for FailGuard {
1513        fn drop(&mut self) {
1514            let _ = fail::cfg(self.0, "off");
1515        }
1516    }
1517
1518    #[test]
1519    #[serial_test::serial(agent_api_failpoint)]
1520    fn to_pretty_failpoint_propagates() {
1521        let _guard = FailGuard("agent_api::to-pretty");
1522        fail::cfg("agent_api::to-pretty", "return")
1523            .expect("activate failpoint");
1524
1525        let dir = tempdir().unwrap();
1526        let build = dir.path().join("build");
1527        let site = dir.path().join("site");
1528        fs::create_dir_all(build.join(".meta")).unwrap();
1529        fs::create_dir_all(&site).unwrap();
1530        let cfg = SsgConfig::builder()
1531            .site_name("Example".to_string())
1532            .build()
1533            .expect("config");
1534        let ctx = PluginContext::with_config(
1535            dir.path(),
1536            &build,
1537            &site,
1538            dir.path(),
1539            cfg,
1540        );
1541
1542        let err = AgentApiPlugin::default()
1543            .after_compile(&ctx)
1544            .expect_err("injected serialisation failure must propagate");
1545        assert!(format!("{err:?}").contains("injected: agent_api::to-pretty"));
1546    }
1547}