use std::collections::HashSet;
use serde::Serialize;
use crate::drift::{DriftConflict, EntityKind};
use crate::facts_scan::FactConflict;
#[derive(Debug, Clone, Default, Serialize)]
pub struct WorldReport {
pub facts_total: usize,
pub facts_conflicts: Vec<FactConflict>,
pub facts_prose_findings: usize,
pub drift_conflicts: Vec<DriftConflict>,
pub continuity_attributes: usize,
pub characters: usize,
pub places: usize,
pub artefacts: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anachronism_flags: Vec<AnachronismFlag>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub undescribed: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnachronismFlag {
pub term: String,
pub chapter: String,
}
pub fn undescribed_of(
lexicon: &[(String, EntityKind)],
appeared: &HashSet<String>,
) -> Vec<(String, EntityKind)> {
lexicon
.iter()
.filter(|(name, _)| !appeared.contains(&name.to_lowercase()))
.cloned()
.collect()
}
impl WorldReport {
pub fn entity_total(&self) -> usize {
self.characters + self.places + self.artefacts
}
pub fn issue_count(&self) -> usize {
self.facts_conflicts.len()
+ self.facts_prose_findings
+ self.drift_conflicts.len()
+ self.anachronism_flags.len()
}
pub fn summary(&self) -> String {
let n = self.issue_count();
if n == 0 {
return format!(
"World: ✓ consistent — {} fact(s), {} entit{}",
self.facts_total,
self.entity_total(),
if self.entity_total() == 1 { "y" } else { "ies" }
);
}
let mut parts = Vec::new();
if !self.facts_conflicts.is_empty() {
parts.push(plural(self.facts_conflicts.len(), "fact conflict", "fact conflicts"));
}
if self.facts_prose_findings > 0 {
parts.push(format!("{} prose-vs-fact", self.facts_prose_findings));
}
if !self.drift_conflicts.is_empty() {
parts.push(format!("{} drift", self.drift_conflicts.len()));
}
if !self.anachronism_flags.is_empty() {
parts.push(plural(self.anachronism_flags.len(), "anachronism", "anachronisms"));
}
format!("World: {n} issue(s) — {}", parts.join(" · "))
}
}
fn plural(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::drift::EntityKind;
fn fact_conflict() -> FactConflict {
FactConflict { a: "mild winters".into(), b: "harbor freezes".into(), detail: "x".into() }
}
fn drift_conflict() -> DriftConflict {
DriftConflict {
entity: "The Goose".into(),
kind: EntityKind::Place,
a: "smoky".into(),
b: "airy".into(),
chapter_a: "ch-2".into(),
chapter_b: "ch-20".into(),
paragraph_b: None,
detail: "y".into(),
}
}
#[test]
fn issue_count_sums_contradictions_and_anachronisms_only() {
let r = WorldReport {
facts_total: 40,
facts_conflicts: vec![fact_conflict()],
facts_prose_findings: 2,
drift_conflicts: vec![drift_conflict(), drift_conflict()],
continuity_attributes: 12, characters: 5,
places: 3,
artefacts: 1,
anachronism_flags: vec![AnachronismFlag { term: "telephone".into(), chapter: "ch-1".into() }],
undescribed: vec!["Joss".into()], };
assert_eq!(r.issue_count(), 6);
assert_eq!(r.entity_total(), 9);
}
#[test]
fn undescribed_of_returns_unnamed_entities() {
let lexicon = vec![
("Mara".to_string(), EntityKind::Character),
("Joss".to_string(), EntityKind::Character),
("The Goose".to_string(), EntityKind::Place),
];
let appeared: HashSet<String> = ["mara", "the goose"].iter().map(|s| s.to_string()).collect();
let out = undescribed_of(&lexicon, &appeared);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, "Joss", "Joss is defined but never named in prose");
}
#[test]
fn summary_clean_vs_issues() {
let clean = WorldReport { facts_total: 40, characters: 9, ..Default::default() };
assert_eq!(clean.issue_count(), 0);
assert!(clean.summary().contains("✓ consistent"));
assert!(clean.summary().contains("40 fact(s)"));
let bad = WorldReport {
facts_conflicts: vec![fact_conflict()],
drift_conflicts: vec![drift_conflict(), drift_conflict()],
..Default::default()
};
let s = bad.summary();
assert!(s.starts_with("World: 3 issue(s) —"), "got: {s}");
assert!(s.contains("1 fact conflict") && s.contains("2 drift"));
assert!(!s.contains("prose-vs-fact"), "zero categories are omitted");
}
}