use std::path::Path;
use crate::ai::AiClient;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::tension::{parse_tension_lines, TensionKind, TensionLedger};
use super::TensionCommand;
pub fn run(project: &Path, cmd: TensionCommand) -> Result<()> {
match cmd {
TensionCommand::Scan { provider } => scan(project, provider.as_deref()),
TensionCommand::List => list(project),
}
}
const SYSTEM_PROMPT: &str = "You are a story editor analysing narrative tension. \
For a chapter, you identify the dramatic tensions / questions / goals it \
INTRODUCES (raises for the first time) and the ones it RESOLVES (pays off). \
A tension is a dramatic promise to the reader: a mystery, a threat, a goal, \
an unanswered question, a relationship in jeopardy. Output one per line in the \
exact form:\n\
introduce | short topic\n\
resolve | short topic\n\
Use a short, consistent topic phrase so an introduction and its later \
resolution share key words (e.g. introduce: `the missing letter` … resolve: \
`the missing letter found`). Output nothing else — no preamble, no commentary. \
If a chapter introduces or resolves nothing, output nothing.";
fn scan(project: &Path, provider: Option<&str>) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg)
.map_err(|e| Error::Store(e.to_string()))?;
let hierarchy =
Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
let ai = AiClient::from_config(&cfg.llm)?;
let (model, _env) = ai.resolve_provider(&cfg.llm, provider)?;
let language = if cfg.language.trim().is_empty() {
"English".to_string()
} else {
cfg.language.clone()
};
let chapters = hierarchy.user_book_chapters();
if chapters.is_empty() {
return Err(Error::Store(
"tension scan: no user-book chapters found".into(),
));
}
eprintln!(
"inkhaven tension scan · language: {language} · model: {model} · {} chapter(s)",
chapters.len(),
);
let mut ledger = TensionLedger {
version: env!("CARGO_PKG_VERSION").to_string(),
language: language.clone(),
tags: Vec::new(),
};
for (idx, (chapter_id, chapter_title)) in chapters.iter().enumerate() {
let prose =
crate::cli::book_walk::chapter_raw_prose(&layout, &hierarchy, *chapter_id);
let plain = crate::audiobook::typst_to_plain(&prose);
if plain.trim().is_empty() {
continue;
}
eprint!(" [{}/{}] {chapter_title} ", idx + 1, chapters.len());
let prompt = build_prompt(&language, chapter_title, &plain);
let raw = run_blocking(&ai, model, &prompt)?;
let tags = parse_tension_lines(&raw, chapter_title, idx);
let (intro, res) = tag_counts(&tags);
eprintln!("→ {intro} introduced, {res} resolved");
ledger.tags.extend(tags);
}
ledger
.save(&layout.root)
.map_err(|e| Error::Store(format!("tension save: {e}")))?;
println!(
"tension: tagged {} tension(s) across {} chapter(s) → {}\n\
run `inkhaven doctor --scan --class unresolved-tension` to flag the open ones",
ledger.tags.len(),
chapters.len(),
TensionLedger::sidecar_path(&layout.root).display(),
);
Ok(())
}
fn tag_counts(tags: &[crate::tension::TensionTag]) -> (usize, usize) {
let intro = tags.iter().filter(|t| t.kind == TensionKind::Introduce).count();
(intro, tags.len() - intro)
}
fn build_prompt(language: &str, chapter: &str, prose: &str) -> String {
format!(
"Language of the manuscript: {language}.\n\
List the tensions introduced and resolved in this chapter \
(\"{chapter}\"). One per line, `introduce | topic` or \
`resolve | topic`, no other output.\n\n\
--- CHAPTER PROSE ---\n{prose}\n--- END ---",
)
}
fn run_blocking(ai: &AiClient, model: &str, prompt: &str) -> Result<String> {
crate::ai::stream::collect_blocking(
ai.client.clone(),
model.to_string(),
Some(SYSTEM_PROMPT.to_string()),
prompt.to_string(),
)
.map_err(|e| Error::Store(format!("inference error: {e}")))
}
fn list(project: &Path) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let ledger = TensionLedger::load(&layout.root)
.map_err(|e| Error::Store(format!("tension load: {e}")))?;
if ledger.tags.is_empty() {
println!("tension: no tags yet — run `inkhaven tension scan` first");
return Ok(());
}
println!(
"Tension ledger — {} tag(s), language {}\n",
ledger.tags.len(),
ledger.language,
);
for t in &ledger.tags {
let mark = match t.kind {
TensionKind::Introduce => "▲ introduce",
TensionKind::Resolve => "▼ resolve ",
};
println!(" {mark} {:<32} [{}]", t.topic, t.chapter);
}
Ok(())
}