use std::collections::HashMap;
use anyhow::Result;
use crate::store::node::Node;
use crate::store::{NodeKind, Store};
use crate::store::hierarchy::Hierarchy;
use super::store::CharStore;
pub(super) fn planning_gaps(scene_chapters: &[u32], total: u32) -> Vec<(&'static str, String)> {
let mut out = Vec::new();
if scene_chapters.is_empty() {
out.push((
"no_scenes",
"declares an arc but no scene card on the Planning Board names this character".to_string(),
));
return out;
}
if total == 0 {
return out;
}
let all_first_half = scene_chapters.iter().all(|&c| c * 2 <= total);
let in_final_act = scene_chapters.iter().any(|&c| c * 4 > total * 3);
if all_first_half {
out.push((
"all_first_half",
"every arc-linked scene card sits in the first half of the book; the arc has no \
second-half presence on the Planning Board"
.to_string(),
));
} else if !in_final_act {
out.push((
"no_final_act",
"no arc-linked scene card sits in the final act; the arc's resolution is unplanned"
.to_string(),
));
}
out
}
fn chapter_index(h: &Hierarchy, book: &Node) -> (u32, HashMap<String, u32>) {
let mut map = HashMap::new();
let mut total = 0u32;
for (idx, ch) in h
.children_of(Some(book.id))
.into_iter()
.filter(|n| n.kind == NodeKind::Chapter)
.enumerate()
{
let ord = (idx + 1) as u32;
total = ord;
map.insert(ch.slug.to_lowercase(), ord);
map.insert(ch.title.trim().to_lowercase(), ord);
}
(total, map)
}
fn resolve_chapter(chapter: &str, index: &HashMap<String, u32>) -> Option<u32> {
let key = chapter.trim().to_lowercase();
if key.is_empty() {
return None;
}
index.get(&key).copied()
}
pub(crate) fn run_planning(
store: &CharStore,
main_store: &Store,
h: &Hierarchy,
book: &Node,
) -> Result<usize> {
store.clear_scene_links(&book.slug)?;
store.clear_planning_findings(&book.slug)?;
let (total, index) = chapter_index(h, book);
let now = chrono::Utc::now().to_rfc3339();
for (id, scene) in crate::cli::plan::load_scenes(main_store, h) {
if scene.characters.is_empty() {
continue;
}
let ord = resolve_chapter(&scene.chapter, &index);
let scene_id = id.to_string();
for raw in &scene.characters {
let name = raw.trim();
if name.is_empty() {
continue;
}
store.upsert_scene_link(
&book.slug,
&scene_id,
name,
scene.arc_function.as_deref(),
ord,
)?;
}
}
let mut findings = 0;
for decl in store.all_declarations(&book.slug)? {
let chapters = store.scene_chapters(&book.slug, &decl.character_name)?;
for (ftype, desc) in planning_gaps(&chapters, total) {
store.upsert_planning_finding(&book.slug, &decl.character_name, ftype, &desc, &now)?;
findings += 1;
}
}
Ok(findings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_scenes_when_unlinked() {
let g = planning_gaps(&[], 10);
assert_eq!(g.len(), 1);
assert_eq!(g[0].0, "no_scenes");
}
#[test]
fn all_first_half_subsumes_final_act() {
let g = planning_gaps(&[1, 3, 5], 10);
assert_eq!(g.len(), 1);
assert_eq!(g[0].0, "all_first_half");
}
#[test]
fn no_final_act_when_spread_but_no_last_quarter() {
let g = planning_gaps(&[2, 7], 12);
assert_eq!(g.len(), 1);
assert_eq!(g[0].0, "no_final_act");
}
#[test]
fn no_gap_when_arc_reaches_final_act() {
let g = planning_gaps(&[2, 7, 11], 12);
assert!(g.is_empty());
}
#[test]
fn no_act_gap_without_chapters() {
let g = planning_gaps(&[1], 0);
assert!(g.is_empty());
}
#[test]
fn resolve_matches_slug_or_title() {
let mut idx = HashMap::new();
idx.insert("ch-one".to_string(), 1);
idx.insert("the beginning".to_string(), 1);
assert_eq!(resolve_chapter("Ch-One", &idx), Some(1));
assert_eq!(resolve_chapter(" the beginning ", &idx), Some(1));
assert_eq!(resolve_chapter("nowhere", &idx), None);
assert_eq!(resolve_chapter("", &idx), None);
}
}