use std::path::Path;
use crate::character::{
ArcCheckType, ArcDeclaration, CharStore, read_arc_declarations, run_agency, run_arc_checks,
run_extraction, run_planning,
};
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use super::CharacterCommand;
pub fn run(project: &Path, cmd: CharacterCommand) -> Result<()> {
match cmd {
CharacterCommand::Arc { name, book } => arc(project, &name, book.as_deref()),
CharacterCommand::Check { book, json } => check(project, book.as_deref(), json),
CharacterCommand::Refresh { book, name } => {
refresh(project, book.as_deref(), name.as_deref())
}
CharacterCommand::Plan { book, json } => plan(project, book.as_deref(), json),
}
}
fn open(project: &Path) -> Result<(ProjectLayout, Config, Store, Hierarchy)> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)?;
let h = Hierarchy::load(&store)?;
Ok((layout, cfg, store, h))
}
fn cstore(store: &Store) -> Result<CharStore> {
CharStore::open(store.project_root()).map_err(se)
}
fn se(e: anyhow::Error) -> Error {
Error::Store(e.to_string())
}
fn sync_declarations(cs: &CharStore, h: &Hierarchy, layout: &ProjectLayout, book_slug: &str) -> Result<()> {
for (decl, hash) in read_arc_declarations(h, layout) {
cs.upsert_declaration(book_slug, &decl, hash).map_err(se)?;
}
Ok(())
}
fn fmt_opt_f32(v: Option<f32>) -> String {
v.map(|x| format!("{x:.2}")).unwrap_or_else(|| "—".to_string())
}
fn arc(project: &Path, name: &str, book_name: Option<&str>) -> Result<()> {
let (layout, _cfg, store, h) = open(project)?;
let book = super::resolve_user_book(&h, book_name, "character").map_err(Error::Store)?;
let cs = cstore(&store)?;
sync_declarations(&cs, &h, &layout, &book.slug)?;
let roster = crate::character::character_names(&h);
let canonical = roster
.iter()
.find(|n| n.eq_ignore_ascii_case(name))
.cloned()
.unwrap_or_else(|| name.to_string());
let decl = cs.declaration(&book.slug, &canonical).map_err(se)?;
let states = cs.states_for_character(&book.slug, &canonical).map_err(se)?;
let checks = cs.checks_for_character(&book.slug, &canonical).map_err(se)?;
let planning: Vec<_> = cs
.planning_findings(&book.slug)
.map_err(se)?
.into_iter()
.filter(|(n, _, _)| n.eq_ignore_ascii_case(&canonical))
.collect();
println!("Character arc — {canonical} · `{}`", book.title);
println!("{}", "─".repeat(72));
match &decl {
Some(d) => {
println!("Declared arc: {}", d.arc_type.as_code());
println!(" start : {}", d.desired_state_start);
if let Some(m) = &d.desired_midpoint_state {
println!(" midpoint : {m}");
}
println!(" end : {}", d.desired_state_end);
}
None => println!("Declared arc: (none — add a `character_arc` block in the Characters book)"),
}
println!();
if states.is_empty() {
println!("State chain: (empty — run `inkhaven character refresh` to extract)");
} else {
println!("State chain ({} chapters):", states.len());
for s in &states {
let mark = if s.changed { "✦" } else { "·" };
println!(
" {mark} ch.{:>2} agency {} ({}a/{}p) {}",
s.chapter_ord,
fmt_opt_f32(s.agency_score),
s.active_count,
s.passive_count,
s.state_summary,
);
if let Some(cd) = &s.change_description {
println!(" change: {cd}");
}
}
}
println!();
if checks.is_empty() {
println!("Arc checks: (none — run `inkhaven character check`)");
} else {
println!("Arc checks:");
for c in &checks {
let loc = c.chapter_ord.map(|o| format!(" (ch.{o})")).unwrap_or_default();
println!(" [{}] {}{loc} — {}", c.verdict.as_code(), c.check_type.label(), c.description);
}
}
if !planning.is_empty() {
println!();
println!("Planning gaps:");
for (_, ft, desc) in &planning {
println!(" [{ft}] {desc}");
}
}
println!("{}", "─".repeat(72));
Ok(())
}
fn refresh(project: &Path, book_name: Option<&str>, only: Option<&str>) -> Result<()> {
let (layout, cfg, store, h) = open(project)?;
let book = super::resolve_user_book(&h, book_name, "character").map_err(Error::Store)?;
let cs = cstore(&store)?;
sync_declarations(&cs, &h, &layout, &book.slug)?;
let cells = run_agency(&cs, &layout, &h, &cfg, book).map_err(se)?;
println!("Agency: {cells} (character, chapter) cells computed.");
let decls: Vec<ArcDeclaration> = cs
.all_declarations(&book.slug)
.map_err(se)?
.into_iter()
.filter(|d| only.is_none_or(|n| d.character_name.eq_ignore_ascii_case(n)))
.collect();
if decls.is_empty() {
println!("No declared arcs to extract. (Add a `character_arc` block in the Characters book.)");
return Ok(());
}
for d in &decls {
let n = run_extraction(&cs, &cfg, &layout, &h, book, &d.character_name, Some(d)).map_err(se)?;
println!(" {} — {n} chapter(s) (re)extracted.", d.character_name);
}
Ok(())
}
fn check(project: &Path, book_name: Option<&str>, json: bool) -> Result<()> {
let (layout, cfg, store, h) = open(project)?;
let book = super::resolve_user_book(&h, book_name, "character").map_err(Error::Store)?;
let cs = cstore(&store)?;
sync_declarations(&cs, &h, &layout, &book.slug)?;
run_agency(&cs, &layout, &h, &cfg, book).map_err(se)?;
let decls = cs.all_declarations(&book.slug).map_err(se)?;
let mut all = Vec::new();
for d in &decls {
run_extraction(&cs, &cfg, &layout, &h, book, &d.character_name, Some(d)).map_err(se)?;
let states = cs.states_for_character(&book.slug, &d.character_name).map_err(se)?;
let checks = run_arc_checks(
&cs,
&cfg,
&book.slug,
d,
&states,
cfg.char.stall_threshold,
cfg.char.min_chapters_for_check,
)
.map_err(se)?;
all.extend(checks);
}
if json {
let arr: Vec<serde_json::Value> = all
.iter()
.map(|c| {
serde_json::json!({
"character": c.character_name,
"check": c.check_type.as_code(),
"verdict": c.verdict.as_code(),
"description": c.description,
"chapter": c.chapter_ord,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default());
} else {
println!("Character arc checks — `{}` [{}]", book.title, cfg.language);
println!("{}", "─".repeat(72));
if decls.is_empty() {
println!("No declared arcs. (Add a `character_arc` block in the Characters book.)");
}
for c in &all {
let loc = c.chapter_ord.map(|o| format!(" (ch.{o})")).unwrap_or_default();
let flag = if c.verdict.is_problem() { "⚠" } else { " " };
println!(
"{flag} {} · [{}] {}{loc} — {}",
c.character_name,
c.verdict.as_code(),
c.check_type.label(),
loc_trim(&c.description),
);
}
let problems = all.iter().filter(|c| c.verdict.is_problem()).count();
println!("{}", "─".repeat(72));
println!("{problems} problem(s) across {} check(s).", all.len());
}
let serious = all.iter().any(|c| {
c.verdict.is_problem()
&& matches!(c.check_type, ArcCheckType::EndAlignment | ArcCheckType::ArcEarned)
});
let any_problem = all.iter().any(|c| c.verdict.is_problem());
if serious {
std::process::exit(2);
}
if any_problem {
std::process::exit(1);
}
Ok(())
}
fn loc_trim(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn plan(project: &Path, book_name: Option<&str>, json: bool) -> Result<()> {
let (layout, _cfg, store, h) = open(project)?;
let book = super::resolve_user_book(&h, book_name, "character").map_err(Error::Store)?;
let cs = cstore(&store)?;
sync_declarations(&cs, &h, &layout, &book.slug)?;
run_planning(&cs, &store, &h, book).map_err(se)?;
let findings = cs.planning_findings(&book.slug).map_err(se)?;
if json {
let arr: Vec<serde_json::Value> = findings
.iter()
.map(|(name, ft, desc)| {
serde_json::json!({ "character": name, "type": ft, "description": desc })
})
.collect();
println!("{}", serde_json::to_string_pretty(&arr).unwrap_or_default());
} else {
println!("Planning-Board arc gaps — `{}`", book.title);
println!("{}", "─".repeat(72));
if findings.is_empty() {
println!("No planning gaps. Every declared arc has scene-card coverage through the book.");
}
for (name, ft, desc) in &findings {
println!(" {name} · [{ft}] {desc}");
}
}
if !findings.is_empty() {
std::process::exit(1);
}
Ok(())
}