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