Skip to main content

ssg/plugins/
ai.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! AI-readiness content hooks.
5//!
6//! Provides algorithmic content enhancements for Generative Engine
7//! Optimization (GEO) and Answer Engine Optimization (AEO):
8//!
9//! - Auto-generate meta descriptions from page content when missing
10//! - Validate all `<img>` elements have alt text (log warnings)
11//! - Generate `llms.txt` and `llms-full.txt` for AI crawler guidance
12
13use crate::error::SsgError;
14use crate::plugin::{Plugin, PluginContext};
15use crate::util::head_dom::inject_before_head_close;
16use anyhow::Result;
17use std::{
18    collections::BTreeMap,
19    fs,
20    path::{Path, PathBuf},
21};
22
23/// Plugin for AI-readiness content validation and enhancement.
24///
25/// Runs in `after_compile`:
26/// - Checks all images have alt text (logs warnings for missing)
27/// - Generates `llms.txt` and `llms-full.txt` in the site root
28/// - Adds max-snippet meta for AI citation eligibility
29#[derive(Debug, Clone, Copy)]
30pub struct AiPlugin;
31
32impl Plugin for AiPlugin {
33    fn name(&self) -> &'static str {
34        "ai"
35    }
36
37    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
38        if !ctx.site_dir.exists() {
39            return Ok(());
40        }
41
42        generate_llms_txt(&ctx.site_dir, ctx.config.as_ref())
43            .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
44        generate_llms_full_txt(&ctx.site_dir, ctx.config.as_ref())
45            .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
46
47        let html_files = collect_html_files(&ctx.site_dir)
48            .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
49        let pages_with_missing_alt =
50            process_html_for_ai(&html_files, &ctx.site_dir)
51                .map_err(|e| SsgError::io(e, &ctx.site_dir))?;
52
53        if pages_with_missing_alt > 0 {
54            log::warn!(
55                "[ai] {pages_with_missing_alt} page(s) have images without alt text"
56            );
57        }
58
59        Ok(())
60    }
61}
62
63/// Processes HTML files: injects max-snippet meta tags and checks for missing alt text.
64fn process_html_for_ai(
65    html_files: &[PathBuf],
66    site_dir: &Path,
67) -> Result<usize> {
68    let mut pages_with_missing_alt = 0usize;
69
70    for path in html_files {
71        let html = fs::read_to_string(path)?;
72        let modified = inject_max_snippet(&html);
73
74        check_alt_text(path, &modified, site_dir, &mut pages_with_missing_alt);
75
76        if modified != html {
77            fs::write(path, modified)?;
78        }
79    }
80
81    Ok(pages_with_missing_alt)
82}
83
84/// Injects the max-snippet meta tag before `</head>` if not already present.
85fn inject_max_snippet(html: &str) -> String {
86    if html.contains("max-snippet") {
87        return html.to_string();
88    }
89    let tag = "<meta name=\"robots\" content=\"max-snippet:-1, max-image-preview:large, max-video-preview:-1\">\n";
90    inject_before_head_close(html, tag)
91}
92
93/// Checks for missing alt text and logs a warning if found.
94fn check_alt_text(
95    path: &Path,
96    html: &str,
97    site_dir: &Path,
98    counter: &mut usize,
99) {
100    let missing = count_missing_alt(html);
101    if missing > 0 {
102        let rel = path.strip_prefix(site_dir).unwrap_or(path).display();
103        log::warn!("[ai] {missing} image(s) missing alt text in {rel}");
104        *counter += 1;
105    }
106}
107
108// -------------------------------------------------------------------
109// llms.txt generation — llmstxt.org v1 spec
110// -------------------------------------------------------------------
111
112/// Collects page metadata from `.meta.json` sidecars in the site dir.
113///
114/// Returns a list of `(title, relative_url, description)` tuples for
115/// pages that should appear in `llms.txt`.
116fn collect_page_entries(
117    site_dir: &Path,
118) -> Result<Vec<(String, String, String)>> {
119    let html_files = collect_html_files(site_dir)?;
120    let mut entries = Vec::new();
121
122    for html_path in &html_files {
123        let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
124
125        // Read the companion sidecar
126        let sidecar_path = html_path.with_extension("meta.json");
127        let meta: serde_json::Map<String, serde_json::Value> =
128            if sidecar_path.exists() {
129                if let Ok(content) = fs::read_to_string(&sidecar_path) {
130                    serde_json::from_str(&content).unwrap_or_default()
131                } else {
132                    serde_json::Map::new()
133                }
134            } else {
135                serde_json::Map::new()
136            };
137
138        if is_excluded_page(rel, &meta) {
139            continue;
140        }
141
142        let title = meta
143            .get("title")
144            .and_then(serde_json::Value::as_str)
145            .unwrap_or_default()
146            .to_string();
147        let description = meta
148            .get("description")
149            .and_then(serde_json::Value::as_str)
150            .unwrap_or_default()
151            .to_string();
152
153        // Build a URL path from the relative file path
154        let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
155
156        if !title.is_empty() {
157            entries.push((title, url, description));
158        }
159    }
160
161    Ok(entries)
162}
163
164/// Returns true if a page should be excluded from `llms.txt`.
165///
166/// Excludes pages that are drafts, private, or error pages (404).
167fn is_excluded_page(
168    path: &Path,
169    frontmatter: &serde_json::Map<String, serde_json::Value>,
170) -> bool {
171    // Exclude 404 and error pages
172    let file_name = path
173        .file_name()
174        .map(|n| n.to_string_lossy().to_lowercase())
175        .unwrap_or_default();
176    if file_name == "404.html" || file_name.starts_with("error") {
177        return true;
178    }
179
180    // Exclude drafts
181    if let Some(draft) = frontmatter.get("draft") {
182        if draft.as_bool().unwrap_or(false)
183            || draft.as_str().is_some_and(|s| s == "true")
184        {
185            return true;
186        }
187    }
188
189    // Exclude private pages
190    if let Some(private) = frontmatter.get("private") {
191        if private.as_bool().unwrap_or(false)
192            || private.as_str().is_some_and(|s| s == "true")
193        {
194            return true;
195        }
196    }
197
198    false
199}
200
201/// Groups page entries by their top-level directory.
202///
203/// Files at the root level are grouped under `"Pages"`.
204/// Subdirectory names are title-cased (e.g., `blog/` becomes `"Blog"`).
205fn group_pages_by_section(
206    entries: &[(String, String, String)],
207) -> BTreeMap<String, Vec<(String, String, String)>> {
208    let mut sections: BTreeMap<String, Vec<(String, String, String)>> =
209        BTreeMap::new();
210
211    for (title, url, description) in entries {
212        // url looks like "/blog/post.html" or "/index.html"
213        let trimmed = url.trim_start_matches('/');
214        let section = if let Some(slash) = trimmed.find('/') {
215            let dir = &trimmed[..slash];
216            titlecase_word(dir)
217        } else {
218            "Pages".to_string()
219        };
220
221        sections.entry(section).or_default().push((
222            title.clone(),
223            url.clone(),
224            description.clone(),
225        ));
226    }
227
228    sections
229}
230
231/// Title-cases a single word (first char uppercase, rest lowercase).
232fn titlecase_word(s: &str) -> String {
233    let mut chars = s.chars();
234    match chars.next() {
235        None => String::new(),
236        Some(first) => {
237            let upper: String = first.to_uppercase().collect();
238            format!("{upper}{}", chars.as_str().to_lowercase())
239        }
240    }
241}
242
243/// Parses `Disallow:` patterns from an existing `robots.txt` file.
244fn parse_robots_disallow(site_dir: &Path) -> Vec<String> {
245    let robots_path = site_dir.join("robots.txt");
246    let Ok(content) = fs::read_to_string(&robots_path) else {
247        return Vec::new();
248    };
249
250    content
251        .lines()
252        .filter_map(|line| {
253            let trimmed = line.trim();
254            if let Some(rest) = trimmed.strip_prefix("Disallow:") {
255                let pattern = rest.trim();
256                if !pattern.is_empty() {
257                    return Some(pattern.to_string());
258                }
259            }
260            None
261        })
262        .collect()
263}
264
265/// Generates `llms.txt` following the llmstxt.org v1 specification.
266///
267/// Format:
268/// ```text
269/// # {site_name}
270///
271/// > {site_description}
272///
273/// Language: {language}
274///
275/// ## {Section Name}
276/// - [{Page Title}]({URL}): {Description}
277///
278/// ## Disallow
279/// - {pattern from robots.txt}
280/// ```
281fn generate_llms_txt(
282    site_dir: &Path,
283    config: Option<&crate::cmd::SsgConfig>,
284) -> Result<()> {
285    let site_name = config.map_or("Site", |c| c.site_name.as_str());
286    let base_url = config.map_or("", |c| c.base_url.as_str());
287    let description = config.map_or("", |c| c.site_description.as_str());
288    let language = config
289        .map(|c| c.language.as_str())
290        .filter(|l| !l.is_empty())
291        .unwrap_or("en");
292    let canonical_root = base_url.trim_end_matches('/');
293
294    let mut content =
295        format!("# {site_name}\n\n> {description}\n\nLanguage: {language}\n");
296
297    // Collect and group pages
298    let entries = collect_page_entries(site_dir).unwrap_or_default();
299    let sections = group_pages_by_section(&entries);
300
301    for (section, pages) in &sections {
302        content.push_str(&format!("\n## {section}\n"));
303        for (title, url, desc) in pages {
304            let full_url = if canonical_root.is_empty() {
305                url.clone()
306            } else {
307                format!("{canonical_root}{url}")
308            };
309            if desc.is_empty() {
310                content.push_str(&format!("- [{title}]({full_url})\n"));
311            } else {
312                content.push_str(&format!("- [{title}]({full_url}): {desc}\n"));
313            }
314        }
315    }
316
317    // Disallow section from robots.txt
318    let disallow = parse_robots_disallow(site_dir);
319    if !disallow.is_empty() {
320        content.push_str("\n## Disallow\n");
321        for pattern in &disallow {
322            content.push_str(&format!("- {pattern}\n"));
323        }
324    }
325
326    fs::write(site_dir.join("llms.txt"), content)?;
327    log::info!("[ai] Generated llms.txt");
328    Ok(())
329}
330
331/// Generates `llms-full.txt` with full text content for each page.
332///
333/// Follows the same structure as `llms.txt` but includes the stripped
334/// HTML body content for each page rather than just a link index.
335fn generate_llms_full_txt(
336    site_dir: &Path,
337    config: Option<&crate::cmd::SsgConfig>,
338) -> Result<()> {
339    let site_name = config.map_or("Site", |c| c.site_name.as_str());
340    let base_url = config.map_or("", |c| c.base_url.as_str());
341    let description = config.map_or("", |c| c.site_description.as_str());
342    let language = config
343        .map(|c| c.language.as_str())
344        .filter(|l| !l.is_empty())
345        .unwrap_or("en");
346    let canonical_root = base_url.trim_end_matches('/');
347
348    let mut content =
349        format!("# {site_name}\n\n> {description}\n\nLanguage: {language}\n");
350
351    let html_files = collect_html_files(site_dir)?;
352
353    for html_path in &html_files {
354        let rel = html_path.strip_prefix(site_dir).unwrap_or(html_path);
355
356        // Read sidecar
357        let sidecar_path = html_path.with_extension("meta.json");
358        let meta: serde_json::Map<String, serde_json::Value> =
359            if sidecar_path.exists() {
360                if let Ok(c) = fs::read_to_string(&sidecar_path) {
361                    serde_json::from_str(&c).unwrap_or_default()
362                } else {
363                    serde_json::Map::new()
364                }
365            } else {
366                serde_json::Map::new()
367            };
368
369        if is_excluded_page(rel, &meta) {
370            continue;
371        }
372
373        let title = meta
374            .get("title")
375            .and_then(serde_json::Value::as_str)
376            .unwrap_or_default();
377
378        if title.is_empty() {
379            continue;
380        }
381
382        let url = format!("/{}", rel.to_string_lossy().replace('\\', "/"));
383        let full_url = if canonical_root.is_empty() {
384            url.clone()
385        } else {
386            format!("{canonical_root}{url}")
387        };
388
389        // Read and strip HTML content
390        let html = fs::read_to_string(html_path).unwrap_or_default();
391        let body_text = strip_html_tags(&extract_body(&html));
392        let trimmed = collapse_whitespace(&body_text);
393
394        content.push_str(&format!("\n---\n\n## [{title}]({full_url})\n\n"));
395        if !trimmed.is_empty() {
396            content.push_str(&trimmed);
397            content.push('\n');
398        }
399    }
400
401    fs::write(site_dir.join("llms-full.txt"), content)?;
402    log::info!("[ai] Generated llms-full.txt");
403    Ok(())
404}
405
406/// Extracts the content between `<body>` and `</body>` tags.
407fn extract_body(html: &str) -> String {
408    let lower = html.to_lowercase();
409    let start = lower
410        .find("<body")
411        .and_then(|i| lower[i..].find('>').map(|j| i + j + 1))
412        .unwrap_or(0);
413    let end = lower.find("</body>").unwrap_or(html.len());
414    html[start..end].to_string()
415}
416
417/// Strips HTML tags from a string, preserving text content.
418fn strip_html_tags(html: &str) -> String {
419    let mut result = String::with_capacity(html.len());
420    let mut in_tag = false;
421    for ch in html.chars() {
422        match ch {
423            '<' => in_tag = true,
424            '>' => in_tag = false,
425            _ if !in_tag => result.push(ch),
426            _ => {}
427        }
428    }
429    result
430}
431
432/// Collapses runs of whitespace into single spaces and trims.
433fn collapse_whitespace(s: &str) -> String {
434    let mut result = String::with_capacity(s.len());
435    let mut prev_ws = true; // start true to trim leading
436    for ch in s.chars() {
437        if ch.is_whitespace() {
438            if !prev_ws {
439                result.push(' ');
440                prev_ws = true;
441            }
442        } else {
443            result.push(ch);
444            prev_ws = false;
445        }
446    }
447    // Trim trailing space
448    if result.ends_with(' ') {
449        let _ = result.pop();
450    }
451    result
452}
453
454/// Counts `<img>` tags with no text alternative in an HTML string.
455///
456/// Uses the same rule as the accessibility check (`ssg_a11y`), so an
457/// empty `alt` on a decorative image (`aria-hidden="true"`,
458/// `role="presentation"`) is not counted, and both report the same
459/// images.
460fn count_missing_alt(html: &str) -> usize {
461    let lower = html.to_ascii_lowercase();
462    let mut count = 0;
463    let mut pos = 0;
464    while let Some(start) = lower[pos..].find("<img") {
465        let abs = pos + start;
466        let tag_end = ssg_a11y::find_tag_end(&lower, abs);
467        if ssg_a11y::img_missing_alt(&lower[abs..tag_end]) {
468            count += 1;
469        }
470        pos = tag_end;
471    }
472    count
473}
474
475/// Recursively collects HTML files (delegates to `crate::walk`).
476fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>> {
477    fail_point!("ai::collect-html-files", |_| {
478        Err(anyhow::anyhow!("injected: ai::collect-html-files"))
479    });
480    crate::walk::walk_files(dir, "html").map_err(Into::into)
481}
482
483#[cfg(test)]
484mod tests {
485    #![allow(clippy::unwrap_used, clippy::expect_used)]
486
487    use super::*;
488    use crate::cmd::SsgConfig;
489    use crate::test_support::init_logger;
490    use std::path::PathBuf;
491    use tempfile::{tempdir, TempDir};
492
493    // -------------------------------------------------------------------
494    // Test fixtures
495    // -------------------------------------------------------------------
496
497    fn make_site() -> (TempDir, PathBuf, PluginContext) {
498        init_logger();
499        let dir = tempdir().expect("create tempdir");
500        let site = dir.path().join("site");
501        fs::create_dir_all(&site).expect("mkdir site");
502        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
503        (dir, site, ctx)
504    }
505
506    /// Writes an HTML file and a companion `.meta.json` sidecar.
507    fn write_page(
508        site: &Path,
509        rel_path: &str,
510        title: &str,
511        description: &str,
512        extra_fields: &str,
513    ) {
514        let html_path = site.join(rel_path);
515        if let Some(parent) = html_path.parent() {
516            fs::create_dir_all(parent).unwrap();
517        }
518        let html = format!(
519            "<html><head><title>{title}</title></head>\
520             <body><h1>{title}</h1><p>{description}</p></body></html>"
521        );
522        fs::write(&html_path, html).unwrap();
523
524        let mut sidecar_json =
525            format!(r#"{{"title": "{title}", "description": "{description}""#);
526        if !extra_fields.is_empty() {
527            sidecar_json.push_str(", ");
528            sidecar_json.push_str(extra_fields);
529        }
530        sidecar_json.push('}');
531        fs::write(html_path.with_extension("meta.json"), sidecar_json).unwrap();
532    }
533
534    // -------------------------------------------------------------------
535    // AiPlugin — derive surface
536    // -------------------------------------------------------------------
537
538    #[test]
539    fn ai_plugin_is_copy_after_move() {
540        // Guards the `Copy` derive added in v0.0.34.
541        let plugin = AiPlugin;
542        let _copy = plugin;
543        assert_eq!(plugin.name(), "ai");
544    }
545
546    #[test]
547    fn name_returns_static_ai_identifier() {
548        assert_eq!(AiPlugin.name(), "ai");
549    }
550
551    // -------------------------------------------------------------------
552    // count_missing_alt — table-driven over the logical paths
553    // -------------------------------------------------------------------
554
555    #[test]
556    fn count_missing_alt_table_driven() {
557        let cases: &[(&str, usize, &str)] = &[
558            // (input, expected_count, comment)
559            (
560                r#"<img src="a.jpg" alt="ok">"#,
561                0,
562                "alt present and non-empty",
563            ),
564            (r#"<img src="a.jpg">"#, 1, "no alt attribute at all"),
565            (r#"<img src="a.jpg" alt="">"#, 1, "empty double-quoted alt"),
566            (r#"<img src="a.jpg" alt=''>"#, 1, "empty single-quoted alt"),
567            (
568                r#"<img src="a.jpg"><img src="b.jpg" alt="ok">"#,
569                1,
570                "first missing, second ok",
571            ),
572            (
573                r#"<img src="a.jpg"><img src="b.jpg">"#,
574                2,
575                "both missing — sequential scan progresses",
576            ),
577            ("", 0, "empty input → zero"),
578            ("<p>no images here</p>", 0, "no <img> tags at all"),
579            (r#"<IMG SRC="a.jpg" ALT="ok">"#, 0, "case-insensitive ALT"),
580            (r#"<IMG SRC="a.jpg">"#, 1, "uppercase tag, no alt"),
581            (
582                "<img class=\"brand-mark\" src=\"/assets/logo.svg\"\n     \
583                 width=\"28\" height=\"28\" alt=\"\" aria-hidden=\"true\" />",
584                0,
585                "empty alt on an aria-hidden image is decorative",
586            ),
587            (
588                r#"<img src="a.jpg" alt="" role="presentation">"#,
589                0,
590                "empty alt on a role=presentation image is decorative",
591            ),
592            (
593                r#"<img src="a.jpg" aria-hidden="true">"#,
594                1,
595                "aria-hidden does not excuse a missing alt attribute",
596            ),
597            (
598                r#"<img src="a.jpg" alt="" aria-hidden="false">"#,
599                1,
600                "aria-hidden=false is not decorative",
601            ),
602            (r#"<img src=a.jpg alt=photo>"#, 0, "unquoted alt"),
603            (
604                r#"<img src="data:image/svg+xml,<svg>" alt="ok">"#,
605                0,
606                "a '>' inside a quoted src does not end the tag",
607            ),
608        ];
609        for (input, expected, comment) in cases {
610            assert_eq!(
611                count_missing_alt(input),
612                *expected,
613                "{comment}: count_missing_alt({input:?})"
614            );
615        }
616    }
617
618    #[test]
619    fn count_missing_alt_unterminated_tag_does_not_panic() {
620        let result = count_missing_alt("<img src=foo");
621        assert!(result <= 1);
622    }
623
624    // -------------------------------------------------------------------
625    // parse_robots_disallow
626    // -------------------------------------------------------------------
627
628    #[test]
629    fn test_parse_robots_disallow() {
630        let dir = tempdir().expect("tempdir");
631
632        // Standard robots.txt with multiple directives
633        fs::write(
634            dir.path().join("robots.txt"),
635            "User-agent: *\nDisallow: /admin/\nDisallow: /private/\nAllow: /\n",
636        )
637        .unwrap();
638        let result = parse_robots_disallow(dir.path());
639        assert_eq!(result, vec!["/admin/", "/private/"]);
640    }
641
642    #[test]
643    fn test_parse_robots_disallow_empty_file() {
644        let dir = tempdir().expect("tempdir");
645        fs::write(dir.path().join("robots.txt"), "").unwrap();
646        let result = parse_robots_disallow(dir.path());
647        assert!(result.is_empty(), "{:?}", result);
648    }
649
650    #[test]
651    fn test_parse_robots_disallow_no_disallow_lines() {
652        let dir = tempdir().expect("tempdir");
653        fs::write(
654            dir.path().join("robots.txt"),
655            "User-agent: *\nAllow: /\nSitemap: https://example.com/sitemap.xml\n",
656        )
657        .unwrap();
658        let result = parse_robots_disallow(dir.path());
659        assert!(result.is_empty(), "{:?}", result);
660    }
661
662    #[test]
663    fn test_parse_robots_disallow_multiple_user_agents() {
664        let dir = tempdir().expect("tempdir");
665        fs::write(
666            dir.path().join("robots.txt"),
667            "User-agent: Googlebot\nDisallow: /nogoogle/\n\n\
668             User-agent: *\nDisallow: /secret/\n",
669        )
670        .unwrap();
671        let result = parse_robots_disallow(dir.path());
672        assert_eq!(result, vec!["/nogoogle/", "/secret/"]);
673    }
674
675    #[test]
676    fn test_parse_robots_disallow_missing_file() {
677        let dir = tempdir().expect("tempdir");
678        let result = parse_robots_disallow(dir.path());
679        assert!(result.is_empty(), "{:?}", result);
680    }
681
682    #[test]
683    fn test_parse_robots_disallow_empty_pattern_skipped() {
684        // `Disallow:` with no path means allow all — should be skipped
685        let dir = tempdir().expect("tempdir");
686        fs::write(
687            dir.path().join("robots.txt"),
688            "User-agent: *\nDisallow:\nDisallow: /blocked/\n",
689        )
690        .unwrap();
691        let result = parse_robots_disallow(dir.path());
692        assert_eq!(result, vec!["/blocked/"]);
693    }
694
695    // -------------------------------------------------------------------
696    // is_excluded_page
697    // -------------------------------------------------------------------
698
699    #[test]
700    fn test_is_excluded_page_draft() {
701        let mut meta = serde_json::Map::new();
702        let _ = meta.insert("draft".to_string(), serde_json::Value::Bool(true));
703        assert!(is_excluded_page(Path::new("post.html"), &meta));
704    }
705
706    #[test]
707    fn test_is_excluded_page_draft_string() {
708        let mut meta = serde_json::Map::new();
709        let _ = meta.insert(
710            "draft".to_string(),
711            serde_json::Value::String("true".to_string()),
712        );
713        assert!(is_excluded_page(Path::new("post.html"), &meta));
714    }
715
716    #[test]
717    fn test_is_excluded_page_private() {
718        let mut meta = serde_json::Map::new();
719        let _ =
720            meta.insert("private".to_string(), serde_json::Value::Bool(true));
721        assert!(is_excluded_page(Path::new("post.html"), &meta));
722    }
723
724    #[test]
725    fn test_is_excluded_page_404() {
726        let meta = serde_json::Map::new();
727        assert!(is_excluded_page(Path::new("404.html"), &meta));
728    }
729
730    #[test]
731    fn test_is_excluded_page_normal() {
732        let mut meta = serde_json::Map::new();
733        let _ = meta.insert(
734            "title".to_string(),
735            serde_json::Value::String("Hello".to_string()),
736        );
737        assert!(!is_excluded_page(Path::new("index.html"), &meta));
738    }
739
740    #[test]
741    fn test_is_excluded_page_error_page() {
742        let meta = serde_json::Map::new();
743        assert!(is_excluded_page(Path::new("error500.html"), &meta));
744    }
745
746    // -------------------------------------------------------------------
747    // group_pages_by_section
748    // -------------------------------------------------------------------
749
750    #[test]
751    fn test_group_pages_by_section() {
752        let entries = vec![
753            (
754                "Home".to_string(),
755                "/index.html".to_string(),
756                "Welcome".to_string(),
757            ),
758            (
759                "Post 1".to_string(),
760                "/blog/post1.html".to_string(),
761                "First".to_string(),
762            ),
763            (
764                "Post 2".to_string(),
765                "/blog/post2.html".to_string(),
766                "Second".to_string(),
767            ),
768            (
769                "API Ref".to_string(),
770                "/docs/api.html".to_string(),
771                "API docs".to_string(),
772            ),
773        ];
774        let grouped = group_pages_by_section(&entries);
775
776        assert_eq!(grouped.len(), 3);
777        assert!(grouped.contains_key("Pages"));
778        assert!(grouped.contains_key("Blog"));
779        assert!(grouped.contains_key("Docs"));
780        assert_eq!(grouped["Pages"].len(), 1);
781        assert_eq!(grouped["Blog"].len(), 2);
782        assert_eq!(grouped["Docs"].len(), 1);
783    }
784
785    #[test]
786    fn test_group_pages_by_section_root_only() {
787        let entries = vec![
788            (
789                "About".to_string(),
790                "/about.html".to_string(),
791                String::new(),
792            ),
793            (
794                "Contact".to_string(),
795                "/contact.html".to_string(),
796                String::new(),
797            ),
798        ];
799        let grouped = group_pages_by_section(&entries);
800        assert_eq!(grouped.len(), 1);
801        assert_eq!(grouped["Pages"].len(), 2);
802    }
803
804    #[test]
805    fn test_group_pages_by_section_deterministic_order() {
806        let entries = vec![
807            ("Z".to_string(), "/zebra/z.html".to_string(), String::new()),
808            ("A".to_string(), "/alpha/a.html".to_string(), String::new()),
809            ("M".to_string(), "/middle/m.html".to_string(), String::new()),
810        ];
811        let grouped = group_pages_by_section(&entries);
812        let keys: Vec<&String> = grouped.keys().collect();
813        assert_eq!(keys, vec!["Alpha", "Middle", "Zebra"]);
814    }
815
816    // -------------------------------------------------------------------
817    // generate_llms_txt — spec compliance
818    // -------------------------------------------------------------------
819
820    #[test]
821    #[serial_test::parallel(ai_failpoint)]
822    fn generate_llms_txt_with_full_config_includes_all_fields() {
823        let dir = tempdir().expect("tempdir");
824        let config = SsgConfig {
825            site_name: "My Site".to_string(),
826            site_description: "A great site".to_string(),
827            base_url: "https://example.com".to_string(),
828            language: "en".to_string(),
829            ..Default::default()
830        };
831
832        generate_llms_txt(dir.path(), Some(&config)).unwrap();
833        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
834        assert!(body.contains("# My Site"));
835        assert!(body.contains("> A great site"));
836        assert!(body.contains("Language: en"));
837    }
838
839    #[test]
840    #[serial_test::parallel(ai_failpoint)]
841    fn generate_llms_txt_without_config_uses_defaults() {
842        let dir = tempdir().expect("tempdir");
843        generate_llms_txt(dir.path(), None).unwrap();
844
845        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
846        assert!(body.contains("# Site"));
847        assert!(body.contains("Language: en"));
848    }
849
850    #[test]
851    #[serial_test::parallel(ai_failpoint)]
852    fn generate_llms_txt_strips_trailing_slash_from_base_url() {
853        let dir = tempdir().expect("tempdir");
854        let config = SsgConfig {
855            site_name: "S".to_string(),
856            site_description: "D".to_string(),
857            base_url: "https://example.com/".to_string(),
858            ..Default::default()
859        };
860
861        // Write a page so we can verify URL formatting
862        write_page(dir.path(), "index.html", "Home", "Welcome", "");
863
864        generate_llms_txt(dir.path(), Some(&config)).unwrap();
865        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
866        // URLs should not have double slashes
867        assert!(
868            !body.contains("//index.html"),
869            "trailing slash should be normalised:\n{body}"
870        );
871    }
872
873    #[test]
874    #[serial_test::parallel(ai_failpoint)]
875    fn generate_llms_txt_into_missing_parent_returns_err() {
876        let bogus = Path::new("/this/path/should/not/exist");
877        assert!(generate_llms_txt(bogus, None).is_err());
878    }
879
880    #[test]
881    #[serial_test::parallel(ai_failpoint)]
882    fn test_llms_txt_contains_language() {
883        let dir = tempdir().expect("tempdir");
884        let config = SsgConfig {
885            language: "fr".to_string(),
886            ..Default::default()
887        };
888        generate_llms_txt(dir.path(), Some(&config)).unwrap();
889        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
890        assert!(
891            body.contains("Language: fr"),
892            "llms.txt must include Language field:\n{body}"
893        );
894    }
895
896    #[test]
897    #[serial_test::parallel(ai_failpoint)]
898    fn test_llms_txt_contains_language_defaults_to_en() {
899        let dir = tempdir().expect("tempdir");
900        let config = SsgConfig {
901            language: String::new(),
902            ..Default::default()
903        };
904        generate_llms_txt(dir.path(), Some(&config)).unwrap();
905        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
906        assert!(
907            body.contains("Language: en"),
908            "empty language should default to en:\n{body}"
909        );
910    }
911
912    #[test]
913    #[serial_test::parallel(ai_failpoint)]
914    fn test_llms_txt_excludes_drafts() {
915        let dir = tempdir().expect("tempdir");
916        write_page(dir.path(), "published.html", "Published", "Visible", "");
917        write_page(
918            dir.path(),
919            "draft.html",
920            "Draft Post",
921            "Hidden",
922            r#""draft": true"#,
923        );
924
925        generate_llms_txt(dir.path(), None).unwrap();
926        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
927        assert!(
928            body.contains("Published"),
929            "published page must appear:\n{body}"
930        );
931        assert!(
932            !body.contains("Draft Post"),
933            "draft page must be excluded:\n{body}"
934        );
935    }
936
937    #[test]
938    #[serial_test::parallel(ai_failpoint)]
939    fn test_llms_txt_excludes_private() {
940        let dir = tempdir().expect("tempdir");
941        write_page(dir.path(), "public.html", "Public", "Visible", "");
942        write_page(
943            dir.path(),
944            "secret.html",
945            "Secret",
946            "Hidden",
947            r#""private": true"#,
948        );
949
950        generate_llms_txt(dir.path(), None).unwrap();
951        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
952        assert!(
953            !body.contains("Secret"),
954            "private page must be excluded:\n{body}"
955        );
956    }
957
958    #[test]
959    #[serial_test::parallel(ai_failpoint)]
960    fn test_llms_txt_excludes_404() {
961        let dir = tempdir().expect("tempdir");
962        write_page(dir.path(), "index.html", "Home", "Welcome", "");
963        write_page(dir.path(), "404.html", "Not Found", "Error page", "");
964
965        generate_llms_txt(dir.path(), None).unwrap();
966        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
967        assert!(
968            !body.contains("Not Found"),
969            "404 page must be excluded:\n{body}"
970        );
971    }
972
973    #[test]
974    #[serial_test::parallel(ai_failpoint)]
975    fn test_llms_txt_contains_sections() {
976        let dir = tempdir().expect("tempdir");
977        write_page(dir.path(), "index.html", "Home", "Welcome", "");
978        write_page(dir.path(), "blog/post.html", "My Post", "A blog post", "");
979        write_page(
980            dir.path(),
981            "docs/api.html",
982            "API Docs",
983            "API reference",
984            "",
985        );
986
987        generate_llms_txt(dir.path(), None).unwrap();
988        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
989        assert!(
990            body.contains("## Pages"),
991            "should have Pages section:\n{body}"
992        );
993        assert!(
994            body.contains("## Blog"),
995            "should have Blog section:\n{body}"
996        );
997        assert!(
998            body.contains("## Docs"),
999            "should have Docs section:\n{body}"
1000        );
1001        assert!(
1002            body.contains("- [My Post]"),
1003            "should contain page link:\n{body}"
1004        );
1005    }
1006
1007    #[test]
1008    #[serial_test::parallel(ai_failpoint)]
1009    fn test_llms_txt_contains_disallow_section() {
1010        let dir = tempdir().expect("tempdir");
1011        fs::write(
1012            dir.path().join("robots.txt"),
1013            "User-agent: *\nDisallow: /admin/\n",
1014        )
1015        .unwrap();
1016
1017        generate_llms_txt(dir.path(), None).unwrap();
1018        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
1019        assert!(
1020            body.contains("## Disallow"),
1021            "should have Disallow section:\n{body}"
1022        );
1023        assert!(
1024            body.contains("- /admin/"),
1025            "should contain disallow pattern:\n{body}"
1026        );
1027    }
1028
1029    #[test]
1030    #[serial_test::parallel(ai_failpoint)]
1031    fn test_llms_txt_no_disallow_without_robots() {
1032        let dir = tempdir().expect("tempdir");
1033        generate_llms_txt(dir.path(), None).unwrap();
1034        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
1035        assert!(
1036            !body.contains("## Disallow"),
1037            "no robots.txt means no Disallow section:\n{body}"
1038        );
1039    }
1040
1041    // -------------------------------------------------------------------
1042    // generate_llms_full_txt
1043    // -------------------------------------------------------------------
1044
1045    #[test]
1046    #[serial_test::parallel(ai_failpoint)]
1047    fn test_llms_full_txt_contains_body_content() {
1048        let dir = tempdir().expect("tempdir");
1049        write_page(dir.path(), "index.html", "Home", "Welcome home", "");
1050
1051        generate_llms_full_txt(dir.path(), None).unwrap();
1052        let body =
1053            fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1054        assert!(body.contains("# Site"), "header present:\n{body}");
1055        assert!(body.contains("Language: en"), "language present:\n{body}");
1056        assert!(body.contains("## [Home]"), "page title present:\n{body}");
1057        assert!(body.contains("Welcome home"), "body text present:\n{body}");
1058    }
1059
1060    #[test]
1061    #[serial_test::parallel(ai_failpoint)]
1062    fn test_llms_full_txt_excludes_drafts() {
1063        let dir = tempdir().expect("tempdir");
1064        write_page(dir.path(), "ok.html", "Visible", "Content", "");
1065        write_page(
1066            dir.path(),
1067            "hidden.html",
1068            "Hidden",
1069            "Secret",
1070            r#""draft": true"#,
1071        );
1072
1073        generate_llms_full_txt(dir.path(), None).unwrap();
1074        let body =
1075            fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1076        assert!(body.contains("Visible"), "published page present:\n{body}");
1077        assert!(!body.contains("Hidden"), "draft excluded:\n{body}");
1078    }
1079
1080    #[test]
1081    #[serial_test::parallel(ai_failpoint)]
1082    fn test_llms_full_txt_excludes_404() {
1083        let dir = tempdir().expect("tempdir");
1084        write_page(dir.path(), "index.html", "Home", "Welcome", "");
1085        write_page(dir.path(), "404.html", "Not Found", "Error", "");
1086
1087        generate_llms_full_txt(dir.path(), None).unwrap();
1088        let body =
1089            fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1090        assert!(!body.contains("Not Found"), "404 excluded:\n{body}");
1091    }
1092
1093    // -------------------------------------------------------------------
1094    // strip_html_tags / extract_body / collapse_whitespace
1095    // -------------------------------------------------------------------
1096
1097    #[test]
1098    fn test_strip_html_tags() {
1099        assert_eq!(strip_html_tags("<p>hello</p>"), "hello");
1100        assert_eq!(strip_html_tags("<div><b>bold</b> text</div>"), "bold text");
1101        assert_eq!(strip_html_tags("no tags"), "no tags");
1102        assert_eq!(strip_html_tags(""), "");
1103    }
1104
1105    #[test]
1106    fn test_extract_body() {
1107        let html =
1108            "<html><head><title>T</title></head><body>Content</body></html>";
1109        assert_eq!(extract_body(html), "Content");
1110    }
1111
1112    #[test]
1113    fn test_extract_body_with_attributes() {
1114        let html = "<html><body class=\"main\">Content</body></html>";
1115        assert_eq!(extract_body(html), "Content");
1116    }
1117
1118    #[test]
1119    fn test_extract_body_no_body_tag() {
1120        let html = "<p>Just a fragment</p>";
1121        assert_eq!(extract_body(html), html);
1122    }
1123
1124    #[test]
1125    fn test_collapse_whitespace() {
1126        assert_eq!(collapse_whitespace("  hello   world  "), "hello world");
1127        assert_eq!(collapse_whitespace("no  extra"), "no extra");
1128        assert_eq!(collapse_whitespace(""), "");
1129    }
1130
1131    // -------------------------------------------------------------------
1132    // titlecase_word
1133    // -------------------------------------------------------------------
1134
1135    #[test]
1136    fn test_titlecase_word() {
1137        assert_eq!(titlecase_word("blog"), "Blog");
1138        assert_eq!(titlecase_word("DOCS"), "Docs");
1139        assert_eq!(titlecase_word(""), "");
1140        assert_eq!(titlecase_word("a"), "A");
1141    }
1142
1143    // -------------------------------------------------------------------
1144    // after_compile — short-circuit + dispatch paths
1145    // -------------------------------------------------------------------
1146
1147    #[test]
1148    fn after_compile_missing_site_dir_returns_ok_without_writing() {
1149        let dir = tempdir().expect("tempdir");
1150        let missing = dir.path().join("missing");
1151        let ctx =
1152            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
1153
1154        AiPlugin.after_compile(&ctx).expect("missing site is fine");
1155        assert!(!missing.exists());
1156        assert!(!dir.path().join("llms.txt").exists());
1157    }
1158
1159    #[test]
1160    #[serial_test::parallel(ai_failpoint)]
1161    fn after_compile_injects_max_snippet_meta_tag() {
1162        let (_tmp, site, ctx) = make_site();
1163        let html = "<html><head><title>X</title></head><body></body></html>";
1164        fs::write(site.join("index.html"), html).unwrap();
1165
1166        AiPlugin.after_compile(&ctx).unwrap();
1167        let output = fs::read_to_string(site.join("index.html")).unwrap();
1168        assert!(output.contains("max-snippet"));
1169        assert!(output.contains("max-image-preview:large"));
1170    }
1171
1172    #[test]
1173    #[serial_test::parallel(ai_failpoint)]
1174    fn after_compile_creates_llms_txt_in_site_root() {
1175        let (_tmp, site, ctx) = make_site();
1176        AiPlugin.after_compile(&ctx).unwrap();
1177        assert!(site.join("llms.txt").exists());
1178    }
1179
1180    #[test]
1181    #[serial_test::parallel(ai_failpoint)]
1182    fn after_compile_creates_llms_full_txt_in_site_root() {
1183        let (_tmp, site, ctx) = make_site();
1184        AiPlugin.after_compile(&ctx).unwrap();
1185        assert!(site.join("llms-full.txt").exists());
1186    }
1187
1188    #[test]
1189    #[serial_test::parallel(ai_failpoint)]
1190    fn after_compile_idempotent_does_not_duplicate_meta_tag() {
1191        let (_tmp, site, ctx) = make_site();
1192        let html = "<html><head><title>X</title></head><body></body></html>";
1193        fs::write(site.join("index.html"), html).unwrap();
1194
1195        AiPlugin.after_compile(&ctx).unwrap();
1196        AiPlugin.after_compile(&ctx).unwrap();
1197
1198        let output = fs::read_to_string(site.join("index.html")).unwrap();
1199        assert_eq!(output.matches("max-snippet").count(), 1);
1200    }
1201
1202    #[test]
1203    #[serial_test::parallel(ai_failpoint)]
1204    fn after_compile_skips_html_files_without_head_tag() {
1205        let (_tmp, site, ctx) = make_site();
1206        fs::write(site.join("fragment.html"), "<p>just a fragment</p>")
1207            .unwrap();
1208
1209        AiPlugin.after_compile(&ctx).unwrap();
1210        let output = fs::read_to_string(site.join("fragment.html")).unwrap();
1211        assert!(!output.contains("max-snippet"));
1212        assert_eq!(output, "<p>just a fragment</p>");
1213    }
1214
1215    #[test]
1216    #[serial_test::parallel(ai_failpoint)]
1217    fn after_compile_processes_files_in_subdirectories() {
1218        let (_tmp, site, ctx) = make_site();
1219        let nested = site.join("blog");
1220        fs::create_dir_all(&nested).unwrap();
1221        fs::write(
1222            nested.join("post.html"),
1223            "<html><head></head><body></body></html>",
1224        )
1225        .unwrap();
1226
1227        AiPlugin.after_compile(&ctx).unwrap();
1228        let output = fs::read_to_string(nested.join("post.html")).unwrap();
1229        assert!(output.contains("max-snippet"));
1230    }
1231
1232    #[test]
1233    #[serial_test::parallel(ai_failpoint)]
1234    fn after_compile_logs_warning_for_pages_with_missing_alt() {
1235        let (_tmp, site, ctx) = make_site();
1236        fs::write(
1237            site.join("bad.html"),
1238            r#"<html><head></head><body><img src="a.jpg"></body></html>"#,
1239        )
1240        .unwrap();
1241        fs::write(
1242            site.join("worse.html"),
1243            r#"<html><head></head><body><img src="a.jpg" alt=""></body></html>"#,
1244        )
1245        .unwrap();
1246
1247        AiPlugin.after_compile(&ctx).unwrap();
1248        let bad = fs::read_to_string(site.join("bad.html")).unwrap();
1249        assert!(bad.contains("max-snippet"));
1250    }
1251
1252    #[test]
1253    #[serial_test::parallel(ai_failpoint)]
1254    fn after_compile_does_not_rewrite_unchanged_files() {
1255        let (_tmp, site, ctx) = make_site();
1256        let html = "<html><head><meta name=\"robots\" content=\"max-snippet:-1\"></head><body></body></html>";
1257        fs::write(site.join("index.html"), html).unwrap();
1258        let original_mtime = fs::metadata(site.join("index.html"))
1259            .unwrap()
1260            .modified()
1261            .unwrap();
1262
1263        AiPlugin.after_compile(&ctx).unwrap();
1264        let after = fs::read_to_string(site.join("index.html")).unwrap();
1265        assert_eq!(after, html, "unchanged file body must be preserved");
1266        let _ = original_mtime;
1267    }
1268
1269    // -------------------------------------------------------------------
1270    // collect_html_files — recursion + filtering
1271    // -------------------------------------------------------------------
1272
1273    #[test]
1274    #[serial_test::parallel(ai_failpoint)]
1275    fn collect_html_files_returns_empty_for_missing_directory() {
1276        let dir = tempdir().expect("tempdir");
1277        let result = collect_html_files(&dir.path().join("missing")).unwrap();
1278        assert!(result.is_empty(), "{:?}", result);
1279    }
1280
1281    #[test]
1282    #[serial_test::parallel(ai_failpoint)]
1283    fn collect_html_files_filters_non_html_extensions() {
1284        let dir = tempdir().expect("tempdir");
1285        fs::write(dir.path().join("a.html"), "").unwrap();
1286        fs::write(dir.path().join("b.css"), "").unwrap();
1287        fs::write(dir.path().join("c.js"), "").unwrap();
1288
1289        let result = collect_html_files(dir.path()).unwrap();
1290        assert_eq!(result.len(), 1);
1291    }
1292
1293    #[test]
1294    #[serial_test::parallel(ai_failpoint)]
1295    fn collect_html_files_recurses_into_nested_subdirectories() {
1296        let dir = tempdir().expect("tempdir");
1297        let nested = dir.path().join("a").join("b");
1298        fs::create_dir_all(&nested).unwrap();
1299        fs::write(dir.path().join("top.html"), "").unwrap();
1300        fs::write(nested.join("deep.html"), "").unwrap();
1301
1302        let result = collect_html_files(dir.path()).unwrap();
1303        assert_eq!(result.len(), 2);
1304    }
1305
1306    #[test]
1307    #[serial_test::parallel(ai_failpoint)]
1308    fn collect_html_files_returns_results_sorted() {
1309        let dir = tempdir().expect("tempdir");
1310        for name in ["zebra.html", "apple.html", "mango.html"] {
1311            fs::write(dir.path().join(name), "").unwrap();
1312        }
1313        let result = collect_html_files(dir.path()).unwrap();
1314        let names: Vec<_> = result
1315            .iter()
1316            .map(|p| p.file_name().unwrap().to_str().unwrap())
1317            .collect();
1318        assert_eq!(names, vec!["apple.html", "mango.html", "zebra.html"]);
1319    }
1320
1321    // -------------------------------------------------------------------
1322    // is_excluded_page — falsy draft/private values and string forms
1323    // -------------------------------------------------------------------
1324
1325    #[test]
1326    fn test_is_excluded_page_draft_false_is_not_excluded() {
1327        let mut meta = serde_json::Map::new();
1328        let _ =
1329            meta.insert("draft".to_string(), serde_json::Value::Bool(false));
1330        assert!(!is_excluded_page(Path::new("post.html"), &meta));
1331    }
1332
1333    #[test]
1334    fn test_is_excluded_page_private_string_true() {
1335        let mut meta = serde_json::Map::new();
1336        let _ = meta.insert(
1337            "private".to_string(),
1338            serde_json::Value::String("true".to_string()),
1339        );
1340        assert!(is_excluded_page(Path::new("post.html"), &meta));
1341    }
1342
1343    #[test]
1344    fn test_is_excluded_page_private_false_is_not_excluded() {
1345        let mut meta = serde_json::Map::new();
1346        let _ =
1347            meta.insert("private".to_string(), serde_json::Value::Bool(false));
1348        assert!(!is_excluded_page(Path::new("post.html"), &meta));
1349    }
1350
1351    // -------------------------------------------------------------------
1352    // Sidecar read failures — a directory named *.meta.json exists but
1353    // cannot be read as a file, driving the empty-map fallback arm.
1354    // -------------------------------------------------------------------
1355
1356    #[test]
1357    #[serial_test::parallel(ai_failpoint)]
1358    fn collect_page_entries_unreadable_sidecar_falls_back_to_empty_meta() {
1359        let dir = tempdir().expect("tempdir");
1360        fs::write(
1361            dir.path().join("page.html"),
1362            "<html><head></head><body>Hi</body></html>",
1363        )
1364        .unwrap();
1365        // Directory at the sidecar path: exists() is true, but
1366        // read_to_string fails (EISDIR), so meta falls back to an
1367        // empty map and the page (no title) is dropped.
1368        fs::create_dir_all(dir.path().join("page.meta.json")).unwrap();
1369
1370        let entries = collect_page_entries(dir.path()).unwrap();
1371        assert!(
1372            entries.is_empty(),
1373            "titleless page must be dropped: {entries:?}"
1374        );
1375    }
1376
1377    #[test]
1378    #[serial_test::parallel(ai_failpoint)]
1379    fn llms_full_txt_unreadable_sidecar_skips_page() {
1380        let dir = tempdir().expect("tempdir");
1381        fs::write(
1382            dir.path().join("page.html"),
1383            "<html><head></head><body>Hidden body</body></html>",
1384        )
1385        .unwrap();
1386        fs::create_dir_all(dir.path().join("page.meta.json")).unwrap();
1387
1388        generate_llms_full_txt(dir.path(), None).unwrap();
1389        let body =
1390            fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1391        assert!(
1392            !body.contains("Hidden body"),
1393            "page without readable sidecar (no title) must be skipped:\n{body}"
1394        );
1395    }
1396
1397    // -------------------------------------------------------------------
1398    // llms.txt — entry line without a description
1399    // -------------------------------------------------------------------
1400
1401    #[test]
1402    #[serial_test::parallel(ai_failpoint)]
1403    fn llms_txt_entry_without_description_omits_colon_suffix() {
1404        let dir = tempdir().expect("tempdir");
1405        write_page(dir.path(), "index.html", "Home", "", "");
1406
1407        generate_llms_txt(dir.path(), None).unwrap();
1408        let body = fs::read_to_string(dir.path().join("llms.txt")).unwrap();
1409        assert!(
1410            body.contains("- [Home](/index.html)\n"),
1411            "description-less entry must be a bare link:\n{body}"
1412        );
1413        assert!(
1414            !body.contains("- [Home](/index.html):"),
1415            "no trailing colon without a description:\n{body}"
1416        );
1417    }
1418
1419    // -------------------------------------------------------------------
1420    // llms-full.txt — canonical absolute URLs when base_url is set
1421    // -------------------------------------------------------------------
1422
1423    #[test]
1424    #[serial_test::parallel(ai_failpoint)]
1425    fn llms_full_txt_uses_canonical_root_for_urls() {
1426        let dir = tempdir().expect("tempdir");
1427        write_page(dir.path(), "index.html", "Home", "Welcome", "");
1428
1429        let config = SsgConfig {
1430            site_name: "S".to_string(),
1431            base_url: "https://example.com/".to_string(),
1432            ..Default::default()
1433        };
1434        generate_llms_full_txt(dir.path(), Some(&config)).unwrap();
1435        let body =
1436            fs::read_to_string(dir.path().join("llms-full.txt")).unwrap();
1437        assert!(
1438            body.contains("## [Home](https://example.com/index.html)"),
1439            "page URL must be prefixed with the canonical root:\n{body}"
1440        );
1441    }
1442
1443    // -------------------------------------------------------------------
1444    // is_excluded_page — string values that don't match "true": the
1445    // `is_some_and(|s| s == "true")` closure only runs when the field
1446    // is a JSON string, and only returns `true` when it isn't literally
1447    // "true"/"false" as a bool. Prior tests only exercised Bool and the
1448    // exact string "true", so the closure body evaluating to `false`
1449    // was never exercised.
1450    // -------------------------------------------------------------------
1451
1452    #[test]
1453    fn test_is_excluded_page_draft_string_non_true_is_not_excluded() {
1454        let mut meta = serde_json::Map::new();
1455        let _ = meta.insert(
1456            "draft".to_string(),
1457            serde_json::Value::String("no".to_string()),
1458        );
1459        assert!(!is_excluded_page(Path::new("post.html"), &meta));
1460    }
1461
1462    #[test]
1463    fn test_is_excluded_page_private_string_non_true_is_not_excluded() {
1464        let mut meta = serde_json::Map::new();
1465        let _ = meta.insert(
1466            "private".to_string(),
1467            serde_json::Value::String("no".to_string()),
1468        );
1469        assert!(!is_excluded_page(Path::new("post.html"), &meta));
1470    }
1471
1472    // -------------------------------------------------------------------
1473    // after_compile — error propagation through each of the four
1474    // `.map_err(...)` sites (llms.txt, llms-full.txt, collect_html_files,
1475    // process_html_for_ai). Each squats the plugin's own output path
1476    // with a directory so the underlying `fs::write`/read fails.
1477    // -------------------------------------------------------------------
1478
1479    #[test]
1480    #[serial_test::parallel(ai_failpoint)]
1481    fn after_compile_propagates_llms_txt_write_error() {
1482        let (_tmp, site, ctx) = make_site();
1483        // `llms.txt` is a pre-existing directory, so `fs::write` fails
1484        // inside `generate_llms_txt`, and the error must propagate
1485        // through the first `.map_err` in `after_compile`.
1486        fs::create_dir_all(site.join("llms.txt")).unwrap();
1487        let err = AiPlugin.after_compile(&ctx).unwrap_err();
1488        assert!(!format!("{err}").is_empty(), "should not be empty");
1489    }
1490
1491    #[test]
1492    #[serial_test::parallel(ai_failpoint)]
1493    fn after_compile_propagates_llms_full_txt_write_error() {
1494        let (_tmp, site, ctx) = make_site();
1495        // `llms.txt` writes fine; `llms-full.txt` is squatted by a
1496        // directory so the second stage fails.
1497        fs::create_dir_all(site.join("llms-full.txt")).unwrap();
1498        let err = AiPlugin.after_compile(&ctx).unwrap_err();
1499        assert!(!format!("{err}").is_empty(), "should not be empty");
1500    }
1501
1502    #[test]
1503    #[serial_test::parallel(ai_failpoint)]
1504    #[cfg(unix)]
1505    fn after_compile_fails_when_html_file_is_unreadable() {
1506        use std::os::unix::fs::PermissionsExt;
1507        let (_tmp, site, ctx) = make_site();
1508        let html = site.join("locked.html");
1509        fs::write(&html, "<html><head></head><body></body></html>").unwrap();
1510        fs::set_permissions(&html, fs::Permissions::from_mode(0o000)).unwrap();
1511
1512        let res = AiPlugin.after_compile(&ctx);
1513
1514        let _ = fs::set_permissions(&html, fs::Permissions::from_mode(0o644));
1515        // Root CI runners bypass permission checks; only assert when the
1516        // read genuinely failed.
1517        if let Err(e) = res {
1518            assert!(!format!("{e}").is_empty(), "should not be empty");
1519        }
1520    }
1521}
1522
1523// -------------------------------------------------------------------
1524// Fault injection — `ai::collect-html-files` isolates the third of
1525// three `collect_html_files` calls that a single `after_compile` run
1526// makes (the first, inside `collect_page_entries`, is swallowed by
1527// `generate_llms_txt`'s `unwrap_or_default`; the second, inside
1528// `generate_llms_full_txt`, propagates but is indistinguishable from
1529// the plugin's own direct call without sequencing the failpoint).
1530// -------------------------------------------------------------------
1531#[cfg(all(test, feature = "test-fault-injection"))]
1532mod fault_tests {
1533    use super::*;
1534    use crate::plugin::PluginContext;
1535    use tempfile::tempdir;
1536
1537    /// RAII guard that disables a failpoint on drop.
1538    struct FailGuard(&'static str);
1539
1540    impl Drop for FailGuard {
1541        fn drop(&mut self) {
1542            let _ = fail::cfg(self.0, "off");
1543        }
1544    }
1545
1546    #[test]
1547    #[serial_test::serial(ai_failpoint)]
1548    fn collect_html_files_third_call_failpoint_propagates() {
1549        let _guard = FailGuard("ai::collect-html-files");
1550        // Let the first two invocations (collect_page_entries via
1551        // generate_llms_txt, then generate_llms_full_txt's own call)
1552        // succeed; fail on the third (after_compile's direct call).
1553        fail::cfg("ai::collect-html-files", "2*off->1*return")
1554            .expect("activate failpoint");
1555
1556        let dir = tempdir().unwrap();
1557        let site = dir.path().join("site");
1558        fs::create_dir_all(&site).unwrap();
1559        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1560
1561        let err = AiPlugin
1562            .after_compile(&ctx)
1563            .expect_err("third collect_html_files call must propagate");
1564        assert!(format!("{err:?}").contains("injected: ai::collect-html-files"));
1565    }
1566}