1use 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
31const DEFAULT_LLM_TIMEOUT_SECS: u64 = 120;
38
39const HEALTH_CHECK_TIMEOUT_SECS: u64 = 2;
45
46#[derive(Debug, Clone)]
48pub struct LlmConfig {
49 pub model: String,
51 pub endpoint: String,
53 pub dry_run: bool,
55 pub target_grade: f64,
57 pub max_refinement_attempts: usize,
59 pub timeout_secs: u64,
66 pub cache_disabled: bool,
72 pub cache_dir: Option<PathBuf>,
78}
79
80impl Default for LlmConfig {
81 fn default() -> Self {
82 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#[derive(Debug)]
107pub struct LlmPlugin {
108 config: LlmConfig,
109}
110
111impl LlmPlugin {
112 #[must_use]
124 pub const fn new(config: LlmConfig) -> Self {
125 Self { config }
126 }
127}
128
129#[derive(Debug, Clone, serde::Serialize)]
131pub struct FileAuditResult {
132 pub path: String,
134 pub grade_level: f64,
136 pub reading_ease: f64,
138 pub avg_sentence_len: f64,
140 pub passes: bool,
142}
143
144#[derive(Debug, Clone, serde::Serialize)]
146pub struct AuditReport {
147 pub target_grade: f64,
149 pub total_files: usize,
151 pub passing: usize,
153 pub failing: usize,
155 pub results: Vec<FileAuditResult>,
157}
158
159#[derive(Debug, Clone, serde::Serialize)]
161pub struct AiFixResult {
162 pub path: String,
164 pub before_grade: f64,
166 pub after_grade: f64,
168 pub improved: bool,
170 pub action: String,
172}
173
174#[derive(Debug, Clone, serde::Serialize)]
176pub struct AiFixReport {
177 pub total_audited: usize,
179 pub total_failing: usize,
181 pub total_fixed: usize,
183 pub results: Vec<AiFixResult>,
185}
186
187impl LlmPlugin {
188 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; };
222 let body = strip_frontmatter(&content);
224 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 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 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 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
524fn 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
538fn 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
564fn 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 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 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
607fn 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 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 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 modified = inject_jsonld_description(&modified, &desc);
672 }
673 }
674 }
675
676 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
707fn 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
721fn 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 !html.contains("name=\"description\"")
734}
735
736fn 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
764fn inject_meta_description(html: &str, description: &str) -> String {
766 let escaped = description
767 .replace('&', "&")
768 .replace('"', """)
769 .replace('<', "<");
770 let tag = format!("<meta name=\"description\" content=\"{escaped}\">\n");
771 inject_before_head_close(html, &tag)
772}
773
774fn 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 let fillable = !tag.contains("alt=") || tag.contains("alt=\"\"");
799 if fillable && ssg_a11y::img_missing_alt(&tag.to_ascii_lowercase()) {
800 let src = extract_attr(tag, "src").unwrap_or_default();
802 let prompt = format!(
803 "Describe this image for an alt text attribute. The image file is named '{}'. \
804 Return ONLY the alt text (max 125 characters), no quotes:\n",
805 src
806 );
807
808 if let Some(alt) = call_ollama(endpoint, model, &prompt) {
809 let alt = alt.trim().replace('"', """);
810 if dry_run {
811 let rel =
812 path.strip_prefix(site_dir).unwrap_or(path).display();
813 log::info!(
814 "[llm] [dry-run] {rel}: alt=\"{alt}\" for {src}"
815 );
816 } else {
817 let new_tag = if tag.contains("alt=\"\"") {
819 tag.replace("alt=\"\"", &format!("alt=\"{alt}\""))
820 } else {
821 tag.replace("<img", &format!("<img alt=\"{alt}\""))
822 };
823 html.replace_range(abs_start..tag_end_abs, &new_tag);
824 }
825 count += 1;
826 }
827 }
828
829 search_from = tag_end_abs;
830 }
831
832 count
833}
834
835fn extract_page_text(html: &str, max_chars: usize) -> String {
837 let body_start = html
838 .find("<main")
839 .or_else(|| html.find("<body"))
840 .unwrap_or(0);
841 let body = &html[body_start..];
842
843 let mut text = String::with_capacity(max_chars + 50);
844 let mut in_tag = false;
845 for ch in body.chars() {
846 if text.len() >= max_chars {
847 break;
848 }
849 match ch {
850 '<' => in_tag = true,
851 '>' => in_tag = false,
852 _ if !in_tag && !ch.is_control() => text.push(ch),
853 _ => {}
854 }
855 }
856
857 text.split_whitespace().collect::<Vec<_>>().join(" ")
858}
859
860fn extract_attr(tag: &str, attr: &str) -> Option<String> {
862 let pattern = format!("{attr}=\"");
863 let start = tag.find(&pattern)? + pattern.len();
864 let end = tag[start..].find('"')? + start;
865 Some(tag[start..end].to_string())
866}
867
868#[derive(Debug, Clone, Copy, PartialEq, Eq)]
877#[non_exhaustive]
878pub enum ReadabilityFormula {
879 FleschKincaid,
881 KandelMoles,
883 WienerSachtextformel,
885 Gulpease,
887 Lix,
889 FernandezHuerta,
891}
892
893impl ReadabilityFormula {
894 #[must_use]
908 pub fn from_lang(lang: &str) -> Option<Self> {
909 let primary = lang.split(['-', '_']).next().unwrap_or(lang);
910 match primary.to_lowercase().as_str() {
911 "en" => Some(Self::FleschKincaid),
912 "fr" => Some(Self::KandelMoles),
913 "de" => Some(Self::WienerSachtextformel),
914 "it" => Some(Self::Gulpease),
915 "sv" | "nb" | "nn" | "da" | "no" => Some(Self::Lix),
916 "es" => Some(Self::FernandezHuerta),
917 _ => None,
918 }
919 }
920}
921
922#[derive(Debug, Clone, Copy)]
924pub struct ReadabilityAudit {
925 pub grade_level: f64,
927 pub reading_ease: f64,
929 pub avg_sentence_len: f64,
931}
932
933impl ReadabilityAudit {
934 #[must_use]
945 pub fn analyze(text: &str) -> Self {
946 let words = count_words(text);
947 let sentences = count_sentences(text);
948 let syllables = count_syllables(text);
949
950 if words == 0 || sentences == 0 {
951 return Self {
952 grade_level: 0.0,
953 reading_ease: 100.0,
954 avg_sentence_len: 0.0,
955 };
956 }
957
958 let wps = words as f64 / sentences as f64;
959 let spw = syllables as f64 / words as f64;
960
961 let grade = 0.39f64.mul_add(wps, 11.8f64.mul_add(spw, -15.59));
962 let ease = (-1.015f64).mul_add(wps, (-84.6f64).mul_add(spw, 206.835));
963
964 Self {
965 grade_level: grade.max(0.0),
966 reading_ease: ease.clamp(0.0, 100.0),
967 avg_sentence_len: wps,
968 }
969 }
970
971 #[must_use]
984 pub fn analyze_with_lang(text: &str, lang: &str) -> Self {
985 let formula = if lang.is_empty() {
986 ReadabilityFormula::FleschKincaid
987 } else {
988 ReadabilityFormula::from_lang(lang)
989 .unwrap_or(ReadabilityFormula::FleschKincaid)
990 };
991
992 let words = count_words(text);
993 let sentences = count_sentences(text);
994 let syllables = count_syllables(text);
995 let chars: usize = text.chars().filter(|c| c.is_alphanumeric()).count();
996
997 if words == 0 || sentences == 0 {
998 return Self {
999 grade_level: 0.0,
1000 reading_ease: 100.0,
1001 avg_sentence_len: 0.0,
1002 };
1003 }
1004
1005 let wps = words as f64 / sentences as f64;
1006 let spw = syllables as f64 / words as f64;
1007
1008 match formula {
1009 ReadabilityFormula::FleschKincaid => Self::analyze(text),
1010
1011 ReadabilityFormula::KandelMoles => {
1012 let ease = 68.0f64.mul_add(-spw, 1.15f64.mul_add(-wps, 209.0));
1014 Self {
1015 grade_level: ((100.0 - ease.clamp(0.0, 100.0)) / 10.0)
1016 .max(0.0),
1017 reading_ease: ease.clamp(0.0, 100.0),
1018 avg_sentence_len: wps,
1019 }
1020 }
1021
1022 ReadabilityFormula::WienerSachtextformel => {
1023 let word_list: Vec<&str> = text.split_whitespace().collect();
1025 let total = word_list.len().max(1) as f64;
1026 let pct_3plus_syl = word_list
1027 .iter()
1028 .filter(|w| count_word_syllables(w) >= 3)
1029 .count() as f64
1030 / total
1031 * 100.0;
1032 let pct_6plus_char = word_list
1033 .iter()
1034 .filter(|w| {
1035 w.chars().filter(|c| c.is_alphabetic()).count() > 6
1036 })
1037 .count() as f64
1038 / total
1039 * 100.0;
1040 let pct_1syl = word_list
1041 .iter()
1042 .filter(|w| count_word_syllables(w) == 1)
1043 .count() as f64
1044 / total
1045 * 100.0;
1046
1047 let grade = 0.1935f64.mul_add(
1048 pct_3plus_syl,
1049 0.1672f64.mul_add(
1050 wps,
1051 (-0.1297f64).mul_add(
1052 pct_6plus_char,
1053 (-0.0327f64).mul_add(pct_1syl, -0.875),
1054 ),
1055 ),
1056 );
1057
1058 Self {
1059 grade_level: grade.max(0.0),
1060 reading_ease: grade
1061 .clamp(0.0, 20.0)
1062 .mul_add(-5.0, 100.0)
1063 .clamp(0.0, 100.0),
1064 avg_sentence_len: wps,
1065 }
1066 }
1067
1068 ReadabilityFormula::Gulpease => {
1069 let ease = 89.0
1071 + 10.0f64
1072 .mul_add(-(chars as f64), 300.0 * sentences as f64)
1073 / words as f64;
1074 Self {
1075 grade_level: ((100.0 - ease.clamp(0.0, 100.0)) / 10.0)
1076 .max(0.0),
1077 reading_ease: ease.clamp(0.0, 100.0),
1078 avg_sentence_len: wps,
1079 }
1080 }
1081
1082 ReadabilityFormula::Lix => {
1083 let word_list: Vec<&str> = text.split_whitespace().collect();
1085 let total = word_list.len().max(1) as f64;
1086 let long_words = word_list
1087 .iter()
1088 .filter(|w| {
1089 w.chars().filter(|c| c.is_alphabetic()).count() > 6
1090 })
1091 .count() as f64;
1092 let lix = wps + 100.0 * long_words / total;
1093 Self {
1096 grade_level: (lix / 5.0).max(0.0),
1097 reading_ease: (100.0 - lix).clamp(0.0, 100.0),
1098 avg_sentence_len: wps,
1099 }
1100 }
1101
1102 ReadabilityFormula::FernandezHuerta => {
1103 let ease =
1105 1.02f64.mul_add(-wps, (-60.0f64).mul_add(spw, 206.84));
1106 Self {
1107 grade_level: ((100.0 - ease.clamp(0.0, 100.0)) / 10.0)
1108 .max(0.0),
1109 reading_ease: ease.clamp(0.0, 100.0),
1110 avg_sentence_len: wps,
1111 }
1112 }
1113 }
1114 }
1115}
1116
1117fn count_words(text: &str) -> usize {
1119 text.split_whitespace().count()
1120}
1121
1122fn count_sentences(text: &str) -> usize {
1124 text.chars()
1125 .filter(|&c| c == '.' || c == '!' || c == '?')
1126 .count()
1127 .max(1)
1128}
1129
1130fn count_syllables(text: &str) -> usize {
1135 text.split_whitespace()
1136 .map(|word| count_word_syllables(word))
1137 .sum()
1138}
1139
1140fn count_word_syllables(word: &str) -> usize {
1142 let word = word.to_lowercase();
1143 let chars: Vec<char> = word.chars().filter(|c| c.is_alphabetic()).collect();
1144 if chars.is_empty() {
1145 return 1;
1146 }
1147
1148 let vowels = b"aeiouy";
1149 let mut count = 0usize;
1150 let mut prev_vowel = false;
1151
1152 for &ch in &chars {
1153 let is_vowel = vowels.contains(&(ch as u8));
1154 if is_vowel && !prev_vowel {
1155 count += 1;
1156 }
1157 prev_vowel = is_vowel;
1158 }
1159
1160 if chars.len() > 2 && chars.last() == Some(&'e') && count > 1 {
1162 count -= 1;
1163 }
1164
1165 count.max(1)
1166}
1167
1168fn generate_with_refinement(
1173 endpoint: &str,
1174 model: &str,
1175 prompt: &str,
1176 target_grade: f64,
1177 max_attempts: usize,
1178) -> Option<String> {
1179 let mut text = call_ollama(endpoint, model, prompt)?;
1180 let mut audit = ReadabilityAudit::analyze(&text);
1181
1182 for attempt in 0..max_attempts {
1183 if audit.grade_level <= target_grade {
1184 break;
1185 }
1186
1187 let attempt_num = attempt + 1;
1188 log::info!(
1189 "[llm] Grade {:.1} exceeds target {:.1}, refining (attempt {})",
1190 audit.grade_level,
1191 target_grade,
1192 attempt_num
1193 );
1194
1195 let simplify_prompt = format!(
1196 "Rewrite this text at a 6th-grade reading level. \
1197 Use short sentences (max 20 words). Use simple words. \
1198 Keep all facts and numbers exactly the same. \
1199 Return ONLY the rewritten text:\n\n{text}"
1200 );
1201
1202 if let Some(refined) = call_ollama(endpoint, model, &simplify_prompt) {
1203 let refined_audit = ReadabilityAudit::analyze(&refined);
1204 if refined_audit.grade_level < audit.grade_level {
1205 text = refined;
1206 audit = refined_audit;
1207 }
1208 }
1209 }
1210
1211 Some(text)
1212}
1213
1214fn inject_jsonld_description(html: &str, description: &str) -> String {
1223 if html.contains("\"@type\":\"Article\"")
1225 && html.contains("\"description\"")
1226 {
1227 return html.to_string();
1228 }
1229
1230 let jsonld = serde_json::json!({
1231 "@context": "https://schema.org",
1232 "@type": "Article",
1233 "description": description,
1234 });
1235
1236 let script =
1237 format!("<script type=\"application/ld+json\">{}</script>\n", jsonld);
1238 inject_before_head_close(html, &script)
1239}
1240
1241fn call_ollama(endpoint: &str, model: &str, prompt: &str) -> Option<String> {
1249 query_ollama(endpoint, model, prompt, DEFAULT_LLM_TIMEOUT_SECS).ok()
1250}
1251
1252pub fn query_ollama(
1280 endpoint: &str,
1281 model: &str,
1282 prompt: &str,
1283 timeout_secs: u64,
1284) -> Result<String, SsgError> {
1285 let url = format!("{}/api/generate", endpoint.trim_end_matches('/'));
1286 let payload = serde_json::json!({
1287 "model": model,
1288 "prompt": prompt,
1289 "stream": false,
1290 });
1291
1292 let timeout = Duration::from_secs(timeout_secs);
1293 let agent = ureq::AgentBuilder::new().timeout(timeout).build();
1294
1295 let response = agent
1296 .post(&url)
1297 .set("Content-Type", "application/json")
1298 .send_json(payload)
1299 .map_err(|err| classify_ureq_error(err, &url, timeout))?;
1300
1301 let body: serde_json::Value =
1302 response
1303 .into_json()
1304 .map_err(|e| SsgError::LlmInvalidResponse {
1305 message: format!("malformed JSON response body: {e}"),
1306 })?;
1307
1308 body.get("response")
1309 .and_then(|v| v.as_str())
1310 .map(|s| s.trim().to_string())
1311 .filter(|s| !s.is_empty())
1312 .ok_or_else(|| SsgError::LlmInvalidResponse {
1313 message: "missing or empty 'response' field".into(),
1314 })
1315}
1316
1317fn classify_ureq_error(
1327 err: ureq::Error,
1328 url: &str,
1329 timeout: Duration,
1330) -> SsgError {
1331 match err {
1332 ureq::Error::Status(code, resp) => SsgError::LlmInvalidResponse {
1333 message: format!(
1334 "HTTP {code} from {url}: {}",
1335 resp.into_string().unwrap_or_default()
1336 ),
1337 },
1338 ureq::Error::Transport(transport) => {
1339 let kind = transport.kind();
1346 let msg = transport.to_string();
1347 if is_timeout_transport(kind, &msg) {
1348 SsgError::LlmTimeout { duration: timeout }
1349 } else {
1350 SsgError::LlmEndpointUnreachable {
1351 url: url.to_string(),
1352 source: Box::new(transport),
1353 }
1354 }
1355 }
1356 }
1357}
1358
1359fn is_timeout_transport(kind: ureq::ErrorKind, msg: &str) -> bool {
1370 matches!(
1371 kind,
1372 ureq::ErrorKind::Io | ureq::ErrorKind::ConnectionFailed
1373 ) && (msg.contains("timed out")
1374 || msg.contains("timeout")
1375 || msg.contains("deadline")
1376 || msg.contains("os error 10060"))
1377}
1378
1379impl LlmPlugin {
1380 pub fn query(&self, prompt: &str) -> Result<String, SsgError> {
1408 if !self.config.cache_disabled {
1414 let root = self
1415 .config
1416 .cache_dir
1417 .clone()
1418 .unwrap_or_else(LlmCache::default_cache_dir);
1419 let cache = LlmCache::new(root);
1420 let key = LlmCache::compute_key(
1421 &self.config.endpoint,
1422 &self.config.model,
1423 prompt,
1424 self.config.timeout_secs,
1425 );
1426 if let Some(cached) = cache.get(&key) {
1427 return Ok(cached);
1428 }
1429 let response = query_ollama(
1430 &self.config.endpoint,
1431 &self.config.model,
1432 prompt,
1433 self.config.timeout_secs,
1434 )?;
1435 let _ = cache.set(&key, &response);
1436 return Ok(response);
1437 }
1438 query_ollama(
1439 &self.config.endpoint,
1440 &self.config.model,
1441 prompt,
1442 self.config.timeout_secs,
1443 )
1444 }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::*;
1450
1451 #[test]
1452 fn needs_meta_description_missing() {
1453 assert!(needs_meta_description("<html><head></head></html>"));
1454 }
1455
1456 #[test]
1457 fn needs_meta_description_short() {
1458 let html = r#"<html><head><meta name="description" content="Short"></head></html>"#;
1459 assert!(needs_meta_description(html));
1460 }
1461
1462 #[test]
1463 fn needs_meta_description_adequate() {
1464 let html = r#"<html><head><meta name="description" content="This is a sufficiently long meta description that exceeds fifty characters easily"></head></html>"#;
1465 assert!(!needs_meta_description(html));
1466 }
1467
1468 #[test]
1469 fn inject_meta_description_into_head() {
1470 let html = "<html><head><title>T</title></head><body></body></html>";
1471 let result = inject_meta_description(html, "Test description");
1472 assert!(result.contains("name=\"description\""));
1473 assert!(result.contains("Test description"));
1474 }
1475
1476 #[test]
1477 fn extract_attr_basic() {
1478 assert_eq!(
1479 extract_attr(r#"<img src="photo.jpg" alt="x">"#, "src"),
1480 Some("photo.jpg".to_string())
1481 );
1482 }
1483
1484 #[test]
1485 fn extract_attr_missing() {
1486 assert_eq!(extract_attr(r#"<img src="x.jpg">"#, "alt"), None);
1487 }
1488
1489 #[test]
1490 fn extract_page_text_strips_tags() {
1491 let html = "<body><p>Hello <b>world</b></p></body>";
1492 let text = extract_page_text(html, 100);
1493 assert_eq!(text, "Hello world");
1494 }
1495
1496 #[test]
1497 fn llm_plugin_name() {
1498 let plugin = LlmPlugin::new(LlmConfig::default());
1499 assert_eq!(plugin.name(), "llm");
1500 }
1501
1502 #[test]
1505 fn flesch_kincaid_simple_text() {
1506 let audit = ReadabilityAudit::analyze("The cat sat on the mat.");
1508 assert!(
1509 audit.grade_level < 4.0,
1510 "Simple text should be below grade 4, got {:.1}",
1511 audit.grade_level
1512 );
1513 assert!(audit.reading_ease > 80.0);
1514 }
1515
1516 #[test]
1517 fn flesch_kincaid_complex_text() {
1518 let text = "The implementation of sophisticated cryptographic \
1519 algorithms necessitates comprehensive understanding \
1520 of mathematical foundations. Asymmetric encryption \
1521 protocols demonstrate considerable computational \
1522 overhead compared to symmetric alternatives.";
1523 let audit = ReadabilityAudit::analyze(text);
1524 assert!(
1525 audit.grade_level > 12.0,
1526 "Complex text should be above grade 12, got {:.1}",
1527 audit.grade_level
1528 );
1529 }
1530
1531 #[test]
1532 fn flesch_kincaid_empty_text() {
1533 let audit = ReadabilityAudit::analyze("");
1534 assert!(audit.grade_level.abs() < f64::EPSILON);
1535 assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
1536 }
1537
1538 #[test]
1539 fn syllable_count_known_words() {
1540 assert_eq!(count_word_syllables("cat"), 1);
1541 assert_eq!(count_word_syllables("hello"), 2);
1542 assert_eq!(count_word_syllables("beautiful"), 3);
1543 assert_eq!(count_word_syllables("implementation"), 5);
1544 }
1545
1546 #[test]
1547 fn count_sentences_basic() {
1548 assert_eq!(count_sentences("Hello. World!"), 2);
1549 assert_eq!(count_sentences("One sentence"), 1); assert_eq!(count_sentences("A? B? C!"), 3);
1551 }
1552
1553 #[test]
1556 fn inject_jsonld_adds_article_block() {
1557 let html = "<html><head><title>T</title></head><body></body></html>";
1558 let result = inject_jsonld_description(html, "Test desc");
1559 assert!(result.contains("application/ld+json"));
1560 assert!(result.contains("\"@type\":\"Article\""));
1561 assert!(result.contains("Test desc"));
1562 }
1563
1564 #[test]
1565 fn inject_jsonld_skips_existing() {
1566 let html = r#"<html><head><script type="application/ld+json">{"@type":"Article","description":"Existing"}</script></head></html>"#;
1567 let result = inject_jsonld_description(html, "New desc");
1568 assert!(!result.contains("New desc"));
1569 assert!(result.contains("Existing"));
1570 }
1571
1572 #[test]
1575 fn audit_all_scans_markdown_files() {
1576 let dir = tempfile::tempdir().unwrap();
1577 let content = dir.path().join("content");
1578 fs::create_dir_all(&content).unwrap();
1579
1580 fs::write(
1581 content.join("simple.md"),
1582 "---\ntitle: Simple\n---\nThe cat sat on the mat. It was a good day.",
1583 )
1584 .unwrap();
1585 fs::write(
1586 content.join("complex.md"),
1587 "---\ntitle: Complex\n---\n\
1588 The implementation of sophisticated cryptographic algorithms \
1589 necessitates comprehensive understanding of mathematical \
1590 foundations and computational complexity theory.",
1591 )
1592 .unwrap();
1593
1594 let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
1595 assert_eq!(report.total_files, 2);
1596 assert!(report.failing > 0, "complex.md should fail grade 8");
1597 }
1598
1599 #[test]
1600 fn audit_all_empty_dir() {
1601 let dir = tempfile::tempdir().unwrap();
1602 let content = dir.path().join("empty");
1603 fs::create_dir_all(&content).unwrap();
1604
1605 let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
1606 assert_eq!(report.total_files, 0);
1607 assert_eq!(report.failing, 0);
1608 }
1609
1610 #[test]
1611 fn strip_frontmatter_yaml() {
1612 let input = "---\ntitle: Hello\n---\nBody text here.";
1613 let body = strip_frontmatter(input);
1614 assert!(body.contains("Body text here"));
1615 assert!(!body.contains("title:"));
1616 }
1617
1618 #[test]
1619 fn strip_frontmatter_toml() {
1620 let input = "+++\ntitle = \"Hello\"\n+++\nBody text here.";
1621 let body = strip_frontmatter(input);
1622 assert!(body.contains("Body text here"));
1623 assert!(!body.contains("title"));
1624 }
1625
1626 #[test]
1627 fn strip_frontmatter_none() {
1628 let input = "Just plain content.";
1629 assert_eq!(strip_frontmatter(input), input);
1630 }
1631
1632 #[test]
1633 fn split_frontmatter_preserves_delimiters() {
1634 let input = "---\ntitle: Hello\ndate: 2026-01-01\n---\n\n# Body text";
1635 let (fm, body) = split_frontmatter(input);
1636 assert!(fm.starts_with("---"));
1637 assert!(fm.ends_with("---"));
1638 assert!(fm.contains("title: Hello"));
1639 assert!(body.contains("# Body text"));
1640 }
1641
1642 #[test]
1643 fn split_frontmatter_toml_preserves() {
1644 let input = "+++\ntitle = \"Hello\"\n+++\nBody.";
1645 let (fm, body) = split_frontmatter(input);
1646 assert!(fm.starts_with("+++"));
1647 assert!(body.contains("Body."));
1648 }
1649
1650 #[test]
1651 fn split_frontmatter_no_frontmatter() {
1652 let input = "Just plain content.";
1653 let (fm, body) = split_frontmatter(input);
1654 assert!(fm.is_empty(), "{:?}", fm);
1655 assert_eq!(body, input);
1656 }
1657
1658 #[test]
1659 fn audit_and_fix_skips_when_ollama_unavailable() {
1660 let dir = tempfile::tempdir().unwrap();
1661 let content = dir.path().join("content");
1662 fs::create_dir_all(&content).unwrap();
1663 fs::write(content.join("test.md"), "---\ntitle: T\n---\nSimple text.")
1664 .unwrap();
1665
1666 let config = LlmConfig {
1667 endpoint: "http://localhost:99999".to_string(),
1668 ..LlmConfig::default()
1669 };
1670 let result = LlmPlugin::audit_and_fix(&content, &config).unwrap();
1671 assert_eq!(result, 0);
1672 }
1673
1674 #[test]
1675 fn full_repo_readability_audit() {
1676 let empty = tempfile::tempdir().unwrap();
1680 let empty_dir = empty.path().to_string_lossy().to_string();
1681 let dirs = [
1682 ("docs/guide".to_string(), 15.0),
1683 ("examples/basic/content".to_string(), 10.0),
1684 ("examples/blog/content".to_string(), 10.0),
1685 ("examples/docs/content".to_string(), 13.0),
1686 ("examples/landing/content".to_string(), 10.0),
1687 ("examples/plugins/content".to_string(), 10.0),
1688 ("examples/portfolio/content".to_string(), 10.0),
1689 ("examples/quickstart/content".to_string(), 10.0),
1690 ("examples/content/en".to_string(), 10.0),
1691 ("this/path/does/not/exist".to_string(), 10.0),
1692 (empty_dir, 10.0),
1693 ];
1694
1695 let mut total_files = 0usize;
1696 let mut total_pass = 0usize;
1697 let mut total_fail = 0usize;
1698
1699 println!("\n{}", "=".repeat(60));
1700 println!(" FULL REPOSITORY READABILITY AUDIT");
1701 println!("{}\n", "=".repeat(60));
1702
1703 for (dir, target) in &dirs {
1704 let path = Path::new(dir);
1705 if !path.exists() {
1706 continue;
1707 }
1708
1709 let report = LlmPlugin::audit_all(path, *target).unwrap();
1710 if report.total_files == 0 {
1711 continue;
1712 }
1713
1714 println!("── {dir} (target: grade {target:.0}) ��─");
1715 for r in &report.results {
1716 let status = if r.passes { "PASS" } else { "FAIL" };
1717 println!(
1718 " {:.<40} grade {:>5.1} ease {:>5.1} [{status}]",
1719 r.path, r.grade_level, r.reading_ease
1720 );
1721 }
1722 println!(" → {}/{} pass\n", report.passing, report.total_files);
1723
1724 total_files += report.total_files;
1725 total_pass += report.passing;
1726 total_fail += report.failing;
1727 }
1728
1729 println!("{}", "=".repeat(60));
1730 println!(
1731 " TOTAL: {total_files} files — {total_pass} pass, {total_fail} fail"
1732 );
1733 println!("{}\n", "=".repeat(60));
1734 }
1735
1736 fn run_docs_guide_audit(guide_dir: &Path) {
1739 if !guide_dir.exists() {
1740 return; }
1742
1743 let report = LlmPlugin::audit_all(guide_dir, 17.0).unwrap();
1744 for result in &report.results {
1745 let status = if result.passes { "PASS" } else { "FAIL" };
1746 println!(
1747 "[readability] {}: grade={:.1}, ease={:.1}, avg_sentence={:.1} — {}",
1748 result.path,
1749 result.grade_level,
1750 result.reading_ease,
1751 result.avg_sentence_len,
1752 status
1753 );
1754 }
1755
1756 println!(
1757 "\n[readability] {}/{} files pass (target: grade {:.0})",
1758 report.passing, report.total_files, report.target_grade
1759 );
1760 }
1761
1762 #[test]
1763 fn audit_docs_guide() {
1764 run_docs_guide_audit(Path::new("docs/guide"));
1769 run_docs_guide_audit(Path::new("docs/this-guide-does-not-exist"));
1771 }
1772
1773 #[test]
1776 fn is_ollama_available_unreachable() {
1777 assert!(!is_ollama_available("http://localhost:99999"));
1778 }
1779
1780 #[test]
1781 fn call_ollama_unreachable_returns_none() {
1782 assert!(call_ollama("http://localhost:99999", "llama3", "hi").is_none());
1783 }
1784
1785 #[test]
1786 fn needs_meta_description_with_content_attr_first() {
1787 let html = r#"<meta content="Decent length description that is more than fifty characters long enough" name="description">"#;
1789 assert!(!needs_meta_description(html));
1791 }
1792
1793 #[test]
1794 fn inject_meta_description_no_head() {
1795 let html = "<html><body>No head tag</body></html>";
1796 let result = inject_meta_description(html, "desc");
1797 assert_eq!(result, html); }
1799
1800 #[test]
1801 fn inject_jsonld_no_head() {
1802 let html = "<html><body>No head</body></html>";
1803 let result = inject_jsonld_description(html, "desc");
1804 assert_eq!(result, html);
1805 }
1806
1807 #[test]
1808 fn extract_page_text_no_body() {
1809 let html = "just plain text no tags";
1810 let text = extract_page_text(html, 100);
1811 assert_eq!(text, "just plain text no tags");
1812 }
1813
1814 #[test]
1815 fn extract_page_text_truncates() {
1816 let html = "<body><p>word </p></body>";
1817 let text = extract_page_text(html, 3);
1818 assert!(text.len() <= 5);
1819 }
1820
1821 #[test]
1822 fn generate_missing_alt_text_no_images() {
1823 let mut html = "<html><body><p>No images</p></body></html>".to_string();
1824 let count = generate_missing_alt_text(
1825 &mut html,
1826 "llama3",
1827 "http://localhost:99999",
1828 true,
1829 Path::new("test.html"),
1830 Path::new("."),
1831 );
1832 assert_eq!(count, 0);
1833 }
1834
1835 #[test]
1836 fn readability_audit_single_word() {
1837 let audit = ReadabilityAudit::analyze("Hello");
1838 assert!(audit.grade_level >= 0.0);
1839 assert!(audit.avg_sentence_len >= 0.0);
1840 }
1841
1842 #[test]
1843 fn count_word_syllables_empty() {
1844 assert_eq!(count_word_syllables(""), 1);
1845 }
1846
1847 #[test]
1848 fn count_word_syllables_numbers() {
1849 assert_eq!(count_word_syllables("123"), 1);
1850 }
1851
1852 #[test]
1853 fn split_frontmatter_unclosed() {
1854 let input = "---\ntitle: Hello\nNo closing delimiter";
1855 let (fm, body) = split_frontmatter(input);
1856 assert!(fm.is_empty(), "{:?}", fm);
1857 assert_eq!(body, input);
1858 }
1859
1860 #[test]
1861 fn llm_plugin_skips_missing_site_dir() {
1862 let plugin = LlmPlugin::new(LlmConfig::default());
1863 let ctx = PluginContext::new(
1864 Path::new("/tmp/c"),
1865 Path::new("/tmp/b"),
1866 Path::new("/nonexistent/site"),
1867 Path::new("/tmp/t"),
1868 );
1869 assert!(plugin.after_compile(&ctx).is_ok());
1870 }
1871
1872 #[test]
1873 fn config_defaults_readability() {
1874 let config = LlmConfig::default();
1875 assert!((config.target_grade - 8.0).abs() < f64::EPSILON);
1876 assert_eq!(config.max_refinement_attempts, 1);
1877 }
1878
1879 #[test]
1880 fn llm_plugin_skips_when_ollama_unavailable() {
1881 let plugin = LlmPlugin::new(LlmConfig {
1882 endpoint: "http://localhost:99999".to_string(),
1883 ..LlmConfig::default()
1884 });
1885
1886 let dir = tempfile::tempdir().unwrap();
1887 let site = dir.path().join("site");
1888 fs::create_dir_all(&site).unwrap();
1889 fs::write(site.join("index.html"), "<html><body></body></html>")
1890 .unwrap();
1891
1892 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
1893 plugin.after_compile(&ctx).unwrap();
1895 }
1896
1897 #[test]
1900 fn ai_fix_report_serializes_to_json() {
1901 let report = AiFixReport {
1902 total_audited: 10,
1903 total_failing: 3,
1904 total_fixed: 2,
1905 results: vec![
1906 AiFixResult {
1907 path: "docs/guide.md".to_string(),
1908 before_grade: 12.5,
1909 after_grade: 7.2,
1910 improved: true,
1911 action: "rewritten".to_string(),
1912 },
1913 AiFixResult {
1914 path: "docs/api.md".to_string(),
1915 before_grade: 14.0,
1916 after_grade: 13.8,
1917 improved: false,
1918 action: "no-improvement".to_string(),
1919 },
1920 ],
1921 };
1922 let json = serde_json::to_string(&report).unwrap();
1923 assert!(json.contains("\"total_fixed\":2"));
1924 assert!(json.contains("\"action\":\"rewritten\""));
1925 }
1926
1927 #[test]
1928 fn ai_fix_report_skips_when_ollama_unavailable() {
1929 let dir = tempfile::tempdir().unwrap();
1930 let content = dir.path().join("content");
1931 fs::create_dir_all(&content).unwrap();
1932 fs::write(
1933 content.join("test.md"),
1934 "---\ntitle: T\n---\nThe implementation of sophisticated algorithms.",
1935 )
1936 .unwrap();
1937
1938 let config = LlmConfig {
1939 endpoint: "http://localhost:99999".to_string(),
1940 max_refinement_attempts: 3,
1941 ..LlmConfig::default()
1942 };
1943 let report =
1944 LlmPlugin::audit_and_fix_with_report(&content, &config).unwrap();
1945 assert_eq!(report.total_fixed, 0);
1946 assert!(report.results.is_empty());
1947 }
1948
1949 #[test]
1952 fn formula_from_lang_english() {
1953 assert_eq!(
1954 ReadabilityFormula::from_lang("en"),
1955 Some(ReadabilityFormula::FleschKincaid)
1956 );
1957 assert_eq!(
1958 ReadabilityFormula::from_lang("en-US"),
1959 Some(ReadabilityFormula::FleschKincaid)
1960 );
1961 }
1962
1963 #[test]
1964 fn formula_from_lang_french() {
1965 assert_eq!(
1966 ReadabilityFormula::from_lang("fr"),
1967 Some(ReadabilityFormula::KandelMoles)
1968 );
1969 assert_eq!(
1970 ReadabilityFormula::from_lang("fr-CA"),
1971 Some(ReadabilityFormula::KandelMoles)
1972 );
1973 }
1974
1975 #[test]
1976 fn formula_from_lang_german() {
1977 assert_eq!(
1978 ReadabilityFormula::from_lang("de"),
1979 Some(ReadabilityFormula::WienerSachtextformel)
1980 );
1981 assert_eq!(
1982 ReadabilityFormula::from_lang("de-AT"),
1983 Some(ReadabilityFormula::WienerSachtextformel)
1984 );
1985 }
1986
1987 #[test]
1988 fn formula_from_lang_italian() {
1989 assert_eq!(
1990 ReadabilityFormula::from_lang("it"),
1991 Some(ReadabilityFormula::Gulpease)
1992 );
1993 }
1994
1995 #[test]
1996 fn formula_from_lang_swedish() {
1997 assert_eq!(
1998 ReadabilityFormula::from_lang("sv"),
1999 Some(ReadabilityFormula::Lix)
2000 );
2001 assert_eq!(
2002 ReadabilityFormula::from_lang("nb"),
2003 Some(ReadabilityFormula::Lix)
2004 );
2005 assert_eq!(
2006 ReadabilityFormula::from_lang("da"),
2007 Some(ReadabilityFormula::Lix)
2008 );
2009 }
2010
2011 #[test]
2012 fn formula_from_lang_spanish() {
2013 assert_eq!(
2014 ReadabilityFormula::from_lang("es"),
2015 Some(ReadabilityFormula::FernandezHuerta)
2016 );
2017 }
2018
2019 #[test]
2020 fn formula_from_lang_unsupported() {
2021 assert_eq!(ReadabilityFormula::from_lang("ja"), None);
2022 assert_eq!(ReadabilityFormula::from_lang("zh"), None);
2023 assert_eq!(ReadabilityFormula::from_lang("ar"), None);
2024 }
2025
2026 #[test]
2027 fn kandel_moles_simple_french() {
2028 let text = "Le chat est sur le tapis. Il fait beau. Le soleil brille.";
2029 let audit = ReadabilityAudit::analyze_with_lang(text, "fr");
2030 assert!(
2031 audit.reading_ease > 50.0,
2032 "Simple French should be readable, got {:.1}",
2033 audit.reading_ease
2034 );
2035 }
2036
2037 #[test]
2038 fn wiener_simple_german() {
2039 let text = "Die Katze sitzt auf der Matte. Es ist ein guter Tag. Die Sonne scheint.";
2040 let audit = ReadabilityAudit::analyze_with_lang(text, "de");
2041 assert!(
2042 audit.grade_level < 15.0,
2043 "Simple German got grade {:.1}",
2044 audit.grade_level
2045 );
2046 }
2047
2048 #[test]
2049 fn gulpease_simple_italian() {
2050 let text = "Il gatto si siede sul tappeto. Il sole splende. Oggi è una bella giornata.";
2051 let audit = ReadabilityAudit::analyze_with_lang(text, "it");
2052 assert!(
2053 audit.reading_ease > 40.0,
2054 "Simple Italian got ease {:.1}",
2055 audit.reading_ease
2056 );
2057 }
2058
2059 #[test]
2060 fn lix_simple_swedish() {
2061 let text = "Katten sitter på mattan. Solen skiner. Det är en fin dag.";
2062 let audit = ReadabilityAudit::analyze_with_lang(text, "sv");
2063 assert!(audit.grade_level >= 0.0);
2064 assert!(audit.reading_ease > 0.0);
2065 }
2066
2067 #[test]
2068 fn fernandez_huerta_simple_spanish() {
2069 let text = "El gato está en la mesa. El sol brilla. Es un buen día.";
2070 let audit = ReadabilityAudit::analyze_with_lang(text, "es");
2071 assert!(
2072 audit.reading_ease > 50.0,
2073 "Simple Spanish got ease {:.1}",
2074 audit.reading_ease
2075 );
2076 }
2077
2078 #[test]
2079 fn analyze_with_lang_empty_defaults_to_english() {
2080 let text = "The cat sat on the mat.";
2081 let a = ReadabilityAudit::analyze(text);
2082 let b = ReadabilityAudit::analyze_with_lang(text, "");
2083 assert!((a.grade_level - b.grade_level).abs() < f64::EPSILON);
2084 }
2085
2086 #[test]
2087 fn analyze_with_lang_unsupported_falls_back() {
2088 let text = "The cat sat on the mat.";
2089 let a = ReadabilityAudit::analyze(text);
2090 let b = ReadabilityAudit::analyze_with_lang(text, "ja");
2091 assert!((a.grade_level - b.grade_level).abs() < f64::EPSILON);
2092 }
2093
2094 #[test]
2095 fn extract_frontmatter_lang_yaml() {
2096 let content = "---\ntitle: Hello\nlanguage: fr\n---\nBody.";
2097 assert_eq!(extract_frontmatter_lang(content), "fr");
2098 }
2099
2100 #[test]
2101 fn extract_frontmatter_lang_yaml_short() {
2102 let content = "---\ntitle: Hello\nlang: de\n---\nBody.";
2103 assert_eq!(extract_frontmatter_lang(content), "de");
2104 }
2105
2106 #[test]
2107 fn extract_frontmatter_lang_toml() {
2108 let content = "+++\ntitle = \"Hello\"\nlanguage = \"it\"\n+++\nBody.";
2109 assert_eq!(extract_frontmatter_lang(content), "it");
2110 }
2111
2112 #[test]
2113 fn extract_frontmatter_lang_missing() {
2114 let content = "---\ntitle: Hello\n---\nBody.";
2115 assert_eq!(extract_frontmatter_lang(content), "");
2116 }
2117
2118 #[test]
2119 fn extract_frontmatter_lang_no_frontmatter() {
2120 let content = "Just plain text.";
2121 assert_eq!(extract_frontmatter_lang(content), "");
2122 }
2123
2124 #[test]
2125 fn audit_all_respects_language() {
2126 let dir = tempfile::tempdir().unwrap();
2127 let content = dir.path().join("content");
2128 fs::create_dir_all(&content).unwrap();
2129
2130 fs::write(
2131 content.join("french.md"),
2132 "---\ntitle: Bonjour\nlanguage: fr\n---\nLe chat est sur le tapis. Il fait beau.",
2133 )
2134 .unwrap();
2135
2136 let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
2137 assert_eq!(report.total_files, 1);
2138 }
2140
2141 #[test]
2144 fn kandel_moles_empty_text() {
2145 let audit = ReadabilityAudit::analyze_with_lang("", "fr");
2146 assert!(audit.grade_level.abs() < f64::EPSILON);
2147 assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2148 assert!(audit.avg_sentence_len.abs() < f64::EPSILON);
2149 }
2150
2151 #[test]
2152 fn wiener_empty_text() {
2153 let audit = ReadabilityAudit::analyze_with_lang("", "de");
2154 assert!(audit.grade_level.abs() < f64::EPSILON);
2155 assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2156 }
2157
2158 #[test]
2159 fn gulpease_empty_text() {
2160 let audit = ReadabilityAudit::analyze_with_lang("", "it");
2161 assert!(audit.grade_level.abs() < f64::EPSILON);
2162 assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2163 }
2164
2165 #[test]
2166 fn lix_empty_text() {
2167 let audit = ReadabilityAudit::analyze_with_lang("", "sv");
2168 assert!(audit.grade_level.abs() < f64::EPSILON);
2169 assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2170 }
2171
2172 #[test]
2173 fn fernandez_huerta_empty_text() {
2174 let audit = ReadabilityAudit::analyze_with_lang("", "es");
2175 assert!(audit.grade_level.abs() < f64::EPSILON);
2176 assert!((audit.reading_ease - 100.0).abs() < f64::EPSILON);
2177 }
2178
2179 #[test]
2182 fn kandel_moles_single_word() {
2183 let audit = ReadabilityAudit::analyze_with_lang("Bonjour", "fr");
2184 assert!(audit.grade_level >= 0.0);
2185 assert!(audit.reading_ease >= 0.0);
2186 assert!(audit.avg_sentence_len >= 1.0);
2187 }
2188
2189 #[test]
2190 fn wiener_single_word() {
2191 let audit = ReadabilityAudit::analyze_with_lang("Hallo", "de");
2192 assert!(audit.grade_level >= 0.0);
2193 assert!(audit.avg_sentence_len >= 1.0);
2194 }
2195
2196 #[test]
2197 fn gulpease_single_word() {
2198 let audit = ReadabilityAudit::analyze_with_lang("Ciao", "it");
2199 assert!(audit.grade_level >= 0.0);
2200 assert!(audit.avg_sentence_len >= 1.0);
2201 }
2202
2203 #[test]
2204 fn lix_single_word() {
2205 let audit = ReadabilityAudit::analyze_with_lang("Hej", "sv");
2206 assert!(audit.grade_level >= 0.0);
2207 }
2208
2209 #[test]
2210 fn fernandez_huerta_single_word() {
2211 let audit = ReadabilityAudit::analyze_with_lang("Hola", "es");
2212 assert!(audit.grade_level >= 0.0);
2213 }
2214
2215 #[test]
2218 fn kandel_moles_long_text() {
2219 let text = "Le développement de nouvelles infrastructures \
2220 technologiques nécessite une compréhension \
2221 approfondie des systèmes complexes. \
2222 Les algorithmes sophistiqués démontrent \
2223 une efficacité considérable. \
2224 La modernisation progressive des architectures \
2225 informatiques représente un défi majeur.";
2226 let audit = ReadabilityAudit::analyze_with_lang(text, "fr");
2227 assert!(audit.grade_level > 0.0);
2228 assert!(audit.reading_ease >= 0.0);
2229 assert!(audit.avg_sentence_len > 1.0);
2230 }
2231
2232 #[test]
2233 fn wiener_long_text() {
2234 let text = "Die Implementierung fortschrittlicher kryptografischer \
2235 Algorithmen erfordert umfassendes Verständnis \
2236 mathematischer Grundlagen. Asymmetrische \
2237 Verschlüsselungsprotokolle weisen erheblichen \
2238 Rechenaufwand auf. Die systematische Optimierung \
2239 komplexer Datenstrukturen bleibt herausfordernd.";
2240 let audit = ReadabilityAudit::analyze_with_lang(text, "de");
2241 assert!(audit.grade_level > 0.0);
2242 assert!(audit.avg_sentence_len > 1.0);
2243 }
2244
2245 #[test]
2246 fn gulpease_long_text() {
2247 let text = "L'implementazione di algoritmi crittografici sofisticati \
2248 richiede una comprensione approfondita dei fondamenti \
2249 matematici. I protocolli di crittografia asimmetrica \
2250 dimostrano un considerevole sovraccarico computazionale. \
2251 L'ottimizzazione sistematica delle strutture dati \
2252 complesse rimane impegnativa.";
2253 let audit = ReadabilityAudit::analyze_with_lang(text, "it");
2254 assert!(audit.grade_level > 0.0);
2255 assert!(audit.avg_sentence_len > 1.0);
2256 }
2257
2258 #[test]
2259 fn lix_long_text() {
2260 let text = "Implementeringen av avancerade kryptografiska algoritmer \
2261 kräver omfattande förståelse av matematiska grunder. \
2262 Asymmetriska krypteringsprotokoll uppvisar betydande \
2263 beräkningsbelastning. Systematisk optimering av komplexa \
2264 datastrukturer förblir utmanande.";
2265 let audit = ReadabilityAudit::analyze_with_lang(text, "sv");
2266 assert!(audit.grade_level > 0.0);
2267 assert!(audit.avg_sentence_len > 1.0);
2268 }
2269
2270 #[test]
2271 fn fernandez_huerta_long_text() {
2272 let text =
2273 "La implementación de algoritmos criptográficos sofisticados \
2274 requiere una comprensión profunda de los fundamentos \
2275 matemáticos. Los protocolos de cifrado asimétrico \
2276 demuestran una considerable sobrecarga computacional. \
2277 La optimización sistemática de estructuras de datos \
2278 complejas sigue siendo un desafío.";
2279 let audit = ReadabilityAudit::analyze_with_lang(text, "es");
2280 assert!(audit.grade_level > 0.0);
2281 assert!(audit.avg_sentence_len > 1.0);
2282 }
2283
2284 #[test]
2287 fn wiener_mixed_syllable_words() {
2288 let text = "Ich bin gut. Das Haus ist sehr interessant. \
2290 Die Universität hat viele Studenten.";
2291 let audit = ReadabilityAudit::analyze_with_lang(text, "de");
2292 assert!(audit.grade_level >= 0.0);
2293 assert!(audit.reading_ease >= 0.0);
2294 assert!(audit.reading_ease <= 100.0);
2295 }
2296
2297 #[test]
2300 fn lix_mixed_word_lengths() {
2301 let text = "En bok om programmering. \
2303 Datavetenskapliga beräkningar kräver noggrannhet.";
2304 let audit = ReadabilityAudit::analyze_with_lang(text, "sv");
2305 assert!(audit.grade_level > 0.0);
2306 assert!(audit.reading_ease >= 0.0);
2307 assert!(audit.reading_ease <= 100.0);
2308 }
2309
2310 #[test]
2313 fn extract_frontmatter_lang_toml_with_quotes() {
2314 let content =
2315 "+++\ntitle = \"Hello\"\nlanguage = \"en-US\"\n+++\nBody.";
2316 assert_eq!(extract_frontmatter_lang(content), "en-US");
2317 }
2318
2319 #[test]
2320 fn extract_frontmatter_lang_first_wins() {
2321 let content = "---\nlanguage: fr\nlang: de\n---\nBody.";
2323 assert_eq!(extract_frontmatter_lang(content), "fr");
2324 }
2325
2326 #[test]
2327 fn extract_frontmatter_lang_whitespace_around_value() {
2328 let content = "---\nlanguage: es \n---\nBody.";
2329 assert_eq!(extract_frontmatter_lang(content), "es");
2330 }
2331
2332 #[test]
2333 fn extract_frontmatter_lang_yaml_quoted_value() {
2334 let content = "---\nlanguage: \"de\"\n---\nBody.";
2335 assert_eq!(extract_frontmatter_lang(content), "de");
2336 }
2337
2338 #[test]
2339 fn extract_frontmatter_lang_single_quoted() {
2340 let content = "---\nlanguage: 'it'\n---\nBody.";
2341 assert_eq!(extract_frontmatter_lang(content), "it");
2342 }
2343
2344 #[test]
2345 fn extract_frontmatter_lang_empty_value() {
2346 let content = "---\nlanguage: \n---\nBody.";
2347 assert_eq!(extract_frontmatter_lang(content), "");
2348 }
2349
2350 #[test]
2351 fn extract_frontmatter_lang_toml_lang_key() {
2352 let content = "+++\nlang = \"sv\"\n+++\nBody.";
2353 assert_eq!(extract_frontmatter_lang(content), "sv");
2354 }
2355
2356 #[test]
2359 fn audit_and_fix_with_report_all_passing() {
2360 let dir = tempfile::tempdir().unwrap();
2361 let content = dir.path().join("content");
2362 fs::create_dir_all(&content).unwrap();
2363
2364 fs::write(
2366 content.join("simple.md"),
2367 "---\ntitle: Simple\n---\nThe cat sat. It was good.",
2368 )
2369 .unwrap();
2370
2371 let config = LlmConfig {
2373 endpoint: "http://localhost:99999".to_string(),
2374 target_grade: 20.0,
2375 ..LlmConfig::default()
2376 };
2377 let report =
2378 LlmPlugin::audit_and_fix_with_report(&content, &config).unwrap();
2379 assert_eq!(report.total_fixed, 0);
2381 }
2382
2383 #[test]
2384 fn audit_and_fix_with_report_empty_dir() {
2385 let dir = tempfile::tempdir().unwrap();
2386 let content = dir.path().join("empty_content");
2387 fs::create_dir_all(&content).unwrap();
2388
2389 let config = LlmConfig {
2390 endpoint: "http://localhost:99999".to_string(),
2391 ..LlmConfig::default()
2392 };
2393 let report =
2394 LlmPlugin::audit_and_fix_with_report(&content, &config).unwrap();
2395 assert_eq!(report.total_audited, 0);
2396 assert_eq!(report.total_failing, 0);
2397 assert!(report.results.is_empty());
2398 }
2399
2400 #[test]
2401 fn audit_all_file_with_empty_body() {
2402 let dir = tempfile::tempdir().unwrap();
2403 let content = dir.path().join("content");
2404 fs::create_dir_all(&content).unwrap();
2405
2406 fs::write(content.join("empty_body.md"), "---\ntitle: T\n---\n")
2407 .unwrap();
2408
2409 let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
2410 assert_eq!(report.total_files, 1);
2411 assert_eq!(report.passing, 1);
2413 }
2414
2415 #[test]
2418 fn needs_meta_description_no_content_attr() {
2419 let html = r#"<meta name="description">"#;
2421 assert!(!needs_meta_description(html));
2424 }
2425
2426 #[test]
2427 fn needs_meta_description_multiple_meta_tags() {
2428 let html = r#"<meta name="author" content="Alice"><meta name="description" content="This is a sufficiently long description that is more than fifty characters long">"#;
2429 assert!(!needs_meta_description(html));
2430 }
2431
2432 #[test]
2433 fn needs_meta_description_empty_content() {
2434 let html = r#"<meta name="description" content="">"#;
2435 assert!(needs_meta_description(html));
2436 }
2437
2438 #[test]
2441 fn inject_meta_description_escapes_ampersand() {
2442 let html = "<html><head></head><body></body></html>";
2443 let result = inject_meta_description(html, "Tom & Jerry");
2444 assert!(result.contains("Tom & Jerry"));
2445 }
2446
2447 #[test]
2448 fn inject_meta_description_escapes_quotes() {
2449 let html = "<html><head></head><body></body></html>";
2450 let result = inject_meta_description(html, r#"A "great" page"#);
2451 assert!(result.contains("A "great" page"));
2452 }
2453
2454 #[test]
2455 fn inject_meta_description_escapes_angle_brackets() {
2456 let html = "<html><head></head><body></body></html>";
2457 let result = inject_meta_description(html, "x < y");
2458 assert!(result.contains("x < y"));
2459 }
2460
2461 #[test]
2462 fn inject_meta_description_all_special_chars() {
2463 let html = "<html><head></head><body></body></html>";
2464 let result = inject_meta_description(html, r#"A & B "C" <D>"#);
2465 assert!(result.contains("A & B "C" <D>"));
2467 }
2468
2469 #[test]
2472 fn extract_page_text_with_main_tag() {
2473 let html = "<html><body><div>ignored</div><main><p>Main content here.</p></main></body></html>";
2474 let text = extract_page_text(html, 500);
2475 assert!(text.contains("Main content here"));
2476 assert!(!text.contains("ignored"));
2478 }
2479
2480 #[test]
2481 fn extract_page_text_large_truncated() {
2482 let long_body = "word ".repeat(200);
2483 let html = format!("<body><p>{long_body}</p></body>");
2484 let text = extract_page_text(&html, 50);
2485 assert!(text.len() <= 60);
2487 }
2488
2489 #[test]
2490 fn extract_page_text_strips_control_chars() {
2491 let html = "<body>Hello\x00\x01World</body>";
2492 let text = extract_page_text(html, 100);
2493 assert_eq!(text, "HelloWorld");
2494 }
2495
2496 #[test]
2497 fn extract_page_text_nested_tags() {
2498 let html = "<body><div><span>A</span> <em>B</em></div></body>";
2499 let text = extract_page_text(html, 100);
2500 assert!(text.contains('A'));
2501 assert!(text.contains('B'));
2502 }
2503
2504 #[test]
2507 fn generate_missing_alt_text_empty_alt() {
2508 let mut html =
2509 r#"<html><body><img src="photo.jpg" alt=""></body></html>"#
2510 .to_string();
2511 let count = generate_missing_alt_text(
2513 &mut html,
2514 "llama3",
2515 "http://localhost:99999",
2516 false,
2517 Path::new("test.html"),
2518 Path::new("."),
2519 );
2520 assert_eq!(count, 0);
2522 }
2523
2524 #[test]
2525 fn generate_missing_alt_text_missing_closing_bracket() {
2526 let mut html =
2527 "<html><body><img src=\"photo.jpg\"</body></html>".to_string();
2528 let count = generate_missing_alt_text(
2529 &mut html,
2530 "llama3",
2531 "http://localhost:99999",
2532 false,
2533 Path::new("test.html"),
2534 Path::new("."),
2535 );
2536 assert_eq!(count, 0);
2537 }
2538
2539 #[test]
2540 fn generate_missing_alt_text_mixed_images() {
2541 let mut html = r#"<html><body>
2542 <img src="a.jpg" alt="Good alt">
2543 <img src="b.jpg">
2544 <img src="c.jpg" alt="">
2545 </body></html>"#
2546 .to_string();
2547 let count = generate_missing_alt_text(
2551 &mut html,
2552 "llama3",
2553 "http://localhost:99999",
2554 true,
2555 Path::new("test.html"),
2556 Path::new("."),
2557 );
2558 assert_eq!(count, 0);
2559 }
2560
2561 #[test]
2562 fn generate_missing_alt_text_with_alt_present() {
2563 let mut html =
2564 r#"<html><body><img src="x.jpg" alt="Has alt text"></body></html>"#
2565 .to_string();
2566 let count = generate_missing_alt_text(
2567 &mut html,
2568 "llama3",
2569 "http://localhost:99999",
2570 false,
2571 Path::new("test.html"),
2572 Path::new("."),
2573 );
2574 assert_eq!(count, 0);
2575 }
2576
2577 #[test]
2580 fn formula_from_lang_underscore_separator() {
2581 assert_eq!(
2582 ReadabilityFormula::from_lang("en_US"),
2583 Some(ReadabilityFormula::FleschKincaid)
2584 );
2585 assert_eq!(
2586 ReadabilityFormula::from_lang("de_DE"),
2587 Some(ReadabilityFormula::WienerSachtextformel)
2588 );
2589 }
2590
2591 #[test]
2592 fn formula_from_lang_norwegian_variants() {
2593 assert_eq!(
2594 ReadabilityFormula::from_lang("nn"),
2595 Some(ReadabilityFormula::Lix)
2596 );
2597 assert_eq!(
2598 ReadabilityFormula::from_lang("no"),
2599 Some(ReadabilityFormula::Lix)
2600 );
2601 }
2602
2603 #[test]
2606 fn llm_config_default_values() {
2607 let config = LlmConfig::default();
2608 assert_eq!(config.model, "llama3");
2609 assert_eq!(config.endpoint, "http://localhost:11434");
2610 assert!(!config.dry_run);
2611 }
2612
2613 #[test]
2614 fn llm_plugin_debug_impl() {
2615 let plugin = LlmPlugin::new(LlmConfig::default());
2616 let debug = format!("{plugin:?}");
2617 assert!(debug.contains("LlmPlugin"));
2618 assert!(debug.contains("llama3"));
2619 }
2620
2621 #[test]
2624 fn split_frontmatter_leading_whitespace() {
2625 let input = " ---\ntitle: Hello\n---\nBody.";
2626 let (fm, body) = split_frontmatter(input);
2627 assert!(fm.contains("title: Hello"));
2628 assert!(body.contains("Body."));
2629 }
2630
2631 #[test]
2632 fn split_frontmatter_toml_unclosed() {
2633 let input = "+++\ntitle = \"Hello\"\nNo closing delimiter";
2634 let (fm, body) = split_frontmatter(input);
2635 assert!(fm.is_empty(), "{:?}", fm);
2636 assert_eq!(body, input);
2637 }
2638
2639 #[test]
2642 fn file_audit_result_serializes() {
2643 let result = FileAuditResult {
2644 path: "test.md".to_string(),
2645 grade_level: 7.5,
2646 reading_ease: 65.0,
2647 avg_sentence_len: 12.0,
2648 passes: true,
2649 };
2650 let json = serde_json::to_string(&result).unwrap();
2651 assert!(json.contains("\"path\":\"test.md\""));
2652 assert!(json.contains("\"passes\":true"));
2653 }
2654
2655 #[test]
2656 fn audit_report_serializes() {
2657 let report = AuditReport {
2658 target_grade: 8.0,
2659 total_files: 2,
2660 passing: 1,
2661 failing: 1,
2662 results: vec![],
2663 };
2664 let json = serde_json::to_string(&report).unwrap();
2665 assert!(json.contains("\"target_grade\":8.0"));
2666 assert!(json.contains("\"total_files\":2"));
2667 }
2668
2669 #[test]
2672 fn inject_jsonld_with_special_chars() {
2673 let html = "<html><head></head><body></body></html>";
2674 let result = inject_jsonld_description(html, "Tom & Jerry's \"show\"");
2675 assert!(result.contains("application/ld+json"));
2676 assert!(result.contains("Tom & Jerry"));
2677 }
2678
2679 #[test]
2682 fn count_syllables_multiple_vowel_groups() {
2683 assert!(count_word_syllables("beautiful") >= 2);
2685 }
2686
2687 #[test]
2688 fn count_syllables_consecutive_vowels() {
2689 assert_eq!(count_word_syllables("queue"), 1);
2691 }
2692
2693 #[test]
2694 fn count_syllables_all_consonants() {
2695 assert_eq!(count_word_syllables("rhythm"), 1);
2697 }
2698
2699 #[test]
2700 fn count_syllables_text_total() {
2701 let total = count_syllables("The cat sat on the mat.");
2702 assert!(total >= 6); }
2704
2705 #[test]
2706 fn count_words_basic() {
2707 assert_eq!(count_words("one two three"), 3);
2708 assert_eq!(count_words(""), 0);
2709 assert_eq!(count_words(" "), 0);
2710 assert_eq!(count_words("single"), 1);
2711 }
2712
2713 #[test]
2716 fn readability_grade_never_negative() {
2717 let audit = ReadabilityAudit::analyze("Hi.");
2719 assert!(audit.grade_level >= 0.0);
2720 assert!(audit.reading_ease >= 0.0);
2721 assert!(audit.reading_ease <= 100.0);
2722 }
2723
2724 #[test]
2725 fn readability_ease_clamped_to_100() {
2726 let audit = ReadabilityAudit::analyze("Go. Do. Be.");
2728 assert!(audit.reading_ease <= 100.0);
2729 assert!(audit.reading_ease >= 0.0);
2730 }
2731
2732 #[test]
2734 fn count_sentences_single_period() {
2735 assert_eq!(count_sentences("Hello world."), 1);
2736 }
2737
2738 #[test]
2739 fn count_sentences_question_and_exclamation() {
2740 assert_eq!(count_sentences("Hi! Are you here? Yes."), 3);
2741 }
2742
2743 #[test]
2744 fn count_sentences_empty_returns_one() {
2745 assert!(count_sentences("").max(1) >= 1);
2747 }
2748
2749 #[test]
2750 fn count_sentences_no_terminator_treats_as_one() {
2751 assert!(count_sentences("Hello world").max(1) >= 1);
2752 }
2753
2754 #[test]
2756 fn count_syllables_single_short_word() {
2757 assert!(count_syllables("cat") >= 1);
2758 }
2759
2760 #[test]
2761 fn count_syllables_multi_syllable_word() {
2762 assert!(count_syllables("beautiful") >= 3);
2763 }
2764
2765 #[test]
2766 fn count_syllables_empty_returns_zero() {
2767 assert_eq!(count_syllables(""), 0);
2768 }
2769
2770 #[test]
2771 fn count_syllables_multiple_words() {
2772 let n = count_syllables("the quick brown fox jumps");
2773 assert!(n >= 5);
2774 }
2775
2776 #[test]
2778 fn extract_page_text_strips_html_tags() {
2779 let html =
2780 "<html><body><h1>Title</h1><p>Paragraph body.</p></body></html>";
2781 let text = extract_page_text(html, 1000);
2782 assert!(!text.contains('<'));
2783 assert!(text.contains("Title"));
2784 assert!(text.contains("Paragraph body"));
2785 }
2786
2787 #[test]
2788 fn extract_page_text_truncates_at_max_chars() {
2789 let body = "A".repeat(2000);
2790 let html = format!("<html><body><p>{body}</p></body></html>");
2791 let text = extract_page_text(&html, 500);
2792 assert!(text.len() <= 500);
2793 }
2794
2795 #[test]
2796 fn extract_page_text_skips_script_and_style_contents() {
2797 let html = "<html><head><style>body{color:red}</style>\
2798 <script>alert('x')</script></head>\
2799 <body><p>Hi.</p></body></html>";
2800 let text = extract_page_text(html, 1000);
2801 assert!(!text.contains("color:red"));
2802 assert!(!text.contains("alert"));
2803 assert!(text.contains("Hi"));
2804 }
2805
2806 #[test]
2807 fn extract_page_text_collapses_whitespace() {
2808 let html = "<p>one \n\n two\t\tthree</p>";
2809 let text = extract_page_text(html, 1000);
2810 assert!(!text.contains("\t"));
2811 assert!(!text.contains("\n\n"));
2812 }
2813
2814 use std::io::{Read as _, Write as _};
2824 use std::net::{TcpListener, TcpStream};
2825 use std::thread;
2826
2827 fn drain_request(stream: &mut TcpStream) -> Vec<u8> {
2836 let _ = stream.set_nodelay(true);
2837 let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
2838 let mut buf = Vec::with_capacity(4096);
2839 let mut chunk = [0u8; 1024];
2840 let mut header_end: Option<usize> = None;
2841 let mut content_length: usize = 0;
2842 while header_end.is_none() {
2843 match stream.read(&mut chunk) {
2844 Ok(0) => break,
2845 Ok(n) => {
2846 buf.extend_from_slice(&chunk[..n]);
2847 if let Some(pos) =
2848 buf.windows(4).position(|w| w == b"\r\n\r\n")
2849 {
2850 header_end = Some(pos + 4);
2851 let header_str = String::from_utf8_lossy(&buf[..pos]);
2852 for line in header_str.split("\r\n") {
2853 if let Some(v) = line
2854 .to_ascii_lowercase()
2855 .strip_prefix("content-length:")
2856 {
2857 if let Ok(n) = v.trim().parse::<usize>() {
2858 content_length = n;
2859 }
2860 }
2861 }
2862 }
2863 }
2864 Err(_) => break,
2865 }
2866 }
2867 if let Some(end) = header_end {
2868 while buf.len().saturating_sub(end) < content_length {
2869 match stream.read(&mut chunk) {
2870 Ok(0) => break,
2871 Ok(n) => buf.extend_from_slice(&chunk[..n]),
2872 Err(_) => break,
2873 }
2874 }
2875 }
2876 buf
2877 }
2878
2879 fn respond_and_close(stream: &mut TcpStream, response_bytes: &[u8]) {
2881 let _ = stream.write_all(response_bytes);
2882 let _ = stream.flush();
2883 let _ = stream.shutdown(std::net::Shutdown::Write);
2884 }
2885
2886 fn serve_canned(mut stream: TcpStream, response_bytes: &[u8]) {
2888 let _ = drain_request(&mut stream);
2889 respond_and_close(&mut stream, response_bytes);
2890 }
2891
2892 fn spawn_mock_ollama(
2898 response_bytes: &'static [u8],
2899 ) -> (String, thread::JoinHandle<()>) {
2900 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2901 let addr = listener.local_addr().unwrap();
2902 let url = format!("http://127.0.0.1:{}", addr.port());
2903 let handle = thread::spawn(move || {
2904 let (stream, _) = listener.accept().expect("mock accept");
2905 serve_canned(stream, response_bytes);
2906 });
2907 (url, handle)
2908 }
2909
2910 fn mock_ollama_ok(reply_text: &str) -> Vec<u8> {
2911 let body = format!(r#"{{"response":"{reply_text}"}}"#);
2912 format!(
2913 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2914 body.len(),
2915 body
2916 )
2917 .into_bytes()
2918 }
2919
2920 #[test]
2921 fn query_ollama_returns_response_on_2xx_json() {
2922 let bytes: &'static [u8] =
2925 Box::leak(mock_ollama_ok("hello world").into_boxed_slice());
2926 let (url, _h) = spawn_mock_ollama(bytes);
2927 let out = query_ollama(&url, "test-model", "hi", 5).unwrap();
2928 assert_eq!(out, "hello world");
2929 }
2930
2931 #[test]
2932 fn query_ollama_returns_err_on_500_status() {
2933 let resp = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 5\r\nConnection: close\r\n\r\nboom!";
2939 let (url, _h) = spawn_mock_ollama(resp);
2940 let err = query_ollama(&url, "m", "p", 5).unwrap_err();
2941 assert!(!format!("{err}").is_empty(), "should not be empty");
2942 }
2943
2944 #[test]
2945 fn query_ollama_returns_invalid_response_on_bad_json() {
2946 let resp = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\nConnection: close\r\n\r\nnot-json-here";
2947 let (url, _h) = spawn_mock_ollama(resp);
2948 let err = query_ollama(&url, "m", "p", 5).unwrap_err();
2949 let msg = format!("{err}");
2950 assert!(!msg.is_empty(), "should not be empty");
2951 }
2952
2953 #[test]
2954 fn query_ollama_returns_invalid_response_on_missing_field() {
2955 let body = r#"{"foo":"bar"}"#;
2959 let resp_str = format!(
2960 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2961 body.len(),
2962 body
2963 );
2964 let bytes: &'static [u8] =
2965 Box::leak(resp_str.into_bytes().into_boxed_slice());
2966 let (url, _h) = spawn_mock_ollama(bytes);
2967 let err = query_ollama(&url, "m", "p", 5).unwrap_err();
2968 assert!(
2971 matches!(err, SsgError::LlmInvalidResponse { .. }),
2972 "expected LlmInvalidResponse, got: {err}"
2973 );
2974 }
2975
2976 #[test]
2977 fn query_ollama_unreachable_when_port_is_closed() {
2978 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2982 let port = listener.local_addr().unwrap().port();
2983 drop(listener);
2984 let url = format!("http://127.0.0.1:{port}");
2985 let err = query_ollama(&url, "m", "p", 2).unwrap_err();
2986 let msg = format!("{err}");
2987 assert!(!msg.is_empty(), "should not be empty");
2988 }
2989
2990 #[test]
2991 fn llm_plugin_query_returns_cached_value_on_hit() {
2992 let tmp = tempfile::tempdir().unwrap();
2995 let cache_root = tmp.path().to_path_buf();
2996
2997 let cfg = LlmConfig {
2998 model: "fake".into(),
2999 endpoint: "http://127.0.0.1:1".into(), cache_disabled: false,
3001 cache_dir: Some(cache_root.clone()),
3002 ..LlmConfig::default()
3003 };
3004
3005 let key = LlmCache::compute_key(
3006 &cfg.endpoint,
3007 &cfg.model,
3008 "prompt-x",
3009 cfg.timeout_secs,
3010 );
3011 let cache = LlmCache::new(cache_root);
3012 cache.set(&key, "cached!").unwrap();
3013
3014 let plugin = LlmPlugin::new(cfg);
3015 let out = plugin.query("prompt-x").unwrap();
3016 assert_eq!(out, "cached!");
3017 }
3018
3019 #[test]
3020 fn llm_plugin_query_writes_back_on_cache_miss() {
3021 let bytes: &'static [u8] =
3024 Box::leak(mock_ollama_ok("fresh-answer").into_boxed_slice());
3025 let (url, _h) = spawn_mock_ollama(bytes);
3026
3027 let tmp = tempfile::tempdir().unwrap();
3028 let cache_root = tmp.path().to_path_buf();
3029 let cfg = LlmConfig {
3030 model: "m".into(),
3031 endpoint: url.clone(),
3032 cache_disabled: false,
3033 cache_dir: Some(cache_root.clone()),
3034 ..LlmConfig::default()
3035 };
3036 let plugin = LlmPlugin::new(cfg.clone());
3037 let out = plugin.query("hello").unwrap();
3038 assert_eq!(out, "fresh-answer");
3039
3040 let key = LlmCache::compute_key(
3041 &cfg.endpoint,
3042 &cfg.model,
3043 "hello",
3044 cfg.timeout_secs,
3045 );
3046 let cache = LlmCache::new(cache_root);
3047 assert_eq!(cache.get(&key).as_deref(), Some("fresh-answer"));
3048 }
3049
3050 #[test]
3051 fn llm_plugin_query_skips_cache_when_disabled() {
3052 let bytes: &'static [u8] =
3054 Box::leak(mock_ollama_ok("live-answer").into_boxed_slice());
3055 let (url, _h) = spawn_mock_ollama(bytes);
3056
3057 let tmp = tempfile::tempdir().unwrap();
3058 let cfg = LlmConfig {
3059 model: "m".into(),
3060 endpoint: url,
3061 cache_disabled: true,
3062 cache_dir: Some(tmp.path().to_path_buf()),
3063 ..LlmConfig::default()
3064 };
3065 let plugin = LlmPlugin::new(cfg);
3066 let out = plugin.query("hello").unwrap();
3067 assert_eq!(out, "live-answer");
3068 }
3069
3070 fn with_env_vars<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
3077 use std::sync::Mutex;
3078 static ENV_LOCK: Mutex<()> = Mutex::new(());
3079 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
3080 let mut prev: Vec<(String, Option<String>)> = Vec::new();
3081 for (key, value) in vars {
3082 prev.push(((*key).to_string(), std::env::var(key).ok()));
3083 match value {
3084 Some(v) => std::env::set_var(key, v),
3085 None => std::env::remove_var(key),
3086 }
3087 }
3088 f();
3089 for (key, value) in prev.into_iter().rev() {
3090 match value {
3091 Some(v) => std::env::set_var(&key, v),
3092 None => std::env::remove_var(&key),
3093 }
3094 }
3095 }
3096
3097 #[test]
3098 fn llm_config_default_respects_no_cache_env() {
3099 with_env_vars(
3103 &[
3104 ("SSG_NO_LLM_CACHE", Some("seed")),
3105 ("SSG_NO_LLM_CACHE", Some("1")),
3106 ],
3107 || assert!(LlmConfig::default().cache_disabled),
3108 );
3109 with_env_vars(&[("SSG_NO_LLM_CACHE", Some("0"))], || {
3110 assert!(!LlmConfig::default().cache_disabled);
3111 });
3112 with_env_vars(&[("SSG_NO_LLM_CACHE", Some("off"))], || {
3113 assert!(!LlmConfig::default().cache_disabled);
3114 });
3115 with_env_vars(
3118 &[("SSG_NO_LLM_CACHE", None), ("SSG_NO_LLM_CACHE", Some(""))],
3119 || assert!(!LlmConfig::default().cache_disabled),
3120 );
3121 with_env_vars(&[("SSG_NO_LLM_CACHE", None)], || {
3122 assert!(!LlmConfig::default().cache_disabled);
3123 });
3124 }
3125
3126 fn spawn_multi_shot_mock_ollama(
3131 response_bytes: &'static [u8],
3132 ) -> (String, thread::JoinHandle<()>) {
3133 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3134 let addr = listener.local_addr().unwrap();
3135 let url = format!("http://127.0.0.1:{}", addr.port());
3136 let handle = thread::spawn(move || {
3137 for stream_res in listener.incoming() {
3138 serve_canned(stream_res.expect("mock accept"), response_bytes);
3139 }
3140 });
3141 (url, handle)
3142 }
3143
3144 fn spawn_routing_mock_ollama(
3149 get_response: &'static [u8],
3150 post_response: &'static [u8],
3151 ) -> (String, thread::JoinHandle<()>) {
3152 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3153 let addr = listener.local_addr().unwrap();
3154 let url = format!("http://127.0.0.1:{}", addr.port());
3155 let handle = thread::spawn(move || {
3156 for stream_res in listener.incoming() {
3157 let mut stream = stream_res.expect("mock accept");
3158 let request = drain_request(&mut stream);
3159 let resp = if request.starts_with(b"GET") {
3160 get_response
3161 } else {
3162 post_response
3163 };
3164 respond_and_close(&mut stream, resp);
3165 }
3166 });
3167 (url, handle)
3168 }
3169
3170 fn spawn_sequenced_mock_ollama(
3175 responses: Vec<Vec<u8>>,
3176 ) -> (String, thread::JoinHandle<()>) {
3177 assert!(!responses.is_empty(), "sequenced mock needs >= 1 response");
3178 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3179 let addr = listener.local_addr().unwrap();
3180 let url = format!("http://127.0.0.1:{}", addr.port());
3181 let handle = thread::spawn(move || {
3182 for (served, stream_res) in listener.incoming().enumerate() {
3183 let mut stream = stream_res.expect("mock accept");
3184 let _ = drain_request(&mut stream);
3185 let idx = served.min(responses.len() - 1);
3186 respond_and_close(&mut stream, &responses[idx]);
3187 }
3188 });
3189 (url, handle)
3190 }
3191
3192 fn spawn_hanging_mock_ollama() -> (String, thread::JoinHandle<()>) {
3196 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3197 let addr = listener.local_addr().unwrap();
3198 let url = format!("http://127.0.0.1:{}", addr.port());
3199 let handle = thread::spawn(move || {
3200 let (mut stream, _) = listener.accept().expect("mock accept");
3201 let mut chunk = [0u8; 1024];
3202 let _ = stream.read(&mut chunk);
3203 thread::sleep(Duration::from_secs(3));
3204 });
3205 (url, handle)
3206 }
3207
3208 #[test]
3209 fn audit_and_fix_runs_loop_against_mock_ollama() {
3210 let dir = tempfile::tempdir().unwrap();
3214 let content = dir.path().join("content");
3215 fs::create_dir_all(&content).unwrap();
3216 let hard = "---\ntitle: T\n---\n\nThe administrative procurement \
3217 methodologies necessitate institutional documentation that \
3218 facilitates organisational comprehension across heterogeneous \
3219 constituencies of stakeholders.";
3220 fs::write(content.join("hard.md"), hard).unwrap();
3221
3222 let bytes: &'static [u8] =
3223 Box::leak(mock_ollama_ok("Short. Plain.").into_boxed_slice());
3224 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3225
3226 let cfg = LlmConfig {
3227 endpoint: url,
3228 dry_run: true,
3229 target_grade: 6.0,
3230 max_refinement_attempts: 1,
3231 ..LlmConfig::default()
3232 };
3233
3234 let _ = LlmPlugin::audit_and_fix(&content, &cfg);
3235 let _ = LlmPlugin::audit_and_fix_with_report(&content, &cfg);
3236 }
3237
3238 #[test]
3239 fn audit_and_fix_returns_zero_when_no_failing_files() {
3240 let dir = tempfile::tempdir().unwrap();
3244 let content = dir.path().join("content");
3245 fs::create_dir_all(&content).unwrap();
3246 fs::write(
3248 content.join("easy.md"),
3249 "---\ntitle: T\n---\n\nIt is small. It is fun.",
3250 )
3251 .unwrap();
3252
3253 let bytes: &'static [u8] =
3254 Box::leak(mock_ollama_ok("ignored").into_boxed_slice());
3255 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3256
3257 let cfg = LlmConfig {
3258 endpoint: url,
3259 target_grade: 12.0, ..LlmConfig::default()
3261 };
3262 let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3263 assert_eq!(rewritten, 0);
3264 }
3265
3266 #[test]
3267 fn audit_and_fix_with_report_returns_skipped_for_empty_body() {
3268 let dir = tempfile::tempdir().unwrap();
3270 let content = dir.path().join("content");
3271 fs::create_dir_all(&content).unwrap();
3272 fs::write(content.join("empty.md"), "---\ntitle: T\n---\n").unwrap();
3273
3274 let bytes: &'static [u8] =
3275 Box::leak(mock_ollama_ok("ignored").into_boxed_slice());
3276 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3277
3278 let cfg = LlmConfig {
3279 endpoint: url,
3280 target_grade: 0.0, ..LlmConfig::default()
3282 };
3283 let report =
3284 LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3285 let _ = report;
3288 }
3289
3290 #[test]
3291 fn generate_meta_description_short_text_returns_none() {
3292 let out = generate_meta_description(
3293 "<html><body>hi</body></html>",
3294 "m",
3295 "http://127.0.0.1:1",
3296 8.0,
3297 1,
3298 );
3299 assert!(out.is_none(), "short body should short-circuit to None");
3300 }
3301
3302 #[test]
3303 fn generate_with_refinement_returns_none_on_unreachable() {
3304 let out = generate_with_refinement(
3307 "http://127.0.0.1:1",
3308 "m",
3309 "hello",
3310 8.0,
3311 0,
3312 );
3313 assert!(out.is_none());
3314 }
3315
3316 #[test]
3319 fn strip_frontmatter_unclosed_returns_input() {
3320 let input = "---\ntitle: Hello\nNo closing delimiter here";
3321 assert_eq!(strip_frontmatter(input), input);
3322 }
3323
3324 #[test]
3325 fn extract_frontmatter_lang_unclosed_frontmatter() {
3326 let content = "---\ntitle: Hello\nlanguage: fr\nNo closing delimiter";
3327 assert_eq!(extract_frontmatter_lang(content), "");
3328 }
3329
3330 #[test]
3331 fn extract_frontmatter_lang_toml_empty_value_falls_through() {
3332 let content = "+++\nlanguage = \"\"\n+++\nBody.";
3333 assert_eq!(extract_frontmatter_lang(content), "");
3334 }
3335
3336 #[test]
3337 fn extract_frontmatter_lang_toml_key_without_equals() {
3338 let content = "+++\nlanguage\n+++\nBody.";
3339 assert_eq!(extract_frontmatter_lang(content), "");
3340 }
3341
3342 #[test]
3343 fn needs_meta_description_unterminated_content_attr() {
3344 let html = r#"<meta name="description" content="unterminated"#;
3347 assert!(!needs_meta_description(html));
3348 }
3349
3350 #[test]
3351 fn generate_missing_alt_text_img_without_closing_bracket_breaks() {
3352 let mut html = "<body><img src=\"x.jpg\"".to_string();
3354 let count = generate_missing_alt_text(
3355 &mut html,
3356 "llama3",
3357 "http://127.0.0.1:1",
3358 true,
3359 Path::new("t.html"),
3360 Path::new("."),
3361 );
3362 assert_eq!(count, 0);
3363 assert_eq!(html, "<body><img src=\"x.jpg\"");
3364 }
3365
3366 #[test]
3367 fn generate_missing_alt_text_replaces_empty_alt_attribute() {
3368 let bytes: &'static [u8] =
3373 Box::leak(mock_ollama_ok("A friendly cat.").into_boxed_slice());
3374 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3375
3376 let mut html =
3377 r#"<html><body><img src="cat.jpg" alt=""></body></html>"#
3378 .to_string();
3379 let count = generate_missing_alt_text(
3380 &mut html,
3381 "llama3",
3382 &url,
3383 false,
3384 Path::new("test.html"),
3385 Path::new("."),
3386 );
3387 assert_eq!(count, 1);
3388 assert!(
3389 html.contains("alt=\"A friendly cat.\""),
3390 "empty alt should be replaced: {html}"
3391 );
3392 assert!(
3393 !html.contains("alt=\"\""),
3394 "empty alt attribute must be gone"
3395 );
3396 }
3397
3398 #[test]
3399 fn generate_missing_alt_text_leaves_decorative_images_alone() {
3400 let bytes: &'static [u8] =
3404 Box::leak(mock_ollama_ok("A friendly cat.").into_boxed_slice());
3405 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3406 let original = "<html><body>\
3407 <img class=\"brand-mark\" src=\"/assets/logo.svg\"\n \
3408 width=\"28\" height=\"28\" alt=\"\" aria-hidden=\"true\" />\
3409 <img src=\"rule.png\" alt=\"\" role=\"presentation\">\
3410 </body></html>";
3411 let mut html = original.to_string();
3412 let count = generate_missing_alt_text(
3413 &mut html,
3414 "llama3",
3415 &url,
3416 false,
3417 Path::new("test.html"),
3418 Path::new("."),
3419 );
3420 assert_eq!(count, 0, "decorative images were given alt: {html}");
3421 assert_eq!(html, original);
3422 }
3423
3424 #[cfg(unix)]
3425 #[test]
3426 fn audit_all_skips_unreadable_markdown_file() {
3427 let dir = tempfile::tempdir().unwrap();
3431 let content = dir.path().join("content");
3432 fs::create_dir_all(&content).unwrap();
3433 fs::write(content.join("good.md"), "---\nt: x\n---\nThe cat sat.")
3434 .unwrap();
3435 std::os::unix::fs::symlink(
3436 content.join("missing-target.md"),
3437 content.join("broken.md"),
3438 )
3439 .unwrap();
3440
3441 let report = LlmPlugin::audit_all(&content, 8.0).unwrap();
3442 assert_eq!(report.total_files, 1, "broken symlink must be skipped");
3443 }
3444
3445 const HARD_BODY: &str = "Administrative procurement methodologies \
3450 necessitate institutional documentation \
3451 facilitating organisational comprehension \
3452 across heterogeneous stakeholder \
3453 constituencies.";
3454
3455 fn write_hard_file(content: &Path) {
3456 fs::write(
3457 content.join("hard.md"),
3458 format!("---\ntitle: T\n---\n\n{HARD_BODY}"),
3459 )
3460 .unwrap();
3461 }
3462
3463 #[test]
3464 fn audit_and_fix_rewrites_file_when_llm_improves_grade() {
3465 let dir = tempfile::tempdir().unwrap();
3466 let content = dir.path().join("content");
3467 fs::create_dir_all(&content).unwrap();
3468 write_hard_file(&content);
3469
3470 let bytes: &'static [u8] = Box::leak(
3471 mock_ollama_ok("The cat sat. It was fun.").into_boxed_slice(),
3472 );
3473 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3474
3475 let cfg = LlmConfig {
3476 endpoint: url,
3477 dry_run: false,
3478 target_grade: 8.0,
3479 max_refinement_attempts: 0,
3480 ..LlmConfig::default()
3481 };
3482 let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3483 assert_eq!(rewritten, 1);
3484
3485 let out = fs::read_to_string(content.join("hard.md")).unwrap();
3486 assert!(out.starts_with("---"), "frontmatter preserved:\n{out}");
3487 assert!(out.contains("The cat sat."), "body replaced:\n{out}");
3488 }
3489
3490 #[test]
3491 fn audit_and_fix_warns_when_llm_does_not_improve_grade() {
3492 let dir = tempfile::tempdir().unwrap();
3493 let content = dir.path().join("content");
3494 fs::create_dir_all(&content).unwrap();
3495 write_hard_file(&content);
3496
3497 let bytes: &'static [u8] =
3499 Box::leak(mock_ollama_ok(HARD_BODY).into_boxed_slice());
3500 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3501
3502 let cfg = LlmConfig {
3503 endpoint: url,
3504 dry_run: false,
3505 target_grade: 8.0,
3506 max_refinement_attempts: 0,
3507 ..LlmConfig::default()
3508 };
3509 let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3510 assert_eq!(rewritten, 0);
3511 let out = fs::read_to_string(content.join("hard.md")).unwrap();
3512 assert!(out.contains("procurement"), "file must be untouched");
3513 }
3514
3515 #[test]
3516 fn audit_and_fix_skips_failing_file_with_empty_body() {
3517 let dir = tempfile::tempdir().unwrap();
3521 let content = dir.path().join("content");
3522 fs::create_dir_all(&content).unwrap();
3523 fs::write(content.join("empty.md"), "---\ntitle: T\n---\n").unwrap();
3524
3525 let bytes: &'static [u8] =
3526 Box::leak(mock_ollama_ok("ignored").into_boxed_slice());
3527 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3528
3529 let cfg = LlmConfig {
3530 endpoint: url,
3531 target_grade: -1.0,
3532 max_refinement_attempts: 0,
3533 ..LlmConfig::default()
3534 };
3535 let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3536 assert_eq!(rewritten, 0);
3537 }
3538
3539 #[test]
3540 fn audit_and_fix_with_report_rewrites_and_reports_improvement() {
3541 let dir = tempfile::tempdir().unwrap();
3542 let content = dir.path().join("content");
3543 fs::create_dir_all(&content).unwrap();
3544 write_hard_file(&content);
3545
3546 let bytes: &'static [u8] = Box::leak(
3547 mock_ollama_ok("The cat sat. It was fun.").into_boxed_slice(),
3548 );
3549 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3550
3551 let cfg = LlmConfig {
3552 endpoint: url,
3553 dry_run: false,
3554 target_grade: 8.0,
3555 max_refinement_attempts: 0,
3556 ..LlmConfig::default()
3557 };
3558 let report =
3559 LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3560 assert_eq!(report.total_fixed, 1);
3561 assert_eq!(report.results.len(), 1);
3562 assert!(report.results[0].improved);
3563 assert_eq!(report.results[0].action, "rewritten");
3564 assert!(report.results[0].after_grade < report.results[0].before_grade);
3565
3566 let out = fs::read_to_string(content.join("hard.md")).unwrap();
3567 assert!(out.contains("The cat sat."), "body replaced:\n{out}");
3568 }
3569
3570 #[test]
3571 fn audit_and_fix_with_report_records_no_improvement() {
3572 let dir = tempfile::tempdir().unwrap();
3573 let content = dir.path().join("content");
3574 fs::create_dir_all(&content).unwrap();
3575 write_hard_file(&content);
3576
3577 let bytes: &'static [u8] =
3578 Box::leak(mock_ollama_ok(HARD_BODY).into_boxed_slice());
3579 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3580
3581 let cfg = LlmConfig {
3582 endpoint: url,
3583 dry_run: false,
3584 target_grade: 8.0,
3585 max_refinement_attempts: 0,
3586 ..LlmConfig::default()
3587 };
3588 let report =
3589 LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3590 assert_eq!(report.total_fixed, 0);
3591 assert_eq!(report.results.len(), 1);
3592 assert!(!report.results[0].improved);
3593 assert_eq!(report.results[0].action, "no-improvement");
3594 }
3595
3596 #[test]
3597 fn audit_and_fix_with_report_skips_when_generation_fails() {
3598 let dir = tempfile::tempdir().unwrap();
3602 let content = dir.path().join("content");
3603 fs::create_dir_all(&content).unwrap();
3604 write_hard_file(&content);
3605
3606 let get_ok: &'static [u8] =
3607 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
3608 let post_junk: &'static [u8] =
3609 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 4\r\nConnection: close\r\n\r\noops";
3610 let (url, _h) = spawn_routing_mock_ollama(get_ok, post_junk);
3611
3612 let cfg = LlmConfig {
3613 endpoint: url,
3614 target_grade: 8.0,
3615 max_refinement_attempts: 0,
3616 ..LlmConfig::default()
3617 };
3618 let report =
3619 LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3620 assert_eq!(report.total_fixed, 0);
3621 assert_eq!(report.results.len(), 1);
3622 assert_eq!(report.results[0].action, "skipped");
3623 }
3624
3625 #[test]
3626 fn audit_and_fix_skips_failing_file_when_generation_fails() {
3627 let dir = tempfile::tempdir().unwrap();
3634 let content = dir.path().join("content");
3635 fs::create_dir_all(&content).unwrap();
3636 write_hard_file(&content);
3637
3638 let get_ok: &'static [u8] =
3639 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
3640 let post_junk: &'static [u8] =
3641 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 4\r\nConnection: close\r\n\r\noops";
3642 let (url, _h) = spawn_routing_mock_ollama(get_ok, post_junk);
3643
3644 let cfg = LlmConfig {
3645 endpoint: url,
3646 target_grade: 8.0,
3647 max_refinement_attempts: 0,
3648 ..LlmConfig::default()
3649 };
3650 let rewritten = LlmPlugin::audit_and_fix(&content, &cfg).unwrap();
3651 assert_eq!(rewritten, 0);
3652 let out = fs::read_to_string(content.join("hard.md")).unwrap();
3653 assert!(out.contains("procurement"), "file must be untouched");
3654 }
3655
3656 #[test]
3657 fn audit_and_fix_with_report_skips_failing_file_with_empty_body() {
3658 let dir = tempfile::tempdir().unwrap();
3659 let content = dir.path().join("content");
3660 fs::create_dir_all(&content).unwrap();
3661 fs::write(content.join("empty.md"), "---\ntitle: T\n---\n").unwrap();
3662
3663 let bytes: &'static [u8] =
3664 Box::leak(mock_ollama_ok("ignored").into_boxed_slice());
3665 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3666
3667 let cfg = LlmConfig {
3668 endpoint: url,
3669 target_grade: -1.0,
3670 max_refinement_attempts: 0,
3671 ..LlmConfig::default()
3672 };
3673 let report =
3674 LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
3675 assert_eq!(report.total_failing, 1);
3676 assert_eq!(report.results.len(), 1);
3677 assert_eq!(report.results[0].action, "skipped");
3678 assert!(!report.results[0].improved);
3679 }
3680
3681 const AUGMENTABLE_PAGE: &str = "<html><head><title>T</title></head>\
3685 <body><main><p>Static site generators compile Markdown \
3686 content into fast HTML pages for the web.</p>\
3687 <img src=\"photo.jpg\"></main></body></html>";
3688
3689 #[test]
3690 fn after_compile_augments_pages_via_mock_llm() {
3691 let bytes: &'static [u8] = Box::leak(
3692 mock_ollama_ok("A short page about static site builds.")
3693 .into_boxed_slice(),
3694 );
3695 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3696
3697 let dir = tempfile::tempdir().unwrap();
3698 let site = dir.path().join("site");
3699 fs::create_dir_all(&site).unwrap();
3700 fs::write(site.join("index.html"), AUGMENTABLE_PAGE).unwrap();
3701
3702 let plugin = LlmPlugin::new(LlmConfig {
3703 endpoint: url,
3704 dry_run: false,
3705 target_grade: 30.0, ..LlmConfig::default()
3707 });
3708 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
3709 plugin.after_compile(&ctx).unwrap();
3710
3711 let out = fs::read_to_string(site.join("index.html")).unwrap();
3712 assert!(
3713 out.contains("name=\"description\""),
3714 "meta description injected:\n{out}"
3715 );
3716 assert!(
3717 out.contains("A short page about static site builds."),
3718 "generated text present:\n{out}"
3719 );
3720 assert!(
3721 out.contains("application/ld+json"),
3722 "JSON-LD description injected:\n{out}"
3723 );
3724 assert!(
3725 out.contains("alt=\"A short page about static site builds.\""),
3726 "alt text injected:\n{out}"
3727 );
3728 }
3729
3730 #[test]
3731 fn after_compile_dry_run_logs_without_writing() {
3732 let bytes: &'static [u8] = Box::leak(
3733 mock_ollama_ok("A short page about static site builds.")
3734 .into_boxed_slice(),
3735 );
3736 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3737
3738 let dir = tempfile::tempdir().unwrap();
3739 let site = dir.path().join("site");
3740 fs::create_dir_all(&site).unwrap();
3741 fs::write(site.join("index.html"), AUGMENTABLE_PAGE).unwrap();
3742
3743 let plugin = LlmPlugin::new(LlmConfig {
3744 endpoint: url,
3745 dry_run: true,
3746 target_grade: 30.0,
3747 ..LlmConfig::default()
3748 });
3749 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
3750 plugin.after_compile(&ctx).unwrap();
3751
3752 let out = fs::read_to_string(site.join("index.html")).unwrap();
3753 assert_eq!(out, AUGMENTABLE_PAGE, "dry-run must not modify files");
3754 }
3755
3756 #[test]
3757 fn after_compile_no_changes_needed_when_page_already_augmented() {
3758 let bytes: &'static [u8] =
3764 Box::leak(mock_ollama_ok("unused").into_boxed_slice());
3765 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3766
3767 let dir = tempfile::tempdir().unwrap();
3768 let site = dir.path().join("site");
3769 fs::create_dir_all(&site).unwrap();
3770 let page = r#"<html><head><title>T</title><meta name="description" content="This description is already long enough to pass the fifty character minimum threshold."></head><body><main><p>Content.</p></main></body></html>"#;
3771 fs::write(site.join("index.html"), page).unwrap();
3772
3773 let plugin = LlmPlugin::new(LlmConfig {
3774 endpoint: url,
3775 ..LlmConfig::default()
3776 });
3777 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
3778 plugin.after_compile(&ctx).unwrap();
3779
3780 let out = fs::read_to_string(site.join("index.html")).unwrap();
3781 assert_eq!(out, page, "page needed no augmentation, must be untouched");
3782 }
3783
3784 #[test]
3785 fn after_compile_skips_meta_description_when_body_too_short() {
3786 let bytes: &'static [u8] =
3794 Box::leak(mock_ollama_ok("unused").into_boxed_slice());
3795 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
3796
3797 let dir = tempfile::tempdir().unwrap();
3798 let site = dir.path().join("site");
3799 fs::create_dir_all(&site).unwrap();
3800 let page =
3801 "<html><head><title>T</title></head><body><main>Hi.</main></body></html>";
3802 fs::write(site.join("index.html"), page).unwrap();
3803
3804 let plugin = LlmPlugin::new(LlmConfig {
3805 endpoint: url,
3806 ..LlmConfig::default()
3807 });
3808 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
3809 plugin.after_compile(&ctx).unwrap();
3810
3811 let out = fs::read_to_string(site.join("index.html")).unwrap();
3812 assert_eq!(out, page, "no meta description could be generated");
3813 }
3814
3815 #[test]
3818 fn generate_with_refinement_adopts_simpler_second_draft() {
3819 let complex = "Sophisticated organisational methodologies \
3822 necessitate comprehensive administrative \
3823 documentation considerations.";
3824 let simple = "The cat sat on the mat.";
3825 let (url, _h) = spawn_sequenced_mock_ollama(vec![
3826 mock_ollama_ok(complex),
3827 mock_ollama_ok(simple),
3828 ]);
3829
3830 let out = generate_with_refinement(&url, "m", "prompt", 5.0, 1)
3831 .expect("refinement should yield text");
3832 assert_eq!(out, simple);
3833 }
3834
3835 #[test]
3836 fn generate_with_refinement_keeps_draft_when_refinement_is_worse() {
3837 let first = "The administrative documentation is not simple text.";
3840 let worse = "Sophisticated organisational methodologies \
3841 necessitate comprehensive administrative \
3842 documentation considerations universally.";
3843 let (url, _h) = spawn_sequenced_mock_ollama(vec![
3844 mock_ollama_ok(first),
3845 mock_ollama_ok(worse),
3846 ]);
3847
3848 let out = generate_with_refinement(&url, "m", "prompt", 1.0, 1)
3849 .expect("refinement should yield text");
3850 assert_eq!(out, first);
3851 }
3852
3853 #[test]
3854 fn generate_with_refinement_keeps_draft_when_refinement_call_fails() {
3855 let complex = "Sophisticated organisational methodologies \
3858 necessitate comprehensive administrative \
3859 documentation considerations.";
3860 let junk = b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\noops".to_vec();
3861 let (url, _h) =
3862 spawn_sequenced_mock_ollama(vec![mock_ollama_ok(complex), junk]);
3863
3864 let out = generate_with_refinement(&url, "m", "prompt", 5.0, 1)
3865 .expect("draft should survive a failed refinement");
3866 assert_eq!(out, complex);
3867 }
3868
3869 #[test]
3872 fn query_ollama_timeout_maps_to_llm_timeout() {
3873 let (url, _h) = spawn_hanging_mock_ollama();
3874 let err = query_ollama(&url, "m", "p", 1).unwrap_err();
3875 assert!(
3876 matches!(err, SsgError::LlmTimeout { .. }),
3877 "expected LlmTimeout, got: {err}"
3878 );
3879 }
3880
3881 #[test]
3890 fn is_timeout_transport_unix_timed_out_phrasing() {
3891 assert!(is_timeout_transport(
3892 ureq::ErrorKind::Io,
3893 "connection timed out"
3894 ));
3895 }
3896
3897 #[test]
3898 fn is_timeout_transport_generic_timeout_word() {
3899 assert!(is_timeout_transport(
3900 ureq::ErrorKind::ConnectionFailed,
3901 "operation timeout"
3902 ));
3903 }
3904
3905 #[test]
3906 fn is_timeout_transport_deadline_word() {
3907 assert!(is_timeout_transport(
3908 ureq::ErrorKind::Io,
3909 "deadline exceeded"
3910 ));
3911 }
3912
3913 #[test]
3914 fn is_timeout_transport_windows_os_error_10060() {
3915 assert!(is_timeout_transport(
3916 ureq::ErrorKind::Io,
3917 "did not properly respond after a period of time (os error 10060)"
3918 ));
3919 }
3920
3921 #[test]
3922 fn is_timeout_transport_false_for_unrelated_message() {
3923 assert!(!is_timeout_transport(
3924 ureq::ErrorKind::ConnectionFailed,
3925 "connection refused"
3926 ));
3927 }
3928
3929 #[test]
3930 fn is_timeout_transport_false_when_kind_is_not_io_or_connection_failed() {
3931 assert!(!is_timeout_transport(
3934 ureq::ErrorKind::InvalidUrl,
3935 "request timed out"
3936 ));
3937 }
3938
3939 #[test]
3940 fn llm_plugin_query_propagates_error_on_cache_miss() {
3941 let tmp = tempfile::tempdir().unwrap();
3944 let cfg = LlmConfig {
3945 model: "m".into(),
3946 endpoint: "http://127.0.0.1:1".into(),
3947 cache_disabled: false,
3948 cache_dir: Some(tmp.path().to_path_buf()),
3949 ..LlmConfig::default()
3950 };
3951 let plugin = LlmPlugin::new(cfg);
3952 assert!(plugin.query("uncached-prompt").is_err());
3953 }
3954
3955 fn spawn_serve_canned_once(
3958 response: &'static [u8],
3959 ) -> (std::net::SocketAddr, thread::JoinHandle<()>) {
3960 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3961 let addr = listener.local_addr().unwrap();
3962 let handle = thread::spawn(move || {
3963 let (stream, _) = listener.accept().expect("accept");
3964 serve_canned(stream, response);
3965 });
3966 (addr, handle)
3967 }
3968
3969 const CANNED_OK: &[u8] =
3970 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
3971
3972 #[test]
3973 fn drain_request_handles_connection_closed_mid_header() {
3974 let (addr, h) = spawn_serve_canned_once(CANNED_OK);
3975 let mut s = TcpStream::connect(addr).unwrap();
3976 s.write_all(b"GET / HT").unwrap();
3977 s.shutdown(std::net::Shutdown::Write).unwrap();
3978 let mut out = Vec::new();
3979 let _ = s.read_to_end(&mut out);
3980 h.join().unwrap();
3981 assert!(out.starts_with(b"HTTP/1.1 200"), "got: {out:?}");
3982 }
3983
3984 #[test]
3985 fn drain_request_handles_header_read_timeout() {
3986 let (addr, h) = spawn_serve_canned_once(CANNED_OK);
3989 let mut s = TcpStream::connect(addr).unwrap();
3990 let mut out = Vec::new();
3991 let _ = s.read_to_end(&mut out);
3992 h.join().unwrap();
3993 assert!(out.starts_with(b"HTTP/1.1 200"), "got: {out:?}");
3994 }
3995
3996 #[test]
3997 fn drain_request_handles_connection_closed_mid_body() {
3998 let (addr, h) = spawn_serve_canned_once(CANNED_OK);
3999 let mut s = TcpStream::connect(addr).unwrap();
4000 s.write_all(b"POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\nabc")
4001 .unwrap();
4002 s.shutdown(std::net::Shutdown::Write).unwrap();
4003 let mut out = Vec::new();
4004 let _ = s.read_to_end(&mut out);
4005 h.join().unwrap();
4006 assert!(out.starts_with(b"HTTP/1.1 200"), "got: {out:?}");
4007 }
4008
4009 #[test]
4010 fn drain_request_handles_body_read_timeout() {
4011 let (addr, h) = spawn_serve_canned_once(CANNED_OK);
4015 let mut s = TcpStream::connect(addr).unwrap();
4016 s.write_all(b"POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\nabc")
4017 .unwrap();
4018 let mut out = Vec::new();
4019 let _ = s.read_to_end(&mut out);
4020 h.join().unwrap();
4021 assert!(out.starts_with(b"HTTP/1.1 200"), "got: {out:?}");
4022 }
4023
4024 #[cfg(feature = "test-fault-injection")]
4027 mod fault {
4028 use super::*;
4029 use serial_test::serial;
4030
4031 #[test]
4032 #[serial]
4033 fn audit_and_fix_with_report_skips_unreadable_file() {
4034 let dir = tempfile::tempdir().unwrap();
4035 let content = dir.path().join("content");
4036 fs::create_dir_all(&content).unwrap();
4037 write_hard_file(&content);
4038
4039 let bytes: &'static [u8] =
4040 Box::leak(mock_ollama_ok("The cat sat.").into_boxed_slice());
4041 let (url, _h) = spawn_multi_shot_mock_ollama(bytes);
4042 let cfg = LlmConfig {
4043 endpoint: url,
4044 target_grade: 8.0,
4045 max_refinement_attempts: 0,
4046 ..LlmConfig::default()
4047 };
4048
4049 fail::cfg("llm::fix-read", "return").unwrap();
4050 let report =
4051 LlmPlugin::audit_and_fix_with_report(&content, &cfg).unwrap();
4052 let _ = fail::cfg("llm::fix-read", "off");
4053
4054 assert_eq!(report.results.len(), 1);
4055 assert_eq!(report.results[0].action, "skipped");
4056 assert!(!report.results[0].improved);
4057 assert_eq!(report.total_fixed, 0);
4058 }
4059 }
4060}