use std::path::Path;
use crate::cli::doctor_scan::{self, ScanClass};
use crate::editorial::{self, EditorialFinding};
use crate::error::{Error, Result};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::NodeKind;
pub fn collect(
project: &Path,
book_name: Option<&str>,
only: Option<&[String]>,
include_deferred: bool,
) -> Result<editorial::EditorialReport> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let mut raw: Vec<EditorialFinding> = Vec::new();
let mut scan = doctor_scan::scan_project(project, None)?.findings;
scan.extend(doctor_scan::scan_project(project, Some(ScanClass::UnresolvedTension))?.findings);
raw.extend(scan.iter().filter_map(editorial::from_scan_finding));
let mut sidecar_fps: Vec<u64> = Vec::new();
if let Ok(facts) = crate::facts_scan::FactScanReport::load(&layout.root) {
sidecar_fps.push(facts.manuscript_fingerprint);
raw.extend(facts.findings.iter().map(editorial::from_fact_finding));
}
if let Ok(check) = crate::facts_scan::FactCheckReport::load(&layout.root) {
sidecar_fps.push(check.manuscript_fingerprint);
raw.extend(check.conflicts.iter().map(editorial::from_fact_conflict));
}
if let Ok(drift) = crate::drift::DriftReport::load(&layout.root) {
sidecar_fps.push(drift.manuscript_fingerprint);
raw.extend(drift.conflicts.iter().map(editorial::from_drift_conflict));
}
raw.extend(plan_warnings(project, book_name).into_iter().map(|w| editorial::from_plan_warning(&w)));
let mut stale = false;
if let Ok(cfg) = crate::config::Config::load_layered(&layout.config_path()) {
if let Ok(store) = crate::store::Store::open(layout.clone(), &cfg) {
if let Ok(h) = Hierarchy::load(&store) {
let current = crate::world_report::manuscript_fingerprint(&h);
stale = sidecar_fps
.iter()
.any(|fp| crate::world_report::is_stale(*fp, current));
raw.extend(prose_style_findings(&cfg, &store, &h));
for (name, kind, node) in super::world::undescribed_entities(&store, &h) {
raw.push(EditorialFinding {
category: "coverage".into(),
severity: editorial::Severity::Info,
location: editorial::Location {
paragraph: Some(node),
..Default::default()
},
message: format!(
"coverage: {} “{}” is defined but never named in the prose",
kind.label(),
name
),
hint: None,
source: "world",
autofixable: false,
});
}
resolve_locations(&mut raw, &h, &layout);
}
}
}
if let Some(cats) = only {
raw.retain(|f| cats.iter().any(|c| c.trim().eq_ignore_ascii_case(&f.category)));
}
let before = raw.len();
if !include_deferred {
let dismissed = editorial::Dismissed::load(&layout.root);
if !dismissed.fingerprints.is_empty() {
raw.retain(|f| !dismissed.fingerprints.contains(&f.fingerprint()));
}
}
let deferred = before - raw.len();
let mut report = editorial::aggregate(raw);
report.deferred = deferred;
report.stale = stale;
Ok(report)
}
fn prose_style_findings(
cfg: &crate::config::Config,
store: &crate::store::Store,
h: &Hierarchy,
) -> Vec<EditorialFinding> {
let sdt = crate::tui::style_warnings::ShowDontTellDetector::new(
&cfg.editor.style_warnings.show_dont_tell,
&cfg.language,
);
let anach = crate::tui::style_warnings::AnachronismDetector::new(
&cfg.editor.style_warnings.anachronism,
);
let filter = crate::tui::style_warnings::FilterWordsDetector::new(
&cfg.editor.style_warnings.filter_words,
&cfg.language,
);
if sdt.is_empty() && anach.is_empty() && filter.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for book in h.iter().filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none()) {
for chapter in h.children_of(Some(book.id)) {
if chapter.kind != NodeKind::Chapter {
continue;
}
let chap = if chapter.title.trim().is_empty() {
chapter.slug.clone()
} else {
chapter.title.clone()
};
for pid in h.collect_subtree(chapter.id) {
if h.get(pid).map(|n| n.kind) != Some(NodeKind::Paragraph) {
continue;
}
let Some(body) = store.get_content(pid).ok().flatten() else {
continue;
};
let text = String::from_utf8_lossy(&body);
if !sdt.is_empty() {
out.extend(paragraph_show_tell_findings(&text, pid, &chap, &sdt));
}
if !anach.is_empty() {
out.extend(paragraph_anachronism_findings(&text, pid, &chap, &anach));
}
if !filter.is_empty() {
out.extend(paragraph_filter_word_findings(&text, pid, &chap, &filter));
}
}
}
}
out
}
fn paragraph_anachronism_findings(
text: &str,
pid: uuid::Uuid,
chapter: &str,
det: &crate::tui::style_warnings::AnachronismDetector,
) -> Vec<EditorialFinding> {
let mut out = Vec::new();
let mut row_off = 0usize;
for line in text.lines() {
for hit in det.detect(line) {
let term: String = line
.chars()
.skip(hit.col_start)
.take(hit.col_end.saturating_sub(hit.col_start))
.collect();
let earliest = det.earliest(&term);
let yr = earliest.map(|y| format!(" (not before {y})")).unwrap_or_default();
out.push(EditorialFinding {
category: "anachronism".into(),
severity: editorial::Severity::Warn,
location: editorial::Location {
chapter: Some(chapter.to_string()),
paragraph: Some(pid),
char_range: Some((row_off + hit.col_start, row_off + hit.col_end)),
path: None,
},
message: format!("anachronism: “{}” postdates the setting{yr}", term.trim()),
hint: None,
source: "style",
autofixable: false,
});
}
row_off += line.chars().count() + 1;
}
out
}
fn paragraph_show_tell_findings(
text: &str,
pid: uuid::Uuid,
chapter: &str,
det: &crate::tui::style_warnings::ShowDontTellDetector,
) -> Vec<EditorialFinding> {
let mut out = Vec::new();
let mut row_off = 0usize; for line in text.lines() {
for hit in det.detect(line) {
let phrase: String = line
.chars()
.skip(hit.col_start)
.take(hit.col_end.saturating_sub(hit.col_start))
.collect();
out.push(EditorialFinding {
category: "show-tell".into(),
severity: editorial::Severity::Info,
location: editorial::Location {
chapter: Some(chapter.to_string()),
paragraph: Some(pid),
char_range: Some((row_off + hit.col_start, row_off + hit.col_end)),
path: None,
},
message: format!("telling: “{}” — show it instead", phrase.trim()),
hint: None,
source: "style",
autofixable: false,
});
}
row_off += line.chars().count() + 1; }
out
}
fn paragraph_filter_word_findings(
text: &str,
pid: uuid::Uuid,
chapter: &str,
det: &crate::tui::style_warnings::FilterWordsDetector,
) -> Vec<EditorialFinding> {
let mut out = Vec::new();
let mut row_off = 0usize; for line in text.lines() {
for hit in det.detect(line) {
let word: String = line
.chars()
.skip(hit.col_start)
.take(hit.col_end.saturating_sub(hit.col_start))
.collect();
out.push(EditorialFinding {
category: "filter".into(),
severity: editorial::Severity::Info,
location: editorial::Location {
chapter: Some(chapter.to_string()),
paragraph: Some(pid),
char_range: Some((row_off + hit.col_start, row_off + hit.col_end)),
path: None,
},
message: format!("filter word: “{}” — consider cutting", word.trim()),
hint: None,
source: "style",
autofixable: false,
});
}
row_off += line.chars().count() + 1; }
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anachronism_mapping_names_the_term_and_earliest_year() {
let det = crate::tui::style_warnings::AnachronismDetector::new(
&crate::config::AnachronismConfig { year: Some(1840), terms: Vec::new() },
);
let id = uuid::Uuid::now_v7();
let f = paragraph_anachronism_findings("She glanced at her wristwatch.", id, "ch-1", &det);
assert_eq!(f.len(), 1);
let e = &f[0];
assert_eq!(e.category, "anachronism");
assert_eq!(e.severity, crate::editorial::Severity::Warn);
assert_eq!(e.location.paragraph, Some(id));
assert!(e.location.char_range.is_some());
assert!(e.message.contains("wristwatch") && e.message.contains("1900"));
assert!(!e.rewritable(), "anachronism is jump-only, not AI-rewritable");
}
#[test]
fn show_tell_mapping_locates_the_phrase_and_is_rewritable() {
let det = crate::tui::style_warnings::ShowDontTellDetector::new(
&crate::config::ShowDontTellConfig::default(),
"english",
);
let id = uuid::Uuid::now_v7();
let f = paragraph_show_tell_findings("She was angry at the news.", id, "ch-1", &det);
assert_eq!(f.len(), 1, "one telling phrase flagged");
let e = &f[0];
assert_eq!(e.category, "show-tell");
assert_eq!(e.location.paragraph, Some(id));
assert!(e.location.char_range.is_some(), "carries a char range");
assert!(e.rewritable(), "show-tell + a paragraph → f-rewritable");
assert!(e.message.contains("show it instead"));
}
#[test]
fn filter_word_mapping_is_info_and_span_rewritable() {
let det = crate::tui::style_warnings::FilterWordsDetector::new(
&crate::config::FilterWordsConfig::default(),
"english",
);
assert!(!det.is_empty(), "built-in english list populates the detector");
let id = uuid::Uuid::now_v7();
let f = paragraph_filter_word_findings("It was very cold.", id, "ch-1", &det);
assert_eq!(f.len(), 1, "one filter word flagged");
let e = &f[0];
assert_eq!(e.category, "filter");
assert_eq!(e.severity, crate::editorial::Severity::Info, "filter words sort last");
assert_eq!(e.location.paragraph, Some(id));
assert!(e.location.char_range.is_some(), "carries a char range");
assert!(e.rewritable(), "filter + a paragraph → f-rewritable (span)");
assert!(e.message.contains("very") && e.message.contains("cutting"));
}
}
fn resolve_locations(findings: &mut [EditorialFinding], h: &Hierarchy, layout: &ProjectLayout) {
for f in findings.iter_mut() {
if f.location.paragraph.is_some() {
continue;
}
if let Some(p) = &f.location.path {
let rel = Path::new(p)
.strip_prefix(&layout.root)
.map(|r| r.to_string_lossy().into_owned())
.unwrap_or_else(|_| p.clone());
if let Some(node) = h.iter().find(|n| n.file.as_deref() == Some(rel.as_str())) {
f.location.paragraph = Some(node.id);
continue;
}
}
if let Some(ch) = &f.location.chapter {
if let Some(chapter) = h.iter().find(|n| {
n.kind == NodeKind::Chapter
&& (n.title.eq_ignore_ascii_case(ch) || n.slug.eq_ignore_ascii_case(ch))
}) {
if let Some(first) = h.collect_subtree(chapter.id).into_iter().find(|id| {
h.get(*id).map(|n| n.kind == NodeKind::Paragraph).unwrap_or(false)
}) {
f.location.paragraph = Some(first);
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn run(
project: &Path,
json: bool,
only: Option<Vec<String>>,
book_name: Option<&str>,
show_deferred: bool,
deep: bool,
provider: Option<&str>,
) -> Result<()> {
if deep {
if json {
return Err(Error::Store(
"edit: --deep can't combine with --json (the AI scans print progress) — run the scans separately, then `edit --json`".into(),
));
}
deep_refresh(project, provider);
}
let report = collect(project, book_name, only.as_deref(), show_deferred)?;
if json {
let out = serde_json::to_string_pretty(&report)
.map_err(|e| Error::Store(format!("edit: {e}")))?;
println!("{out}");
} else {
render(&report);
}
Ok(())
}
fn deep_refresh(project: &Path, provider: Option<&str>) {
eprintln!("edit --deep: refreshing AI sidecars (facts · tension · continuity · drift)…");
let p = || provider.map(String::from);
if let Err(e) = super::facts_scan::run(project, super::FactsCommand::Scan { provider: p(), json: false }) {
eprintln!(" facts scan skipped: {e}");
}
if let Err(e) = super::tension::run(project, super::TensionCommand::Scan { provider: p() }) {
eprintln!(" tension scan skipped: {e}");
}
if let Err(e) = super::continuity::run(project, super::ContinuityCommand::Extract { provider: p() }) {
eprintln!(" continuity extract skipped: {e}");
}
if let Err(e) = super::drift::run(project, super::DriftCommand::Scan { provider: p(), json: false }) {
eprintln!(" drift scan skipped: {e}");
}
eprintln!();
}
fn plan_warnings(project: &Path, book_name: Option<&str>) -> Vec<String> {
let layout = ProjectLayout::new(project);
let Ok(cfg) = crate::config::Config::load_layered(&layout.config_path()) else {
return Vec::new();
};
let Ok(store) = crate::store::Store::open(layout.clone(), &cfg) else {
return Vec::new();
};
let Ok(h) = crate::store::hierarchy::Hierarchy::load(&store) else {
return Vec::new();
};
let Ok(book) = super::resolve_user_book(&h, book_name, "edit") else {
return Vec::new();
};
let book = book.clone();
match super::plan::build_report(&store, &layout, &h, &book, 0.10) {
Ok((report, _, _)) => report.warnings,
Err(_) => Vec::new(),
}
}
fn render(report: &editorial::EditorialReport) {
if report.findings.is_empty() {
println!("editorial pass: ✓ no findings — the manuscript reads clean");
return;
}
println!(
"EDITORIAL PASS · {} finding(s) ({} error · {} warn · {} info)\n",
report.findings.len(),
report.errors,
report.warnings,
report.infos,
);
if report.stale {
println!("⚠ some AI findings (facts / drift) predate the latest edits — re-run `inkhaven edit --deep`\n");
}
for f in &report.findings {
println!(
" {} {:<10} {:<14} {}",
f.severity.icon(),
f.category,
truncate(&f.location.label(), 14),
f.message,
);
if let Some(hint) = &f.hint {
println!(" ↳ {hint}");
}
}
if report.deferred > 0 {
println!(
"\n ({} deferred, hidden — `--show-deferred` to include, or clear in the cockpit)",
report.deferred
);
}
println!("\n walk + jump + defer in the cockpit: `Ctrl+V Shift+R` (1.3.6)");
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
format!("{}…", s.chars().take(max - 1).collect::<String>())
}
}