# Inkhaven 1.2.22 — Project-wide editing & pre-1.3 tidy-up

_The last 1.2.  Two jobs: ship one solid author feature, and leave the
ground clean (and pre-poured) for the 1.3 "Editorial Pass" cycle.  The
headline is **project-wide find & replace, review-gated** — the #1
unmet structural-edit need, which doubles as the apply-with-review
substrate 1.3's agentic editing will stand on.  Around it: pay down the
consolidation tail and clear the last stability carryover._

## Why this is the right final 1.2

Across 1.2.x we built a deep single-author tool, but two gaps remain
that 1.3 wants closed first:

1. **No safe way to edit across the book.**  Find/replace is
   per-paragraph (`src/tui/search_replace.rs`); the concordance is
   read-only.  Renaming a character across 40 chapters is impossible
   without it — and the *review-gated apply-with-snapshots* machinery
   it needs is exactly what 1.3's agentic editor (AI proposes edits,
   author approves) will reuse.  Build it now → authors get the feature
   and 1.3 inherits the substrate.
2. **Loose ends.**  A few consolidation carryovers and one stability
   item are cheaper to clear now than to carry across a major boundary.

## Decisions (locked)

1. **Matching is lexical, not semantic, and there is no FTS index.**
   Literal substring by default, **word-boundary** toggle (on by
   default in the TUI — kills the `Will`/`will` footgun), **opt-in
   regex**, optional case-insensitive.  A manuscript is one book (a few
   MB); find-replace needs *exhaustive exact* matches with byte
   positions, which an inverted index can neither rank-replace nor do
   for substring/regex/word-boundary — so it's a **linear scan** over
   paragraph bodies.  **No Tantivy** (removed in 1.2.16, HNSW-only
   since; re-adding means a second index to sync, for zero benefit).
   The vector index is never touched by replace.  **Zero new deps** —
   the `regex` crate is already in-tree (it backs the per-paragraph
   `search_replace.rs`).
2. **Nothing applies unreviewed, everything is reversible.**  TUI: a
   review modal with per-match accept/skip.  CLI: `--dry-run` preview
   then explicit `--yes`.  Every touched paragraph is **snapshotted
   before the write** (the existing snapshot path), writes are atomic
   (`io_atomic`).  Undo = the snapshots.
3. **Scope is user books by default.**  System books (Notes / Research
   / Facts / Characters / …) are excluded unless `--include-system`
   (or a modal toggle) — a manuscript rename shouldn't silently rewrite
   your Facts/Notes.  Optional `--book <name>` narrows further.
4. **Paragraphs only.**  HJSON-data and Script leaves are out of scope
   for replace this round.

## Phase R — Project-wide find & replace (headline)

### R.1 — The matcher + match model (pure)

New `src/replace.rs` (pure, unit-tested): the match/apply primitives,
no I/O.

* `ReplaceOpts { regex: bool, word_boundary: bool, ignore_case: bool }`.
* `find_matches(text: &str, pattern: &str, repl: &str, opts) -> Vec<Hit>`
  where `Hit { line, col, byte_range, matched: String, preview: String }`
  (`preview` = KWIC context — reuse the concordance's snippet shaping).
  Literal → escaped regex or `match_indices`; word-boundary → `\b`
  wrap; regex → compiled `regex::Regex`.  Invalid regex → a clean
  `Err`, surfaced to the user (mirrors `search_replace.rs` today).
* `apply(text, hits_accepted, repl) -> String` — apply accepted hits
  **right-to-left by byte offset** so earlier replacements don't
  invalidate later positions.  Regex captures (`$1`) honoured in repl.

Tests: literal vs word-boundary (`Mark`/`market`), regex + captures,
case-insensitive, multi-match-per-line right-to-left apply, no-op when
nothing accepted, invalid-regex error.

### R.2 — Project scan

A walk that runs `find_matches` over every in-scope paragraph and
returns `Vec<ParaMatches { para_id, title, slug_path, hits }>`.

* User-book paragraphs only by default (`hierarchy` children with
  `system_tag.is_none()` → `collect_subtree` → `NodeKind::Paragraph`),
  bodies via `store.get_content` (the path `facts_scan` / export already
  use).  `--book` / `--include-system` adjust the set.
* Linear, no index.  Over-read is fine — we already do full walks for
  export / audiobook / `facts scan`.

### R.3 — Review modal (TUI)

`Modal::ReplaceReview { pattern, repl, opts, matches, marked, cursor,
scroll }` — clone the two-phase shape of the `Modal::FactsSearch` modal
(query/edit ↔ results, `Space` multi-select) shipped in 1.2.21.

* Entry: extend the existing find/replace flow (`search_replace.rs`)
  with a **scope toggle** (paragraph ↔ book); on "book", after the
  pattern + replacement are entered, run R.2 and open the review.
  Reuses the existing input UI — minimal new chrome.
* Render: each match in KWIC context with the matched span highlighted
  + a `[x]`/`[ ]` mark box; header shows `pattern → repl` + the active
  opts; footer the keys.  Group rows by paragraph (breadcrumb headers).
* Keys: `↑↓` navigate, `Space` toggle, `a` mark-all / `A` none,
  `w` word-boundary, `i` ignore-case (re-runs the scan), `Enter` apply
  marked, `Esc` cancel.
* No matches → status "no matches"; nothing marked + Enter → no-op.

### R.4 — Snapshot + atomic apply

For each paragraph with ≥1 accepted hit: build the new body with
`replace::apply`, **snapshot the paragraph first** (the
`snapshot_impl` / store snapshot path, annotated e.g. `before replace:
"Anne"→"Anna"`), then `io_atomic::write` the `.typ` +
`store.update_paragraph_content`.  Reload the open buffer if it was
touched.  Summary: `replaced N occurrence(s) in M paragraph(s) —
snapshots taken (F6 to restore)`.

### R.5 — CLI `inkhaven replace`

`inkhaven replace <pattern> <replacement> [--regex] [--word]
[--ignore-case] [--book <name>] [--include-system] [--dry-run]
[--yes]`.

* `--dry-run` prints every match (KWIC + `slug:line`) and the count,
  changes nothing — CI / script-safe, the way to preview before
  trusting a bulk rename.
* Without `--yes`, refuse to write (print the count + "re-run with
  `--yes` to apply").  With `--yes`, apply all matches with the same
  snapshot-before-write as the TUI.
* Per-match accept/skip is **TUI-only**; the CLI is preview +
  apply-all-with-snapshots (the scripted/CI path).  Reuses R.1–R.2 +
  the R.4 apply.

## Phase D — Consolidation tail (pre-1.3 hygiene)

Net-negative LOC, low risk, so 1.3's aggregator builds on tidy ground.

* **D.7** — unify reading-time (`text_utils::format_reading_time`,
  fixed 250 wpm, vs the configurable `tui/reading_time`) onto the
  configurable one; share the sentence-split boundary logic between
  `continuity.rs` and `sentence_rhythm.rs` (keep their distinct return
  shapes as thin wrappers).
* **D.3.b** — the store-side `append_branch_prose` shape shared by
  `cli::epub` + `cli::audiobook`; unify only the genuinely-identical
  part (they diverge on xhtml+`<h2>` vs plain).
* **D.8 (optional)** — extract the repeated "walk user-book chapters /
  paragraphs" into one shared helper (continuity / tension / facts_scan
  each re-implement `user_book_chapters`).  Direct 1.3 prep — the
  editorial pass iterates exactly this.

## Phase M — Stability carryover

* **M.1** — cap the crash-report action-ring `detail` length
  (`src/crash/`).  The last open item from the 1.2.15 stability bar; a
  cheap defensive bound to leave 1.2 clean.  (M3 script-modal-spoofing
  stays deferred — theoretical for a single-user local tool.)

## Out of scope — owned by 1.3 / 2.0

* **Unified `Finding` type + the Editorial Pass aggregator** — the
  heart of 1.3; doing it here would blur the release boundary.
* **Agentic editing** (AI proposes edits applied through the review
  path) — 2.0; R.3/R.4 is the substrate it will reuse.
* **Semantic-scoped replace** ("replace only in chapters about X") —
  stretch; the scan + a vector pre-filter, deferred.
* **Tantivy / a second full-text index** — explicitly not (see
  Decision 1).
* Replace in HJSON-data / Script leaves; multi-file regex across
  non-prose.

## Recommended scope for 1.2.22

**Phase R (R.1–R.5) + D.7 + D.3.b + M.1.**  D.8 if time allows.  A
coherent "last 1.2": one headline author feature that bridges to 1.3,
plus a clean-desk pass.

## Implementation log

_(entries land here as the work lands)_

### R.1 — pure matcher (landed)

New `src/replace.rs` — the lexical match/apply primitives, no I/O:

* `ReplaceOpts { regex, word_boundary, ignore_case }` — `Default` is
  literal + whole-word (the safe default; no `Will`/`will` surprise).
* `find_matches(text, pattern, repl, opts) -> Result<Vec<Hit>, _>` —
  builds one `regex::Regex` (literal → `regex::escape`; word-boundary →
  `\b(?:…)\b`; case via the builder), walks `captures_iter`, skips
  zero-width matches, and precomputes each hit's replacement (regex
  captures expand via `Captures::expand`; literal mode is verbatim, so
  `$1` stays literal).  `Hit { start, end, line, col, line_text,
  matched, replacement }` carries byte range + 1-based line/char-col +
  the context line.  Empty pattern → `EmptyPattern`; bad regex →
  `BadRegex`.
* `apply(text, accepted)` — splices the accepted subset right-to-left by
  byte offset so earlier edits don't shift later ones; char-boundary
  guarded.

14 unit tests (word-boundary vs substring, the `Will`/`will` footgun,
regex `$1`/`$2` capture expansion, literal-`$`-not-expanded, right-to-
left apply, subset apply, unicode char-columns + byte-correct splice,
zero-width skip, empty/invalid-pattern errors).  `#![allow(dead_code)]`
until R.2 wires the first caller.  Suite 1169 → 1183.  Zero new deps
(`regex` already in-tree).

### R.2 — project scan (landed)

Clean split: a pure, unit-tested `scan_bodies` + a thin I/O `scan_project`.

* Refactored `find_matches` onto a `find_with(&Regex, …)` core so the
  scan compiles the regex **once** and reuses it across every paragraph
  (not once-per-paragraph).
* `ParaMatches { para_id, title, slug_path, hits }`; `ScanScope`
  (`UserBooks` default / `IncludingSystem` / `Book(id)`).
* `scan_bodies(iter<(id,title,slug,body)>, …) -> Result<Vec<ParaMatches>>`
  — pure: runs the matcher over supplied bodies, keeps only paragraphs
  with ≥1 hit, in order.  `scan_project(store, hierarchy, scope, …)`
  gathers the bodies (top-level books filtered by `system_tag` unless
  `IncludingSystem`/`Book`; `collect_subtree` → paragraphs; bodies via
  `store.get_content`; `slug_path` for display) and delegates.  Pattern
  validated up front so a bad regex / empty pattern errors even on an
  empty project.
* 3 scan tests (only-with-hits + order, word-boundary per paragraph,
  error propagation).  Suite 1183 → 1186.  `#![allow(dead_code)]` stays
  until R.3/R.5 wire the first caller.

### R.4 + R.5 — apply substrate + CLI `inkhaven replace` (landed)

`apply_project(store, hierarchy, matches, annotation)` (in
`src/replace.rs`, reusable by R.3): for each matched paragraph, snapshot
the current body (`create_snapshot_annotated`, for undo), build the new
body with `replace::apply`, write the `.typ` + `update_paragraph_content`;
returns `ApplyReport { paragraphs, occurrences, snapshots }`.
`ParaMatches` now carries `body` so apply splices into the exact text
the hits were found in.

`inkhaven replace <pattern> <replacement> [--regex] [--substring]
[--ignore-case] [--book <name>] [--include-system] [--dry-run] [--yes]`
(`src/cli/replace.rs`): scans via `scan_project`, prints a `»…«`-marked
KWIC preview, and gates writes — `--dry-run` changes nothing, no `--yes`
refuses, `--yes` applies via `apply_project`.  Default is whole-word
(`--substring` opts out) — safer for the no-review `--yes` path; chose
this over the plan's opt-in `--word`.  The module-wide
`allow(dead_code)` is gone; only `find_matches` (the single-text
primitive) keeps a targeted allow.

Builds clean, suite 1186, command registered.  NOTE: the live
store/apply path (dry-run → --yes → on-disk verify) is unit-tested +
compiles but the end-to-end fixture smoke was not run — worth a live
check before the release cut.

### R.3 — TUI review modal (landed)

Entry reuses the existing find/replace (discoverable, no crowded new
chord): in the Find & Replace modal, **Ctrl+B** toggles scope between
"this paragraph" and "whole book" (shown as a scope chip; replace mode
only).  On a book-scope commit, `commit_find` branches to
`open_replace_review` instead of the in-buffer search.

`Modal::ReplaceReview { pattern, replacement, matches, flat, cursor,
skipped }` — `open_replace_review` runs `replace::scan_project`
(user-books, literal whole-word — the safe default; regex/substring stay
on `inkhaven replace` for v1), flattens the matches for navigation, and
opens the review.  Keys: `↑↓` move, **Space** skip the cursor's match,
`a` keep all / `n` skip none, **Enter** apply, **Esc** cancel.  Apply
filters out the skipped hits and calls `replace::apply_project`
(snapshot-before-write + atomic), reloads the open buffer if it was
touched (`open_paragraph_by_uuid`), and reports the counts.  Render:
matches grouped by paragraph (slug breadcrumb), each a `[x]`/`[ ]`
keep/skip row with the matched span highlighted in its line; cursor row
reversed, skipped rows dimmed.

Deferred to a follow-up: in-modal opts toggles (regex / substring /
ignore-case re-scan) — the CLI covers those; the TUI is the safe
whole-word common case.  Builds clean, suite 1186.  TUI interaction —
not unit-tested; a live smoke is pending.

### D.7 + D.3.b — examined, closed as intentional non-duplicates

Close inspection showed all three are **false duplicates** — shared
shape, divergent substance — so merging would change behaviour or add
abstraction for negative LOC.  Resolved by documenting the divergence at
each site (so the audit isn't re-attempted), not merging:

* **D.7a reading-time** — `tui::text_utils::format_reading_time`
  (ceil-minutes, fixed 250 wpm, `~Xm`/`<1m` estimate format, PDF read-out)
  vs `tui::reading_time` (floor-seconds, configurable `reading_wpm`,
  `XhYYm`/`Xs`, reading-pace chip).  Different rounding / rate / format /
  consumer; merging changes displayed output + breaks the threshold
  tests.
* **D.7b sentence split** — `continuity::split_sentences` (naive `.!?`
  → `Vec<String>`) vs `tui::sentence_rhythm::split_sentences`
  (abbreviation-aware, `Mr.`/`Dr.`-suppressing → position-carrying
  `RawSentence`s).  Different *boundary algorithms*; sharing would change
  continuity's behaviour (a shipped 1.2.19 feature).
* **D.3.b `append_branch_prose`** — only the recursion skeleton matches;
  epub (`is_sub` + `<h2>` + `typst_to_xhtml`) and audiobook (plain,
  empty-skip, newline-join) diverge entirely.  A shared higher-order
  walk is net-positive LOC for two callers.

Divergence notes added to `format_reading_time`,
`continuity::split_sentences`, and both `append_branch_prose` sites.
The one genuine option (D.7b: continuity adopting the abbreviation-aware
boundary as a deliberate *improvement*) was offered and declined — left
as-is.  No behaviour change; doc-only.
