use std::collections::BTreeMap;
use super::gather;
use super::{BondFinding, CoScene, Declared, Severity};
use crate::config::Config;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::Node;
type Pair = (String, String);
fn group_declared(declared: &[Declared]) -> BTreeMap<Pair, Vec<&Declared>> {
let mut m: BTreeMap<Pair, Vec<&Declared>> = BTreeMap::new();
for d in declared {
m.entry((d.a.clone(), d.b.clone())).or_default().push(d);
}
for v in m.values_mut() {
v.sort_by_key(|d| d.at);
}
m
}
fn group_coscenes(coscenes: &[CoScene]) -> BTreeMap<Pair, Vec<&CoScene>> {
let mut m: BTreeMap<Pair, Vec<&CoScene>> = BTreeMap::new();
for c in coscenes {
m.entry((c.a.clone(), c.b.clone())).or_default().push(c);
}
for v in m.values_mut() {
v.sort_by_key(|c| c.at);
}
m
}
fn unwritten_bonds(
declared: &BTreeMap<Pair, Vec<&Declared>>,
coscenes: &BTreeMap<Pair, Vec<&CoScene>>,
min_co_presence: u32,
) -> Vec<BondFinding> {
let mut out = Vec::new();
for (pair, decls) in declared {
let n = coscenes.get(pair).map_or(0, |v| v.len()) as u32;
if n < min_co_presence {
let first = decls[0];
out.push(BondFinding {
kind: "unwritten_bond",
severity: Severity::Notice,
chapter: first.at.chapter_ord,
anchor: Some(first.anchor),
a: pair.0.clone(),
b: pair.1.clone(),
message: format!(
"Declared {} bond between {} and {}, but they share {} scene(s) on the \
page (want ≥ {}). Asserted, not dramatised.",
first.kind, pair.0, pair.1, n, min_co_presence
),
});
}
}
out
}
fn unearned_shifts(
declared: &BTreeMap<Pair, Vec<&Declared>>,
coscenes: &BTreeMap<Pair, Vec<&CoScene>>,
) -> Vec<BondFinding> {
let mut out = Vec::new();
for (pair, decls) in declared {
for w in decls.windows(2) {
let (prev, next) = (w[0], w[1]);
if prev.kind == next.kind {
continue; }
let turned = coscenes
.get(pair)
.is_some_and(|cs| cs.iter().any(|c| c.at > prev.at && c.at <= next.at));
if !turned {
out.push(BondFinding {
kind: "unearned_shift",
severity: Severity::Break,
chapter: next.at.chapter_ord,
anchor: Some(next.anchor),
a: pair.0.clone(),
b: pair.1.clone(),
message: format!(
"Bond between {} and {} shifts {} → {} (ch. {} → ch. {}) with no shared \
scene to turn it.",
pair.0, pair.1, prev.kind, next.kind, prev.at.chapter_ord,
next.at.chapter_ord
),
});
}
}
}
out
}
fn dropped_bonds(
declared: &BTreeMap<Pair, Vec<&Declared>>,
coscenes: &BTreeMap<Pair, Vec<&CoScene>>,
dormancy_window: u32,
) -> Vec<BondFinding> {
let mut out = Vec::new();
for (pair, decls) in declared {
let mut chapters: Vec<u32> = decls.iter().map(|d| d.at.chapter_ord).collect();
if let Some(cs) = coscenes.get(pair) {
chapters.extend(cs.iter().map(|c| c.at.chapter_ord));
}
chapters.sort_unstable();
chapters.dedup();
for w in chapters.windows(2) {
let (before, after) = (w[0], w[1]);
if after.saturating_sub(before) > dormancy_window {
let anchor = coscenes
.get(pair)
.and_then(|cs| cs.iter().find(|c| c.at.chapter_ord == after).map(|c| c.anchor))
.or_else(|| decls.iter().find(|d| d.at.chapter_ord == after).map(|d| d.anchor));
out.push(BondFinding {
kind: "dropped_bond",
severity: Severity::Notice,
chapter: after,
anchor,
a: pair.0.clone(),
b: pair.1.clone(),
message: format!(
"Bond between {} and {} goes dormant for {} chapters (ch. {} → ch. {}) \
before resurfacing.",
pair.0, pair.1, after - before, before, after
),
});
break; }
}
}
out
}
pub fn run(layout: &ProjectLayout, h: &Hierarchy, cfg: &Config, book: &Node) -> Vec<BondFinding> {
let (declared, coscenes, _paras) = gather::build_bonds(layout, h, book);
if declared.is_empty() {
return Vec::new();
}
let dbp = group_declared(&declared);
let cbp = group_coscenes(&coscenes);
let mut out = Vec::new();
out.extend(unwritten_bonds(&dbp, &cbp, cfg.bonds.min_co_presence));
out.extend(unearned_shifts(&dbp, &cbp));
out.extend(dropped_bonds(&dbp, &cbp, cfg.bonds.dormancy_window));
out.sort_by(|x, y| {
y.severity
.rank()
.cmp(&x.severity.rank())
.then(x.chapter.cmp(&y.chapter))
.then(x.a.cmp(&y.a))
.then(x.b.cmp(&y.b))
});
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bonds::ScenePos;
use uuid::Uuid;
fn pos(c: u32, s: u32) -> ScenePos {
ScenePos { chapter_ord: c, scene_index: s }
}
fn decl(kind: &str, at: ScenePos) -> Declared {
Declared::new(kind, "Mara", "Kell", at, Uuid::from_u128(at.chapter_ord as u128))
}
fn cos(at: ScenePos) -> CoScene {
CoScene::new("Mara", "Kell", at, Uuid::from_u128(1000 + at.chapter_ord as u128))
}
#[test]
fn unwritten_bond_fires_when_barely_co_present() {
let d = vec![decl("ally", pos(1, 0))];
let c: Vec<CoScene> = vec![]; let f = unwritten_bonds(&group_declared(&d), &group_coscenes(&c), 2);
assert_eq!(f.len(), 1);
assert_eq!(f[0].kind, "unwritten_bond");
let c2 = vec![cos(pos(1, 1)), cos(pos(2, 0))];
assert!(unwritten_bonds(&group_declared(&d), &group_coscenes(&c2), 2).is_empty());
}
#[test]
fn unearned_shift_fires_when_no_scene_turns_it() {
let d = vec![decl("ally", pos(1, 0)), decl("enemy", pos(9, 0))];
let none: Vec<CoScene> = vec![];
let f = unearned_shifts(&group_declared(&d), &group_coscenes(&none));
assert_eq!(f.len(), 1);
assert_eq!(f[0].kind, "unearned_shift");
assert_eq!(f[0].severity, Severity::Break);
assert_eq!(f[0].chapter, 9);
let turned = vec![cos(pos(5, 0))];
assert!(unearned_shifts(&group_declared(&d), &group_coscenes(&turned)).is_empty());
let early = vec![cos(pos(1, 0))]; assert_eq!(unearned_shifts(&group_declared(&d), &group_coscenes(&early)).len(), 1);
}
#[test]
fn dropped_bond_fires_on_a_wide_gap_that_resurfaces() {
let d = vec![decl("ally", pos(1, 0))];
let c = vec![cos(pos(1, 0)), cos(pos(10, 0))];
let f = dropped_bonds(&group_declared(&d), &group_coscenes(&c), 6);
assert_eq!(f.len(), 1);
assert_eq!(f[0].kind, "dropped_bond");
assert_eq!(f[0].chapter, 10);
let c2 = vec![cos(pos(1, 0)), cos(pos(5, 0))];
assert!(dropped_bonds(&group_declared(&d), &group_coscenes(&c2), 6).is_empty());
}
}