Skip to main content

ssg/plugins/postprocess/agentic_discovery/
ai_plugin.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! `.well-known/ai-plugin.json` emitter — `OpenAI` plugin manifest spec.
5//!
6//! The `OpenAI` plugin manifest is still the de-facto plugin descriptor
7//! across agent runtimes in 2026 (`ChatGPT`, Claude, Perplexity, IDE
8//! plugins). It contains the metadata an agent needs to fetch a site's
9//! `OpenAPI` spec, plus human/model-facing names and descriptions.
10//!
11//! Reference: <https://platform.openai.com/docs/plugins/getting-started/plugin-manifest>
12//!
13//! Shape emitted (AC3):
14//!
15//! ```jsonc
16//! {
17//!   "schema_version":         "v1",
18//!   "name_for_human":         "<site_title>",
19//!   "name_for_model":         "<site_name slug>",
20//!   "description_for_human":  "<site_description>",
21//!   "description_for_model":  "<site_description, model-facing>",
22//!   "auth":                   { "type": "none" },
23//!   "api":                    { "type": "openapi", "url": ".../openapi.yaml" },
24//!   "logo_url":               "<base_url>/favicon.ico",
25//!   "contact_email":          "support@<host>",
26//!   "legal_info_url":         "<base_url>/legal"
27//! }
28//! ```
29
30use crate::cmd::SsgConfig;
31use crate::error::{PathErrorExt, SsgError};
32use crate::plugin::PluginContext;
33use serde_json::{json, Value};
34use std::fs;
35
36/// Render and write `.well-known/ai-plugin.json` under `ctx.site_dir`.
37///
38/// # Errors
39///
40/// Returns [`SsgError::Io`] if the `.well-known` directory cannot be
41/// created or the manifest cannot be written.
42///
43/// # Examples
44///
45/// ```
46/// use ssg::cmd::SsgConfig;
47/// use ssg::plugin::PluginContext;
48/// use ssg::postprocess::agentic_discovery::write_ai_plugin_json;
49/// let tmp = tempfile::tempdir().unwrap();
50/// let cfg = SsgConfig::builder()
51///     .site_name("Example".into())
52///     .base_url("https://example.com".into())
53///     .build()
54///     .unwrap();
55/// let ctx = PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
56/// write_ai_plugin_json(&ctx, &cfg).unwrap();
57/// assert!(tmp.path().join(".well-known/ai-plugin.json").exists());
58/// ```
59pub fn write_ai_plugin_json(
60    ctx: &PluginContext,
61    cfg: &SsgConfig,
62) -> Result<(), SsgError> {
63    let well_known = ctx.site_dir.join(".well-known");
64    fs::create_dir_all(&well_known).with_path(&well_known)?;
65    let path = well_known.join("ai-plugin.json");
66    let manifest = build_manifest(cfg);
67    let body =
68        serialize_ai_plugin(&manifest).map_err(|e| SsgError::io(e, &path))?;
69    fs::write(&path, body).with_path(&path)?;
70    Ok(())
71}
72
73/// Serialize the manifest with a fault-injection hook so tests can
74/// drive the error-mapping branch (pretty-printing a `Value` cannot
75/// fail in practice).
76fn serialize_ai_plugin(manifest: &Value) -> serde_json::Result<String> {
77    fail_point!("postprocess::ai-plugin-serialize", |_| Err(
78        <serde_json::Error as serde::ser::Error>::custom(
79            "injected: postprocess::ai-plugin-serialize"
80        )
81    ));
82    serde_json::to_string_pretty(manifest)
83}
84
85/// Pure-function manifest builder — split from `write_ai_plugin_json`
86/// so unit tests can assert the JSON shape without touching the
87/// filesystem.
88///
89/// # Examples
90///
91/// ```
92/// use ssg::cmd::SsgConfig;
93/// use ssg::postprocess::agentic_discovery::build_manifest;
94/// let cfg = SsgConfig::builder()
95///     .site_name("Example".into())
96///     .base_url("https://example.com".into())
97///     .build()
98///     .unwrap();
99/// let m = build_manifest(&cfg);
100/// assert_eq!(m["schema_version"], "v1");
101/// assert_eq!(m["auth"]["type"], "none");
102/// ```
103#[must_use]
104pub fn build_manifest(cfg: &SsgConfig) -> Value {
105    let base_url = cfg.base_url.trim_end_matches('/').to_string();
106
107    let human_name = if cfg.site_title.is_empty() {
108        cfg.site_name.clone()
109    } else {
110        cfg.site_title.clone()
111    };
112
113    // OpenAI requires `name_for_model` to be lowercase letters,
114    // digits and underscores. Slugify the site name aggressively.
115    let model_name = slugify_for_model(&cfg.site_name);
116
117    let description = if cfg.site_description.is_empty() {
118        // Always emit something — empty `description_for_*` would fail
119        // schema validation on the agent runtime side.
120        format!("Content from {human_name}")
121    } else {
122        cfg.site_description.clone()
123    };
124
125    // OpenAPI URL: site author may host their own spec; we point at
126    // a conventional location. The audit gate will surface a warning
127    // if the file doesn't exist, but the manifest itself is valid.
128    let openapi_url = if base_url.is_empty() {
129        "/openapi.yaml".to_string()
130    } else {
131        format!("{base_url}/openapi.yaml")
132    };
133
134    let logo_url = if base_url.is_empty() {
135        "/favicon.ico".to_string()
136    } else {
137        format!("{base_url}/favicon.ico")
138    };
139
140    let legal_url = if base_url.is_empty() {
141        "/legal".to_string()
142    } else {
143        format!("{base_url}/legal")
144    };
145
146    let contact_email = derive_contact_email(&base_url);
147
148    json!({
149        "schema_version":         "v1",
150        "name_for_human":         human_name,
151        "name_for_model":         model_name,
152        "description_for_human":  description,
153        "description_for_model":  description_for_model(&human_name, cfg),
154        "auth":                   { "type": "none" },
155        "api": {
156            "type":               "openapi",
157            "url":                openapi_url,
158            "is_user_authenticated": false,
159        },
160        "logo_url":               logo_url,
161        "contact_email":          contact_email,
162        "legal_info_url":         legal_url,
163    })
164}
165
166/// Generates a model-facing description that's slightly more
167/// directive than the human-facing one (agents perform better when
168/// the description tells them *when* to invoke the plugin).
169fn description_for_model(human_name: &str, cfg: &SsgConfig) -> String {
170    // Site author supplied their own description? Trust it. Appending a
171    // stock "Use this plugin to ..." sentence to an already-good
172    // description reads like an auto-generated mash-up to LLM agents
173    // (and to humans browsing the registry).
174    if !cfg.site_description.is_empty() {
175        return cfg.site_description.clone();
176    }
177    // No description configured — synthesise a model-friendly one with
178    // an invocation hint.
179    format!(
180        "Plugin for accessing content from {human_name}. \
181         Use this plugin to search and retrieve content from \
182         {human_name}."
183    )
184}
185
186/// Best-effort `contact_email` derivation: prefer `support@<host>` so
187/// the manifest at least carries a deliverable address. Falls back to
188/// `[email protected]` when no host is available.
189fn derive_contact_email(base_url: &str) -> String {
190    if let Some(host) = host_from_url(base_url) {
191        format!("support@{host}")
192    } else {
193        "[email protected]".to_string()
194    }
195}
196
197fn host_from_url(url: &str) -> Option<String> {
198    let without_scheme = url
199        .strip_prefix("https://")
200        .or_else(|| url.strip_prefix("http://"))?;
201    // `split` always yields at least one segment, so this cannot be
202    // empty-handed; `unwrap_or_default` keeps the expression total
203    // without an unreachable `None` branch.
204    let host = without_scheme.split('/').next().unwrap_or_default();
205    if host.is_empty() {
206        None
207    } else {
208        Some(host.to_string())
209    }
210}
211
212/// Coerce a free-form site name into something safe for
213/// `name_for_model`. The spec allows `[a-z0-9_]` up to 50 chars.
214fn slugify_for_model(name: &str) -> String {
215    let mut out = String::with_capacity(name.len());
216    for c in name.chars() {
217        if c.is_ascii_alphanumeric() {
218            for lower in c.to_lowercase() {
219                out.push(lower);
220            }
221        } else if c == '_' || c == ' ' || c == '-' {
222            out.push('_');
223        }
224        // Other characters are dropped silently.
225    }
226    // Collapse repeated underscores and trim.
227    let collapsed: String = out
228        .chars()
229        .fold(String::new(), |mut acc, c| {
230            if c == '_' && acc.ends_with('_') {
231                // Skip duplicate underscore.
232            } else {
233                acc.push(c);
234            }
235            acc
236        })
237        .trim_matches('_')
238        .to_string();
239    if collapsed.is_empty() {
240        "site".to_string()
241    } else if collapsed.len() > 50 {
242        collapsed[..50].to_string()
243    } else {
244        collapsed
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::cmd::{ImageConfig, SsgConfig};
252    use std::path::PathBuf;
253
254    fn cfg() -> SsgConfig {
255        SsgConfig {
256            listings: Vec::new(),
257            site_name: "Example Site".to_string(),
258            site_title: "Example".to_string(),
259            site_description: "A demo".to_string(),
260            base_url: "https://example.com".to_string(),
261            language: "en".to_string(),
262            content_dir: PathBuf::from("content"),
263            output_dir: PathBuf::from("build"),
264            template_dir: PathBuf::from("templates"),
265            theme: None,
266            serve_dir: None,
267            #[cfg(feature = "i18n")]
268            i18n: None,
269            cdn_prefix: None,
270            og_image: None,
271            image: ImageConfig::default(),
272            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
273            agents: None,
274            transitions: false,
275            security: crate::cmd::SecurityConfig::default(),
276            no_taxonomy_pages: false,
277        }
278    }
279
280    #[test]
281    fn manifest_has_all_required_keys() {
282        // AC3: the seven keys listed in the issue body must all be
283        // present and non-null.
284        let m = build_manifest(&cfg());
285        for key in [
286            "schema_version",
287            "name_for_human",
288            "name_for_model",
289            "description_for_human",
290            "description_for_model",
291            "auth",
292            "api",
293        ] {
294            assert!(m.get(key).is_some(), "missing key: {key}");
295            assert!(!m[key].is_null(), "key {key} is null");
296        }
297    }
298
299    #[test]
300    fn schema_version_is_v1() {
301        // OpenAI's plugin manifest spec is v1 — bumping this would be
302        // an external break, so pin it.
303        let m = build_manifest(&cfg());
304        assert_eq!(m["schema_version"], "v1");
305    }
306
307    #[test]
308    fn auth_is_none() {
309        // Static sites have no authentication — emit the explicit
310        // none-auth descriptor so the schema is valid.
311        let m = build_manifest(&cfg());
312        assert_eq!(m["auth"]["type"], "none");
313    }
314
315    #[test]
316    fn api_uses_openapi_url() {
317        let m = build_manifest(&cfg());
318        assert_eq!(m["api"]["type"], "openapi");
319        assert_eq!(m["api"]["url"], "https://example.com/openapi.yaml");
320    }
321
322    #[test]
323    fn name_for_model_is_slug_safe() {
324        // OpenAI requires [a-z0-9_]; verify a space becomes _ and
325        // uppercase letters are downcased.
326        let mut c = cfg();
327        c.site_name = "Hello World!".to_string();
328        let m = build_manifest(&c);
329        let model_name = m["name_for_model"].as_str().unwrap();
330        assert!(
331            model_name.chars().all(|ch| ch.is_ascii_lowercase()
332                || ch.is_ascii_digit()
333                || ch == '_'),
334            "name_for_model must be [a-z0-9_]+, got {model_name:?}"
335        );
336    }
337
338    #[test]
339    fn name_for_model_collapses_underscores() {
340        // Multiple separators in a row should collapse into one to
341        // avoid `__` strings.
342        assert_eq!(slugify_for_model("Hello   World!!"), "hello_world");
343        assert_eq!(slugify_for_model("foo - bar"), "foo_bar");
344    }
345
346    #[test]
347    fn name_for_model_falls_back_to_site_when_all_dropped() {
348        // A name made entirely of dropped chars must not panic and
349        // must yield a non-empty fallback.
350        assert_eq!(slugify_for_model("!!!"), "site");
351        assert_eq!(slugify_for_model(""), "site");
352    }
353
354    #[test]
355    fn falls_back_to_site_name_when_title_empty() {
356        // `name_for_human` prefers `site_title` but must fall back to
357        // `site_name` when title is empty.
358        let mut c = cfg();
359        c.site_title = String::new();
360        let m = build_manifest(&c);
361        assert_eq!(m["name_for_human"], "Example Site");
362    }
363
364    #[test]
365    fn description_for_model_returns_site_description_verbatim_when_present() {
366        // When the site author supplied a description, we trust it —
367        // no stock "Use this plugin to ..." suffix that turns their
368        // copy into an obvious auto-generated mash-up.
369        let m = build_manifest(&cfg());
370        let desc = m["description_for_model"].as_str().unwrap();
371        assert_eq!(desc, "A demo");
372    }
373
374    #[test]
375    fn description_for_model_synthesises_invocation_hint_when_empty() {
376        // No description configured? Synthesise one with an explicit
377        // invocation hint — that's what agents need to know when to
378        // invoke the plugin.
379        let mut c = cfg();
380        c.site_description = String::new();
381        let m = build_manifest(&c);
382        let desc = m["description_for_model"].as_str().unwrap();
383        assert!(
384            desc.contains("Use this plugin"),
385            "synthesised description should hint at when to invoke, got {desc:?}"
386        );
387        // Non-short-circuiting `|` so both operands are evaluated.
388        let mentions_site = desc.contains(c.site_title.as_str())
389            | desc.contains(c.site_name.as_str());
390        assert!(
391            mentions_site,
392            "synthesised description should mention the site, got {desc:?}"
393        );
394    }
395
396    #[test]
397    fn host_extraction_is_lenient() {
398        assert_eq!(
399            host_from_url("https://example.com/foo"),
400            Some("example.com".to_string())
401        );
402        assert_eq!(
403            host_from_url("http://example.com"),
404            Some("example.com".to_string())
405        );
406        assert_eq!(host_from_url("notaurl"), None);
407        assert_eq!(host_from_url(""), None);
408    }
409
410    #[test]
411    fn contact_email_falls_back_when_no_host() {
412        let mut c = cfg();
413        c.base_url = String::new();
414        let m = build_manifest(&c);
415        let email = m["contact_email"].as_str().unwrap();
416        assert!(email.contains('@'), "contact_email must contain @");
417    }
418
419    #[test]
420    fn manifest_is_valid_json() {
421        // serde_json::to_string_pretty must produce parseable JSON.
422        let m = build_manifest(&cfg());
423        let s = serde_json::to_string_pretty(&m).unwrap();
424        let parsed: Value = serde_json::from_str(&s).unwrap();
425        assert_eq!(parsed["schema_version"], "v1");
426    }
427
428    #[test]
429    fn synthesises_empty_description_safely() {
430        // An empty site_description must still produce a usable
431        // description (agents will reject null/empty values).
432        let mut c = cfg();
433        c.site_description = String::new();
434        let m = build_manifest(&c);
435        let h = m["description_for_human"].as_str().unwrap();
436        let model = m["description_for_model"].as_str().unwrap();
437        assert!(!h.is_empty());
438        assert!(!model.is_empty());
439    }
440
441    #[test]
442    fn host_from_url_empty_host_returns_none() {
443        // Scheme present but nothing after it → empty host → None.
444        assert_eq!(host_from_url("https://"), None);
445        assert_eq!(host_from_url("https:///path"), None);
446    }
447
448    #[test]
449    fn name_for_model_truncates_to_fifty_chars() {
450        let long = "a".repeat(60);
451        let slug = slugify_for_model(&long);
452        assert_eq!(slug.len(), 50);
453        assert!(slug.chars().all(|c| c == 'a'));
454    }
455
456    #[test]
457    fn write_ai_plugin_json_errors_when_well_known_is_a_file() {
458        use crate::plugin::PluginContext;
459        let tmp = tempfile::tempdir().unwrap();
460        fs::write(tmp.path().join(".well-known"), "file").unwrap();
461        let ctx =
462            PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
463        let err = write_ai_plugin_json(&ctx, &cfg()).unwrap_err();
464        assert!(format!("{err}").contains(".well-known"));
465    }
466
467    #[test]
468    #[serial_test::parallel]
469    fn write_ai_plugin_json_errors_when_target_is_a_directory() {
470        use crate::plugin::PluginContext;
471        let tmp = tempfile::tempdir().unwrap();
472        fs::create_dir_all(tmp.path().join(".well-known/ai-plugin.json"))
473            .unwrap();
474        let ctx =
475            PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
476        let err = write_ai_plugin_json(&ctx, &cfg()).unwrap_err();
477        assert!(format!("{err}").contains("ai-plugin.json"));
478    }
479}
480
481#[cfg(all(test, feature = "test-fault-injection"))]
482mod fault_tests {
483    use super::*;
484    use crate::plugin::PluginContext;
485    use serial_test::serial;
486
487    /// RAII guard that disables a failpoint on drop.
488    struct FailGuard(&'static str);
489
490    impl Drop for FailGuard {
491        fn drop(&mut self) {
492            let _ = fail::cfg(self.0, "off");
493        }
494    }
495
496    fn cfg() -> SsgConfig {
497        SsgConfig {
498            listings: Vec::new(),
499            site_name: "Example Site".to_string(),
500            site_title: "Example".to_string(),
501            site_description: "A demo".to_string(),
502            base_url: "https://example.com".to_string(),
503            language: "en".to_string(),
504            content_dir: std::path::PathBuf::from("content"),
505            output_dir: std::path::PathBuf::from("build"),
506            template_dir: std::path::PathBuf::from("templates"),
507            theme: None,
508            serve_dir: None,
509            #[cfg(feature = "i18n")]
510            i18n: None,
511            cdn_prefix: None,
512            og_image: None,
513            image: crate::cmd::ImageConfig::default(),
514            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
515            agents: None,
516            transitions: false,
517            security: crate::cmd::SecurityConfig::default(),
518            no_taxonomy_pages: false,
519        }
520    }
521
522    #[test]
523    #[serial]
524    fn write_ai_plugin_json_maps_serialize_failure_to_io_error() {
525        let _guard = FailGuard("postprocess::ai-plugin-serialize");
526        fail::cfg("postprocess::ai-plugin-serialize", "return")
527            .expect("activate failpoint");
528
529        let tmp = tempfile::tempdir().unwrap();
530        let ctx =
531            PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
532        let err = write_ai_plugin_json(&ctx, &cfg())
533            .expect_err("injected serialize failure must propagate");
534        let msg = format!("{err}");
535        assert!(msg.contains("ai-plugin.json"), "got: {msg}");
536        assert!(
537            msg.contains("injected: postprocess::ai-plugin-serialize"),
538            "got: {msg}"
539        );
540    }
541}