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