ssg/plugins/postprocess/agentic_discovery/
ai_plugin.rs1use crate::cmd::SsgConfig;
31use crate::error::{PathErrorExt, SsgError};
32use crate::plugin::PluginContext;
33use serde_json::{json, Value};
34use std::fs;
35
36pub 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
73fn 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#[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 let model_name = slugify_for_model(&cfg.site_name);
116
117 let description = if cfg.site_description.is_empty() {
118 format!("Content from {human_name}")
121 } else {
122 cfg.site_description.clone()
123 };
124
125 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
166fn description_for_model(human_name: &str, cfg: &SsgConfig) -> String {
170 if !cfg.site_description.is_empty() {
175 return cfg.site_description.clone();
176 }
177 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
186fn 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 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
212fn 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 }
226 let collapsed: String = out
228 .chars()
229 .fold(String::new(), |mut acc, c| {
230 if c == '_' && acc.ends_with('_') {
231 } 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 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 let m = build_manifest(&cfg());
304 assert_eq!(m["schema_version"], "v1");
305 }
306
307 #[test]
308 fn auth_is_none() {
309 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 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 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 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 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 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 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 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 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 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 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 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}