use std::collections::{BTreeMap, HashSet};
use uuid::Uuid;
use crate::config::Config;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::Node;
use super::scenes::book_scenes;
use super::vocab::interiority_verbs;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ScenePov {
Single(String),
First,
Omniscient,
Unknown,
}
impl ScenePov {
pub(crate) fn describe(&self) -> String {
match self {
ScenePov::Single(n) => format!("POV {n}"),
ScenePov::First => "first person".into(),
ScenePov::Omniscient => "omniscient".into(),
ScenePov::Unknown => "POV unknown".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HeadHop {
pub experiencer: String,
pub count: usize,
}
fn norm(tok: &str) -> String {
tok.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase()
}
fn name_parts(name: &str) -> Vec<String> {
name.split_whitespace().map(|p| p.to_lowercase()).collect()
}
fn matches_at(tokens: &[String], i: usize, parts: &[String]) -> bool {
i + parts.len() <= tokens.len() && parts.iter().enumerate().all(|(k, p)| &tokens[i + k] == p)
}
pub(crate) fn scene_pov(text: &str, roster: &[String], declared: Option<&str>) -> ScenePov {
if let Some(d) = declared {
let dl = d.trim().to_lowercase();
return match dl.as_str() {
"omniscient" | "omni" | "multi" => ScenePov::Omniscient,
"first" | "1st" | "i" => ScenePov::First,
_ => {
let canonical = roster.iter().find(|n| n.to_lowercase() == dl).cloned();
ScenePov::Single(canonical.unwrap_or_else(|| d.trim().to_string()))
}
};
}
match most_mentioned(text, roster) {
Some(name) => ScenePov::Single(name),
None => ScenePov::Unknown,
}
}
fn most_mentioned(text: &str, roster: &[String]) -> Option<String> {
let tokens: Vec<String> = text.split_whitespace().map(|t| norm(&t)).collect();
let mut best: Option<(String, usize, usize)> = None; for name in roster {
let parts = name_parts(name);
if parts.is_empty() {
continue;
}
let (mut count, mut first) = (0usize, usize::MAX);
for i in 0..tokens.len() {
if matches_at(&tokens, i, &parts) {
if count == 0 {
first = i;
}
count += 1;
}
}
if count > 0 {
let better = match &best {
None => true,
Some((_, bc, bf)) => count > *bc || (count == *bc && first < *bf),
};
if better {
best = Some((name.clone(), count, first));
}
}
}
best.map(|(n, _, _)| n)
}
pub(crate) fn head_hops(
text: &str,
roster: &[String],
verbs: &HashSet<String>,
pov: &ScenePov,
) -> Vec<HeadHop> {
if matches!(pov, ScenePov::Omniscient | ScenePov::Unknown) {
return Vec::new();
}
let tokens: Vec<String> = text.split_whitespace().map(|t| norm(&t)).collect();
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for name in roster {
let parts = name_parts(name);
if parts.is_empty() || !is_leak(pov, name) {
continue;
}
for i in 0..tokens.len() {
if matches_at(&tokens, i, &parts) {
let verb_idx = i + parts.len();
if verb_idx < tokens.len() && verbs.contains(&tokens[verb_idx]) {
*counts.entry(name.clone()).or_default() += 1;
}
}
}
}
counts.into_iter().map(|(experiencer, count)| HeadHop { experiencer, count }).collect()
}
fn is_leak(pov: &ScenePov, experiencer: &str) -> bool {
match pov {
ScenePov::First => true,
ScenePov::Single(name) => name.to_lowercase() != experiencer.to_lowercase(),
ScenePov::Omniscient | ScenePov::Unknown => false,
}
}
pub(crate) struct SceneHeadHops {
pub chapter_ord: u32,
pub scene_index: u32,
pub pov: ScenePov,
pub first_para: Uuid,
pub hops: Vec<HeadHop>,
}
pub(crate) fn scan_head_hops(
layout: &ProjectLayout,
h: &Hierarchy,
cfg: &Config,
book: &Node,
) -> Vec<SceneHeadHops> {
let (lang, _) = crate::prose::resolve_prose_language(None, &cfg.language);
let roster = crate::dialogue::character_names(h);
let verbs: HashSet<String> = interiority_verbs(&lang).iter().map(|s| s.to_string()).collect();
let mut out = Vec::new();
for s in book_scenes(layout, h, book) {
let pov = scene_pov(&s.text, &roster, s.declared_pov.as_deref());
let hops = head_hops(&s.text, &roster, &verbs, &pov);
if !hops.is_empty() {
out.push(SceneHeadHops {
chapter_ord: s.chapter_ord,
scene_index: s.scene_index,
pov,
first_para: s.first_para,
hops,
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::prose::ProseLanguage;
fn en_verbs() -> HashSet<String> {
interiority_verbs(&ProseLanguage::En).iter().map(|s| s.to_string()).collect()
}
#[test]
fn scene_pov_declared_and_inferred() {
let roster = vec!["Mara".to_string(), "Joren".to_string()];
assert_eq!(scene_pov("", &roster, Some("Mara")), ScenePov::Single("Mara".into()));
assert_eq!(scene_pov("", &roster, Some("mara")), ScenePov::Single("Mara".into()));
assert_eq!(scene_pov("", &roster, Some("omniscient")), ScenePov::Omniscient);
assert_eq!(scene_pov("", &roster, Some("first")), ScenePov::First);
assert_eq!(
scene_pov("Mara went in. Joren waited. Mara sighed.", &roster, None),
ScenePov::Single("Mara".into())
);
assert_eq!(scene_pov("The wind rose.", &roster, None), ScenePov::Unknown);
}
#[test]
fn head_hop_flags_a_named_non_pov_experiencer() {
let roster = vec!["Mara".to_string(), "Joren".to_string()];
let v = en_verbs();
let pov = ScenePov::Single("Mara".into());
let hops = head_hops("Mara looked out. Joren wondered whether she knew.", &roster, &v, &pov);
assert_eq!(hops, vec![HeadHop { experiencer: "Joren".into(), count: 1 }]);
assert!(head_hops("Mara thought about the tide.", &roster, &v, &pov).is_empty());
}
#[test]
fn omniscient_and_first_person() {
let roster = vec!["Mara".to_string(), "Joren".to_string()];
let v = en_verbs();
assert!(head_hops("Joren wondered.", &roster, &v, &ScenePov::Omniscient).is_empty());
let hops = head_hops("Joren wondered. Mara realised.", &roster, &v, &ScenePov::First);
assert_eq!(hops.len(), 2);
}
#[test]
fn works_in_russian() {
let roster = vec!["Мара".to_string(), "Джорен".to_string()];
let v: HashSet<String> =
interiority_verbs(&ProseLanguage::Ru).iter().map(|s| s.to_string()).collect();
let pov = scene_pov("Мара смотрела в окно. Джорен подумал о ней.", &roster, None);
assert_eq!(pov, ScenePov::Single("Мара".into()));
let hops = head_hops("Мара смотрела в окно. Джорен подумал о ней.", &roster, &v, &pov);
assert_eq!(hops, vec![HeadHop { experiencer: "Джорен".into(), count: 1 }]);
}
}