1use 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
63const API_DIR: &str = "api/agents";
65
66#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct PostEntry {
87 pub title: String,
89 pub url: String,
91 pub date: String,
93 pub description: String,
95 pub tags: Vec<String>,
97 pub locale: String,
99 pub word_count: u64,
101 pub author: Option<String>,
103 pub topic_clusters: Vec<String>,
105}
106
107#[derive(Debug, Clone, Copy)]
117pub struct AgentApiPlugin {
118 enabled: bool,
119}
120
121impl Default for AgentApiPlugin {
122 fn default() -> Self {
124 Self { enabled: true }
125 }
126}
127
128impl AgentApiPlugin {
129 #[must_use]
138 pub fn new() -> Self {
139 Self::default()
140 }
141
142 #[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
199fn 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#[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
258fn 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
281fn 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
307fn 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
313fn 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
333fn 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
386fn 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
401fn 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
412fn 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
423fn 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
431fn 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
455fn 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
465fn 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#[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
516fn 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
528fn 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#[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#[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#[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#[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#[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
680fn 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#[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 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 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 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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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 assert!(posts.iter().any(|p| p.title == "OK"));
1384 }
1385
1386 #[test]
1387 fn site_dir_walk_skips_meta_tree_and_drafts() {
1388 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 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 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 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 #[test]
1477 #[serial_test::parallel(agent_api_failpoint)]
1478 fn pretty_url_used_when_directory_index_html_exists() {
1479 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#[cfg(all(test, feature = "test-fault-injection"))]
1504mod fault_tests {
1505 use super::*;
1506 use crate::cmd::SsgConfig;
1507 use tempfile::tempdir;
1508
1509 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}