Skip to main content

ssg/plugins/
llm.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Local LLM content plugin.
5//!
6//! Invokes a local LLM (Ollama, llama.cpp) at build time to auto-generate:
7//! - `alt` text for images missing it
8//! - `meta description` for pages where it's empty or < 50 chars
9//! - JSON-LD `description` fields from page content
10//!
11//! Configured via the `[ai]` section in `ssg.toml`:
12//! ```toml
13//! [ai]
14//! model = "llama3"
15//! endpoint = "http://localhost:11434"
16//! ```
17//!
18//! Graceful fallback: if no LLM is reachable, logs a warning and skips.
19
20use super::llm_cache::LlmCache;
21use crate::error::{PathErrorExt, SsgError};
22use crate::plugin::{Plugin, PluginContext};
23use crate::util::head_dom::inject_before_head_close;
24use anyhow::Result;
25use std::{
26    fs,
27    path::{Path, PathBuf},
28    time::Duration,
29};
30
31/// Default per-call timeout for the local LLM HTTP roundtrip.
32///
33/// Matches the `llm.timeout_secs` config field (issue #520). Two
34/// minutes covers a cold-load of a ~7B parameter model on a
35/// modest workstation while still failing fast in the common
36/// "endpoint refused" path.
37const DEFAULT_LLM_TIMEOUT_SECS: u64 = 120;
38
39/// Short timeout used for the "is the endpoint alive?" probe.
40///
41/// Keeps build pipelines fast: if the user does not have Ollama
42/// running locally, the plugin must bail in well under a second
43/// rather than blocking the whole compile on a 2-minute timeout.
44const HEALTH_CHECK_TIMEOUT_SECS: u64 = 2;
45
46/// Configuration for the LLM plugin.
47#[derive(Debug, Clone)]
48pub struct LlmConfig {
49    /// Model name (e.g., `"llama3"`, `"mistral"`).
50    pub model: String,
51    /// Ollama API endpoint.
52    pub endpoint: String,
53    /// If true, print generated text but don't write files.
54    pub dry_run: bool,
55    /// Target Flesch-Kincaid Grade Level (default: 8.0).
56    pub target_grade: f64,
57    /// Max refinement attempts if readability exceeds target (default: 1).
58    pub max_refinement_attempts: usize,
59    /// Per-call HTTP timeout for the local LLM endpoint, in seconds
60    /// (default: `120`). Set via `llm.timeout_secs` in `ssg.toml`.
61    /// Exceeding this budget returns
62    /// [`SsgError::LlmTimeout`](crate::error::SsgError::LlmTimeout) —
63    /// no zombie subprocess is left behind because the call goes
64    /// through `ureq`, not `curl` (issue #520).
65    pub timeout_secs: u64,
66    /// When `true`, skip the deterministic content-hash cache and
67    /// always perform a live inference (issue #528). Driven by the
68    /// `--no-llm-cache` CLI flag and the `SSG_NO_LLM_CACHE` env var
69    /// so users debugging non-determinism can rule the cache out
70    /// without nuking it on disk.
71    pub cache_disabled: bool,
72    /// Optional override for the on-disk cache root. `None` resolves
73    /// to [`LlmCache::default_cache_dir`] at call time — the
74    /// platform-correct path (XDG / Library / `%LOCALAPPDATA%`)
75    /// chosen by [`LlmCache`]. Tests and operators wanting a
76    /// project-local cache override this directly.
77    pub cache_dir: Option<PathBuf>,
78}
79
80impl Default for LlmConfig {
81    fn default() -> Self {
82        // `SSG_NO_LLM_CACHE` (any non-empty value other than `0`,
83        // `false`, `off`) disables the deterministic cache. The
84        // pipeline sets this when `--no-llm-cache` is passed; users
85        // can also export it ad-hoc to debug a cache pathology.
86        let cache_disabled = std::env::var("SSG_NO_LLM_CACHE")
87            .ok()
88            .filter(|v| !v.is_empty())
89            .is_some_and(|v| {
90                !matches!(v.as_str(), "0" | "false" | "off" | "FALSE" | "OFF")
91            });
92        Self {
93            model: "llama3".to_string(),
94            endpoint: "http://localhost:11434".to_string(),
95            dry_run: false,
96            target_grade: 8.0,
97            max_refinement_attempts: 1,
98            timeout_secs: DEFAULT_LLM_TIMEOUT_SECS,
99            cache_disabled,
100            cache_dir: None,
101        }
102    }
103}
104
105/// Plugin that uses a local LLM to augment content at build time.
106#[derive(Debug)]
107pub struct LlmPlugin {
108    config: LlmConfig,
109}
110
111impl LlmPlugin {
112    /// Creates a new `LlmPlugin` with the given configuration.
113    ///
114    /// # Examples
115    ///
116    /// ```rust
117    /// use ssg::llm::{LlmConfig, LlmPlugin};
118    /// use ssg::plugin::Plugin;
119    ///
120    /// let p = LlmPlugin::new(LlmConfig::default());
121    /// assert_eq!(p.name(), "llm");
122    /// ```
123    #[must_use]
124    pub const fn new(config: LlmConfig) -> Self {
125        Self { config }
126    }
127}
128
129/// Result of auditing a single file's readability.
130#[derive(Debug, Clone, serde::Serialize)]
131pub struct FileAuditResult {
132    /// Relative file path.
133    pub path: String,
134    /// Flesch-Kincaid Grade Level.
135    pub grade_level: f64,
136    /// Flesch Reading Ease score.
137    pub reading_ease: f64,
138    /// Average words per sentence.
139    pub avg_sentence_len: f64,
140    /// Whether it passes the target grade threshold.
141    pub passes: bool,
142}
143
144/// Aggregated readability audit report.
145#[derive(Debug, Clone, serde::Serialize)]
146pub struct AuditReport {
147    /// Target grade level used for pass/fail.
148    pub target_grade: f64,
149    /// Total files scanned.
150    pub total_files: usize,
151    /// Files that pass the readability threshold.
152    pub passing: usize,
153    /// Files that exceed the readability threshold.
154    pub failing: usize,
155    /// Per-file results.
156    pub results: Vec<FileAuditResult>,
157}
158
159/// Result of the agentic AI fix pipeline for a single file.
160#[derive(Debug, Clone, serde::Serialize)]
161pub struct AiFixResult {
162    /// Relative file path.
163    pub path: String,
164    /// Grade level before fix attempt.
165    pub before_grade: f64,
166    /// Grade level after fix attempt (same as before if not improved).
167    pub after_grade: f64,
168    /// Whether the fix improved readability.
169    pub improved: bool,
170    /// Action taken: "rewritten", "skipped", "no-improvement", "ollama-unavailable".
171    pub action: String,
172}
173
174/// Aggregated report from the agentic AI fix pipeline.
175#[derive(Debug, Clone, serde::Serialize)]
176pub struct AiFixReport {
177    /// Total files audited.
178    pub total_audited: usize,
179    /// Files that failed the readability threshold.
180    pub total_failing: usize,
181    /// Files successfully improved.
182    pub total_fixed: usize,
183    /// Per-file results.
184    pub results: Vec<AiFixResult>,
185}
186
187impl LlmPlugin {
188    /// Audits all Markdown files in a directory for readability.
189    ///
190    /// Returns a structured report with per-file Flesch-Kincaid scores.
191    /// Does not require an LLM — uses the local `ReadabilityAudit` engine.
192    ///
193    /// **Note:** The syllable heuristic is English-only. Non-English
194    /// content (Bengali, Hindi, Turkish, etc.) produces inflated scores.
195    /// Use the `en/` subdirectory for accurate results on multilingual
196    /// repos, or filter results by locale.
197    ///
198    /// # Examples
199    ///
200    /// ```rust
201    /// use ssg::llm::LlmPlugin;
202    /// use tempfile::tempdir;
203    ///
204    /// let dir = tempdir().unwrap();
205    /// let report = LlmPlugin::audit_all(dir.path(), 8.0).unwrap();
206    /// // Empty dir ⇒ no files audited.
207    /// assert_eq!(report.total_files, 0);
208    /// ```
209    pub fn audit_all(
210        content_dir: &Path,
211        target_grade: f64,
212    ) -> Result<AuditReport> {
213        let md_files =
214            crate::walk::walk_files(content_dir, "md").unwrap_or_default();
215
216        let mut results = Vec::with_capacity(md_files.len());
217
218        for path in &md_files {
219            let Ok(content) = fs::read_to_string(path) else {
220                continue; // File may have been removed by a concurrent test
221            };
222            // Strip frontmatter before auditing prose
223            let body = strip_frontmatter(&content);
224            // Detect language from frontmatter
225            let lang = extract_frontmatter_lang(&content);
226            let audit = ReadabilityAudit::analyze_with_lang(&body, &lang);
227            let rel = path
228                .strip_prefix(content_dir)
229                .unwrap_or(path)
230                .to_string_lossy()
231                .to_string();
232
233            results.push(FileAuditResult {
234                path: rel,
235                grade_level: (audit.grade_level * 10.0).round() / 10.0,
236                reading_ease: (audit.reading_ease * 10.0).round() / 10.0,
237                avg_sentence_len: (audit.avg_sentence_len * 10.0).round()
238                    / 10.0,
239                passes: audit.grade_level <= target_grade,
240            });
241        }
242
243        let passing = results.iter().filter(|r| r.passes).count();
244        let failing = results.len() - passing;
245
246        Ok(AuditReport {
247            target_grade,
248            total_files: results.len(),
249            passing,
250            failing,
251            results,
252        })
253    }
254
255    /// Audits and rewrites failing Markdown files via LLM refinement.
256    ///
257    /// For each file that exceeds `target_grade`:
258    /// 1. Extracts the prose body (strips frontmatter)
259    /// 2. Sends it to the LLM with a simplification prompt
260    /// 3. If the refined version scores better, writes it back
261    ///    (preserving the original frontmatter)
262    /// 4. If `dry_run`, prints the diff without writing
263    ///
264    /// Returns the number of files rewritten.
265    ///
266    /// # Examples
267    ///
268    /// ```rust
269    /// use ssg::llm::{LlmConfig, LlmPlugin};
270    /// use tempfile::tempdir;
271    ///
272    /// let dir = tempdir().unwrap();
273    /// // No Ollama reachable ⇒ returns Ok(0) without writing anything.
274    /// let cfg = LlmConfig {
275    ///     endpoint: "http://127.0.0.1:1".into(),
276    ///     ..LlmConfig::default()
277    /// };
278    /// assert_eq!(LlmPlugin::audit_and_fix(dir.path(), &cfg).unwrap(), 0);
279    /// ```
280    pub fn audit_and_fix(
281        content_dir: &Path,
282        config: &LlmConfig,
283    ) -> Result<usize> {
284        if !is_ollama_available(&config.endpoint) {
285            log::warn!(
286                "[llm] Ollama not reachable at {}, skipping auto-fix",
287                config.endpoint
288            );
289            return Ok(0);
290        }
291
292        let report = Self::audit_all(content_dir, config.target_grade)?;
293        let failing: Vec<_> =
294            report.results.iter().filter(|r| !r.passes).collect();
295
296        if failing.is_empty() {
297            log::info!(
298                "[llm] All {} file(s) pass grade {:.0}",
299                report.total_files,
300                config.target_grade
301            );
302            return Ok(0);
303        }
304
305        let failing_count = failing.len();
306        log::info!(
307            "[llm] {} file(s) exceed grade {:.0}, attempting refinement",
308            failing_count,
309            config.target_grade
310        );
311
312        let mut rewritten = 0usize;
313
314        for result in &failing {
315            let path = content_dir.join(&result.path);
316            let original = fs::read_to_string(&path)?;
317            let (frontmatter_block, body) = split_frontmatter(&original);
318            let body_trimmed = body.trim();
319
320            if body_trimmed.is_empty() {
321                continue;
322            }
323
324            let prompt = format!(
325                "Rewrite this Markdown content at a 6th-grade reading level. \
326                 Rules:\n\
327                 - Max 20 words per sentence\n\
328                 - Max 4 sentences per paragraph\n\
329                 - Use simple, common words\n\
330                 - Keep ALL facts, numbers, dates, and code blocks exactly the same\n\
331                 - Keep ALL Markdown headings (#, ##, ###) and formatting\n\
332                 - Return ONLY the rewritten Markdown, nothing else\n\n\
333                 {body_trimmed}"
334            );
335
336            if let Some(refined) = generate_with_refinement(
337                &config.endpoint,
338                &config.model,
339                &prompt,
340                config.target_grade,
341                config.max_refinement_attempts,
342            ) {
343                let refined_audit = ReadabilityAudit::analyze(&refined);
344                let original_audit = ReadabilityAudit::analyze(body_trimmed);
345
346                if refined_audit.grade_level < original_audit.grade_level {
347                    if config.dry_run {
348                        log::info!(
349                            "[llm] [dry-run] {}: grade {:.1} → {:.1}",
350                            result.path,
351                            original_audit.grade_level,
352                            refined_audit.grade_level
353                        );
354                    } else {
355                        // Reassemble: frontmatter + refined body
356                        let output =
357                            format!("{frontmatter_block}\n{refined}\n");
358                        fs::write(&path, output)?;
359                        log::info!(
360                            "[llm] Rewrote {}: grade {:.1} → {:.1}",
361                            result.path,
362                            original_audit.grade_level,
363                            refined_audit.grade_level
364                        );
365                        rewritten += 1;
366                    }
367                } else {
368                    log::warn!(
369                        "[llm] Could not improve {}: grade {:.1} (refined: {:.1})",
370                        result.path,
371                        original_audit.grade_level,
372                        refined_audit.grade_level
373                    );
374                }
375            }
376        }
377
378        Ok(rewritten)
379    }
380
381    /// Agentic pipeline: audit → diagnose → fix → verify → report.
382    ///
383    /// Like `audit_and_fix()` but returns a detailed JSON-serialisable
384    /// report with before/after scores for each file.
385    ///
386    /// # Examples
387    ///
388    /// ```rust
389    /// use ssg::llm::{LlmConfig, LlmPlugin};
390    /// use tempfile::tempdir;
391    ///
392    /// let dir = tempdir().unwrap();
393    /// let cfg = LlmConfig {
394    ///     endpoint: "http://127.0.0.1:1".into(),
395    ///     ..LlmConfig::default()
396    /// };
397    /// let report = LlmPlugin::audit_and_fix_with_report(dir.path(), &cfg).unwrap();
398    /// assert_eq!(report.total_fixed, 0);
399    /// ```
400    pub fn audit_and_fix_with_report(
401        content_dir: &Path,
402        config: &LlmConfig,
403    ) -> Result<AiFixReport> {
404        if !is_ollama_available(&config.endpoint) {
405            log::warn!(
406                "[ai-fix] Ollama not reachable at {}, skipping",
407                config.endpoint
408            );
409            return Ok(AiFixReport {
410                total_audited: 0,
411                total_failing: 0,
412                total_fixed: 0,
413                results: vec![],
414            });
415        }
416
417        let report = Self::audit_all(content_dir, config.target_grade)?;
418        let failing: Vec<_> =
419            report.results.iter().filter(|r| !r.passes).collect();
420        let mut fix_results = Vec::new();
421
422        for result in &failing {
423            let path = content_dir.join(&result.path);
424            let Ok(original) = read_fix_source(&path) else {
425                fix_results.push(AiFixResult {
426                    path: result.path.clone(),
427                    before_grade: result.grade_level,
428                    after_grade: result.grade_level,
429                    improved: false,
430                    action: "skipped".to_string(),
431                });
432                continue;
433            };
434            let (frontmatter_block, body) = split_frontmatter(&original);
435            let body_trimmed = body.trim();
436
437            if body_trimmed.is_empty() {
438                fix_results.push(AiFixResult {
439                    path: result.path.clone(),
440                    before_grade: result.grade_level,
441                    after_grade: result.grade_level,
442                    improved: false,
443                    action: "skipped".to_string(),
444                });
445                continue;
446            }
447
448            let prompt = format!(
449                "Rewrite this Markdown content at a 6th-grade reading level. \
450                 Rules:\n\
451                 - Max 20 words per sentence\n\
452                 - Max 4 sentences per paragraph\n\
453                 - Use simple, common words\n\
454                 - Keep ALL facts, numbers, dates, and code blocks exactly the same\n\
455                 - Keep ALL Markdown headings (#, ##, ###) and formatting\n\
456                 - Return ONLY the rewritten Markdown, nothing else\n\n\
457                 {body_trimmed}"
458            );
459
460            if let Some(refined) = generate_with_refinement(
461                &config.endpoint,
462                &config.model,
463                &prompt,
464                config.target_grade,
465                config.max_refinement_attempts,
466            ) {
467                let refined_audit = ReadabilityAudit::analyze(&refined);
468                let original_audit = ReadabilityAudit::analyze(body_trimmed);
469
470                if refined_audit.grade_level < original_audit.grade_level {
471                    if !config.dry_run {
472                        let output =
473                            format!("{frontmatter_block}\n{refined}\n");
474                        fs::write(&path, output)?;
475                    }
476                    fix_results.push(AiFixResult {
477                        path: result.path.clone(),
478                        before_grade: (original_audit.grade_level * 10.0)
479                            .round()
480                            / 10.0,
481                        after_grade: (refined_audit.grade_level * 10.0).round()
482                            / 10.0,
483                        improved: true,
484                        action: if config.dry_run {
485                            "dry-run".to_string()
486                        } else {
487                            "rewritten".to_string()
488                        },
489                    });
490                } else {
491                    fix_results.push(AiFixResult {
492                        path: result.path.clone(),
493                        before_grade: (original_audit.grade_level * 10.0)
494                            .round()
495                            / 10.0,
496                        after_grade: (refined_audit.grade_level * 10.0).round()
497                            / 10.0,
498                        improved: false,
499                        action: "no-improvement".to_string(),
500                    });
501                }
502            } else {
503                fix_results.push(AiFixResult {
504                    path: result.path.clone(),
505                    before_grade: result.grade_level,
506                    after_grade: result.grade_level,
507                    improved: false,
508                    action: "skipped".to_string(),
509                });
510            }
511        }
512
513        let total_fixed = fix_results.iter().filter(|r| r.improved).count();
514
515        Ok(AiFixReport {
516            total_audited: report.total_files,
517            total_failing: failing.len(),
518            total_fixed,
519            results: fix_results,
520        })
521    }
522}
523
524/// Re-reads a file selected by the audit pass for LLM refinement.
525///
526/// Kept as a dedicated seam so the unreadable-file arm of
527/// [`LlmPlugin::audit_and_fix_with_report`] is drivable via the
528/// `llm::fix-read` failpoint — the real trigger is a TOCTOU window
529/// (file audited, then removed before the fix pass re-reads it)
530/// that cannot be produced deterministically from a test.
531fn read_fix_source(path: &Path) -> std::io::Result<String> {
532    fail_point!("llm::fix-read", |_| {
533        Err(std::io::Error::other("injected: llm::fix-read"))
534    });
535    fs::read_to_string(path)
536}
537
538/// Splits content into `(frontmatter_block, body)`.
539///
540/// The frontmatter block includes delimiters so it can be
541/// reassembled verbatim. Returns `("", content)` if no
542/// frontmatter is found.
543fn split_frontmatter(content: &str) -> (String, String) {
544    let trimmed = content.trim_start();
545    let leading_ws = &content[..content.len() - trimmed.len()];
546
547    for delim in ["---", "+++"] {
548        if let Some(rest) = trimmed.strip_prefix(delim) {
549            if let Some(end) = rest.find(delim) {
550                let fm_end = delim.len() + end + delim.len();
551                let frontmatter = &trimmed[..fm_end];
552                let body = &trimmed[fm_end..];
553                return (
554                    format!("{leading_ws}{frontmatter}"),
555                    body.to_string(),
556                );
557            }
558        }
559    }
560
561    (String::new(), content.to_string())
562}
563
564/// Extracts the `language` or `lang` field from YAML/TOML frontmatter.
565fn extract_frontmatter_lang(content: &str) -> String {
566    let trimmed = content.trim_start();
567    for delim in ["---", "+++"] {
568        if let Some(rest) = trimmed.strip_prefix(delim) {
569            if let Some(end) = rest.find(delim) {
570                let fm = &rest[..end];
571                // Try YAML-style: `language: en` or `lang: en`
572                for line in fm.lines() {
573                    let line = line.trim();
574                    for key in ["language:", "lang:"] {
575                        if let Some(val) = line.strip_prefix(key) {
576                            let val =
577                                val.trim().trim_matches('"').trim_matches('\'');
578                            if !val.is_empty() {
579                                return val.to_string();
580                            }
581                        }
582                    }
583                }
584                // Try TOML-style: `language = "en"` or `lang = "en"`
585                for line in fm.lines() {
586                    let line = line.trim();
587                    for key in ["language", "lang"] {
588                        if line.starts_with(key) {
589                            if let Some(val) = line.split('=').nth(1) {
590                                let val = val
591                                    .trim()
592                                    .trim_matches('"')
593                                    .trim_matches('\'');
594                                if !val.is_empty() {
595                                    return val.to_string();
596                                }
597                            }
598                        }
599                    }
600                }
601            }
602        }
603    }
604    String::new()
605}
606
607/// Strips YAML/TOML frontmatter from Markdown content.
608fn strip_frontmatter(content: &str) -> String {
609    let trimmed = content.trim_start();
610    for delim in ["---", "+++"] {
611        if let Some(rest) = trimmed.strip_prefix(delim) {
612            if let Some(end) = rest.find(delim) {
613                return rest[end + delim.len()..].to_string();
614            }
615        }
616    }
617    content.to_string()
618}
619
620impl Plugin for LlmPlugin {
621    fn name(&self) -> &'static str {
622        "llm"
623    }
624
625    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
626        if !ctx.site_dir.exists() {
627            return Ok(());
628        }
629
630        // Check if Ollama is available
631        if !is_ollama_available(&self.config.endpoint) {
632            log::warn!(
633                "[llm] Ollama not reachable at {}, skipping AI augmentation",
634                self.config.endpoint
635            );
636            return Ok(());
637        }
638
639        let html_files = ctx.get_html_files();
640        let mut augmented = 0usize;
641
642        for path in &html_files {
643            let html = fs::read_to_string(path).with_path(path)?;
644            let mut modified = html.clone();
645
646            // Auto-generate meta descriptions for pages with short/missing ones
647            if needs_meta_description(&modified) {
648                if let Some(desc) = generate_meta_description(
649                    &modified,
650                    &self.config.model,
651                    &self.config.endpoint,
652                    self.config.target_grade,
653                    self.config.max_refinement_attempts,
654                ) {
655                    let audit = ReadabilityAudit::analyze(&desc);
656                    if self.config.dry_run {
657                        let rel = path
658                            .strip_prefix(&ctx.site_dir)
659                            .unwrap_or(path)
660                            .display();
661                        log::info!(
662                            "[llm] [dry-run] {rel}: description = {desc}"
663                        );
664                        log::info!(
665                            "[llm] [dry-run] {rel}: grade={:.1}, ease={:.1}, avg_sentence={:.1}",
666                            audit.grade_level, audit.reading_ease, audit.avg_sentence_len
667                        );
668                    } else {
669                        modified = inject_meta_description(&modified, &desc);
670                        // Also populate JSON-LD Article description
671                        modified = inject_jsonld_description(&modified, &desc);
672                    }
673                }
674            }
675
676            // Auto-generate alt text for images missing it
677            let alt_count = generate_missing_alt_text(
678                &mut modified,
679                &self.config.model,
680                &self.config.endpoint,
681                self.config.dry_run,
682                path,
683                &ctx.site_dir,
684            );
685
686            if !self.config.dry_run && modified != html {
687                fs::write(path, &modified).with_path(path)?;
688                augmented += 1;
689            }
690
691            if alt_count > 0 {
692                augmented += 1;
693            }
694        }
695
696        if augmented > 0 {
697            log::info!(
698                "[llm] Augmented {augmented} page(s) with model '{}'",
699                self.config.model
700            );
701        }
702
703        Ok(())
704    }
705}
706
707/// Checks if Ollama is reachable at the given endpoint.
708///
709/// Uses an in-process `ureq` GET with a short
710/// `HEALTH_CHECK_TIMEOUT_SECS` budget. Replaces the previous
711/// `curl` shellout (issue #520) so the probe works on Windows
712/// runners without `curl.exe` in `$PATH` and cannot fail
713/// silently in restricted environments.
714fn is_ollama_available(endpoint: &str) -> bool {
715    let agent = ureq::AgentBuilder::new()
716        .timeout(Duration::from_secs(HEALTH_CHECK_TIMEOUT_SECS))
717        .build();
718    matches!(agent.get(endpoint).call(), Ok(resp) if resp.status() < 500)
719}
720
721/// Returns true if the page needs a meta description (missing or < 50 chars).
722fn needs_meta_description(html: &str) -> bool {
723    if let Some(start) = html.find("name=\"description\"") {
724        if let Some(content_start) = html[start..].find("content=\"") {
725            let abs = start + content_start + 9;
726            if let Some(end) = html[abs..].find('"') {
727                let desc = &html[abs..abs + end];
728                return desc.len() < 50;
729            }
730        }
731    }
732    // No description meta tag found
733    !html.contains("name=\"description\"")
734}
735
736/// Generates a meta description via LLM with readability refinement.
737fn generate_meta_description(
738    html: &str,
739    model: &str,
740    endpoint: &str,
741    target_grade: f64,
742    max_attempts: usize,
743) -> Option<String> {
744    let text = extract_page_text(html, 500);
745    if text.len() < 20 {
746        return None;
747    }
748
749    let prompt = format!(
750        "Write a concise SEO meta description (120-155 characters) for this page content. \
751         Use simple words and short sentences. \
752         Return ONLY the description text, no quotes or explanation:\n\n{text}"
753    );
754
755    generate_with_refinement(
756        endpoint,
757        model,
758        &prompt,
759        target_grade,
760        max_attempts,
761    )
762}
763
764/// Injects a meta description tag into the HTML head.
765fn inject_meta_description(html: &str, description: &str) -> String {
766    let escaped = description
767        .replace('&', "&amp;")
768        .replace('"', "&quot;")
769        .replace('<', "&lt;");
770    let tag = format!("<meta name=\"description\" content=\"{escaped}\">\n");
771    inject_before_head_close(html, &tag)
772}
773
774/// Generates alt text for images that are missing it.
775fn generate_missing_alt_text(
776    html: &mut String,
777    model: &str,
778    endpoint: &str,
779    dry_run: bool,
780    path: &Path,
781    site_dir: &Path,
782) -> usize {
783    let mut count = 0;
784    let mut search_from = 0;
785
786    while let Some(start) = html[search_from..].find("<img") {
787        let abs_start = search_from + start;
788        let Some(tag_end) = html[abs_start..].find('>') else {
789            break;
790        };
791        let tag_end_abs = abs_start + tag_end + 1;
792        let tag = &html[abs_start..tag_end_abs];
793
794        // The replacement below can only fill `alt=""` or add a new
795        // attribute, so those are the shapes it acts on; the shared rule
796        // then leaves decorative images (`aria-hidden="true"`,
797        // `role="presentation"`) alone.
798        let fillable = !tag.contains("alt=") || tag.contains("alt=\"\"");
799        if fillable && ssg_a11y::img_missing_alt(&tag.to_ascii_lowercase()) {
800            // Extract src for context
801            let src = extract_attr(tag, "src").unwrap_or_default();
802            let prompt = format!(
803                "Describe this image for an alt text attribute. The image file is named '{}'. \
804                 Return ONLY the alt text (max 125 characters), no quotes:\n",
805                src
806            );
807
808            if let Some(alt) = call_ollama(endpoint, model, &prompt) {
809                let alt = alt.trim().replace('"', "&quot;");
810                if dry_run {
811                    let rel =
812                        path.strip_prefix(site_dir).unwrap_or(path).display();
813                    log::info!(
814                        "[llm] [dry-run] {rel}: alt=\"{alt}\" for {src}"
815                    );
816                } else {
817                    // Replace the tag with one that has alt text
818                    let new_tag = if tag.contains("alt=\"\"") {
819                        tag.replace("alt=\"\"", &format!("alt=\"{alt}\""))
820                    } else {
821                        tag.replace("<img", &format!("<img alt=\"{alt}\""))
822                    };
823                    html.replace_range(abs_start..tag_end_abs, &new_tag);
824                }
825                count += 1;
826            }
827        }
828
829        search_from = tag_end_abs;
830    }
831
832    count
833}
834
835/// Extracts plain text from HTML for LLM prompting.
836fn extract_page_text(html: &str, max_chars: usize) -> String {
837    let body_start = html
838        .find("<main")
839        .or_else(|| html.find("<body"))
840        .unwrap_or(0);
841    let body = &html[body_start..];
842
843    let mut text = String::with_capacity(max_chars + 50);
844    let mut in_tag = false;
845    for ch in body.chars() {
846        if text.len() >= max_chars {
847            break;
848        }
849        match ch {
850            '<' => in_tag = true,
851            '>' => in_tag = false,
852            _ if !in_tag && !ch.is_control() => text.push(ch),
853            _ => {}
854        }
855    }
856
857    text.split_whitespace().collect::<Vec<_>>().join(" ")
858}
859
860/// Extracts an attribute value from an HTML tag.
861fn extract_attr(tag: &str, attr: &str) -> Option<String> {
862    let pattern = format!("{attr}=\"");
863    let start = tag.find(&pattern)? + pattern.len();
864    let end = tag[start..].find('"')? + start;
865    Some(tag[start..end].to_string())
866}
867
868// =====================================================================
869// Readability intelligence
870// =====================================================================
871
872/// Readability formula selection based on content language.
873///
874/// Marked `#[non_exhaustive]` so additional formulae (Dale-Chall,
875/// Linsear-Write, Coleman-Liau) can ship in minor versions.
876#[derive(Debug, Clone, Copy, PartialEq, Eq)]
877#[non_exhaustive]
878pub enum ReadabilityFormula {
879    /// Flesch-Kincaid (English).
880    FleschKincaid,
881    /// Kandel-Moles (French).
882    KandelMoles,
883    /// Wiener Sachtextformel (German).
884    WienerSachtextformel,
885    /// Gulpease index (Italian).
886    Gulpease,
887    /// LIX readability (Swedish/Scandinavian).
888    Lix,
889    /// Fernández Huerta (Spanish).
890    FernandezHuerta,
891}
892
893impl ReadabilityFormula {
894    /// Selects the appropriate formula from a language code.
895    ///
896    /// Accepts BCP 47 codes (e.g., `"en"`, `"fr"`, `"de-AT"`).
897    /// Returns `None` for unsupported languages.
898    ///
899    /// # Examples
900    ///
901    /// ```rust
902    /// use ssg::llm::ReadabilityFormula;
903    ///
904    /// assert_eq!(ReadabilityFormula::from_lang("en"), Some(ReadabilityFormula::FleschKincaid));
905    /// assert_eq!(ReadabilityFormula::from_lang("xx"), None);
906    /// ```
907    #[must_use]
908    pub fn from_lang(lang: &str) -> Option<Self> {
909        let primary = lang.split(['-', '_']).next().unwrap_or(lang);
910        match primary.to_lowercase().as_str() {
911            "en" => Some(Self::FleschKincaid),
912            "fr" => Some(Self::KandelMoles),
913            "de" => Some(Self::WienerSachtextformel),
914            "it" => Some(Self::Gulpease),
915            "sv" | "nb" | "nn" | "da" | "no" => Some(Self::Lix),
916            "es" => Some(Self::FernandezHuerta),
917            _ => None,
918        }
919    }
920}
921
922/// Readability metrics for a text passage.
923#[derive(Debug, Clone, Copy)]
924pub struct ReadabilityAudit {
925    /// Flesch-Kincaid Grade Level (lower = simpler).
926    pub grade_level: f64,
927    /// Flesch Reading Ease (higher = easier, 0–100).
928    pub reading_ease: f64,
929    /// Average words per sentence.
930    pub avg_sentence_len: f64,
931}
932
933impl ReadabilityAudit {
934    /// Analyzes text and returns readability metrics.
935    ///
936    /// # Examples
937    ///
938    /// ```rust
939    /// use ssg::llm::ReadabilityAudit;
940    ///
941    /// let a = ReadabilityAudit::analyze("This is a simple sentence.");
942    /// assert!(a.avg_sentence_len > 0.0);
943    /// ```
944    #[must_use]
945    pub fn analyze(text: &str) -> Self {
946        let words = count_words(text);
947        let sentences = count_sentences(text);
948        let syllables = count_syllables(text);
949
950        if words == 0 || sentences == 0 {
951            return Self {
952                grade_level: 0.0,
953                reading_ease: 100.0,
954                avg_sentence_len: 0.0,
955            };
956        }
957
958        let wps = words as f64 / sentences as f64;
959        let spw = syllables as f64 / words as f64;
960
961        let grade = 0.39f64.mul_add(wps, 11.8f64.mul_add(spw, -15.59));
962        let ease = (-1.015f64).mul_add(wps, (-84.6f64).mul_add(spw, 206.835));
963
964        Self {
965            grade_level: grade.max(0.0),
966            reading_ease: ease.clamp(0.0, 100.0),
967            avg_sentence_len: wps,
968        }
969    }
970
971    /// Analyzes text using the appropriate formula for the given language.
972    ///
973    /// Falls back to Flesch-Kincaid if the language is unsupported or empty.
974    ///
975    /// # Examples
976    ///
977    /// ```rust
978    /// use ssg::llm::ReadabilityAudit;
979    ///
980    /// let a = ReadabilityAudit::analyze_with_lang("Bonjour le monde.", "fr");
981    /// assert!(a.avg_sentence_len > 0.0);
982    /// ```
983    #[must_use]
984    pub fn analyze_with_lang(text: &str, lang: &str) -> Self {
985        let formula = if lang.is_empty() {
986            ReadabilityFormula::FleschKincaid
987        } else {
988            ReadabilityFormula::from_lang(lang)
989                .unwrap_or(ReadabilityFormula::FleschKincaid)
990        };
991
992        let words = count_words(text);
993        let sentences = count_sentences(text);
994        let syllables = count_syllables(text);
995        let chars: usize = text.chars().filter(|c| c.is_alphanumeric()).count();
996
997        if words == 0 || sentences == 0 {
998            return Self {
999                grade_level: 0.0,
1000                reading_ease: 100.0,
1001                avg_sentence_len: 0.0,
1002            };
1003        }
1004
1005        let wps = words as f64 / sentences as f64;
1006        let spw = syllables as f64 / words as f64;
1007
1008        match formula {
1009            ReadabilityFormula::FleschKincaid => Self::analyze(text),
1010
1011            ReadabilityFormula::KandelMoles => {
1012                // Kandel-Moles reading ease (French)
1013                let ease = 68.0f64.mul_add(-spw, 1.15f64.mul_add(-wps, 209.0));
1014                Self {
1015                    grade_level: ((100.0 - ease.clamp(0.0, 100.0)) / 10.0)
1016                        .max(0.0),
1017                    reading_ease: ease.clamp(0.0, 100.0),
1018                    avg_sentence_len: wps,
1019                }
1020            }
1021
1022            ReadabilityFormula::WienerSachtextformel => {
1023                // Wiener Sachtextformel (German)
1024                let word_list: Vec<&str> = text.split_whitespace().collect();
1025                let total = word_list.len().max(1) as f64;
1026                let pct_3plus_syl = word_list
1027                    .iter()
1028                    .filter(|w| count_word_syllables(w) >= 3)
1029                    .count() as f64
1030                    / total
1031                    * 100.0;
1032                let pct_6plus_char = word_list
1033                    .iter()
1034                    .filter(|w| {
1035                        w.chars().filter(|c| c.is_alphabetic()).count() > 6
1036                    })
1037                    .count() as f64
1038                    / total
1039                    * 100.0;
1040                let pct_1syl = word_list
1041                    .iter()
1042                    .filter(|w| count_word_syllables(w) == 1)
1043                    .count() as f64
1044                    / total
1045                    * 100.0;
1046
1047                let grade = 0.1935f64.mul_add(
1048                    pct_3plus_syl,
1049                    0.1672f64.mul_add(
1050                        wps,
1051                        (-0.1297f64).mul_add(
1052                            pct_6plus_char,
1053                            (-0.0327f64).mul_add(pct_1syl, -0.875),
1054                        ),
1055                    ),
1056                );
1057
1058                Self {
1059                    grade_level: grade.max(0.0),
1060                    reading_ease: grade
1061                        .clamp(0.0, 20.0)
1062                        .mul_add(-5.0, 100.0)
1063                        .clamp(0.0, 100.0),
1064                    avg_sentence_len: wps,
1065                }
1066            }
1067
1068            ReadabilityFormula::Gulpease => {
1069                // Gulpease index (Italian)
1070                let ease = 89.0
1071                    + 10.0f64
1072                        .mul_add(-(chars as f64), 300.0 * sentences as f64)
1073                        / words as f64;
1074                Self {
1075                    grade_level: ((100.0 - ease.clamp(0.0, 100.0)) / 10.0)
1076                        .max(0.0),
1077                    reading_ease: ease.clamp(0.0, 100.0),
1078                    avg_sentence_len: wps,
1079                }
1080            }
1081
1082            ReadabilityFormula::Lix => {
1083                // LIX (Swedish/Scandinavian)
1084                let word_list: Vec<&str> = text.split_whitespace().collect();
1085                let total = word_list.len().max(1) as f64;
1086                let long_words = word_list
1087                    .iter()
1088                    .filter(|w| {
1089                        w.chars().filter(|c| c.is_alphabetic()).count() > 6
1090                    })
1091                    .count() as f64;
1092                let lix = wps + 100.0 * long_words / total;
1093                // LIX scale: <25 very easy, 25-35 easy, 35-45 medium,
1094                // 45-55 hard, >55 very hard
1095                Self {
1096                    grade_level: (lix / 5.0).max(0.0),
1097                    reading_ease: (100.0 - lix).clamp(0.0, 100.0),
1098                    avg_sentence_len: wps,
1099                }
1100            }
1101
1102            ReadabilityFormula::FernandezHuerta => {
1103                // Fernández Huerta (Spanish)
1104                let ease =
1105                    1.02f64.mul_add(-wps, (-60.0f64).mul_add(spw, 206.84));
1106                Self {
1107                    grade_level: ((100.0 - ease.clamp(0.0, 100.0)) / 10.0)
1108                        .max(0.0),
1109                    reading_ease: ease.clamp(0.0, 100.0),
1110                    avg_sentence_len: wps,
1111                }
1112            }
1113        }
1114    }
1115}
1116
1117/// Counts words in text (whitespace-separated tokens).
1118fn count_words(text: &str) -> usize {
1119    text.split_whitespace().count()
1120}
1121
1122/// Counts sentences by splitting on `.`, `!`, `?`.
1123fn count_sentences(text: &str) -> usize {
1124    text.chars()
1125        .filter(|&c| c == '.' || c == '!' || c == '?')
1126        .count()
1127        .max(1)
1128}
1129
1130/// Counts syllables using a lightweight heuristic:
1131/// - Count vowel groups (consecutive vowels = 1 syllable)
1132/// - Subtract silent trailing 'e'
1133/// - Minimum 1 syllable per word
1134fn count_syllables(text: &str) -> usize {
1135    text.split_whitespace()
1136        .map(|word| count_word_syllables(word))
1137        .sum()
1138}
1139
1140/// Counts syllables in a single word.
1141fn count_word_syllables(word: &str) -> usize {
1142    let word = word.to_lowercase();
1143    let chars: Vec<char> = word.chars().filter(|c| c.is_alphabetic()).collect();
1144    if chars.is_empty() {
1145        return 1;
1146    }
1147
1148    let vowels = b"aeiouy";
1149    let mut count = 0usize;
1150    let mut prev_vowel = false;
1151
1152    for &ch in &chars {
1153        let is_vowel = vowels.contains(&(ch as u8));
1154        if is_vowel && !prev_vowel {
1155            count += 1;
1156        }
1157        prev_vowel = is_vowel;
1158    }
1159
1160    // Subtract silent trailing 'e'
1161    if chars.len() > 2 && chars.last() == Some(&'e') && count > 1 {
1162        count -= 1;
1163    }
1164
1165    count.max(1)
1166}
1167
1168/// Generates text via LLM with readability-driven refinement.
1169///
1170/// If the initial output exceeds `target_grade`, re-prompts the LLM
1171/// once to simplify. Keeps the best available draft on failure.
1172fn generate_with_refinement(
1173    endpoint: &str,
1174    model: &str,
1175    prompt: &str,
1176    target_grade: f64,
1177    max_attempts: usize,
1178) -> Option<String> {
1179    let mut text = call_ollama(endpoint, model, prompt)?;
1180    let mut audit = ReadabilityAudit::analyze(&text);
1181
1182    for attempt in 0..max_attempts {
1183        if audit.grade_level <= target_grade {
1184            break;
1185        }
1186
1187        let attempt_num = attempt + 1;
1188        log::info!(
1189            "[llm] Grade {:.1} exceeds target {:.1}, refining (attempt {})",
1190            audit.grade_level,
1191            target_grade,
1192            attempt_num
1193        );
1194
1195        let simplify_prompt = format!(
1196            "Rewrite this text at a 6th-grade reading level. \
1197             Use short sentences (max 20 words). Use simple words. \
1198             Keep all facts and numbers exactly the same. \
1199             Return ONLY the rewritten text:\n\n{text}"
1200        );
1201
1202        if let Some(refined) = call_ollama(endpoint, model, &simplify_prompt) {
1203            let refined_audit = ReadabilityAudit::analyze(&refined);
1204            if refined_audit.grade_level < audit.grade_level {
1205                text = refined;
1206                audit = refined_audit;
1207            }
1208        }
1209    }
1210
1211    Some(text)
1212}
1213
1214// =====================================================================
1215// JSON-LD generation
1216// =====================================================================
1217
1218/// Injects or updates a JSON-LD `Article` script block in the HTML head.
1219///
1220/// Populates `description`, `datePublished`, and `author` from the page
1221/// content and frontmatter sidecar.
1222fn inject_jsonld_description(html: &str, description: &str) -> String {
1223    // Skip if JSON-LD Article already has a description
1224    if html.contains("\"@type\":\"Article\"")
1225        && html.contains("\"description\"")
1226    {
1227        return html.to_string();
1228    }
1229
1230    let jsonld = serde_json::json!({
1231        "@context": "https://schema.org",
1232        "@type": "Article",
1233        "description": description,
1234    });
1235
1236    let script =
1237        format!("<script type=\"application/ld+json\">{}</script>\n", jsonld);
1238    inject_before_head_close(html, &script)
1239}
1240
1241/// Calls the Ollama API to generate text.
1242///
1243/// Backward-compatible `Option<String>` wrapper around
1244/// [`query_ollama`] for the in-tree call sites that expect a
1245/// graceful `None` fallback (no LLM available, model errored,
1246/// etc.). New code should prefer [`query_ollama`] for typed
1247/// `SsgError` returns (issue #520, AC4/AC5).
1248fn call_ollama(endpoint: &str, model: &str, prompt: &str) -> Option<String> {
1249    query_ollama(endpoint, model, prompt, DEFAULT_LLM_TIMEOUT_SECS).ok()
1250}
1251
1252/// Typed Ollama generation call backed by `ureq` (issue #520).
1253///
1254/// All HTTP traffic flows through `ureq::post(...).send_json(...)`
1255/// — no subprocess is spawned, so prompts containing shell
1256/// metacharacters (`$(`, backticks, `;`, `&`, `|`) traverse the
1257/// transport as a JSON body byte-for-byte unchanged (AC3).
1258///
1259/// # Errors
1260///
1261/// - [`SsgError::LlmTimeout`] when the call exceeds `timeout_secs`.
1262/// - [`SsgError::LlmEndpointUnreachable`] when the TCP connection
1263///   is refused, the host is unresolvable, or any other transport
1264///   failure occurs before a response is received.
1265/// - [`SsgError::LlmInvalidResponse`] when the server returns a
1266///   non-2xx status, the body is not valid JSON, or the JSON does
1267///   not carry a non-empty `response` field.
1268///
1269/// # Examples
1270///
1271/// ```rust
1272/// use ssg::llm::query_ollama;
1273/// use ssg::SsgError;
1274///
1275/// // No Ollama running on port 1 ⇒ deterministic transport failure.
1276/// let err = query_ollama("http://127.0.0.1:1", "llama2", "hi", 1).unwrap_err();
1277/// assert!(matches!(err, SsgError::LlmEndpointUnreachable { .. } | SsgError::LlmTimeout { .. }));
1278/// ```
1279pub fn query_ollama(
1280    endpoint: &str,
1281    model: &str,
1282    prompt: &str,
1283    timeout_secs: u64,
1284) -> Result<String, SsgError> {
1285    let url = format!("{}/api/generate", endpoint.trim_end_matches('/'));
1286    let payload = serde_json::json!({
1287        "model": model,
1288        "prompt": prompt,
1289        "stream": false,
1290    });
1291
1292    let timeout = Duration::from_secs(timeout_secs);
1293    let agent = ureq::AgentBuilder::new().timeout(timeout).build();
1294
1295    let response = agent
1296        .post(&url)
1297        .set("Content-Type", "application/json")
1298        .send_json(payload)
1299        .map_err(|err| classify_ureq_error(err, &url, timeout))?;
1300
1301    let body: serde_json::Value =
1302        response
1303            .into_json()
1304            .map_err(|e| SsgError::LlmInvalidResponse {
1305                message: format!("malformed JSON response body: {e}"),
1306            })?;
1307
1308    body.get("response")
1309        .and_then(|v| v.as_str())
1310        .map(|s| s.trim().to_string())
1311        .filter(|s| !s.is_empty())
1312        .ok_or_else(|| SsgError::LlmInvalidResponse {
1313            message: "missing or empty 'response' field".into(),
1314        })
1315}
1316
1317/// Maps a `ureq::Error` into the right [`SsgError`] variant.
1318///
1319/// `ureq` does not surface a dedicated "timeout" enum arm; the
1320/// underlying `io::Error` carries `ErrorKind::TimedOut`. We unwrap
1321/// the transport layer so callers get
1322/// [`SsgError::LlmTimeout`] for timeouts and
1323/// [`SsgError::LlmEndpointUnreachable`] for every other transport
1324/// failure, while HTTP non-2xx responses become
1325/// [`SsgError::LlmInvalidResponse`].
1326fn classify_ureq_error(
1327    err: ureq::Error,
1328    url: &str,
1329    timeout: Duration,
1330) -> SsgError {
1331    match err {
1332        ureq::Error::Status(code, resp) => SsgError::LlmInvalidResponse {
1333            message: format!(
1334                "HTTP {code} from {url}: {}",
1335                resp.into_string().unwrap_or_default()
1336            ),
1337        },
1338        ureq::Error::Transport(transport) => {
1339            // `ureq` exposes the kind enum but not the inner
1340            // `io::Error` directly on stable; the timeout case is
1341            // signalled by `ErrorKind::Io` whose message contains
1342            // "timed out", or by `Kind::ConnectionFailed` with a
1343            // wrapped `io::ErrorKind::TimedOut`. We detect via the
1344            // formatted message which is stable across versions.
1345            let kind = transport.kind();
1346            let msg = transport.to_string();
1347            if is_timeout_transport(kind, &msg) {
1348                SsgError::LlmTimeout { duration: timeout }
1349            } else {
1350                SsgError::LlmEndpointUnreachable {
1351                    url: url.to_string(),
1352                    source: Box::new(transport),
1353                }
1354            }
1355        }
1356    }
1357}
1358
1359/// Heuristic: does this transport error look like a client-side
1360/// timeout rather than a hard connection failure?
1361///
1362/// Extracted from [`classify_ureq_error`] as a pure function of
1363/// `(kind, message)` so each OS-specific phrasing — Unix's "timed
1364/// out", the generic "timeout"/"deadline" fallbacks, and Windows'
1365/// WSAETIMEDOUT phrasing ("os error 10060") — is directly unit
1366/// testable without depending on which OS actually produced the
1367/// transport error (`ureq::Transport` has no public constructor, so
1368/// synthesizing a real one in a test is not possible).
1369fn is_timeout_transport(kind: ureq::ErrorKind, msg: &str) -> bool {
1370    matches!(
1371        kind,
1372        ureq::ErrorKind::Io | ureq::ErrorKind::ConnectionFailed
1373    ) && (msg.contains("timed out")
1374        || msg.contains("timeout")
1375        || msg.contains("deadline")
1376        || msg.contains("os error 10060"))
1377}
1378
1379impl LlmPlugin {
1380    /// Typed entry point for invoking the configured local LLM
1381    /// (issue #520, AC4/AC5).
1382    ///
1383    /// Unlike the in-tree augmentation helpers which swallow
1384    /// errors and fall back to leaving content untouched, `query`
1385    /// surfaces transport and protocol failures as typed
1386    /// [`SsgError`] variants so external callers (CLI commands,
1387    /// integration tests, custom pipelines) can react
1388    /// appropriately.
1389    ///
1390    /// # Examples
1391    ///
1392    /// ```rust
1393    /// use ssg::llm::{LlmConfig, LlmPlugin};
1394    ///
1395    /// // No Ollama running ⇒ deterministic transport error.
1396    /// let cfg = LlmConfig {
1397    ///     endpoint: "http://127.0.0.1:1".into(),
1398    ///     ..LlmConfig::default()
1399    /// };
1400    /// let plugin = LlmPlugin::new(cfg);
1401    /// assert!(plugin.query("hi").is_err());
1402    /// ```
1403    ///
1404    /// # Errors
1405    ///
1406    /// See [`query_ollama`] for the exact variants.
1407    pub fn query(&self, prompt: &str) -> Result<String, SsgError> {
1408        // Issue #528 — deterministic content-hash cache. A hit skips
1409        // the HTTP roundtrip entirely (AC1) and a miss writes the
1410        // response back for the next call. The cache is intentionally
1411        // best-effort: any filesystem error falls through to a live
1412        // call so a busted cache directory never wedges the build.
1413        if !self.config.cache_disabled {
1414            let root = self
1415                .config
1416                .cache_dir
1417                .clone()
1418                .unwrap_or_else(LlmCache::default_cache_dir);
1419            let cache = LlmCache::new(root);
1420            let key = LlmCache::compute_key(
1421                &self.config.endpoint,
1422                &self.config.model,
1423                prompt,
1424                self.config.timeout_secs,
1425            );
1426            if let Some(cached) = cache.get(&key) {
1427                return Ok(cached);
1428            }
1429            let response = query_ollama(
1430                &self.config.endpoint,
1431                &self.config.model,
1432                prompt,
1433                self.config.timeout_secs,
1434            )?;
1435            let _ = cache.set(&key, &response);
1436            return Ok(response);
1437        }
1438        query_ollama(
1439            &self.config.endpoint,
1440            &self.config.model,
1441            prompt,
1442            self.config.timeout_secs,
1443        )
1444    }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450
1451    #[test]
1452    fn needs_meta_description_missing() {
1453        assert!(needs_meta_description("<html><head></head></html>"));
1454    }
1455
1456    #[test]
1457    fn needs_meta_description_short() {
1458        let html = r#"<html><head><meta name="description" content="Short"></head></html>"#;
1459        assert!(needs_meta_description(html));
1460    }
1461
1462    #[test]
1463    fn needs_meta_description_adequate() {
1464        let html = r#"<html><head><meta name="description" content="This is a sufficiently long meta description that exceeds fifty characters easily"></head></html>"#;
1465        assert!(!needs_meta_description(html));
1466    }
1467
1468    #[test]
1469    fn inject_meta_description_into_head() {
1470        let html = "<html><head><title>T</title></head><body></body></html>";
1471        let result = inject_meta_description(html, "Test description");
1472        assert!(result.contains("name=\"description\""));
1473        assert!(result.contains("Test description"));
1474    }
1475
1476    #[test]
1477    fn extract_attr_basic() {
1478        assert_eq!(
1479            extract_attr(r#"<img src="photo.jpg" alt="x">"#, "src"),
1480            Some("photo.jpg".to_string())
1481        );
1482    }
1483
1484    #[test]
1485    fn extract_attr_missing() {
1486        assert_eq!(extract_attr(r#"<img src="x.jpg">"#, "alt"), None);
1487    }
1488
1489    #[test]
1490    fn extract_page_text_strips_tags() {
1491        let html = "<body><p>Hello <b>world</b></p></body>";
1492        let text = extract_page_text(html, 100);
1493        assert_eq!(text, "Hello world");
1494    }
1495
1496    #[test]
1497    fn llm_plugin_name() {
1498        let plugin = LlmPlugin::new(LlmConfig::default());
1499        assert_eq!(plugin.name(), "llm");
1500    }
1501
1502    // ── Readability engine tests ──────────────────────────────────
1503
1504    #[test]
1505    fn flesch_kincaid_simple_text() {
1506        // "The cat sat on the mat." — very simple, ~grade 1
1507        let audit = ReadabilityAudit::analyze("The cat sat on the mat.");
1508        assert!(
1509            audit.grade_level < 4.0,
1510            "Simple text should be below grade 4, got {:.1}",
1511            audit.grade_level
1512        );
1513        assert!(audit.reading_ease > 80.0);
1514    }
1515
1516    #[test]
1517    fn flesch_kincaid_complex_text() {
1518        let text = "The implementation of sophisticated cryptographic \
1519                    algorithms necessitates comprehensive understanding \
1520                    of mathematical foundations. Asymmetric encryption \
1521                    protocols demonstrate considerable computational \
1522                    overhead compared to symmetric alternatives.";
1523        let audit = ReadabilityAudit::analyze(text);
1524        assert!(
1525            audit.grade_level > 12.0,
1526            "Complex text should be above grade 12, got {:.1}",
1527            audit.grade_level
1528        );
1529    }
1530
1531    #[test]
1532    fn flesch_kincaid_empty_text() {
1533        let audit = ReadabilityAudit::analyze("");
1534        assert!(audit.grade_level.abs() < f64::EPSILON);
1535        assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
1536    }
1537
1538    #[test]
1539    fn syllable_count_known_words() {
1540        assert_eq!(count_word_syllables("cat"), 1);
1541        assert_eq!(count_word_syllables("hello"), 2);
1542        assert_eq!(count_word_syllables("beautiful"), 3);
1543        assert_eq!(count_word_syllables("implementation"), 5);
1544    }
1545
1546    #[test]
1547    fn count_sentences_basic() {
1548        assert_eq!(count_sentences("Hello. World!"), 2);
1549        assert_eq!(count_sentences("One sentence"), 1); // min 1
1550        assert_eq!(count_sentences("A? B? C!"), 3);
1551    }
1552
1553    // ── JSON-LD tests ───────────────────────────────────────────
1554
1555    #[test]
1556    fn inject_jsonld_adds_article_block() {
1557        let html = "<html><head><title>T</title></head><body></body></html>";
1558        let result = inject_jsonld_description(html, "Test desc");
1559        assert!(result.contains("application/ld+json"));
1560        assert!(result.contains("\"@type\":\"Article\""));
1561        assert!(result.contains("Test desc"));
1562    }
1563
1564    #[test]
1565    fn inject_jsonld_skips_existing() {
1566        let html = r#"<html><head><script type="application/ld+json">{"@type":"Article","description":"Existing"}</script></head></html>"#;
1567        let result = inject_jsonld_description(html, "New desc");
1568        assert!(!result.contains("New desc"));
1569        assert!(result.contains("Existing"));
1570    }
1571
1572    // ── Content audit tests ───────────────────────────────────────
1573
1574    #[test]
1575    fn audit_all_scans_markdown_files() {
1576        let dir = tempfile::tempdir().unwrap();
1577        let content = dir.path().join("content");
1578        fs::create_dir_all(&content).unwrap();
1579
1580        fs::write(
1581            content.join("simple.md"),
1582            "---\ntitle: Simple\n---\nThe cat sat on the mat. It was a good day.",
1583        )
1584        .unwrap();
1585        fs::write(
1586            content.join("complex.md"),
1587            "---\ntitle: Complex\n---\n\
1588             The implementation of sophisticated cryptographic algorithms \
1589             necessitates comprehensive understanding of mathematical \
1590             foundations and computational complexity theory.",
1591        )
1592        .unwrap();
1593
1594        let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
1595        assert_eq!(report.total_files, 2);
1596        assert!(report.failing > 0, "complex.md should fail grade 8");
1597    }
1598
1599    #[test]
1600    fn audit_all_empty_dir() {
1601        let dir = tempfile::tempdir().unwrap();
1602        let content = dir.path().join("empty");
1603        fs::create_dir_all(&content).unwrap();
1604
1605        let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
1606        assert_eq!(report.total_files, 0);
1607        assert_eq!(report.failing, 0);
1608    }
1609
1610    #[test]
1611    fn strip_frontmatter_yaml() {
1612        let input = "---\ntitle: Hello\n---\nBody text here.";
1613        let body = strip_frontmatter(input);
1614        assert!(body.contains("Body text here"));
1615        assert!(!body.contains("title:"));
1616    }
1617
1618    #[test]
1619    fn strip_frontmatter_toml() {
1620        let input = "+++\ntitle = \"Hello\"\n+++\nBody text here.";
1621        let body = strip_frontmatter(input);
1622        assert!(body.contains("Body text here"));
1623        assert!(!body.contains("title"));
1624    }
1625
1626    #[test]
1627    fn strip_frontmatter_none() {
1628        let input = "Just plain content.";
1629        assert_eq!(strip_frontmatter(input), input);
1630    }
1631
1632    #[test]
1633    fn split_frontmatter_preserves_delimiters() {
1634        let input = "---\ntitle: Hello\ndate: 2026-01-01\n---\n\n# Body text";
1635        let (fm, body) = split_frontmatter(input);
1636        assert!(fm.starts_with("---"));
1637        assert!(fm.ends_with("---"));
1638        assert!(fm.contains("title: Hello"));
1639        assert!(body.contains("# Body text"));
1640    }
1641
1642    #[test]
1643    fn split_frontmatter_toml_preserves() {
1644        let input = "+++\ntitle = \"Hello\"\n+++\nBody.";
1645        let (fm, body) = split_frontmatter(input);
1646        assert!(fm.starts_with("+++"));
1647        assert!(body.contains("Body."));
1648    }
1649
1650    #[test]
1651    fn split_frontmatter_no_frontmatter() {
1652        let input = "Just plain content.";
1653        let (fm, body) = split_frontmatter(input);
1654        assert!(fm.is_empty(), "{:?}", fm);
1655        assert_eq!(body, input);
1656    }
1657
1658    #[test]
1659    fn audit_and_fix_skips_when_ollama_unavailable() {
1660        let dir = tempfile::tempdir().unwrap();
1661        let content = dir.path().join("content");
1662        fs::create_dir_all(&content).unwrap();
1663        fs::write(content.join("test.md"), "---\ntitle: T\n---\nSimple text.")
1664            .unwrap();
1665
1666        let config = LlmConfig {
1667            endpoint: "http://localhost:99999".to_string(),
1668            ..LlmConfig::default()
1669        };
1670        let result = LlmPlugin::audit_and_fix(&content, &config).unwrap();
1671        assert_eq!(result, 0);
1672    }
1673
1674    #[test]
1675    fn full_repo_readability_audit() {
1676        // Audits ALL Markdown content across the entire repository.
1677        // The trailing entries exercise the two skip arms: a path
1678        // that does not exist, and an existing dir with no Markdown.
1679        let empty = tempfile::tempdir().unwrap();
1680        let empty_dir = empty.path().to_string_lossy().to_string();
1681        let dirs = [
1682            ("docs/guide".to_string(), 15.0),
1683            ("examples/basic/content".to_string(), 10.0),
1684            ("examples/blog/content".to_string(), 10.0),
1685            ("examples/docs/content".to_string(), 13.0),
1686            ("examples/landing/content".to_string(), 10.0),
1687            ("examples/plugins/content".to_string(), 10.0),
1688            ("examples/portfolio/content".to_string(), 10.0),
1689            ("examples/quickstart/content".to_string(), 10.0),
1690            ("examples/content/en".to_string(), 10.0),
1691            ("this/path/does/not/exist".to_string(), 10.0),
1692            (empty_dir, 10.0),
1693        ];
1694
1695        let mut total_files = 0usize;
1696        let mut total_pass = 0usize;
1697        let mut total_fail = 0usize;
1698
1699        println!("\n{}", "=".repeat(60));
1700        println!("  FULL REPOSITORY READABILITY AUDIT");
1701        println!("{}\n", "=".repeat(60));
1702
1703        for (dir, target) in &dirs {
1704            let path = Path::new(dir);
1705            if !path.exists() {
1706                continue;
1707            }
1708
1709            let report = LlmPlugin::audit_all(path, *target).unwrap();
1710            if report.total_files == 0 {
1711                continue;
1712            }
1713
1714            println!("── {dir} (target: grade {target:.0}) ��─");
1715            for r in &report.results {
1716                let status = if r.passes { "PASS" } else { "FAIL" };
1717                println!(
1718                    "  {:.<40} grade {:>5.1}  ease {:>5.1}  [{status}]",
1719                    r.path, r.grade_level, r.reading_ease
1720                );
1721            }
1722            println!("  → {}/{} pass\n", report.passing, report.total_files);
1723
1724            total_files += report.total_files;
1725            total_pass += report.passing;
1726            total_fail += report.failing;
1727        }
1728
1729        println!("{}", "=".repeat(60));
1730        println!(
1731            "  TOTAL: {total_files} files — {total_pass} pass, {total_fail} fail"
1732        );
1733        println!("{}\n", "=".repeat(60));
1734    }
1735
1736    /// Body of the readability-gate audit, parameterised on the
1737    /// guide directory so the missing-dir skip arm is testable.
1738    fn run_docs_guide_audit(guide_dir: &Path) {
1739        if !guide_dir.exists() {
1740            return; // Skip in environments without the guide
1741        }
1742
1743        let report = LlmPlugin::audit_all(guide_dir, 17.0).unwrap();
1744        for result in &report.results {
1745            let status = if result.passes { "PASS" } else { "FAIL" };
1746            println!(
1747                "[readability] {}: grade={:.1}, ease={:.1}, avg_sentence={:.1} — {}",
1748                result.path,
1749                result.grade_level,
1750                result.reading_ease,
1751                result.avg_sentence_len,
1752                status
1753            );
1754        }
1755
1756        println!(
1757            "\n[readability] {}/{} files pass (target: grade {:.0})",
1758            report.passing, report.total_files, report.target_grade
1759        );
1760    }
1761
1762    #[test]
1763    fn audit_docs_guide() {
1764        // This test is called by the readability-gate CI workflow.
1765        // It audits all .md files in docs/guide/ against grade 17
1766        // (documentation is technical and includes code blocks which
1767        // inflate Flesch-Kincaid scores).
1768        run_docs_guide_audit(Path::new("docs/guide"));
1769        // Missing-dir arm must be a silent no-op.
1770        run_docs_guide_audit(Path::new("docs/this-guide-does-not-exist"));
1771    }
1772
1773    // ── Coverage gap tests ────────────────────────────────────────
1774
1775    #[test]
1776    fn is_ollama_available_unreachable() {
1777        assert!(!is_ollama_available("http://localhost:99999"));
1778    }
1779
1780    #[test]
1781    fn call_ollama_unreachable_returns_none() {
1782        assert!(call_ollama("http://localhost:99999", "llama3", "hi").is_none());
1783    }
1784
1785    #[test]
1786    fn needs_meta_description_with_content_attr_first() {
1787        // content= before name= (different ordering)
1788        let html = r#"<meta content="Decent length description that is more than fifty characters long enough" name="description">"#;
1789        // name="description" is present so returns false-ish check
1790        assert!(!needs_meta_description(html));
1791    }
1792
1793    #[test]
1794    fn inject_meta_description_no_head() {
1795        let html = "<html><body>No head tag</body></html>";
1796        let result = inject_meta_description(html, "desc");
1797        assert_eq!(result, html); // unchanged
1798    }
1799
1800    #[test]
1801    fn inject_jsonld_no_head() {
1802        let html = "<html><body>No head</body></html>";
1803        let result = inject_jsonld_description(html, "desc");
1804        assert_eq!(result, html);
1805    }
1806
1807    #[test]
1808    fn extract_page_text_no_body() {
1809        let html = "just plain text no tags";
1810        let text = extract_page_text(html, 100);
1811        assert_eq!(text, "just plain text no tags");
1812    }
1813
1814    #[test]
1815    fn extract_page_text_truncates() {
1816        let html = "<body><p>word </p></body>";
1817        let text = extract_page_text(html, 3);
1818        assert!(text.len() <= 5);
1819    }
1820
1821    #[test]
1822    fn generate_missing_alt_text_no_images() {
1823        let mut html = "<html><body><p>No images</p></body></html>".to_string();
1824        let count = generate_missing_alt_text(
1825            &mut html,
1826            "llama3",
1827            "http://localhost:99999",
1828            true,
1829            Path::new("test.html"),
1830            Path::new("."),
1831        );
1832        assert_eq!(count, 0);
1833    }
1834
1835    #[test]
1836    fn readability_audit_single_word() {
1837        let audit = ReadabilityAudit::analyze("Hello");
1838        assert!(audit.grade_level >= 0.0);
1839        assert!(audit.avg_sentence_len >= 0.0);
1840    }
1841
1842    #[test]
1843    fn count_word_syllables_empty() {
1844        assert_eq!(count_word_syllables(""), 1);
1845    }
1846
1847    #[test]
1848    fn count_word_syllables_numbers() {
1849        assert_eq!(count_word_syllables("123"), 1);
1850    }
1851
1852    #[test]
1853    fn split_frontmatter_unclosed() {
1854        let input = "---\ntitle: Hello\nNo closing delimiter";
1855        let (fm, body) = split_frontmatter(input);
1856        assert!(fm.is_empty(), "{:?}", fm);
1857        assert_eq!(body, input);
1858    }
1859
1860    #[test]
1861    fn llm_plugin_skips_missing_site_dir() {
1862        let plugin = LlmPlugin::new(LlmConfig::default());
1863        let ctx = PluginContext::new(
1864            Path::new("/tmp/c"),
1865            Path::new("/tmp/b"),
1866            Path::new("/nonexistent/site"),
1867            Path::new("/tmp/t"),
1868        );
1869        assert!(plugin.after_compile(&ctx).is_ok());
1870    }
1871
1872    #[test]
1873    fn config_defaults_readability() {
1874        let config = LlmConfig::default();
1875        assert!((config.target_grade - 8.0).abs() < f64::EPSILON);
1876        assert_eq!(config.max_refinement_attempts, 1);
1877    }
1878
1879    #[test]
1880    fn llm_plugin_skips_when_ollama_unavailable() {
1881        let plugin = LlmPlugin::new(LlmConfig {
1882            endpoint: "http://localhost:99999".to_string(),
1883            ..LlmConfig::default()
1884        });
1885
1886        let dir = tempfile::tempdir().unwrap();
1887        let site = dir.path().join("site");
1888        fs::create_dir_all(&site).unwrap();
1889        fs::write(site.join("index.html"), "<html><body></body></html>")
1890            .unwrap();
1891
1892        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1893        // Should succeed (graceful skip)
1894        plugin.after_compile(&ctx).unwrap();
1895    }
1896
1897    // ── Agentic AI fix pipeline tests ────────────────────────────
1898
1899    #[test]
1900    fn ai_fix_report_serializes_to_json() {
1901        let report = AiFixReport {
1902            total_audited: 10,
1903            total_failing: 3,
1904            total_fixed: 2,
1905            results: vec![
1906                AiFixResult {
1907                    path: "docs/guide.md".to_string(),
1908                    before_grade: 12.5,
1909                    after_grade: 7.2,
1910                    improved: true,
1911                    action: "rewritten".to_string(),
1912                },
1913                AiFixResult {
1914                    path: "docs/api.md".to_string(),
1915                    before_grade: 14.0,
1916                    after_grade: 13.8,
1917                    improved: false,
1918                    action: "no-improvement".to_string(),
1919                },
1920            ],
1921        };
1922        let json = serde_json::to_string(&report).unwrap();
1923        assert!(json.contains("\"total_fixed\":2"));
1924        assert!(json.contains("\"action\":\"rewritten\""));
1925    }
1926
1927    #[test]
1928    fn ai_fix_report_skips_when_ollama_unavailable() {
1929        let dir = tempfile::tempdir().unwrap();
1930        let content = dir.path().join("content");
1931        fs::create_dir_all(&content).unwrap();
1932        fs::write(
1933            content.join("test.md"),
1934            "---\ntitle: T\n---\nThe implementation of sophisticated algorithms.",
1935        )
1936        .unwrap();
1937
1938        let config = LlmConfig {
1939            endpoint: "http://localhost:99999".to_string(),
1940            max_refinement_attempts: 3,
1941            ..LlmConfig::default()
1942        };
1943        let report =
1944            LlmPlugin::audit_and_fix_with_report(&content, &config).unwrap();
1945        assert_eq!(report.total_fixed, 0);
1946        assert!(report.results.is_empty());
1947    }
1948
1949    // ── Multilingual readability tests ──────────────────────────
1950
1951    #[test]
1952    fn formula_from_lang_english() {
1953        assert_eq!(
1954            ReadabilityFormula::from_lang("en"),
1955            Some(ReadabilityFormula::FleschKincaid)
1956        );
1957        assert_eq!(
1958            ReadabilityFormula::from_lang("en-US"),
1959            Some(ReadabilityFormula::FleschKincaid)
1960        );
1961    }
1962
1963    #[test]
1964    fn formula_from_lang_french() {
1965        assert_eq!(
1966            ReadabilityFormula::from_lang("fr"),
1967            Some(ReadabilityFormula::KandelMoles)
1968        );
1969        assert_eq!(
1970            ReadabilityFormula::from_lang("fr-CA"),
1971            Some(ReadabilityFormula::KandelMoles)
1972        );
1973    }
1974
1975    #[test]
1976    fn formula_from_lang_german() {
1977        assert_eq!(
1978            ReadabilityFormula::from_lang("de"),
1979            Some(ReadabilityFormula::WienerSachtextformel)
1980        );
1981        assert_eq!(
1982            ReadabilityFormula::from_lang("de-AT"),
1983            Some(ReadabilityFormula::WienerSachtextformel)
1984        );
1985    }
1986
1987    #[test]
1988    fn formula_from_lang_italian() {
1989        assert_eq!(
1990            ReadabilityFormula::from_lang("it"),
1991            Some(ReadabilityFormula::Gulpease)
1992        );
1993    }
1994
1995    #[test]
1996    fn formula_from_lang_swedish() {
1997        assert_eq!(
1998            ReadabilityFormula::from_lang("sv"),
1999            Some(ReadabilityFormula::Lix)
2000        );
2001        assert_eq!(
2002            ReadabilityFormula::from_lang("nb"),
2003            Some(ReadabilityFormula::Lix)
2004        );
2005        assert_eq!(
2006            ReadabilityFormula::from_lang("da"),
2007            Some(ReadabilityFormula::Lix)
2008        );
2009    }
2010
2011    #[test]
2012    fn formula_from_lang_spanish() {
2013        assert_eq!(
2014            ReadabilityFormula::from_lang("es"),
2015            Some(ReadabilityFormula::FernandezHuerta)
2016        );
2017    }
2018
2019    #[test]
2020    fn formula_from_lang_unsupported() {
2021        assert_eq!(ReadabilityFormula::from_lang("ja"), None);
2022        assert_eq!(ReadabilityFormula::from_lang("zh"), None);
2023        assert_eq!(ReadabilityFormula::from_lang("ar"), None);
2024    }
2025
2026    #[test]
2027    fn kandel_moles_simple_french() {
2028        let text = "Le chat est sur le tapis. Il fait beau. Le soleil brille.";
2029        let audit = ReadabilityAudit::analyze_with_lang(text, "fr");
2030        assert!(
2031            audit.reading_ease > 50.0,
2032            "Simple French should be readable, got {:.1}",
2033            audit.reading_ease
2034        );
2035    }
2036
2037    #[test]
2038    fn wiener_simple_german() {
2039        let text = "Die Katze sitzt auf der Matte. Es ist ein guter Tag. Die Sonne scheint.";
2040        let audit = ReadabilityAudit::analyze_with_lang(text, "de");
2041        assert!(
2042            audit.grade_level < 15.0,
2043            "Simple German got grade {:.1}",
2044            audit.grade_level
2045        );
2046    }
2047
2048    #[test]
2049    fn gulpease_simple_italian() {
2050        let text = "Il gatto si siede sul tappeto. Il sole splende. Oggi è una bella giornata.";
2051        let audit = ReadabilityAudit::analyze_with_lang(text, "it");
2052        assert!(
2053            audit.reading_ease > 40.0,
2054            "Simple Italian got ease {:.1}",
2055            audit.reading_ease
2056        );
2057    }
2058
2059    #[test]
2060    fn lix_simple_swedish() {
2061        let text = "Katten sitter på mattan. Solen skiner. Det är en fin dag.";
2062        let audit = ReadabilityAudit::analyze_with_lang(text, "sv");
2063        assert!(audit.grade_level >= 0.0);
2064        assert!(audit.reading_ease > 0.0);
2065    }
2066
2067    #[test]
2068    fn fernandez_huerta_simple_spanish() {
2069        let text = "El gato está en la mesa. El sol brilla. Es un buen día.";
2070        let audit = ReadabilityAudit::analyze_with_lang(text, "es");
2071        assert!(
2072            audit.reading_ease > 50.0,
2073            "Simple Spanish got ease {:.1}",
2074            audit.reading_ease
2075        );
2076    }
2077
2078    #[test]
2079    fn analyze_with_lang_empty_defaults_to_english() {
2080        let text = "The cat sat on the mat.";
2081        let a = ReadabilityAudit::analyze(text);
2082        let b = ReadabilityAudit::analyze_with_lang(text, "");
2083        assert!((a.grade_level - b.grade_level).abs() < f64::EPSILON);
2084    }
2085
2086    #[test]
2087    fn analyze_with_lang_unsupported_falls_back() {
2088        let text = "The cat sat on the mat.";
2089        let a = ReadabilityAudit::analyze(text);
2090        let b = ReadabilityAudit::analyze_with_lang(text, "ja");
2091        assert!((a.grade_level - b.grade_level).abs() < f64::EPSILON);
2092    }
2093
2094    #[test]
2095    fn extract_frontmatter_lang_yaml() {
2096        let content = "---\ntitle: Hello\nlanguage: fr\n---\nBody.";
2097        assert_eq!(extract_frontmatter_lang(content), "fr");
2098    }
2099
2100    #[test]
2101    fn extract_frontmatter_lang_yaml_short() {
2102        let content = "---\ntitle: Hello\nlang: de\n---\nBody.";
2103        assert_eq!(extract_frontmatter_lang(content), "de");
2104    }
2105
2106    #[test]
2107    fn extract_frontmatter_lang_toml() {
2108        let content = "+++\ntitle = \"Hello\"\nlanguage = \"it\"\n+++\nBody.";
2109        assert_eq!(extract_frontmatter_lang(content), "it");
2110    }
2111
2112    #[test]
2113    fn extract_frontmatter_lang_missing() {
2114        let content = "---\ntitle: Hello\n---\nBody.";
2115        assert_eq!(extract_frontmatter_lang(content), "");
2116    }
2117
2118    #[test]
2119    fn extract_frontmatter_lang_no_frontmatter() {
2120        let content = "Just plain text.";
2121        assert_eq!(extract_frontmatter_lang(content), "");
2122    }
2123
2124    #[test]
2125    fn audit_all_respects_language() {
2126        let dir = tempfile::tempdir().unwrap();
2127        let content = dir.path().join("content");
2128        fs::create_dir_all(&content).unwrap();
2129
2130        fs::write(
2131            content.join("french.md"),
2132            "---\ntitle: Bonjour\nlanguage: fr\n---\nLe chat est sur le tapis. Il fait beau.",
2133        )
2134        .unwrap();
2135
2136        let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
2137        assert_eq!(report.total_files, 1);
2138        // Should use Kandel-Moles, not Flesch-Kincaid
2139    }
2140
2141    // ── Multilingual formulas: empty text ────────────────────────
2142
2143    #[test]
2144    fn kandel_moles_empty_text() {
2145        let audit = ReadabilityAudit::analyze_with_lang("", "fr");
2146        assert!(audit.grade_level.abs() < f64::EPSILON);
2147        assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2148        assert!(audit.avg_sentence_len.abs() < f64::EPSILON);
2149    }
2150
2151    #[test]
2152    fn wiener_empty_text() {
2153        let audit = ReadabilityAudit::analyze_with_lang("", "de");
2154        assert!(audit.grade_level.abs() < f64::EPSILON);
2155        assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2156    }
2157
2158    #[test]
2159    fn gulpease_empty_text() {
2160        let audit = ReadabilityAudit::analyze_with_lang("", "it");
2161        assert!(audit.grade_level.abs() < f64::EPSILON);
2162        assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2163    }
2164
2165    #[test]
2166    fn lix_empty_text() {
2167        let audit = ReadabilityAudit::analyze_with_lang("", "sv");
2168        assert!(audit.grade_level.abs() < f64::EPSILON);
2169        assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2170    }
2171
2172    #[test]
2173    fn fernandez_huerta_empty_text() {
2174        let audit = ReadabilityAudit::analyze_with_lang("", "es");
2175        assert!(audit.grade_level.abs() < f64::EPSILON);
2176        assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2177    }
2178
2179    // ── Multilingual formulas: single-word text ──────────────────
2180
2181    #[test]
2182    fn kandel_moles_single_word() {
2183        let audit = ReadabilityAudit::analyze_with_lang("Bonjour", "fr");
2184        assert!(audit.grade_level >= 0.0);
2185        assert!(audit.reading_ease >= 0.0);
2186        assert!(audit.avg_sentence_len >= 1.0);
2187    }
2188
2189    #[test]
2190    fn wiener_single_word() {
2191        let audit = ReadabilityAudit::analyze_with_lang("Hallo", "de");
2192        assert!(audit.grade_level >= 0.0);
2193        assert!(audit.avg_sentence_len >= 1.0);
2194    }
2195
2196    #[test]
2197    fn gulpease_single_word() {
2198        let audit = ReadabilityAudit::analyze_with_lang("Ciao", "it");
2199        assert!(audit.grade_level >= 0.0);
2200        assert!(audit.avg_sentence_len >= 1.0);
2201    }
2202
2203    #[test]
2204    fn lix_single_word() {
2205        let audit = ReadabilityAudit::analyze_with_lang("Hej", "sv");
2206        assert!(audit.grade_level >= 0.0);
2207    }
2208
2209    #[test]
2210    fn fernandez_huerta_single_word() {
2211        let audit = ReadabilityAudit::analyze_with_lang("Hola", "es");
2212        assert!(audit.grade_level >= 0.0);
2213    }
2214
2215    // ── Multilingual formulas: long text ─────────────────────────
2216
2217    #[test]
2218    fn kandel_moles_long_text() {
2219        let text = "Le développement de nouvelles infrastructures \
2220                    technologiques nécessite une compréhension \
2221                    approfondie des systèmes complexes. \
2222                    Les algorithmes sophistiqués démontrent \
2223                    une efficacité considérable. \
2224                    La modernisation progressive des architectures \
2225                    informatiques représente un défi majeur.";
2226        let audit = ReadabilityAudit::analyze_with_lang(text, "fr");
2227        assert!(audit.grade_level > 0.0);
2228        assert!(audit.reading_ease >= 0.0);
2229        assert!(audit.avg_sentence_len > 1.0);
2230    }
2231
2232    #[test]
2233    fn wiener_long_text() {
2234        let text = "Die Implementierung fortschrittlicher kryptografischer \
2235                    Algorithmen erfordert umfassendes Verständnis \
2236                    mathematischer Grundlagen. Asymmetrische \
2237                    Verschlüsselungsprotokolle weisen erheblichen \
2238                    Rechenaufwand auf. Die systematische Optimierung \
2239                    komplexer Datenstrukturen bleibt herausfordernd.";
2240        let audit = ReadabilityAudit::analyze_with_lang(text, "de");
2241        assert!(audit.grade_level > 0.0);
2242        assert!(audit.avg_sentence_len > 1.0);
2243    }
2244
2245    #[test]
2246    fn gulpease_long_text() {
2247        let text = "L'implementazione di algoritmi crittografici sofisticati \
2248                    richiede una comprensione approfondita dei fondamenti \
2249                    matematici. I protocolli di crittografia asimmetrica \
2250                    dimostrano un considerevole sovraccarico computazionale. \
2251                    L'ottimizzazione sistematica delle strutture dati \
2252                    complesse rimane impegnativa.";
2253        let audit = ReadabilityAudit::analyze_with_lang(text, "it");
2254        assert!(audit.grade_level > 0.0);
2255        assert!(audit.avg_sentence_len > 1.0);
2256    }
2257
2258    #[test]
2259    fn lix_long_text() {
2260        let text = "Implementeringen av avancerade kryptografiska algoritmer \
2261                    kräver omfattande förståelse av matematiska grunder. \
2262                    Asymmetriska krypteringsprotokoll uppvisar betydande \
2263                    beräkningsbelastning. Systematisk optimering av komplexa \
2264                    datastrukturer förblir utmanande.";
2265        let audit = ReadabilityAudit::analyze_with_lang(text, "sv");
2266        assert!(audit.grade_level > 0.0);
2267        assert!(audit.avg_sentence_len > 1.0);
2268    }
2269
2270    #[test]
2271    fn fernandez_huerta_long_text() {
2272        let text =
2273            "La implementación de algoritmos criptográficos sofisticados \
2274                    requiere una comprensión profunda de los fundamentos \
2275                    matemáticos. Los protocolos de cifrado asimétrico \
2276                    demuestran una considerable sobrecarga computacional. \
2277                    La optimización sistemática de estructuras de datos \
2278                    complejas sigue siendo un desafío.";
2279        let audit = ReadabilityAudit::analyze_with_lang(text, "es");
2280        assert!(audit.grade_level > 0.0);
2281        assert!(audit.avg_sentence_len > 1.0);
2282    }
2283
2284    // ── WienerSachtextformel: varying syllable counts ────────────
2285
2286    #[test]
2287    fn wiener_mixed_syllable_words() {
2288        // Mix of 1-syllable, 2-syllable, 3+ syllable words
2289        let text = "Ich bin gut. Das Haus ist sehr interessant. \
2290                    Die Universität hat viele Studenten.";
2291        let audit = ReadabilityAudit::analyze_with_lang(text, "de");
2292        assert!(audit.grade_level >= 0.0);
2293        assert!(audit.reading_ease >= 0.0);
2294        assert!(audit.reading_ease <= 100.0);
2295    }
2296
2297    // ── LIX: varying character lengths ───────────────────────────
2298
2299    #[test]
2300    fn lix_mixed_word_lengths() {
2301        // Short words and long words (>6 chars) to exercise long-word filter
2302        let text = "En bok om programmering. \
2303                    Datavetenskapliga beräkningar kräver noggrannhet.";
2304        let audit = ReadabilityAudit::analyze_with_lang(text, "sv");
2305        assert!(audit.grade_level > 0.0);
2306        assert!(audit.reading_ease >= 0.0);
2307        assert!(audit.reading_ease <= 100.0);
2308    }
2309
2310    // ── extract_frontmatter_lang() edge cases ────────────────────
2311
2312    #[test]
2313    fn extract_frontmatter_lang_toml_with_quotes() {
2314        let content =
2315            "+++\ntitle = \"Hello\"\nlanguage = \"en-US\"\n+++\nBody.";
2316        assert_eq!(extract_frontmatter_lang(content), "en-US");
2317    }
2318
2319    #[test]
2320    fn extract_frontmatter_lang_first_wins() {
2321        // language appears before lang — first one should win
2322        let content = "---\nlanguage: fr\nlang: de\n---\nBody.";
2323        assert_eq!(extract_frontmatter_lang(content), "fr");
2324    }
2325
2326    #[test]
2327    fn extract_frontmatter_lang_whitespace_around_value() {
2328        let content = "---\nlanguage:   es  \n---\nBody.";
2329        assert_eq!(extract_frontmatter_lang(content), "es");
2330    }
2331
2332    #[test]
2333    fn extract_frontmatter_lang_yaml_quoted_value() {
2334        let content = "---\nlanguage: \"de\"\n---\nBody.";
2335        assert_eq!(extract_frontmatter_lang(content), "de");
2336    }
2337
2338    #[test]
2339    fn extract_frontmatter_lang_single_quoted() {
2340        let content = "---\nlanguage: 'it'\n---\nBody.";
2341        assert_eq!(extract_frontmatter_lang(content), "it");
2342    }
2343
2344    #[test]
2345    fn extract_frontmatter_lang_empty_value() {
2346        let content = "---\nlanguage: \n---\nBody.";
2347        assert_eq!(extract_frontmatter_lang(content), "");
2348    }
2349
2350    #[test]
2351    fn extract_frontmatter_lang_toml_lang_key() {
2352        let content = "+++\nlang = \"sv\"\n+++\nBody.";
2353        assert_eq!(extract_frontmatter_lang(content), "sv");
2354    }
2355
2356    // ── audit_and_fix_with_report edge cases ─────────────────────
2357
2358    #[test]
2359    fn audit_and_fix_with_report_all_passing() {
2360        let dir = tempfile::tempdir().unwrap();
2361        let content = dir.path().join("content");
2362        fs::create_dir_all(&content).unwrap();
2363
2364        // Very simple text that passes any reasonable threshold
2365        fs::write(
2366            content.join("simple.md"),
2367            "---\ntitle: Simple\n---\nThe cat sat. It was good.",
2368        )
2369        .unwrap();
2370
2371        // Use a high target so everything passes
2372        let config = LlmConfig {
2373            endpoint: "http://localhost:99999".to_string(),
2374            target_grade: 20.0,
2375            ..LlmConfig::default()
2376        };
2377        let report =
2378            LlmPlugin::audit_and_fix_with_report(&content, &config).unwrap();
2379        // Ollama unreachable => empty report, but test the path
2380        assert_eq!(report.total_fixed, 0);
2381    }
2382
2383    #[test]
2384    fn audit_and_fix_with_report_empty_dir() {
2385        let dir = tempfile::tempdir().unwrap();
2386        let content = dir.path().join("empty_content");
2387        fs::create_dir_all(&content).unwrap();
2388
2389        let config = LlmConfig {
2390            endpoint: "http://localhost:99999".to_string(),
2391            ..LlmConfig::default()
2392        };
2393        let report =
2394            LlmPlugin::audit_and_fix_with_report(&content, &config).unwrap();
2395        assert_eq!(report.total_audited, 0);
2396        assert_eq!(report.total_failing, 0);
2397        assert!(report.results.is_empty());
2398    }
2399
2400    #[test]
2401    fn audit_all_file_with_empty_body() {
2402        let dir = tempfile::tempdir().unwrap();
2403        let content = dir.path().join("content");
2404        fs::create_dir_all(&content).unwrap();
2405
2406        fs::write(content.join("empty_body.md"), "---\ntitle: T\n---\n")
2407            .unwrap();
2408
2409        let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
2410        assert_eq!(report.total_files, 1);
2411        // Empty body => grade 0, passes any threshold
2412        assert_eq!(report.passing, 1);
2413    }
2414
2415    // ── needs_meta_description edge cases ────────────────────────
2416
2417    #[test]
2418    fn needs_meta_description_no_content_attr() {
2419        // Has name="description" but no content attribute
2420        let html = r#"<meta name="description">"#;
2421        // name="description" is found, but content= search fails,
2422        // so falls through to the !html.contains check which is false
2423        assert!(!needs_meta_description(html));
2424    }
2425
2426    #[test]
2427    fn needs_meta_description_multiple_meta_tags() {
2428        let html = r#"<meta name="author" content="Alice"><meta name="description" content="This is a sufficiently long description that is more than fifty characters long">"#;
2429        assert!(!needs_meta_description(html));
2430    }
2431
2432    #[test]
2433    fn needs_meta_description_empty_content() {
2434        let html = r#"<meta name="description" content="">"#;
2435        assert!(needs_meta_description(html));
2436    }
2437
2438    // ── inject_meta_description with special chars ───────────────
2439
2440    #[test]
2441    fn inject_meta_description_escapes_ampersand() {
2442        let html = "<html><head></head><body></body></html>";
2443        let result = inject_meta_description(html, "Tom & Jerry");
2444        assert!(result.contains("Tom &amp; Jerry"));
2445    }
2446
2447    #[test]
2448    fn inject_meta_description_escapes_quotes() {
2449        let html = "<html><head></head><body></body></html>";
2450        let result = inject_meta_description(html, r#"A "great" page"#);
2451        assert!(result.contains("A &quot;great&quot; page"));
2452    }
2453
2454    #[test]
2455    fn inject_meta_description_escapes_angle_brackets() {
2456        let html = "<html><head></head><body></body></html>";
2457        let result = inject_meta_description(html, "x < y");
2458        assert!(result.contains("x &lt; y"));
2459    }
2460
2461    #[test]
2462    fn inject_meta_description_all_special_chars() {
2463        let html = "<html><head></head><body></body></html>";
2464        let result = inject_meta_description(html, r#"A & B "C" <D>"#);
2465        // The function escapes &, ", < but not > (only the dangerous chars in attribute context)
2466        assert!(result.contains("A &amp; B &quot;C&quot; &lt;D>"));
2467    }
2468
2469    // ── extract_page_text edge cases ─────────────────────────────
2470
2471    #[test]
2472    fn extract_page_text_with_main_tag() {
2473        let html = "<html><body><div>ignored</div><main><p>Main content here.</p></main></body></html>";
2474        let text = extract_page_text(html, 500);
2475        assert!(text.contains("Main content here"));
2476        // "ignored" is before <main>, so it should not appear
2477        assert!(!text.contains("ignored"));
2478    }
2479
2480    #[test]
2481    fn extract_page_text_large_truncated() {
2482        let long_body = "word ".repeat(200);
2483        let html = format!("<body><p>{long_body}</p></body>");
2484        let text = extract_page_text(&html, 50);
2485        // Should be truncated well under the full 1000-char body
2486        assert!(text.len() <= 60);
2487    }
2488
2489    #[test]
2490    fn extract_page_text_strips_control_chars() {
2491        let html = "<body>Hello\x00\x01World</body>";
2492        let text = extract_page_text(html, 100);
2493        assert_eq!(text, "HelloWorld");
2494    }
2495
2496    #[test]
2497    fn extract_page_text_nested_tags() {
2498        let html = "<body><div><span>A</span> <em>B</em></div></body>";
2499        let text = extract_page_text(html, 100);
2500        assert!(text.contains('A'));
2501        assert!(text.contains('B'));
2502    }
2503
2504    // ── generate_missing_alt_text edge cases ─────────────────────
2505
2506    #[test]
2507    fn generate_missing_alt_text_empty_alt() {
2508        let mut html =
2509            r#"<html><body><img src="photo.jpg" alt=""></body></html>"#
2510                .to_string();
2511        // Ollama unreachable, so count stays 0, but exercises the tag detection
2512        let count = generate_missing_alt_text(
2513            &mut html,
2514            "llama3",
2515            "http://localhost:99999",
2516            false,
2517            Path::new("test.html"),
2518            Path::new("."),
2519        );
2520        // Can't generate without Ollama, but exercises alt="" detection path
2521        assert_eq!(count, 0);
2522    }
2523
2524    #[test]
2525    fn generate_missing_alt_text_missing_closing_bracket() {
2526        let mut html =
2527            "<html><body><img src=\"photo.jpg\"</body></html>".to_string();
2528        let count = generate_missing_alt_text(
2529            &mut html,
2530            "llama3",
2531            "http://localhost:99999",
2532            false,
2533            Path::new("test.html"),
2534            Path::new("."),
2535        );
2536        assert_eq!(count, 0);
2537    }
2538
2539    #[test]
2540    fn generate_missing_alt_text_mixed_images() {
2541        let mut html = r#"<html><body>
2542            <img src="a.jpg" alt="Good alt">
2543            <img src="b.jpg">
2544            <img src="c.jpg" alt="">
2545        </body></html>"#
2546            .to_string();
2547        // Exercises the loop: first image has alt (skipped),
2548        // second has no alt, third has empty alt.
2549        // Ollama unreachable so no actual generation.
2550        let count = generate_missing_alt_text(
2551            &mut html,
2552            "llama3",
2553            "http://localhost:99999",
2554            true,
2555            Path::new("test.html"),
2556            Path::new("."),
2557        );
2558        assert_eq!(count, 0);
2559    }
2560
2561    #[test]
2562    fn generate_missing_alt_text_with_alt_present() {
2563        let mut html =
2564            r#"<html><body><img src="x.jpg" alt="Has alt text"></body></html>"#
2565                .to_string();
2566        let count = generate_missing_alt_text(
2567            &mut html,
2568            "llama3",
2569            "http://localhost:99999",
2570            false,
2571            Path::new("test.html"),
2572            Path::new("."),
2573        );
2574        assert_eq!(count, 0);
2575    }
2576
2577    // ── ReadabilityFormula edge cases ─────────────────────────────
2578
2579    #[test]
2580    fn formula_from_lang_underscore_separator() {
2581        assert_eq!(
2582            ReadabilityFormula::from_lang("en_US"),
2583            Some(ReadabilityFormula::FleschKincaid)
2584        );
2585        assert_eq!(
2586            ReadabilityFormula::from_lang("de_DE"),
2587            Some(ReadabilityFormula::WienerSachtextformel)
2588        );
2589    }
2590
2591    #[test]
2592    fn formula_from_lang_norwegian_variants() {
2593        assert_eq!(
2594            ReadabilityFormula::from_lang("nn"),
2595            Some(ReadabilityFormula::Lix)
2596        );
2597        assert_eq!(
2598            ReadabilityFormula::from_lang("no"),
2599            Some(ReadabilityFormula::Lix)
2600        );
2601    }
2602
2603    // ── LlmConfig / LlmPlugin additional coverage ───────────────
2604
2605    #[test]
2606    fn llm_config_default_values() {
2607        let config = LlmConfig::default();
2608        assert_eq!(config.model, "llama3");
2609        assert_eq!(config.endpoint, "http://localhost:11434");
2610        assert!(!config.dry_run);
2611    }
2612
2613    #[test]
2614    fn llm_plugin_debug_impl() {
2615        let plugin = LlmPlugin::new(LlmConfig::default());
2616        let debug = format!("{plugin:?}");
2617        assert!(debug.contains("LlmPlugin"));
2618        assert!(debug.contains("llama3"));
2619    }
2620
2621    // ── split_frontmatter edge cases ─────────────────────────────
2622
2623    #[test]
2624    fn split_frontmatter_leading_whitespace() {
2625        let input = "  ---\ntitle: Hello\n---\nBody.";
2626        let (fm, body) = split_frontmatter(input);
2627        assert!(fm.contains("title: Hello"));
2628        assert!(body.contains("Body."));
2629    }
2630
2631    #[test]
2632    fn split_frontmatter_toml_unclosed() {
2633        let input = "+++\ntitle = \"Hello\"\nNo closing delimiter";
2634        let (fm, body) = split_frontmatter(input);
2635        assert!(fm.is_empty(), "{:?}", fm);
2636        assert_eq!(body, input);
2637    }
2638
2639    // ── FileAuditResult / AuditReport serialization ──────────────
2640
2641    #[test]
2642    fn file_audit_result_serializes() {
2643        let result = FileAuditResult {
2644            path: "test.md".to_string(),
2645            grade_level: 7.5,
2646            reading_ease: 65.0,
2647            avg_sentence_len: 12.0,
2648            passes: true,
2649        };
2650        let json = serde_json::to_string(&result).unwrap();
2651        assert!(json.contains("\"path\":\"test.md\""));
2652        assert!(json.contains("\"passes\":true"));
2653    }
2654
2655    #[test]
2656    fn audit_report_serializes() {
2657        let report = AuditReport {
2658            target_grade: 8.0,
2659            total_files: 2,
2660            passing: 1,
2661            failing: 1,
2662            results: vec![],
2663        };
2664        let json = serde_json::to_string(&report).unwrap();
2665        assert!(json.contains("\"target_grade\":8.0"));
2666        assert!(json.contains("\"total_files\":2"));
2667    }
2668
2669    // ── inject_jsonld_description edge cases ─────────────────────
2670
2671    #[test]
2672    fn inject_jsonld_with_special_chars() {
2673        let html = "<html><head></head><body></body></html>";
2674        let result = inject_jsonld_description(html, "Tom & Jerry's \"show\"");
2675        assert!(result.contains("application/ld+json"));
2676        assert!(result.contains("Tom & Jerry"));
2677    }
2678
2679    // ── count_syllables edge cases ───────────────────────────────
2680
2681    #[test]
2682    fn count_syllables_multiple_vowel_groups() {
2683        // "beautiful" has vowel groups: eau-i-u => 3 groups, minus silent e = stays
2684        assert!(count_word_syllables("beautiful") >= 2);
2685    }
2686
2687    #[test]
2688    fn count_syllables_consecutive_vowels() {
2689        // "queue" => qu-eu-e: vowel groups = 2, minus trailing e = 1
2690        assert_eq!(count_word_syllables("queue"), 1);
2691    }
2692
2693    #[test]
2694    fn count_syllables_all_consonants() {
2695        // "rhythm" => y is a vowel => 1 vowel group
2696        assert_eq!(count_word_syllables("rhythm"), 1);
2697    }
2698
2699    #[test]
2700    fn count_syllables_text_total() {
2701        let total = count_syllables("The cat sat on the mat.");
2702        assert!(total >= 6); // 6 monosyllabic words
2703    }
2704
2705    #[test]
2706    fn count_words_basic() {
2707        assert_eq!(count_words("one two three"), 3);
2708        assert_eq!(count_words(""), 0);
2709        assert_eq!(count_words("   "), 0);
2710        assert_eq!(count_words("single"), 1);
2711    }
2712
2713    // ── Readability: numeric edge cases ──────────────────────────
2714
2715    #[test]
2716    fn readability_grade_never_negative() {
2717        // Single short word => formula could produce negative, clamped to 0
2718        let audit = ReadabilityAudit::analyze("Hi.");
2719        assert!(audit.grade_level >= 0.0);
2720        assert!(audit.reading_ease >= 0.0);
2721        assert!(audit.reading_ease <= 100.0);
2722    }
2723
2724    #[test]
2725    fn readability_ease_clamped_to_100() {
2726        // Very simple text should not exceed 100
2727        let audit = ReadabilityAudit::analyze("Go. Do. Be.");
2728        assert!(audit.reading_ease <= 100.0);
2729        assert!(audit.reading_ease >= 0.0);
2730    }
2731
2732    // --- count_sentences ---
2733    #[test]
2734    fn count_sentences_single_period() {
2735        assert_eq!(count_sentences("Hello world."), 1);
2736    }
2737
2738    #[test]
2739    fn count_sentences_question_and_exclamation() {
2740        assert_eq!(count_sentences("Hi! Are you here? Yes."), 3);
2741    }
2742
2743    #[test]
2744    fn count_sentences_empty_returns_one() {
2745        // Defensive floor so downstream division never sees 0.
2746        assert!(count_sentences("").max(1) >= 1);
2747    }
2748
2749    #[test]
2750    fn count_sentences_no_terminator_treats_as_one() {
2751        assert!(count_sentences("Hello world").max(1) >= 1);
2752    }
2753
2754    // --- count_syllables ---
2755    #[test]
2756    fn count_syllables_single_short_word() {
2757        assert!(count_syllables("cat") >= 1);
2758    }
2759
2760    #[test]
2761    fn count_syllables_multi_syllable_word() {
2762        assert!(count_syllables("beautiful") >= 3);
2763    }
2764
2765    #[test]
2766    fn count_syllables_empty_returns_zero() {
2767        assert_eq!(count_syllables(""), 0);
2768    }
2769
2770    #[test]
2771    fn count_syllables_multiple_words() {
2772        let n = count_syllables("the quick brown fox jumps");
2773        assert!(n >= 5);
2774    }
2775
2776    // --- extract_page_text ---
2777    #[test]
2778    fn extract_page_text_strips_html_tags() {
2779        let html =
2780            "<html><body><h1>Title</h1><p>Paragraph body.</p></body></html>";
2781        let text = extract_page_text(html, 1000);
2782        assert!(!text.contains('<'));
2783        assert!(text.contains("Title"));
2784        assert!(text.contains("Paragraph body"));
2785    }
2786
2787    #[test]
2788    fn extract_page_text_truncates_at_max_chars() {
2789        let body = "A".repeat(2000);
2790        let html = format!("<html><body><p>{body}</p></body></html>");
2791        let text = extract_page_text(&html, 500);
2792        assert!(text.len() <= 500);
2793    }
2794
2795    #[test]
2796    fn extract_page_text_skips_script_and_style_contents() {
2797        let html = "<html><head><style>body{color:red}</style>\
2798                    <script>alert('x')</script></head>\
2799                    <body><p>Hi.</p></body></html>";
2800        let text = extract_page_text(html, 1000);
2801        assert!(!text.contains("color:red"));
2802        assert!(!text.contains("alert"));
2803        assert!(text.contains("Hi"));
2804    }
2805
2806    #[test]
2807    fn extract_page_text_collapses_whitespace() {
2808        let html = "<p>one   \n\n  two\t\tthree</p>";
2809        let text = extract_page_text(html, 1000);
2810        assert!(!text.contains("\t"));
2811        assert!(!text.contains("\n\n"));
2812    }
2813
2814    // =====================================================================
2815    // Mock Ollama server (issue #520 coverage harness)
2816    //
2817    // Each test spawns a one-shot TcpListener-backed HTTP server that
2818    // returns a canned response, then points query_ollama / LlmPlugin
2819    // at the resulting `http://127.0.0.1:<port>` URL. This exercises
2820    // the live HTTP transport without depending on a real Ollama.
2821    // =====================================================================
2822
2823    use std::io::{Read as _, Write as _};
2824    use std::net::{TcpListener, TcpStream};
2825    use std::thread;
2826
2827    /// Drains one HTTP/1.1 request (headers AND body) from `stream`.
2828    ///
2829    /// Computes `Content-Length` from the request headers, then keeps
2830    /// reading until that many body bytes are consumed. This
2831    /// guarantees the client has finished sending before the mock
2832    /// replies, which avoids the "Error encountered in a header" race
2833    /// ureq raises when the response arrives mid-request. Returns the
2834    /// raw request bytes so callers can route on the method line.
2835    fn drain_request(stream: &mut TcpStream) -> Vec<u8> {
2836        let _ = stream.set_nodelay(true);
2837        let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
2838        let mut buf = Vec::with_capacity(4096);
2839        let mut chunk = [0u8; 1024];
2840        let mut header_end: Option<usize> = None;
2841        let mut content_length: usize = 0;
2842        while header_end.is_none() {
2843            match stream.read(&mut chunk) {
2844                Ok(0) => break,
2845                Ok(n) => {
2846                    buf.extend_from_slice(&chunk[..n]);
2847                    if let Some(pos) =
2848                        buf.windows(4).position(|w| w == b"\r\n\r\n")
2849                    {
2850                        header_end = Some(pos + 4);
2851                        let header_str = String::from_utf8_lossy(&buf[..pos]);
2852                        for line in header_str.split("\r\n") {
2853                            if let Some(v) = line
2854                                .to_ascii_lowercase()
2855                                .strip_prefix("content-length:")
2856                            {
2857                                if let Ok(n) = v.trim().parse::<usize>() {
2858                                    content_length = n;
2859                                }
2860                            }
2861                        }
2862                    }
2863                }
2864                Err(_) => break,
2865            }
2866        }
2867        if let Some(end) = header_end {
2868            while buf.len().saturating_sub(end) < content_length {
2869                match stream.read(&mut chunk) {
2870                    Ok(0) => break,
2871                    Ok(n) => buf.extend_from_slice(&chunk[..n]),
2872                    Err(_) => break,
2873                }
2874            }
2875        }
2876        buf
2877    }
2878
2879    /// Writes `response_bytes` and half-closes the socket.
2880    fn respond_and_close(stream: &mut TcpStream, response_bytes: &[u8]) {
2881        let _ = stream.write_all(response_bytes);
2882        let _ = stream.flush();
2883        let _ = stream.shutdown(std::net::Shutdown::Write);
2884    }
2885
2886    /// Serves one canned response on an accepted connection.
2887    fn serve_canned(mut stream: TcpStream, response_bytes: &[u8]) {
2888        let _ = drain_request(&mut stream);
2889        respond_and_close(&mut stream, response_bytes);
2890    }
2891
2892    /// Spawns a mock HTTP/1.1 server that responds to exactly one
2893    /// request with `response_bytes`. Returns the bound URL
2894    /// (`http://127.0.0.1:<port>`). The server thread joins on drop
2895    /// of the returned `JoinHandle` — tests can let it leak since the
2896    /// listener auto-closes when the handle is dropped.
2897    fn spawn_mock_ollama(
2898        response_bytes: &'static [u8],
2899    ) -> (String, thread::JoinHandle<()>) {
2900        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2901        let addr = listener.local_addr().unwrap();
2902        let url = format!("http://127.0.0.1:{}", addr.port());
2903        let handle = thread::spawn(move || {
2904            let (stream, _) = listener.accept().expect("mock accept");
2905            serve_canned(stream, response_bytes);
2906        });
2907        (url, handle)
2908    }
2909
2910    fn mock_ollama_ok(reply_text: &str) -> Vec<u8> {
2911        let body = format!(r#"{{"response":"{reply_text}"}}"#);
2912        format!(
2913            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2914            body.len(),
2915            body
2916        )
2917        .into_bytes()
2918    }
2919
2920    #[test]
2921    fn query_ollama_returns_response_on_2xx_json() {
2922        // Leak the response bytes so the closure can hold a 'static
2923        // reference without lifetime gymnastics.
2924        let bytes: &'static [u8] =
2925            Box::leak(mock_ollama_ok("hello world").into_boxed_slice());
2926        let (url, _h) = spawn_mock_ollama(bytes);
2927        let out = query_ollama(&url, "test-model", "hi", 5).unwrap();
2928        assert_eq!(out, "hello world");
2929    }
2930
2931    #[test]
2932    fn query_ollama_returns_err_on_500_status() {
2933        // 500 status — server sends a non-2xx; ureq may report it
2934        // either via the Status arm (LlmInvalidResponse) or via a
2935        // Transport arm if it decides the response is malformed. We
2936        // only assert that the call errors and produces a non-empty
2937        // message — the exact variant is implementation-defined.
2938        let resp = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 5\r\nConnection: close\r\n\r\nboom!";
2939        let (url, _h) = spawn_mock_ollama(resp);
2940        let err = query_ollama(&url, "m", "p", 5).unwrap_err();
2941        assert!(!format!("{err}").is_empty(), "should not be empty");
2942    }
2943
2944    #[test]
2945    fn query_ollama_returns_invalid_response_on_bad_json() {
2946        let resp = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\nConnection: close\r\n\r\nnot-json-here";
2947        let (url, _h) = spawn_mock_ollama(resp);
2948        let err = query_ollama(&url, "m", "p", 5).unwrap_err();
2949        let msg = format!("{err}");
2950        assert!(!msg.is_empty(), "should not be empty");
2951    }
2952
2953    #[test]
2954    fn query_ollama_returns_invalid_response_on_missing_field() {
2955        // Valid JSON, but the `response` field is missing => triggers
2956        // the ok_or_else branch in query_ollama that emits
2957        // LlmInvalidResponse{"missing or empty 'response' field"}.
2958        let body = r#"{"foo":"bar"}"#;
2959        let resp_str = format!(
2960            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2961            body.len(),
2962            body
2963        );
2964        let bytes: &'static [u8] =
2965            Box::leak(resp_str.into_bytes().into_boxed_slice());
2966        let (url, _h) = spawn_mock_ollama(bytes);
2967        let err = query_ollama(&url, "m", "p", 5).unwrap_err();
2968        // The mock drains the full request before replying, so the
2969        // body is delivered intact and the error is deterministic.
2970        assert!(
2971            matches!(err, SsgError::LlmInvalidResponse { .. }),
2972            "expected LlmInvalidResponse, got: {err}"
2973        );
2974    }
2975
2976    #[test]
2977    fn query_ollama_unreachable_when_port_is_closed() {
2978        // Bind then immediately drop the listener so the port is
2979        // closed before the request reaches it. ConnectionRefused →
2980        // Transport arm.
2981        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2982        let port = listener.local_addr().unwrap().port();
2983        drop(listener);
2984        let url = format!("http://127.0.0.1:{port}");
2985        let err = query_ollama(&url, "m", "p", 2).unwrap_err();
2986        let msg = format!("{err}");
2987        assert!(!msg.is_empty(), "should not be empty");
2988    }
2989
2990    #[test]
2991    fn llm_plugin_query_returns_cached_value_on_hit() {
2992        // Pre-populate the cache, point query at an unreachable
2993        // endpoint, and assert the cache short-circuits the HTTP call.
2994        let tmp = tempfile::tempdir().unwrap();
2995        let cache_root = tmp.path().to_path_buf();
2996
2997        let cfg = LlmConfig {
2998            model: "fake".into(),
2999            endpoint: "http://127.0.0.1:1".into(), // closed port
3000            cache_disabled: false,
3001            cache_dir: Some(cache_root.clone()),
3002            ..LlmConfig::default()
3003        };
3004
3005        let key = LlmCache::compute_key(
3006            &cfg.endpoint,
3007            &cfg.model,
3008            "prompt-x",
3009            cfg.timeout_secs,
3010        );
3011        let cache = LlmCache::new(cache_root);
3012        cache.set(&key, "cached!").unwrap();
3013
3014        let plugin = LlmPlugin::new(cfg);
3015        let out = plugin.query("prompt-x").unwrap();
3016        assert_eq!(out, "cached!");
3017    }
3018
3019    #[test]
3020    fn llm_plugin_query_writes_back_on_cache_miss() {
3021        // Cache miss + mock server returning OK → plugin should
3022        // write the response back to the cache.
3023        let bytes: &'static [u8] =
3024            Box::leak(mock_ollama_ok("fresh-answer").into_boxed_slice());
3025        let (url, _h) = spawn_mock_ollama(bytes);
3026
3027        let tmp = tempfile::tempdir().unwrap();
3028        let cache_root = tmp.path().to_path_buf();
3029        let cfg = LlmConfig {
3030            model: "m".into(),
3031            endpoint: url.clone(),
3032            cache_disabled: false,
3033            cache_dir: Some(cache_root.clone()),
3034            ..LlmConfig::default()
3035        };
3036        let plugin = LlmPlugin::new(cfg.clone());
3037        let out = plugin.query("hello").unwrap();
3038        assert_eq!(out, "fresh-answer");
3039
3040        let key = LlmCache::compute_key(
3041            &cfg.endpoint,
3042            &cfg.model,
3043            "hello",
3044            cfg.timeout_secs,
3045        );
3046        let cache = LlmCache::new(cache_root);
3047        assert_eq!(cache.get(&key).as_deref(), Some("fresh-answer"));
3048    }
3049
3050    #[test]
3051    fn llm_plugin_query_skips_cache_when_disabled() {
3052        // cache_disabled=true → live call only, no cache file written.
3053        let bytes: &'static [u8] =
3054            Box::leak(mock_ollama_ok("live-answer").into_boxed_slice());
3055        let (url, _h) = spawn_mock_ollama(bytes);
3056
3057        let tmp = tempfile::tempdir().unwrap();
3058        let cfg = LlmConfig {
3059            model: "m".into(),
3060            endpoint: url,
3061            cache_disabled: true,
3062            cache_dir: Some(tmp.path().to_path_buf()),
3063            ..LlmConfig::default()
3064        };
3065        let plugin = LlmPlugin::new(cfg);
3066        let out = plugin.query("hello").unwrap();
3067        assert_eq!(out, "live-answer");
3068    }
3069
3070    /// Serialised env-var scoping (mirrors the `cmd::tests` pattern).
3071    ///
3072    /// Entries are applied *sequentially* (capture-then-set per entry)
3073    /// and restored in reverse, so a duplicated key deterministically
3074    /// exercises both restore arms: the later entry's captured
3075    /// previous value is whatever the earlier entry just set.
3076    fn with_env_vars<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
3077        use std::sync::Mutex;
3078        static ENV_LOCK: Mutex<()> = Mutex::new(());
3079        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3080        let mut prev: Vec<(String, Option<String>)> = Vec::new();
3081        for (key, value) in vars {
3082            prev.push(((*key).to_string(), std::env::var(key).ok()));
3083            match value {
3084                Some(v) => std::env::set_var(key, v),
3085                None => std::env::remove_var(key),
3086            }
3087        }
3088        f();
3089        for (key, value) in prev.into_iter().rev() {
3090            match value {
3091                Some(v) => std::env::set_var(&key, v),
3092                None => std::env::remove_var(&key),
3093            }
3094        }
3095    }
3096
3097    #[test]
3098    fn llm_config_default_respects_no_cache_env() {
3099        // Duplicated key: the second entry's restore puts back the
3100        // first entry's value (Some arm), the first restores machine
3101        // state.
3102        with_env_vars(
3103            &[
3104                ("SSG_NO_LLM_CACHE", Some("seed")),
3105                ("SSG_NO_LLM_CACHE", Some("1")),
3106            ],
3107            || assert!(LlmConfig::default().cache_disabled),
3108        );
3109        with_env_vars(&[("SSG_NO_LLM_CACHE", Some("0"))], || {
3110            assert!(!LlmConfig::default().cache_disabled);
3111        });
3112        with_env_vars(&[("SSG_NO_LLM_CACHE", Some("off"))], || {
3113            assert!(!LlmConfig::default().cache_disabled);
3114        });
3115        // Unset + empty exercise the removal arm and the
3116        // empty-string filter.
3117        with_env_vars(
3118            &[("SSG_NO_LLM_CACHE", None), ("SSG_NO_LLM_CACHE", Some(""))],
3119            || assert!(!LlmConfig::default().cache_disabled),
3120        );
3121        with_env_vars(&[("SSG_NO_LLM_CACHE", None)], || {
3122            assert!(!LlmConfig::default().cache_disabled);
3123        });
3124    }
3125
3126    /// Spawns a long-running mock that serves the same canned
3127    /// response to every incoming connection until the listener is
3128    /// dropped. Used by tests that need the health-check + actual
3129    /// generate call to both succeed (`audit_and_fix` path).
3130    fn spawn_multi_shot_mock_ollama(
3131        response_bytes: &'static [u8],
3132    ) -> (String, thread::JoinHandle<()>) {
3133        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3134        let addr = listener.local_addr().unwrap();
3135        let url = format!("http://127.0.0.1:{}", addr.port());
3136        let handle = thread::spawn(move || {
3137            for stream_res in listener.incoming() {
3138                serve_canned(stream_res.expect("mock accept"), response_bytes);
3139            }
3140        });
3141        (url, handle)
3142    }
3143
3144    /// Spawns a long-running mock that routes on the request method:
3145    /// `GET` (the health-check probe) gets `get_response`, anything
3146    /// else (the `/api/generate` POST) gets `post_response`. Lets a
3147    /// test pass the availability probe while failing generation.
3148    fn spawn_routing_mock_ollama(
3149        get_response: &'static [u8],
3150        post_response: &'static [u8],
3151    ) -> (String, thread::JoinHandle<()>) {
3152        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3153        let addr = listener.local_addr().unwrap();
3154        let url = format!("http://127.0.0.1:{}", addr.port());
3155        let handle = thread::spawn(move || {
3156            for stream_res in listener.incoming() {
3157                let mut stream = stream_res.expect("mock accept");
3158                let request = drain_request(&mut stream);
3159                let resp = if request.starts_with(b"GET") {
3160                    get_response
3161                } else {
3162                    post_response
3163                };
3164                respond_and_close(&mut stream, resp);
3165            }
3166        });
3167        (url, handle)
3168    }
3169
3170    /// Spawns a long-running mock that serves `responses` in
3171    /// connection order, repeating the last response once the list is
3172    /// exhausted. Drives multi-turn flows (initial draft + refinement
3173    /// pass) where each call must see different output.
3174    fn spawn_sequenced_mock_ollama(
3175        responses: Vec<Vec<u8>>,
3176    ) -> (String, thread::JoinHandle<()>) {
3177        assert!(!responses.is_empty(), "sequenced mock needs >= 1 response");
3178        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3179        let addr = listener.local_addr().unwrap();
3180        let url = format!("http://127.0.0.1:{}", addr.port());
3181        let handle = thread::spawn(move || {
3182            for (served, stream_res) in listener.incoming().enumerate() {
3183                let mut stream = stream_res.expect("mock accept");
3184                let _ = drain_request(&mut stream);
3185                let idx = served.min(responses.len() - 1);
3186                respond_and_close(&mut stream, &responses[idx]);
3187            }
3188        });
3189        (url, handle)
3190    }
3191
3192    /// Spawns a mock that accepts one connection, reads a little, and
3193    /// then sleeps without ever responding — forcing the client-side
3194    /// read timeout.
3195    fn spawn_hanging_mock_ollama() -> (String, thread::JoinHandle<()>) {
3196        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3197        let addr = listener.local_addr().unwrap();
3198        let url = format!("http://127.0.0.1:{}", addr.port());
3199        let handle = thread::spawn(move || {
3200            let (mut stream, _) = listener.accept().expect("mock accept");
3201            let mut chunk = [0u8; 1024];
3202            let _ = stream.read(&mut chunk);
3203            thread::sleep(Duration::from_secs(3));
3204        });
3205        (url, handle)
3206    }
3207
3208    #[test]
3209    fn audit_and_fix_runs_loop_against_mock_ollama() {
3210        // Set up a content dir with a failing-grade file, then back the
3211        // mock LLM with a refined response. audit_and_fix should walk
3212        // its inner refinement loop and emit a result.
3213        let dir = tempfile::tempdir().unwrap();
3214        let content = dir.path().join("content");
3215        fs::create_dir_all(&content).unwrap();
3216        let hard = "---\ntitle: T\n---\n\nThe administrative procurement \
3217                    methodologies necessitate institutional documentation that \
3218                    facilitates organisational comprehension across heterogeneous \
3219                    constituencies of stakeholders.";
3220        fs::write(content.join("hard.md"), hard).unwrap();
3221
3222        let bytes: &'static [u8] =
3223            Box::leak(mock_ollama_ok("Short. Plain.").into_boxed_slice());
3224        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3225
3226        let cfg = LlmConfig {
3227            endpoint: url,
3228            dry_run: true,
3229            target_grade: 6.0,
3230            max_refinement_attempts: 1,
3231            ..LlmConfig::default()
3232        };
3233
3234        let _ = LlmPlugin::audit_and_fix(&content, &cfg);
3235        let _ = LlmPlugin::audit_and_fix_with_report(&content, &cfg);
3236    }
3237
3238    #[test]
3239    fn audit_and_fix_returns_zero_when_no_failing_files() {
3240        // All-passing content => is_ollama_available passes, but the
3241        // failing filter yields an empty list, exercising the early
3242        // "All files pass" log + return arm.
3243        let dir = tempfile::tempdir().unwrap();
3244        let content = dir.path().join("content");
3245        fs::create_dir_all(&content).unwrap();
3246        // Very simple text => low grade level.
3247        fs::write(
3248            content.join("easy.md"),
3249            "---\ntitle: T\n---\n\nIt is small. It is fun.",
3250        )
3251        .unwrap();
3252
3253        let bytes: &'static [u8] =
3254            Box::leak(mock_ollama_ok("ignored").into_boxed_slice());
3255        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3256
3257        let cfg = LlmConfig {
3258            endpoint: url,
3259            target_grade: 12.0, // generous → easy.md passes
3260            ..LlmConfig::default()
3261        };
3262        let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3263        assert_eq!(rewritten, 0);
3264    }
3265
3266    #[test]
3267    fn audit_and_fix_with_report_returns_skipped_for_empty_body() {
3268        // File has frontmatter but empty body => skipped arm fires.
3269        let dir = tempfile::tempdir().unwrap();
3270        let content = dir.path().join("content");
3271        fs::create_dir_all(&content).unwrap();
3272        fs::write(content.join("empty.md"), "---\ntitle: T\n---\n").unwrap();
3273
3274        let bytes: &'static [u8] =
3275            Box::leak(mock_ollama_ok("ignored").into_boxed_slice());
3276        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3277
3278        let cfg = LlmConfig {
3279            endpoint: url,
3280            target_grade: 0.0, // forces the body grade to fail
3281            ..LlmConfig::default()
3282        };
3283        let report =
3284            LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3285        // Either skipped (empty body) or no failing files — both arms
3286        // are valid exits.
3287        let _ = report;
3288    }
3289
3290    #[test]
3291    fn generate_meta_description_short_text_returns_none() {
3292        let out = generate_meta_description(
3293            "<html><body>hi</body></html>",
3294            "m",
3295            "http://127.0.0.1:1",
3296            8.0,
3297            1,
3298        );
3299        assert!(out.is_none(), "short body should short-circuit to None");
3300    }
3301
3302    #[test]
3303    fn generate_with_refinement_returns_none_on_unreachable() {
3304        // call_ollama returns None when the endpoint is unreachable;
3305        // generate_with_refinement propagates that None.
3306        let out = generate_with_refinement(
3307            "http://127.0.0.1:1",
3308            "m",
3309            "hello",
3310            8.0,
3311            0,
3312        );
3313        assert!(out.is_none());
3314    }
3315
3316    // ── Frontmatter / meta-description edge branches ─────────────
3317
3318    #[test]
3319    fn strip_frontmatter_unclosed_returns_input() {
3320        let input = "---\ntitle: Hello\nNo closing delimiter here";
3321        assert_eq!(strip_frontmatter(input), input);
3322    }
3323
3324    #[test]
3325    fn extract_frontmatter_lang_unclosed_frontmatter() {
3326        let content = "---\ntitle: Hello\nlanguage: fr\nNo closing delimiter";
3327        assert_eq!(extract_frontmatter_lang(content), "");
3328    }
3329
3330    #[test]
3331    fn extract_frontmatter_lang_toml_empty_value_falls_through() {
3332        let content = "+++\nlanguage = \"\"\n+++\nBody.";
3333        assert_eq!(extract_frontmatter_lang(content), "");
3334    }
3335
3336    #[test]
3337    fn extract_frontmatter_lang_toml_key_without_equals() {
3338        let content = "+++\nlanguage\n+++\nBody.";
3339        assert_eq!(extract_frontmatter_lang(content), "");
3340    }
3341
3342    #[test]
3343    fn needs_meta_description_unterminated_content_attr() {
3344        // content=" opens but never closes: the inner scan bails and
3345        // the outer contains() check reports the tag as present.
3346        let html = r#"<meta name="description" content="unterminated"#;
3347        assert!(!needs_meta_description(html));
3348    }
3349
3350    #[test]
3351    fn generate_missing_alt_text_img_without_closing_bracket_breaks() {
3352        // No '>' anywhere after the '<img' — the scan must break.
3353        let mut html = "<body><img src=\"x.jpg\"".to_string();
3354        let count = generate_missing_alt_text(
3355            &mut html,
3356            "llama3",
3357            "http://127.0.0.1:1",
3358            true,
3359            Path::new("t.html"),
3360            Path::new("."),
3361        );
3362        assert_eq!(count, 0);
3363        assert_eq!(html, "<body><img src=\"x.jpg\"");
3364    }
3365
3366    #[test]
3367    fn generate_missing_alt_text_replaces_empty_alt_attribute() {
3368        // dry_run=false + a reachable mock Ollama + an `alt=""` tag
3369        // drives the "replace existing empty alt" branch, as opposed
3370        // to the "insert a brand-new alt attribute" branch exercised
3371        // by the mixed-images test above.
3372        let bytes: &'static [u8] =
3373            Box::leak(mock_ollama_ok("A friendly cat.").into_boxed_slice());
3374        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3375
3376        let mut html =
3377            r#"<html><body><img src="cat.jpg" alt=""></body></html>"#
3378                .to_string();
3379        let count = generate_missing_alt_text(
3380            &mut html,
3381            "llama3",
3382            &url,
3383            false,
3384            Path::new("test.html"),
3385            Path::new("."),
3386        );
3387        assert_eq!(count, 1);
3388        assert!(
3389            html.contains("alt=\"A friendly cat.\""),
3390            "empty alt should be replaced: {html}"
3391        );
3392        assert!(
3393            !html.contains("alt=\"\""),
3394            "empty alt attribute must be gone"
3395        );
3396    }
3397
3398    #[test]
3399    fn generate_missing_alt_text_leaves_decorative_images_alone() {
3400        // alt="" with aria-hidden="true" (or role="presentation") marks
3401        // a decorative image. Giving it generated text would make it
3402        // announce noise, so it is not a missing alt.
3403        let bytes: &'static [u8] =
3404            Box::leak(mock_ollama_ok("A friendly cat.").into_boxed_slice());
3405        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3406        let original = "<html><body>\
3407            <img class=\"brand-mark\" src=\"/assets/logo.svg\"\n     \
3408            width=\"28\" height=\"28\" alt=\"\" aria-hidden=\"true\" />\
3409            <img src=\"rule.png\" alt=\"\" role=\"presentation\">\
3410            </body></html>";
3411        let mut html = original.to_string();
3412        let count = generate_missing_alt_text(
3413            &mut html,
3414            "llama3",
3415            &url,
3416            false,
3417            Path::new("test.html"),
3418            Path::new("."),
3419        );
3420        assert_eq!(count, 0, "decorative images were given alt: {html}");
3421        assert_eq!(html, original);
3422    }
3423
3424    #[cfg(unix)]
3425    #[test]
3426    fn audit_all_skips_unreadable_markdown_file() {
3427        // A dangling symlink has the .md extension (so the walker
3428        // collects it) but read_to_string fails, exercising the
3429        // skip-on-read-failure arm.
3430        let dir = tempfile::tempdir().unwrap();
3431        let content = dir.path().join("content");
3432        fs::create_dir_all(&content).unwrap();
3433        fs::write(content.join("good.md"), "---\nt: x\n---\nThe cat sat.")
3434            .unwrap();
3435        std::os::unix::fs::symlink(
3436            content.join("missing-target.md"),
3437            content.join("broken.md"),
3438        )
3439        .unwrap();
3440
3441        let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
3442        assert_eq!(report.total_files, 1, "broken symlink must be skipped");
3443    }
3444
3445    // ── audit_and_fix / with_report against a live mock ──────────
3446
3447    /// A one-sentence, polysyllabic body that fails grade 8 by a
3448    /// wide margin.
3449    const HARD_BODY: &str = "Administrative procurement methodologies \
3450                             necessitate institutional documentation \
3451                             facilitating organisational comprehension \
3452                             across heterogeneous stakeholder \
3453                             constituencies.";
3454
3455    fn write_hard_file(content: &Path) {
3456        fs::write(
3457            content.join("hard.md"),
3458            format!("---\ntitle: T\n---\n\n{HARD_BODY}"),
3459        )
3460        .unwrap();
3461    }
3462
3463    #[test]
3464    fn audit_and_fix_rewrites_file_when_llm_improves_grade() {
3465        let dir = tempfile::tempdir().unwrap();
3466        let content = dir.path().join("content");
3467        fs::create_dir_all(&content).unwrap();
3468        write_hard_file(&content);
3469
3470        let bytes: &'static [u8] = Box::leak(
3471            mock_ollama_ok("The cat sat. It was fun.").into_boxed_slice(),
3472        );
3473        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3474
3475        let cfg = LlmConfig {
3476            endpoint: url,
3477            dry_run: false,
3478            target_grade: 8.0,
3479            max_refinement_attempts: 0,
3480            ..LlmConfig::default()
3481        };
3482        let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3483        assert_eq!(rewritten, 1);
3484
3485        let out = fs::read_to_string(content.join("hard.md")).unwrap();
3486        assert!(out.starts_with("---"), "frontmatter preserved:\n{out}");
3487        assert!(out.contains("The cat sat."), "body replaced:\n{out}");
3488    }
3489
3490    #[test]
3491    fn audit_and_fix_warns_when_llm_does_not_improve_grade() {
3492        let dir = tempfile::tempdir().unwrap();
3493        let content = dir.path().join("content");
3494        fs::create_dir_all(&content).unwrap();
3495        write_hard_file(&content);
3496
3497        // The mock parrots equally complex text — no improvement.
3498        let bytes: &'static [u8] =
3499            Box::leak(mock_ollama_ok(HARD_BODY).into_boxed_slice());
3500        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3501
3502        let cfg = LlmConfig {
3503            endpoint: url,
3504            dry_run: false,
3505            target_grade: 8.0,
3506            max_refinement_attempts: 0,
3507            ..LlmConfig::default()
3508        };
3509        let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3510        assert_eq!(rewritten, 0);
3511        let out = fs::read_to_string(content.join("hard.md")).unwrap();
3512        assert!(out.contains("procurement"), "file must be untouched");
3513    }
3514
3515    #[test]
3516    fn audit_and_fix_skips_failing_file_with_empty_body() {
3517        // A negative target makes even the empty (grade 0) body fail
3518        // the audit, driving the empty-body `continue` in the fix
3519        // loop.
3520        let dir = tempfile::tempdir().unwrap();
3521        let content = dir.path().join("content");
3522        fs::create_dir_all(&content).unwrap();
3523        fs::write(content.join("empty.md"), "---\ntitle: T\n---\n").unwrap();
3524
3525        let bytes: &'static [u8] =
3526            Box::leak(mock_ollama_ok("ignored").into_boxed_slice());
3527        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3528
3529        let cfg = LlmConfig {
3530            endpoint: url,
3531            target_grade: -1.0,
3532            max_refinement_attempts: 0,
3533            ..LlmConfig::default()
3534        };
3535        let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3536        assert_eq!(rewritten, 0);
3537    }
3538
3539    #[test]
3540    fn audit_and_fix_with_report_rewrites_and_reports_improvement() {
3541        let dir = tempfile::tempdir().unwrap();
3542        let content = dir.path().join("content");
3543        fs::create_dir_all(&content).unwrap();
3544        write_hard_file(&content);
3545
3546        let bytes: &'static [u8] = Box::leak(
3547            mock_ollama_ok("The cat sat. It was fun.").into_boxed_slice(),
3548        );
3549        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3550
3551        let cfg = LlmConfig {
3552            endpoint: url,
3553            dry_run: false,
3554            target_grade: 8.0,
3555            max_refinement_attempts: 0,
3556            ..LlmConfig::default()
3557        };
3558        let report =
3559            LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3560        assert_eq!(report.total_fixed, 1);
3561        assert_eq!(report.results.len(), 1);
3562        assert!(report.results[0].improved);
3563        assert_eq!(report.results[0].action, "rewritten");
3564        assert!(report.results[0].after_grade < report.results[0].before_grade);
3565
3566        let out = fs::read_to_string(content.join("hard.md")).unwrap();
3567        assert!(out.contains("The cat sat."), "body replaced:\n{out}");
3568    }
3569
3570    #[test]
3571    fn audit_and_fix_with_report_records_no_improvement() {
3572        let dir = tempfile::tempdir().unwrap();
3573        let content = dir.path().join("content");
3574        fs::create_dir_all(&content).unwrap();
3575        write_hard_file(&content);
3576
3577        let bytes: &'static [u8] =
3578            Box::leak(mock_ollama_ok(HARD_BODY).into_boxed_slice());
3579        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3580
3581        let cfg = LlmConfig {
3582            endpoint: url,
3583            dry_run: false,
3584            target_grade: 8.0,
3585            max_refinement_attempts: 0,
3586            ..LlmConfig::default()
3587        };
3588        let report =
3589            LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3590        assert_eq!(report.total_fixed, 0);
3591        assert_eq!(report.results.len(), 1);
3592        assert!(!report.results[0].improved);
3593        assert_eq!(report.results[0].action, "no-improvement");
3594    }
3595
3596    #[test]
3597    fn audit_and_fix_with_report_skips_when_generation_fails() {
3598        // GET (health probe) succeeds, POST (generate) returns junk
3599        // that fails JSON parsing — generate_with_refinement yields
3600        // None and the per-file "skipped" arm fires.
3601        let dir = tempfile::tempdir().unwrap();
3602        let content = dir.path().join("content");
3603        fs::create_dir_all(&content).unwrap();
3604        write_hard_file(&content);
3605
3606        let get_ok: &'static [u8] =
3607            b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
3608        let post_junk: &'static [u8] =
3609            b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 4\r\nConnection: close\r\n\r\noops";
3610        let (url, _h) = spawn_routing_mock_ollama(get_ok, post_junk);
3611
3612        let cfg = LlmConfig {
3613            endpoint: url,
3614            target_grade: 8.0,
3615            max_refinement_attempts: 0,
3616            ..LlmConfig::default()
3617        };
3618        let report =
3619            LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3620        assert_eq!(report.total_fixed, 0);
3621        assert_eq!(report.results.len(), 1);
3622        assert_eq!(report.results[0].action, "skipped");
3623    }
3624
3625    #[test]
3626    fn audit_and_fix_skips_failing_file_when_generation_fails() {
3627        // Same setup as the `_with_report` counterpart above, but
3628        // against the plain `audit_and_fix` — drives the implicit
3629        // no-op arm of its own
3630        // `if let Some(refined) = generate_with_refinement(..)`
3631        // (generation fails, so the failing file is left untouched
3632        // and `rewritten` is not incremented).
3633        let dir = tempfile::tempdir().unwrap();
3634        let content = dir.path().join("content");
3635        fs::create_dir_all(&content).unwrap();
3636        write_hard_file(&content);
3637
3638        let get_ok: &'static [u8] =
3639            b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
3640        let post_junk: &'static [u8] =
3641            b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 4\r\nConnection: close\r\n\r\noops";
3642        let (url, _h) = spawn_routing_mock_ollama(get_ok, post_junk);
3643
3644        let cfg = LlmConfig {
3645            endpoint: url,
3646            target_grade: 8.0,
3647            max_refinement_attempts: 0,
3648            ..LlmConfig::default()
3649        };
3650        let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3651        assert_eq!(rewritten, 0);
3652        let out = fs::read_to_string(content.join("hard.md")).unwrap();
3653        assert!(out.contains("procurement"), "file must be untouched");
3654    }
3655
3656    #[test]
3657    fn audit_and_fix_with_report_skips_failing_file_with_empty_body() {
3658        let dir = tempfile::tempdir().unwrap();
3659        let content = dir.path().join("content");
3660        fs::create_dir_all(&content).unwrap();
3661        fs::write(content.join("empty.md"), "---\ntitle: T\n---\n").unwrap();
3662
3663        let bytes: &'static [u8] =
3664            Box::leak(mock_ollama_ok("ignored").into_boxed_slice());
3665        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3666
3667        let cfg = LlmConfig {
3668            endpoint: url,
3669            target_grade: -1.0,
3670            max_refinement_attempts: 0,
3671            ..LlmConfig::default()
3672        };
3673        let report =
3674            LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3675        assert_eq!(report.total_failing, 1);
3676        assert_eq!(report.results.len(), 1);
3677        assert_eq!(report.results[0].action, "skipped");
3678        assert!(!report.results[0].improved);
3679    }
3680
3681    // ── after_compile against a live mock ────────────────────────
3682
3683    /// HTML page needing both a meta description and alt text.
3684    const AUGMENTABLE_PAGE: &str = "<html><head><title>T</title></head>\
3685         <body><main><p>Static site generators compile Markdown \
3686         content into fast HTML pages for the web.</p>\
3687         <img src=\"photo.jpg\"></main></body></html>";
3688
3689    #[test]
3690    fn after_compile_augments_pages_via_mock_llm() {
3691        let bytes: &'static [u8] = Box::leak(
3692            mock_ollama_ok("A short page about static site builds.")
3693                .into_boxed_slice(),
3694        );
3695        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3696
3697        let dir = tempfile::tempdir().unwrap();
3698        let site = dir.path().join("site");
3699        fs::create_dir_all(&site).unwrap();
3700        fs::write(site.join("index.html"), AUGMENTABLE_PAGE).unwrap();
3701
3702        let plugin = LlmPlugin::new(LlmConfig {
3703            endpoint: url,
3704            dry_run: false,
3705            target_grade: 30.0, // generous → no refinement roundtrip
3706            ..LlmConfig::default()
3707        });
3708        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
3709        plugin.after_compile(&ctx).unwrap();
3710
3711        let out = fs::read_to_string(site.join("index.html")).unwrap();
3712        assert!(
3713            out.contains("name=\"description\""),
3714            "meta description injected:\n{out}"
3715        );
3716        assert!(
3717            out.contains("A short page about static site builds."),
3718            "generated text present:\n{out}"
3719        );
3720        assert!(
3721            out.contains("application/ld+json"),
3722            "JSON-LD description injected:\n{out}"
3723        );
3724        assert!(
3725            out.contains("alt=\"A short page about static site builds.\""),
3726            "alt text injected:\n{out}"
3727        );
3728    }
3729
3730    #[test]
3731    fn after_compile_dry_run_logs_without_writing() {
3732        let bytes: &'static [u8] = Box::leak(
3733            mock_ollama_ok("A short page about static site builds.")
3734                .into_boxed_slice(),
3735        );
3736        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3737
3738        let dir = tempfile::tempdir().unwrap();
3739        let site = dir.path().join("site");
3740        fs::create_dir_all(&site).unwrap();
3741        fs::write(site.join("index.html"), AUGMENTABLE_PAGE).unwrap();
3742
3743        let plugin = LlmPlugin::new(LlmConfig {
3744            endpoint: url,
3745            dry_run: true,
3746            target_grade: 30.0,
3747            ..LlmConfig::default()
3748        });
3749        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
3750        plugin.after_compile(&ctx).unwrap();
3751
3752        let out = fs::read_to_string(site.join("index.html")).unwrap();
3753        assert_eq!(out, AUGMENTABLE_PAGE, "dry-run must not modify files");
3754    }
3755
3756    #[test]
3757    fn after_compile_no_changes_needed_when_page_already_augmented() {
3758        // Ollama IS reachable, but the page already has an adequate
3759        // meta description and no images at all — exercises the
3760        // implicit no-op arms of `needs_meta_description` being
3761        // false and of the `if augmented > 0` log guard inside
3762        // `after_compile`.
3763        let bytes: &'static [u8] =
3764            Box::leak(mock_ollama_ok("unused").into_boxed_slice());
3765        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3766
3767        let dir = tempfile::tempdir().unwrap();
3768        let site = dir.path().join("site");
3769        fs::create_dir_all(&site).unwrap();
3770        let page = r#"<html><head><title>T</title><meta name="description" content="This description is already long enough to pass the fifty character minimum threshold."></head><body><main><p>Content.</p></main></body></html>"#;
3771        fs::write(site.join("index.html"), page).unwrap();
3772
3773        let plugin = LlmPlugin::new(LlmConfig {
3774            endpoint: url,
3775            ..LlmConfig::default()
3776        });
3777        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
3778        plugin.after_compile(&ctx).unwrap();
3779
3780        let out = fs::read_to_string(site.join("index.html")).unwrap();
3781        assert_eq!(out, page, "page needed no augmentation, must be untouched");
3782    }
3783
3784    #[test]
3785    fn after_compile_skips_meta_description_when_body_too_short() {
3786        // Ollama reachable, page needs a meta description (no tag at
3787        // all), but the extracted body text is under the 20-char
3788        // floor so `generate_meta_description` short-circuits to
3789        // `None` before ever calling the LLM — exercises the
3790        // implicit no-op arm of
3791        // `if let Some(desc) = generate_meta_description(..)` inside
3792        // `after_compile`.
3793        let bytes: &'static [u8] =
3794            Box::leak(mock_ollama_ok("unused").into_boxed_slice());
3795        let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3796
3797        let dir = tempfile::tempdir().unwrap();
3798        let site = dir.path().join("site");
3799        fs::create_dir_all(&site).unwrap();
3800        let page =
3801            "<html><head><title>T</title></head><body><main>Hi.</main></body></html>";
3802        fs::write(site.join("index.html"), page).unwrap();
3803
3804        let plugin = LlmPlugin::new(LlmConfig {
3805            endpoint: url,
3806            ..LlmConfig::default()
3807        });
3808        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
3809        plugin.after_compile(&ctx).unwrap();
3810
3811        let out = fs::read_to_string(site.join("index.html")).unwrap();
3812        assert_eq!(out, page, "no meta description could be generated");
3813    }
3814
3815    // ── generate_with_refinement refinement pass ─────────────────
3816
3817    #[test]
3818    fn generate_with_refinement_adopts_simpler_second_draft() {
3819        // First draft exceeds the target grade; the refinement call
3820        // returns simpler text which must be adopted.
3821        let complex = "Sophisticated organisational methodologies \
3822                       necessitate comprehensive administrative \
3823                       documentation considerations.";
3824        let simple = "The cat sat on the mat.";
3825        let (url, _h) = spawn_sequenced_mock_ollama(vec![
3826            mock_ollama_ok(complex),
3827            mock_ollama_ok(simple),
3828        ]);
3829
3830        let out = generate_with_refinement(&url, "m", "prompt", 5.0, 1)
3831            .expect("refinement should yield text");
3832        assert_eq!(out, simple);
3833    }
3834
3835    #[test]
3836    fn generate_with_refinement_keeps_draft_when_refinement_is_worse() {
3837        // The refinement pass returns *more* complex text — the
3838        // original draft must be kept.
3839        let first = "The administrative documentation is not simple text.";
3840        let worse = "Sophisticated organisational methodologies \
3841                     necessitate comprehensive administrative \
3842                     documentation considerations universally.";
3843        let (url, _h) = spawn_sequenced_mock_ollama(vec![
3844            mock_ollama_ok(first),
3845            mock_ollama_ok(worse),
3846        ]);
3847
3848        let out = generate_with_refinement(&url, "m", "prompt", 1.0, 1)
3849            .expect("refinement should yield text");
3850        assert_eq!(out, first);
3851    }
3852
3853    #[test]
3854    fn generate_with_refinement_keeps_draft_when_refinement_call_fails() {
3855        // The refinement roundtrip itself fails (non-JSON body) —
3856        // the original draft must survive.
3857        let complex = "Sophisticated organisational methodologies \
3858                       necessitate comprehensive administrative \
3859                       documentation considerations.";
3860        let junk = b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\noops".to_vec();
3861        let (url, _h) =
3862            spawn_sequenced_mock_ollama(vec![mock_ollama_ok(complex), junk]);
3863
3864        let out = generate_with_refinement(&url, "m", "prompt", 5.0, 1)
3865            .expect("draft should survive a failed refinement");
3866        assert_eq!(out, complex);
3867    }
3868
3869    // ── Timeout classification + typed query cache path ──────────
3870
3871    #[test]
3872    fn query_ollama_timeout_maps_to_llm_timeout() {
3873        let (url, _h) = spawn_hanging_mock_ollama();
3874        let err = query_ollama(&url, "m", "p", 1).unwrap_err();
3875        assert!(
3876            matches!(err, SsgError::LlmTimeout { .. }),
3877            "expected LlmTimeout, got: {err}"
3878        );
3879    }
3880
3881    // ── is_timeout_transport: every OS-phrasing branch ────────────
3882    //
3883    // `ureq::Transport` has no public constructor, so these branches
3884    // (in particular the "timeout"/"deadline"/"os error 10060"
3885    // fallbacks that no real Unix socket ever actually produces) are
3886    // tested directly against the extracted pure predicate rather
3887    // than through a live socket.
3888
3889    #[test]
3890    fn is_timeout_transport_unix_timed_out_phrasing() {
3891        assert!(is_timeout_transport(
3892            ureq::ErrorKind::Io,
3893            "connection timed out"
3894        ));
3895    }
3896
3897    #[test]
3898    fn is_timeout_transport_generic_timeout_word() {
3899        assert!(is_timeout_transport(
3900            ureq::ErrorKind::ConnectionFailed,
3901            "operation timeout"
3902        ));
3903    }
3904
3905    #[test]
3906    fn is_timeout_transport_deadline_word() {
3907        assert!(is_timeout_transport(
3908            ureq::ErrorKind::Io,
3909            "deadline exceeded"
3910        ));
3911    }
3912
3913    #[test]
3914    fn is_timeout_transport_windows_os_error_10060() {
3915        assert!(is_timeout_transport(
3916            ureq::ErrorKind::Io,
3917            "did not properly respond after a period of time (os error 10060)"
3918        ));
3919    }
3920
3921    #[test]
3922    fn is_timeout_transport_false_for_unrelated_message() {
3923        assert!(!is_timeout_transport(
3924            ureq::ErrorKind::ConnectionFailed,
3925            "connection refused"
3926        ));
3927    }
3928
3929    #[test]
3930    fn is_timeout_transport_false_when_kind_is_not_io_or_connection_failed() {
3931        // Even a "timed out"-shaped message must not classify as a
3932        // timeout when the transport kind is unrelated to sockets.
3933        assert!(!is_timeout_transport(
3934            ureq::ErrorKind::InvalidUrl,
3935            "request timed out"
3936        ));
3937    }
3938
3939    #[test]
3940    fn llm_plugin_query_propagates_error_on_cache_miss() {
3941        // Cache enabled but cold + unreachable endpoint: the live
3942        // call's error must propagate through the caching path.
3943        let tmp = tempfile::tempdir().unwrap();
3944        let cfg = LlmConfig {
3945            model: "m".into(),
3946            endpoint: "http://127.0.0.1:1".into(),
3947            cache_disabled: false,
3948            cache_dir: Some(tmp.path().to_path_buf()),
3949            ..LlmConfig::default()
3950        };
3951        let plugin = LlmPlugin::new(cfg);
3952        assert!(plugin.query("uncached-prompt").is_err());
3953    }
3954
3955    // ── Mock-server helper hardening (drain_request branches) ────
3956
3957    fn spawn_serve_canned_once(
3958        response: &'static [u8],
3959    ) -> (std::net::SocketAddr, thread::JoinHandle<()>) {
3960        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3961        let addr = listener.local_addr().unwrap();
3962        let handle = thread::spawn(move || {
3963            let (stream, _) = listener.accept().expect("accept");
3964            serve_canned(stream, response);
3965        });
3966        (addr, handle)
3967    }
3968
3969    const CANNED_OK: &[u8] =
3970        b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
3971
3972    #[test]
3973    fn drain_request_handles_connection_closed_mid_header() {
3974        let (addr, h) = spawn_serve_canned_once(CANNED_OK);
3975        let mut s = TcpStream::connect(addr).unwrap();
3976        s.write_all(b"GET / HT").unwrap();
3977        s.shutdown(std::net::Shutdown::Write).unwrap();
3978        let mut out = Vec::new();
3979        let _ = s.read_to_end(&mut out);
3980        h.join().unwrap();
3981        assert!(out.starts_with(b"HTTP/1.1 200"), "got: {out:?}");
3982    }
3983
3984    #[test]
3985    fn drain_request_handles_header_read_timeout() {
3986        // Client connects but never sends a byte: the server's 2s
3987        // read timeout fires and it responds anyway.
3988        let (addr, h) = spawn_serve_canned_once(CANNED_OK);
3989        let mut s = TcpStream::connect(addr).unwrap();
3990        let mut out = Vec::new();
3991        let _ = s.read_to_end(&mut out);
3992        h.join().unwrap();
3993        assert!(out.starts_with(b"HTTP/1.1 200"), "got: {out:?}");
3994    }
3995
3996    #[test]
3997    fn drain_request_handles_connection_closed_mid_body() {
3998        let (addr, h) = spawn_serve_canned_once(CANNED_OK);
3999        let mut s = TcpStream::connect(addr).unwrap();
4000        s.write_all(b"POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\nabc")
4001            .unwrap();
4002        s.shutdown(std::net::Shutdown::Write).unwrap();
4003        let mut out = Vec::new();
4004        let _ = s.read_to_end(&mut out);
4005        h.join().unwrap();
4006        assert!(out.starts_with(b"HTTP/1.1 200"), "got: {out:?}");
4007    }
4008
4009    #[test]
4010    fn drain_request_handles_body_read_timeout() {
4011        // Full header promising 50 body bytes, but the body never
4012        // arrives: the body-loop read times out and the server
4013        // responds anyway.
4014        let (addr, h) = spawn_serve_canned_once(CANNED_OK);
4015        let mut s = TcpStream::connect(addr).unwrap();
4016        s.write_all(b"POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\nabc")
4017            .unwrap();
4018        let mut out = Vec::new();
4019        let _ = s.read_to_end(&mut out);
4020        h.join().unwrap();
4021        assert!(out.starts_with(b"HTTP/1.1 200"), "got: {out:?}");
4022    }
4023
4024    // ── Fault injection (feature-gated) ──────────────────────────
4025
4026    #[cfg(feature = "test-fault-injection")]
4027    mod fault {
4028        use super::*;
4029        use serial_test::serial;
4030
4031        #[test]
4032        #[serial]
4033        fn audit_and_fix_with_report_skips_unreadable_file() {
4034            let dir = tempfile::tempdir().unwrap();
4035            let content = dir.path().join("content");
4036            fs::create_dir_all(&content).unwrap();
4037            write_hard_file(&content);
4038
4039            let bytes: &'static [u8] =
4040                Box::leak(mock_ollama_ok("The cat sat.").into_boxed_slice());
4041            let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
4042            let cfg = LlmConfig {
4043                endpoint: url,
4044                target_grade: 8.0,
4045                max_refinement_attempts: 0,
4046                ..LlmConfig::default()
4047            };
4048
4049            fail::cfg("llm::fix-read", "return").unwrap();
4050            let report =
4051                LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
4052            let _ = fail::cfg("llm::fix-read", "off");
4053
4054            assert_eq!(report.results.len(), 1);
4055            assert_eq!(report.results[0].action, "skipped");
4056            assert!(!report.results[0].improved);
4057            assert_eq!(report.total_fixed, 0);
4058        }
4059    }
4060}