use crate::cmd::SsgConfig;
use crate::error::{PathErrorExt, SsgError};
use crate::plugin::PluginContext;
use serde_json::{json, Value};
use std::fs;
pub fn write_ai_plugin_json(
ctx: &PluginContext,
cfg: &SsgConfig,
) -> Result<(), SsgError> {
let well_known = ctx.site_dir.join(".well-known");
fs::create_dir_all(&well_known).with_path(&well_known)?;
let path = well_known.join("ai-plugin.json");
let manifest = build_manifest(cfg);
let body =
serialize_ai_plugin(&manifest).map_err(|e| SsgError::io(e, &path))?;
fs::write(&path, body).with_path(&path)?;
Ok(())
}
fn serialize_ai_plugin(manifest: &Value) -> serde_json::Result<String> {
fail_point!("postprocess::ai-plugin-serialize", |_| Err(
<serde_json::Error as serde::ser::Error>::custom(
"injected: postprocess::ai-plugin-serialize"
)
));
serde_json::to_string_pretty(manifest)
}
#[must_use]
pub fn build_manifest(cfg: &SsgConfig) -> Value {
let base_url = cfg.base_url.trim_end_matches('/').to_string();
let human_name = if cfg.site_title.is_empty() {
cfg.site_name.clone()
} else {
cfg.site_title.clone()
};
let model_name = slugify_for_model(&cfg.site_name);
let description = if cfg.site_description.is_empty() {
format!("Content from {}", &human_name)
} else {
cfg.site_description.clone()
};
let openapi_url = if base_url.is_empty() {
"/openapi.yaml".to_string()
} else {
format!("{base_url}/openapi.yaml")
};
let logo_url = if base_url.is_empty() {
"/favicon.ico".to_string()
} else {
format!("{base_url}/favicon.ico")
};
let legal_url = if base_url.is_empty() {
"/legal".to_string()
} else {
format!("{base_url}/legal")
};
let contact_email = derive_contact_email(&base_url);
json!({
"schema_version": "v1",
"name_for_human": human_name,
"name_for_model": model_name,
"description_for_human": description,
"description_for_model": description_for_model(&human_name, cfg),
"auth": { "type": "none" },
"api": {
"type": "openapi",
"url": openapi_url,
"is_user_authenticated": false,
},
"logo_url": logo_url,
"contact_email": contact_email,
"legal_info_url": legal_url,
})
}
fn description_for_model(human_name: &str, cfg: &SsgConfig) -> String {
if !cfg.site_description.is_empty() {
return cfg.site_description.clone();
}
format!(
"Plugin for accessing content from {human_name}. \
Use this plugin to search and retrieve content from \
{human_name}."
)
}
fn derive_contact_email(base_url: &str) -> String {
if let Some(host) = host_from_url(base_url) {
format!("support@{host}")
} else {
"support@example.invalid".to_string()
}
}
fn host_from_url(url: &str) -> Option<String> {
let without_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let host = without_scheme.split('/').next().unwrap_or_default();
if host.is_empty() {
None
} else {
Some(host.to_string())
}
}
fn slugify_for_model(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() {
for lower in c.to_lowercase() {
out.push(lower);
}
} else if c == '_' || c == ' ' || c == '-' {
out.push('_');
}
}
let collapsed: String = out
.chars()
.fold(String::new(), |mut acc, c| {
if c == '_' && acc.ends_with('_') {
} else {
acc.push(c);
}
acc
})
.trim_matches('_')
.to_string();
if collapsed.is_empty() {
"site".to_string()
} else if collapsed.len() > 50 {
collapsed[..50].to_string()
} else {
collapsed
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cmd::{ImageConfig, SsgConfig};
use std::path::PathBuf;
fn cfg() -> SsgConfig {
SsgConfig {
site_name: "Example Site".to_string(),
site_title: "Example".to_string(),
site_description: "A demo".to_string(),
base_url: "https://example.com".to_string(),
language: "en".to_string(),
content_dir: PathBuf::from("content"),
output_dir: PathBuf::from("build"),
template_dir: PathBuf::from("templates"),
serve_dir: None,
i18n: None,
cdn_prefix: None,
image: ImageConfig::default(),
edge_headers: crate::cmd::EdgeHeadersConfig::default(),
agents: None,
transitions: false,
security: crate::cmd::SecurityConfig::default(),
}
}
#[test]
fn manifest_has_all_required_keys() {
let m = build_manifest(&cfg());
for key in [
"schema_version",
"name_for_human",
"name_for_model",
"description_for_human",
"description_for_model",
"auth",
"api",
] {
assert!(m.get(key).is_some(), "missing key: {key}");
assert!(!m[key].is_null(), "key {key} is null");
}
}
#[test]
fn schema_version_is_v1() {
let m = build_manifest(&cfg());
assert_eq!(m["schema_version"], "v1");
}
#[test]
fn auth_is_none() {
let m = build_manifest(&cfg());
assert_eq!(m["auth"]["type"], "none");
}
#[test]
fn api_uses_openapi_url() {
let m = build_manifest(&cfg());
assert_eq!(m["api"]["type"], "openapi");
assert_eq!(m["api"]["url"], "https://example.com/openapi.yaml");
}
#[test]
fn name_for_model_is_slug_safe() {
let mut c = cfg();
c.site_name = "Hello World!".to_string();
let m = build_manifest(&c);
let model_name = m["name_for_model"].as_str().unwrap();
assert!(
model_name.chars().all(|ch| ch.is_ascii_lowercase()
|| ch.is_ascii_digit()
|| ch == '_'),
"name_for_model must be [a-z0-9_]+, got {model_name:?}"
);
}
#[test]
fn name_for_model_collapses_underscores() {
assert_eq!(slugify_for_model("Hello World!!"), "hello_world");
assert_eq!(slugify_for_model("foo - bar"), "foo_bar");
}
#[test]
fn name_for_model_falls_back_to_site_when_all_dropped() {
assert_eq!(slugify_for_model("!!!"), "site");
assert_eq!(slugify_for_model(""), "site");
}
#[test]
fn falls_back_to_site_name_when_title_empty() {
let mut c = cfg();
c.site_title = String::new();
let m = build_manifest(&c);
assert_eq!(m["name_for_human"], "Example Site");
}
#[test]
fn description_for_model_returns_site_description_verbatim_when_present() {
let m = build_manifest(&cfg());
let desc = m["description_for_model"].as_str().unwrap();
assert_eq!(desc, "A demo");
}
#[test]
fn description_for_model_synthesises_invocation_hint_when_empty() {
let mut c = cfg();
c.site_description = String::new();
let m = build_manifest(&c);
let desc = m["description_for_model"].as_str().unwrap();
assert!(
desc.contains("Use this plugin"),
"synthesised description should hint at when to invoke, got {desc:?}"
);
let mentions_site = desc.contains(c.site_title.as_str())
| desc.contains(c.site_name.as_str());
assert!(
mentions_site,
"synthesised description should mention the site, got {desc:?}"
);
}
#[test]
fn host_extraction_is_lenient() {
assert_eq!(
host_from_url("https://example.com/foo"),
Some("example.com".to_string())
);
assert_eq!(
host_from_url("http://example.com"),
Some("example.com".to_string())
);
assert_eq!(host_from_url("notaurl"), None);
assert_eq!(host_from_url(""), None);
}
#[test]
fn contact_email_falls_back_when_no_host() {
let mut c = cfg();
c.base_url = String::new();
let m = build_manifest(&c);
let email = m["contact_email"].as_str().unwrap();
assert!(email.contains('@'), "contact_email must contain @");
}
#[test]
fn manifest_is_valid_json() {
let m = build_manifest(&cfg());
let s = serde_json::to_string_pretty(&m).unwrap();
let parsed: Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["schema_version"], "v1");
}
#[test]
fn synthesises_empty_description_safely() {
let mut c = cfg();
c.site_description = String::new();
let m = build_manifest(&c);
let h = m["description_for_human"].as_str().unwrap();
let model = m["description_for_model"].as_str().unwrap();
assert!(!h.is_empty());
assert!(!model.is_empty());
}
#[test]
fn host_from_url_empty_host_returns_none() {
assert_eq!(host_from_url("https://"), None);
assert_eq!(host_from_url("https:///path"), None);
}
#[test]
fn name_for_model_truncates_to_fifty_chars() {
let long = "a".repeat(60);
let slug = slugify_for_model(&long);
assert_eq!(slug.len(), 50);
assert!(slug.chars().all(|c| c == 'a'));
}
#[test]
fn write_ai_plugin_json_errors_when_well_known_is_a_file() {
use crate::plugin::PluginContext;
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".well-known"), "file").unwrap();
let ctx =
PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
let err = write_ai_plugin_json(&ctx, &cfg()).unwrap_err();
assert!(format!("{err}").contains(".well-known"));
}
#[test]
#[serial_test::parallel]
fn write_ai_plugin_json_errors_when_target_is_a_directory() {
use crate::plugin::PluginContext;
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join(".well-known/ai-plugin.json"))
.unwrap();
let ctx =
PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
let err = write_ai_plugin_json(&ctx, &cfg()).unwrap_err();
assert!(format!("{err}").contains("ai-plugin.json"));
}
}
#[cfg(all(test, feature = "test-fault-injection"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod fault_tests {
use super::*;
use crate::plugin::PluginContext;
use serial_test::serial;
struct FailGuard(&'static str);
impl Drop for FailGuard {
fn drop(&mut self) {
let _ = fail::cfg(self.0, "off");
}
}
fn cfg() -> SsgConfig {
SsgConfig {
site_name: "Example Site".to_string(),
site_title: "Example".to_string(),
site_description: "A demo".to_string(),
base_url: "https://example.com".to_string(),
language: "en".to_string(),
content_dir: std::path::PathBuf::from("content"),
output_dir: std::path::PathBuf::from("build"),
template_dir: std::path::PathBuf::from("templates"),
serve_dir: None,
i18n: None,
cdn_prefix: None,
image: crate::cmd::ImageConfig::default(),
edge_headers: crate::cmd::EdgeHeadersConfig::default(),
agents: None,
transitions: false,
security: crate::cmd::SecurityConfig::default(),
}
}
#[test]
#[serial]
fn write_ai_plugin_json_maps_serialize_failure_to_io_error() {
let _guard = FailGuard("postprocess::ai-plugin-serialize");
fail::cfg("postprocess::ai-plugin-serialize", "return")
.expect("activate failpoint");
let tmp = tempfile::tempdir().unwrap();
let ctx =
PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
let err = write_ai_plugin_json(&ctx, &cfg())
.expect_err("injected serialize failure must propagate");
let msg = format!("{err}");
assert!(msg.contains("ai-plugin.json"), "got: {msg}");
assert!(
msg.contains("injected: postprocess::ai-plugin-serialize"),
"got: {msg}"
);
}
}