use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::cli::doctor_scan::{ScanFinding, ScanSeverity};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warn,
Info,
}
impl Severity {
fn rank(self) -> u8 {
match self {
Severity::Error => 0,
Severity::Warn => 1,
Severity::Info => 2,
}
}
pub fn icon(self) -> char {
match self {
Severity::Error => '✗',
Severity::Warn => '⚠',
Severity::Info => '·',
}
}
}
impl From<ScanSeverity> for Severity {
fn from(s: ScanSeverity) -> Self {
match s {
ScanSeverity::Critical => Severity::Error,
ScanSeverity::Warning => Severity::Warn,
ScanSeverity::Info => Severity::Info,
}
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct Location {
#[serde(skip_serializing_if = "Option::is_none")]
pub chapter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub paragraph: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub char_range: Option<(usize, usize)>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
impl Location {
pub fn chapter(slug_or_title: impl Into<String>) -> Self {
Self {
chapter: Some(slug_or_title.into()),
..Default::default()
}
}
pub fn path(p: impl Into<String>) -> Self {
Self {
path: Some(p.into()),
..Default::default()
}
}
pub fn label(&self) -> String {
if let Some(c) = &self.chapter {
return c.clone();
}
if let Some(p) = &self.path {
return std::path::Path::new(p)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| p.clone());
}
"—".to_string()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct EditorialFinding {
pub category: String,
pub severity: Severity,
pub location: Location,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
pub source: &'static str,
pub autofixable: bool,
}
impl EditorialFinding {
pub fn fingerprint(&self) -> String {
format!("{}\u{1}{}", self.category, self.message)
}
pub fn rewritable(&self) -> bool {
self.location.paragraph.is_some() && fix_spec(&self.category).is_some()
}
pub fn response(&self) -> ResponseKind {
response_kind(&self.category)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResponseKind {
Rewrite,
Decision,
Brief,
}
impl ResponseKind {
pub fn label(self) -> &'static str {
match self {
ResponseKind::Rewrite => "rewrite",
ResponseKind::Decision => "decision",
ResponseKind::Brief => "brief",
}
}
pub fn glyph(self) -> char {
match self {
ResponseKind::Rewrite => '✎',
ResponseKind::Decision => '⇄',
ResponseKind::Brief => '✉',
}
}
}
pub fn response_kind(category: &str) -> ResponseKind {
match category {
"echo" | "pacing" | "show-tell" | "filter" | "editor" | "voice"
| "anachronism" | "decision-resolve" => ResponseKind::Rewrite,
"co_location" | "char_facts" | "drift" | "introduce" | "confusion"
| "unpaid_setup" | "numeric" | "continuity" | "fact" | "world" => {
ResponseKind::Decision
}
_ => ResponseKind::Brief,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FixScope {
Paragraph,
Span,
}
#[derive(Debug, Clone, Copy)]
pub struct FixSpec {
pub slug: &'static str,
pub builtin: &'static str,
pub label: &'static str,
pub scope: FixScope,
}
pub fn fix_spec(category: &str) -> Option<FixSpec> {
Some(match category {
"echo" => FixSpec {
slug: "editorial-fix-echo",
builtin: "Rewrite the paragraph below to remove the distracting word repetition — vary \
the over-used word with synonyms or restructuring — while preserving the meaning, the author's \
voice, the paragraph's language, and any Typst markup verbatim. Output ONLY the rewritten \
paragraph, no preamble.",
label: "de-echo",
scope: FixScope::Paragraph,
},
"pacing" => FixSpec {
slug: "editorial-fix-pacing",
builtin: "Tighten the overlong paragraph below: cut padding, break or trim run-on \
sentences, sharpen the prose — while preserving the meaning, the author's voice, the paragraph's \
language, and any Typst markup verbatim. Output ONLY the rewritten paragraph, no preamble.",
label: "tighten",
scope: FixScope::Paragraph,
},
"show-tell" => FixSpec {
slug: "editorial-fix-show-tell",
builtin: "You rewrite telling prose to SHOW it — replacing a named emotion or abstract \
summary with concrete action, sensation, and detail — while preserving the meaning, the author's \
voice, the language, and any Typst markup verbatim.",
label: "show-not-tell",
scope: FixScope::Span,
},
"filter" => FixSpec {
slug: "editorial-fix-filter",
builtin: "You remove filter words — intensifier crutches and hedges that weaken prose \
(\"just\", \"really\", \"very\", \"seemed\", \"felt\"). If cutting the marked word leaves the \
sentence intact, return the phrase without it; otherwise replace it with sharper wording — while \
preserving the meaning, the author's voice, the language, and any Typst markup verbatim.",
label: "de-filter",
scope: FixScope::Span,
},
"anachronism" => FixSpec {
slug: "editorial-fix-anachronism",
builtin: "You replace an anachronistic word or phrase — one that postdates the story's \
setting — with an era- and world-appropriate equivalent, while preserving the meaning, the author's \
voice, the language, and any Typst markup verbatim. If no single word fits, lightly rephrase. Output \
ONLY the replacement text for the marked phrase — no « » markers, none of the surrounding \
paragraph, no preamble.",
label: "period-fit",
scope: FixScope::Span,
},
"decision-resolve" => FixSpec {
slug: "editorial-fix-decision",
builtin: "You reconcile a consistency issue in the paragraph below according to the \
author's decision, which follows this instruction. Change ONLY what is needed to make the paragraph \
consistent with that decision — leave everything else untouched. Preserve the meaning elsewhere, \
the author's voice, the language, and any Typst markup verbatim. Output ONLY the rewritten \
paragraph, no preamble.",
label: "reconcile",
scope: FixScope::Paragraph,
},
"editor" => FixSpec {
slug: "editorial-fix-editor",
builtin: "You revise the paragraph below to address the editor's craft note, which \
follows this instruction. Make the smallest change that honestly resolves the note — preserve the \
meaning, the author's voice, the paragraph's language, and any Typst markup verbatim. Output ONLY \
the rewritten paragraph, no preamble.",
label: "editor-note",
scope: FixScope::Paragraph,
},
_ => return None,
})
}
pub type BatchFix = (Uuid, String, Option<(usize, usize)>);
pub fn batch_fix_queue(findings: &[EditorialFinding], filter: Option<&str>) -> Vec<BatchFix> {
findings
.iter()
.filter(|f| filter.is_none_or(|c| f.category == c))
.filter(|f| f.rewritable())
.filter(|f| f.category != "editor")
.map(|f| {
(
f.location.paragraph.expect("rewritable ⇒ has a paragraph"),
f.category.clone(),
f.location.char_range,
)
})
.collect()
}
pub fn splice_span(original: &str, range: (usize, usize), replacement: &str) -> String {
let chars: Vec<char> = original.chars().collect();
let n = chars.len();
let start = range.0.min(n);
let end = range.1.clamp(start, n);
let mut out: String = chars[..start].iter().collect();
out.push_str(replacement);
out.extend(chars[end..].iter());
out
}
pub fn extract_phrase(raw: &str) -> String {
raw.trim()
.trim_matches(|c: char| {
c.is_whitespace()
|| matches!(c, '"' | '\'' | '`' | '«' | '»' | '\u{201c}' | '\u{201d}' | '\u{2018}' | '\u{2019}')
})
.to_string()
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct EditorialReport {
pub findings: Vec<EditorialFinding>,
pub errors: usize,
pub warnings: usize,
pub infos: usize,
#[serde(skip_serializing_if = "is_zero")]
pub deferred: usize,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stale: bool,
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Dismissed {
#[serde(default)]
pub fingerprints: BTreeSet<String>,
}
impl Dismissed {
pub fn sidecar_path(root: &Path) -> PathBuf {
root.join(".inkhaven").join("editorial-dismissed.json")
}
pub fn load(root: &Path) -> Self {
std::fs::read_to_string(Self::sidecar_path(root))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self, root: &Path) -> std::io::Result<()> {
let path = Self::sidecar_path(root);
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
let body = serde_json::to_vec_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
crate::io_atomic::write(&path, &body)
}
pub fn defer(root: &Path, fingerprint: &str) -> std::io::Result<()> {
let mut d = Self::load(root);
d.fingerprints.insert(fingerprint.to_string());
d.save(root)
}
pub fn clear(root: &Path) -> std::io::Result<()> {
Self::default().save(root)
}
}
pub fn from_scan_finding(f: &ScanFinding) -> Option<EditorialFinding> {
let category = f.class.editorial_category()?;
let location = match &f.path {
Some(p) => Location::path(p.clone()),
None => chapter_from_detail(&f.detail).map(Location::chapter).unwrap_or_default(),
};
Some(EditorialFinding {
category: category.to_string(),
severity: f.severity.into(),
location,
message: f.detail.clone(),
hint: None,
source: "doctor",
autofixable: false,
})
}
fn chapter_from_detail(detail: &str) -> Option<String> {
let i = detail.find("chapter `")? + "chapter `".len();
let rest = &detail[i..];
let j = rest.find('`')?;
Some(rest[..j].to_string())
}
pub fn from_fact_finding(f: &crate::facts_scan::FactFinding) -> EditorialFinding {
EditorialFinding {
category: "fact".into(),
severity: Severity::Warn,
location: Location::chapter(f.chapter.clone()),
message: format!("“{}” contradicts: {}", f.claim, f.fact),
hint: (!f.detail.trim().is_empty()).then(|| f.detail.clone()),
source: "facts",
autofixable: false,
}
}
pub fn from_fact_conflict(c: &crate::facts_scan::FactConflict) -> EditorialFinding {
EditorialFinding {
category: "world".into(),
severity: Severity::Warn,
location: Location::default(), message: format!("facts conflict: {} ⟷ {}", c.a, c.b),
hint: (!c.detail.trim().is_empty()).then(|| c.detail.clone()),
source: "facts",
autofixable: false,
}
}
pub fn from_drift_conflict(c: &crate::drift::DriftConflict) -> EditorialFinding {
EditorialFinding {
category: "drift".into(),
severity: Severity::Warn,
location: Location {
chapter: Some(c.chapter_b.clone()),
paragraph: c.paragraph_b,
char_range: None,
path: None,
},
message: format!(
"drift: {} — “{}” ({}) ⟷ “{}” ({})",
c.entity, c.a, c.chapter_a, c.b, c.chapter_b
),
hint: (!c.detail.trim().is_empty()).then(|| c.detail.clone()),
source: "drift",
autofixable: false,
}
}
pub fn from_plan_warning(w: &str) -> EditorialFinding {
let prefix = w.split_once(':').map(|(c, _)| c.trim()).unwrap_or("structure");
let category = match prefix {
"tension" => "tension",
"scene" | "sequel" | "rhythm" => "scene",
"thread" | "threads" => "thread",
_ => "structure",
};
EditorialFinding {
category: category.to_string(),
severity: Severity::Warn,
location: Location::default(),
message: w.to_string(),
hint: None,
source: "plan",
autofixable: false,
}
}
pub(crate) fn from_continuity_finding(
f: &crate::continuity_intel::ContinuityFinding,
paragraph: Option<Uuid>,
) -> EditorialFinding {
use crate::continuity_intel::Severity as CS;
EditorialFinding {
category: f.kind.to_string(),
severity: match f.severity {
CS::Contradiction => Severity::Error,
CS::Warning => Severity::Warn,
CS::Info => Severity::Info,
},
location: Location { chapter: chapter_label(f.chapter), paragraph, ..Default::default() },
message: f.message.clone(),
hint: None,
source: "continuity",
autofixable: false,
}
}
pub(crate) fn from_lector_finding(
f: &crate::lector::ReaderFinding,
paragraph: Option<Uuid>,
) -> EditorialFinding {
use crate::lector::Severity as LS;
EditorialFinding {
category: f.kind.to_string(),
severity: match f.severity {
LS::Concern => Severity::Warn,
_ => Severity::Info,
},
location: Location { chapter: chapter_label(f.chapter), paragraph, ..Default::default() },
message: f.message.clone(),
hint: None,
source: "read-through",
autofixable: false,
}
}
pub(crate) fn from_stylist_finding(f: &crate::inner_stylist::Finding) -> EditorialFinding {
use crate::inner_stylist::Severity as SS;
EditorialFinding {
category: f.kind.to_string(),
severity: match f.severity {
SS::Concern => Severity::Warn,
_ => Severity::Info,
},
location: Location::default(),
message: f.message.clone(),
hint: None,
source: "stylist",
autofixable: false,
}
}
pub(crate) fn from_editor_finding(
f: &crate::inner_editor::StoredEditorFinding,
) -> Option<EditorialFinding> {
use crate::inner_editor::types::EditorSeverity as ES;
let fin = &f.finding;
if fin.suppressed_by.is_some() || matches!(fin.severity, ES::Praise) {
return None;
}
let paragraph = f.paragraph_id?;
Some(EditorialFinding {
category: "editor".into(),
severity: match fin.severity {
ES::Concern => Severity::Warn,
_ => Severity::Info,
},
location: Location { paragraph: Some(paragraph), ..Default::default() },
message: fin.observation.clone(),
hint: fin.evidence.clone(),
source: "editor",
autofixable: false,
})
}
fn chapter_label(chapter: u32) -> Option<String> {
(chapter > 0).then(|| format!("ch. {chapter}"))
}
pub fn aggregate(mut findings: Vec<EditorialFinding>) -> EditorialReport {
findings.sort_by(|a, b| {
a.severity
.rank()
.cmp(&b.severity.rank())
.then_with(|| a.category.cmp(&b.category))
.then_with(|| a.message.cmp(&b.message))
});
findings.dedup_by(|a, b| {
a.category == b.category
&& a.message == b.message
&& a.location.chapter == b.location.chapter
&& a.location.path == b.location.path
});
let errors = findings.iter().filter(|f| f.severity == Severity::Error).count();
let warnings = findings.iter().filter(|f| f.severity == Severity::Warn).count();
let infos = findings.iter().filter(|f| f.severity == Severity::Info).count();
EditorialReport {
findings,
errors,
warnings,
infos,
deferred: 0,
stale: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::doctor_scan::{ScanClass, ScanFinding, ScanSeverity};
fn scan(class: ScanClass, sev: ScanSeverity, detail: &str) -> ScanFinding {
ScanFinding {
class,
severity: sev,
path: Some(format!("/p/{}.typ", detail.len())),
detail: detail.into(),
}
}
#[test]
fn integrity_classes_are_not_editorial() {
assert!(from_scan_finding(&scan(ScanClass::ZeroByteFile, ScanSeverity::Critical, "x")).is_none());
assert!(from_scan_finding(&scan(ScanClass::BdslibOnly, ScanSeverity::Warning, "x")).is_none());
let e = from_scan_finding(&scan(ScanClass::EchoRepetition, ScanSeverity::Info, "echo")).unwrap();
assert_eq!(e.category, "echo");
assert_eq!(e.source, "doctor");
}
#[test]
fn doctor_finding_picks_up_the_embedded_chapter() {
let f = scan(
ScanClass::EchoRepetition,
ScanSeverity::Info,
"echo: `about` appears 5× within ¶1–3 (chapter `Chapter 6: The Letter`)",
);
let mut f = f;
f.path = None;
let e = from_scan_finding(&f).unwrap();
assert_eq!(e.location.chapter.as_deref(), Some("Chapter 6: The Letter"));
}
#[test]
fn drift_conflict_maps_to_jump_only_drift_finding() {
let pid = uuid::Uuid::now_v7();
let c = crate::drift::DriftConflict {
entity: "The Drunken Goose".into(),
kind: crate::drift::EntityKind::Place,
a: "cramped and smoky".into(),
b: "airy and bright".into(),
chapter_a: "ch-2".into(),
chapter_b: "ch-20".into(),
paragraph_b: Some(pid),
detail: "a tavern can't be both".into(),
};
let f = from_drift_conflict(&c);
assert_eq!(f.category, "drift");
assert_eq!(f.severity, Severity::Warn);
assert_eq!(f.location.paragraph, Some(pid), "jumps to the later passage");
assert_eq!(f.location.chapter.as_deref(), Some("ch-20"));
assert!(f.message.contains("The Drunken Goose") && f.message.contains("airy"));
assert_eq!(f.hint.as_deref(), Some("a tavern can't be both"));
assert!(!f.rewritable(), "drift is jump-only");
}
#[test]
fn plan_warning_category_from_prefix() {
assert_eq!(from_plan_warning("drift: `Midpoint` lands at 64%").category, "structure");
assert_eq!(from_plan_warning("tension: `Midpoint` is flat").category, "tension");
assert_eq!(from_plan_warning("sequel: `X` never decides").category, "scene");
assert_eq!(from_plan_warning("thread: `Y` references unknown").category, "thread");
}
#[test]
fn defer_sidecar_round_trips_and_fingerprint_is_stable() {
let f = EditorialFinding {
category: "echo".into(),
severity: Severity::Info,
location: Location::default(),
message: "echo: `about` ×5".into(),
hint: None,
source: "doctor",
autofixable: false,
};
let fp = f.fingerprint();
assert_eq!(fp, f.clone().fingerprint(), "fingerprint is stable");
let dir = tempfile::tempdir().unwrap();
Dismissed::defer(dir.path(), &fp).unwrap();
let d = Dismissed::load(dir.path());
assert!(d.fingerprints.contains(&fp));
Dismissed::clear(dir.path()).unwrap();
assert!(Dismissed::load(dir.path()).fingerprints.is_empty());
}
#[test]
fn rewritable_needs_a_paragraph_and_a_fixable_category() {
let mk = |cat: &str, para: bool| EditorialFinding {
category: cat.into(),
severity: Severity::Info,
location: Location {
paragraph: para.then(uuid::Uuid::now_v7),
..Default::default()
},
message: "m".into(),
hint: None,
source: "doctor",
autofixable: false,
};
assert!(mk("echo", true).rewritable(), "echo + a paragraph → rewritable");
assert!(!mk("echo", false).rewritable(), "no paragraph → not rewritable");
assert!(!mk("structure", true).rewritable(), "judgment category → not rewritable");
assert!(fix_spec("echo").is_some() && fix_spec("structure").is_none());
}
#[test]
fn response_kind_classifies_by_category() {
use ResponseKind::*;
for c in ["echo", "pacing", "show-tell", "filter", "editor", "voice", "anachronism"] {
assert_eq!(response_kind(c), Rewrite, "{c} is a Rewrite");
}
for c in ["co_location", "char_facts", "drift", "introduce", "confusion", "unpaid_setup"] {
assert_eq!(response_kind(c), Decision, "{c} is a Decision");
}
for c in ["structure", "shape_sag", "put_down_risk", "distinctiveness", "tension", "mystery-kind"] {
assert_eq!(response_kind(c), Brief, "{c} is a Brief");
}
}
#[test]
fn finding_reports_its_response_kind() {
let f = EditorialFinding {
category: "co_location".into(),
severity: Severity::Info,
location: Location::default(),
message: "m".into(),
hint: None,
source: "doctor",
autofixable: false,
};
assert_eq!(f.response(), ResponseKind::Decision);
assert_eq!(ResponseKind::Decision.label(), "decision");
}
#[test]
fn judgment_readers_convert_into_the_worklist() {
let para = uuid::Uuid::now_v7();
let cf = crate::continuity_intel::ContinuityFinding {
kind: "co_location",
severity: crate::continuity_intel::Severity::Contradiction,
chapter: 3,
anchor: Some(para),
entities: vec![],
message: "Mara is in two places".into(),
source: "co_location",
dedup_key: "k".into(),
};
let f = from_continuity_finding(&cf, cf.anchor);
assert_eq!(f.category, "co_location");
assert_eq!(f.severity, Severity::Error);
assert_eq!(f.location.paragraph, Some(para));
assert_eq!(f.location.chapter.as_deref(), Some("ch. 3"));
assert_eq!(f.response(), ResponseKind::Decision);
assert_eq!(f.source, "continuity");
let lf = crate::lector::ReaderFinding {
kind: "put_down_risk",
severity: crate::lector::Severity::Concern,
chapter: 5,
anchor: None,
entities: vec![],
message: "flat run".into(),
source: "walk",
dedup_key: "k2".into(),
};
let f = from_lector_finding(&lf, None);
assert_eq!(f.category, "put_down_risk");
assert_eq!(f.severity, Severity::Warn);
assert_eq!(f.response(), ResponseKind::Brief);
assert_eq!(f.source, "read-through");
}
#[test]
fn response_kind_glyphs_are_distinct_and_stable() {
assert_eq!(ResponseKind::Rewrite.glyph(), '✎');
assert_eq!(ResponseKind::Decision.glyph(), '⇄');
assert_eq!(ResponseKind::Brief.glyph(), '✉');
}
#[test]
fn inner_editor_findings_become_finding_aware_editor_rewrites() {
use crate::inner_editor::types::{
EditorCategory, EditorFinding, EditorSeverity,
};
use crate::inner_editor::StoredEditorFinding;
let para = uuid::Uuid::now_v7();
let mk = |sev: EditorSeverity, paragraph: Option<uuid::Uuid>, suppressed: Option<String>| {
StoredEditorFinding {
id: uuid::Uuid::now_v7(),
paragraph_id: paragraph,
finding: EditorFinding {
category: EditorCategory::StyleObservation,
severity: sev,
observation: "the verb tense wobbles mid-paragraph".into(),
observation_en: "the verb tense wobbles mid-paragraph".into(),
evidence: Some("«walked» then «walks»".into()),
conditional: false,
suppressed_by: suppressed,
},
}
};
let f = from_editor_finding(&mk(EditorSeverity::Concern, Some(para), None))
.expect("Concern + anchor → a finding");
assert_eq!(f.category, "editor");
assert_eq!(f.severity, Severity::Warn);
assert_eq!(f.location.paragraph, Some(para));
assert_eq!(f.message, "the verb tense wobbles mid-paragraph");
assert_eq!(f.hint.as_deref(), Some("«walked» then «walks»"));
assert_eq!(f.response(), ResponseKind::Rewrite);
assert!(f.rewritable(), "editor + a paragraph ⇒ AI-rewritable");
assert_eq!(f.source, "editor");
assert_eq!(
from_editor_finding(&mk(EditorSeverity::Note, Some(para), None)).unwrap().severity,
Severity::Info
);
assert!(from_editor_finding(&mk(EditorSeverity::Praise, Some(para), None)).is_none());
assert!(
from_editor_finding(&mk(EditorSeverity::Concern, Some(para), Some("declared".into())))
.is_none()
);
assert!(from_editor_finding(&mk(EditorSeverity::Concern, None, None)).is_none());
}
#[test]
fn editor_rewrites_are_excluded_from_the_batch_sweep() {
let mk = |cat: &str| EditorialFinding {
category: cat.into(),
severity: Severity::Warn,
location: Location { paragraph: Some(uuid::Uuid::now_v7()), ..Default::default() },
message: "m".into(),
hint: None,
source: "x",
autofixable: false,
};
let findings = vec![mk("echo"), mk("editor"), mk("filter")];
let q = batch_fix_queue(&findings, None);
let cats: Vec<&str> = q.iter().map(|(_, c, _)| c.as_str()).collect();
assert_eq!(cats, vec!["echo", "filter"]);
}
#[test]
fn batch_is_rewrite_only_the_reversibility_invariant() {
for cat in ["echo", "pacing", "show-tell", "filter", "anachronism", "editor", "decision-resolve"] {
assert!(fix_spec(cat).is_some(), "{cat} should have a fix recipe");
assert_eq!(response_kind(cat), ResponseKind::Rewrite, "{cat} must be a Rewrite");
}
let mk = |cat: &str| EditorialFinding {
category: cat.into(),
severity: Severity::Warn,
location: Location { paragraph: Some(uuid::Uuid::now_v7()), ..Default::default() },
message: "m".into(),
hint: None,
source: "x",
autofixable: false,
};
let report = vec![
mk("echo"), mk("co_location"), mk("shape_sag"), mk("editor"), mk("anachronism"), ];
let q = batch_fix_queue(&report, None);
assert_eq!(
q.iter().map(|(_, c, _)| c.as_str()).collect::<Vec<_>>(),
vec!["echo", "anachronism"]
);
assert!(
q.iter().all(|(_, c, _)| response_kind(c) == ResponseKind::Rewrite),
"everything the batch yields is Rewrite-classified"
);
}
#[test]
fn fix_scope_paragraph_vs_span() {
assert_eq!(fix_spec("echo").unwrap().scope, FixScope::Paragraph);
assert_eq!(fix_spec("pacing").unwrap().scope, FixScope::Paragraph);
assert_eq!(fix_spec("show-tell").unwrap().scope, FixScope::Span);
assert_eq!(fix_spec("filter").unwrap().scope, FixScope::Span);
assert_eq!(fix_spec("anachronism").unwrap().scope, FixScope::Span);
assert_eq!(response_kind("anachronism"), ResponseKind::Rewrite);
let anach = EditorialFinding {
category: "anachronism".into(),
severity: Severity::Warn,
location: Location { paragraph: Some(uuid::Uuid::now_v7()), ..Default::default() },
message: "m".into(),
hint: None,
source: "world",
autofixable: false,
};
assert!(anach.rewritable(), "anachronism + a paragraph → rewritable");
assert_eq!(fix_spec("decision-resolve").unwrap().scope, FixScope::Paragraph);
}
#[test]
fn batch_fix_queue_keeps_only_filtered_rewritable_in_order() {
let mk = |cat: &str, para: bool, span: Option<(usize, usize)>| EditorialFinding {
category: cat.into(),
severity: Severity::Info,
location: Location {
paragraph: para.then(uuid::Uuid::now_v7),
char_range: span,
..Default::default()
},
message: "m".into(),
hint: None,
source: "style",
autofixable: false,
};
let findings = vec![
mk("show-tell", true, Some((0, 3))), mk("structure", true, None), mk("echo", false, None), mk("filter", true, Some((4, 8))), ];
let all = batch_fix_queue(&findings, None);
assert_eq!(all.len(), 2);
assert_eq!(all[0].1, "show-tell");
assert_eq!(all[0].2, Some((0, 3)));
assert_eq!(all[1].1, "filter");
let only = batch_fix_queue(&findings, Some("filter"));
assert_eq!(only.len(), 1);
assert_eq!(only[0].1, "filter");
assert_eq!(only[0].2, Some((4, 8)));
}
#[test]
fn splice_span_replaces_only_the_range() {
assert_eq!(
splice_span("She was angry.", (4, 13), "clenched her fists"),
"She clenched her fists."
);
assert_eq!(splice_span("foo bar\n", (0, 3), "baz"), "baz bar\n");
assert_eq!(splice_span("hi", (5, 9), "X"), "hiX");
assert_eq!(splice_span("hello", (3, 1), "_"), "hel_lo");
}
#[test]
fn extract_phrase_strips_wrapping_quotes_and_space() {
assert_eq!(extract_phrase(" \"clenched her fists\" \n"), "clenched her fists");
assert_eq!(extract_phrase("«spyglass»"), "spyglass");
assert_eq!(extract_phrase("plain words"), "plain words");
}
#[test]
fn aggregate_ranks_errors_first_and_dedups() {
let mk = |sev, cat: &str, msg: &str| EditorialFinding {
category: cat.into(),
severity: sev,
location: Location::default(),
message: msg.into(),
hint: None,
source: "doctor",
autofixable: false,
};
let r = aggregate(vec![
mk(Severity::Info, "echo", "z"),
mk(Severity::Error, "continuity", "a"),
mk(Severity::Warn, "pacing", "m"),
mk(Severity::Error, "continuity", "a"), ]);
assert_eq!(r.findings.len(), 3, "the duplicate is dropped");
assert_eq!(r.findings[0].severity, Severity::Error, "errors first");
assert_eq!((r.errors, r.warnings, r.infos), (1, 1, 1));
}
}