Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 04c8599edc Release 0.18.1: fix Flatpak update permissions 2026-11-11 14:10:15 -05:00
FredApps 7296cf64de Merge main into release for WordCraft v0.18.0 2026-11-11 19:36:24 +01:00
FredApps a8db235e05 Release: WordCraft v0.18.0 (#6294) 2026-11-11 19:35:57 +01:00
batistalucasv 4706420e94 tools: chunk LCS alignment past MAX_LCS window to prevent false revisions (#6110) (#6124) 2026-11-11 19:11:33 +01:00
batistalucasv ae7ae89eb2 fix(engine): surface character-level compare fallback on long paragraphs (#6111) (#6120) 2026-11-11 19:09:43 +01:00
Dylan M. Taylor 1327d20055 Merge pull request #6333 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:53:23 -05:00
batistalucasv 7013411a2f cli: skip update-fields with warning on protected document (#5839) (#5849) 2026-11-11 18:53:10 +01:00
Dylan M. Taylor 411a4915a1 ci: run Harden-Runner on every job (#6327)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:44:00 -05:00
Dylan M. Taylor 5574b32600 Release 0.17.0 2026-11-09 06:34:22 -05:00
11 changed files with 849 additions and 76 deletions
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+4
View File
@@ -42,6 +42,10 @@ jobs:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
Generated
+19 -19
View File
@@ -6807,7 +6807,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"arboard",
"chrono",
@@ -6847,7 +6847,7 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"serde_json",
@@ -6863,11 +6863,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.15.1"
version = "0.18.1"
[[package]]
name = "wordcraft-doc"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"base64 0.23.1",
"log",
@@ -6881,7 +6881,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"cfb",
"chrono",
@@ -6898,7 +6898,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6923,7 +6923,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"cfb",
"chrono",
@@ -6957,7 +6957,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6971,7 +6971,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6998,7 +6998,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"serde",
@@ -7006,7 +7006,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"craft-proof",
"image",
@@ -7026,7 +7026,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"log",
"serde_json",
@@ -7038,7 +7038,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7046,7 +7046,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"chrono",
"craft-pdf",
@@ -7067,7 +7067,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"log",
@@ -7081,7 +7081,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"accesskit_consumer",
"cfb",
@@ -7114,7 +7114,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"craft-web-api",
"craft-web-host",
@@ -7135,7 +7135,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"log",
"proptest",
@@ -7298,7 +7298,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.15.1"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+28 -3
View File
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use serde_json::{Value, json};
use wordcraft_engine::Session;
use wordcraft_engine::{CmdError, Session};
const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
@@ -19,7 +19,8 @@ wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); --update-fields refreshes unlocked fields before saving;
xml); --update-fields refreshes unlocked fields before saving
(if protection blocks updates, they are skipped with a warning);
an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
--markup (Print Markup): comments beside each page and
@@ -451,6 +452,30 @@ fn report_lines(json: &Value) -> Vec<String> {
wordcraft_engine::strict::lines_from_json(json)
}
/// Explicitly update unlocked fields in a session before saving the document.
///
/// If field updates are disabled by document protection (`CmdError::Disabled`), logs a warning to
/// stderr and continues conversion rather than aborting. Other command failures are converted to
/// [`Failure`] via [`engine_error`].
///
/// # Arguments
///
/// * `s` - The active document [`Session`] whose unlocked fields should be refreshed.
///
/// # Errors
///
/// Returns `Err(Failure)` if the command fails with an error other than `CmdError::Disabled`.
fn update_fields(s: &mut Session) -> Result<(), Failure> {
if let Err(e) = s.run("references.updateFields", &json!({})) {
if matches!(e, CmdError::Disabled(_)) {
eprintln!("wordcraft-cli: warning: {e}");
} else {
return Err(engine_error(e));
}
}
Ok(())
}
/// `convert <in> <out>`: `rest` is the command line after `convert`, `open` opens the input.
fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Session, Failure>) -> Result<(), Failure> {
validate_options("convert", rest, CONVERT_OPTIONS)?;
@@ -473,7 +498,7 @@ fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Sess
let password = password_from(rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(engine_error)?;
update_fields(&mut s)?;
}
let r = if to_png {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
@@ -47,3 +47,65 @@ fn convert_refresh_is_opt_in() {
}
std::fs::remove_dir_all(dir).unwrap();
}
/// Converting a protected document with `--update-fields` skips updating fields with a warning,
/// retains the cached field result, and preserves the protection mode on output.
#[test]
fn convert_protected_document_with_update_fields_skips_with_warning() {
let dir = std::env::temp_dir().join(format!("wc-convert-fields-prot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (mode, expected_warning) in [("readOnly", "protected"), ("forms", "protected"), ("comments", "comments")] {
let mut doc = Document::from_text("Contents\nTarget");
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
doc.insert_object(
&Pos::body(0, 0),
InlineObject::Field {
instr: "PAGEREF target".into(),
result: "104".into(),
locked: false,
dirty: true,
code: false,
form: String::new(),
},
&Default::default(),
)
.unwrap();
doc.settings.protection = Some(mode.into());
let input = dir.join(format!("in_{mode}.json"));
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let docx = dir.join(format!("in_{mode}.docx"));
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
let output = dir.join(format!("out_{mode}.json"));
let _ = std::fs::remove_file(&output);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(&docx).arg(&output).arg("--update-fields");
let run = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&run.stderr);
assert!(run.status.success(), "exit status was failure for mode {mode}: {stderr}");
assert!(stderr.contains("warning:"), "stderr missing warning for mode {mode}: {stderr}");
assert!(stderr.contains(expected_warning), "stderr missing '{expected_warning}' for mode {mode}: {stderr}");
assert!(output.exists(), "output file was not created for mode {mode}");
let out: Document = serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap();
let results: Vec<_> = out
.para(StoryRef::Body, &Path::top(0))
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.as_str()),
_ => None,
})
.collect();
// Result should remain "104" (cached) because the field could not be updated under protection
assert_eq!(results, vec!["104"], "field result was unexpectedly modified for mode {mode}");
assert_eq!(out.settings.protection.as_deref(), Some(mode), "protection mode was not preserved for mode {mode}");
}
std::fs::remove_dir_all(dir).unwrap();
}
+140 -53
View File
@@ -5,7 +5,7 @@ use super::{
use wordcraft_doc::para::OBJ;
/// Compare and Combine take the same settings.
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character" (character: paragraphs over 2000 characters fall back to word; see characterFallback/notHonoured), "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
/// What Compare / Combine compares (the More >> settings of Word's dialog).
struct CompareOptions {
@@ -27,6 +27,15 @@ impl CompareOptions {
}
}
/// Context and tracking counters for diffing paragraph contents.
struct DiffCtx<'a> {
ins: u32,
del: u32,
opts: &'a CompareOptions,
budget: &'a mut u64,
char_fallbacks: &'a mut usize,
}
/// Changed paragraphs longer than this (in characters) are compared word by word even at
/// character level, so the comparison table stays small.
const MAX_CHAR_DIFF: usize = 2000;
@@ -35,6 +44,9 @@ const MAX_CHAR_DIFF: usize = 2000;
/// result shows the differences as tracked insertions, deletions and formatting changes, labelled
/// with `author`. Options Word offers that the comparison can't honour yet (moves, comments,
/// headers and footers, footnotes, text boxes, fields) are accepted and listed in the result.
/// When character-level comparison is requested (`"level": "character"`) but any paragraph exceeds
/// [`MAX_CHAR_DIFF`] characters, it falls back to word-level diff for that paragraph, surfaces the
/// fallback count in `"characterFallback"`, and includes `"level"` in `"notHonoured"`.
pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
let revised = if let Some(t) = p::str(v, "text") {
Document::from_text(t)
@@ -61,7 +73,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
formatting: p::bool(v, "formatting").unwrap_or(true),
characters: matches!(level, "character" | "char"),
};
let not_honoured: Vec<&str> =
let mut not_honoured: Vec<&str> =
["moves", "comments", "headersFooters", "footnotes", "textBoxes", "fields"].into_iter().filter(|k| v.get(*k).is_some()).collect();
let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone());
let date = crate::cmd::now_iso();
@@ -83,7 +95,9 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
};
let mut budget = MAX_COMPARE_CELLS;
let mut char_fallbacks = 0usize;
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
let mut ctx = DiffCtx { ins, del, opts: &opts, budget: &mut budget, char_fallbacks: &mut char_fallbacks };
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
@@ -97,7 +111,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
if !same_objects(&s.doc, pa, pb) {
// Same text, but objects (text boxes, pictures…) differ: diffed, so the
// original's objects aren't lost.
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)));
out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx)));
changes += 1;
i += 1;
j += 1;
@@ -129,9 +143,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
for x in 0..dels.max(inss) {
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
match (a.map(|x| &**x), b.map(|x| &**x)) {
(Some(Block::Para(pa)), Some(Block::Para(pb))) => {
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)))
}
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx))),
_ => {
if let Some(x) = a {
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
@@ -162,8 +174,21 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
// Compare creates an untitled result from the current document.
s.clear_versions();
}
if char_fallbacks > 0 {
log::warn!(
"compare: {char_fallbacks} paragraph(s) exceeded character diff limit of {MAX_CHAR_DIFF}; fell back from character to word level diff"
);
if !not_honoured.contains(&"level") {
not_honoured.push("level");
}
}
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
Ok(json!({
"changes": changes,
"author": author,
"notHonoured": not_honoured,
"characterFallback": char_fallbacks,
}))
}
/// A block's text (a table's cells in order), for matching blocks.
@@ -177,13 +202,14 @@ fn block_text(b: &Block, depth: usize) -> String {
}
}
/// A copy of the block with every run (in tables, every cell's) changed by `f`.
/// Returns a copy of `b` with all text runs and paragraph marks (or nested table cells) updated by `f`.
fn mark_block(b: &Block, f: &dyn Fn(&mut CharProps), depth: usize) -> Block {
match b {
Block::Para(p) => {
let mut p = p.clone();
let len = p.len();
let _ = p.format(0, len, f);
f(&mut p.mark);
Block::Para(p)
}
Block::Table(t) => {
@@ -205,59 +231,96 @@ pub(super) enum Op {
Ins,
}
/// Longest sequence the comparison table is built for; the rest of a longer sequence is
/// compared as deleted and inserted, never dropped.
/// Longest sequence window per LCS comparison table chunk; sequences longer than this are
/// aligned in successive chunked windows rather than truncated, preserving tail alignment.
const MAX_LCS: usize = 4000;
/// Comparison table cells one compare may fill over all its tables (blocks and every changed
/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted.
const MAX_COMPARE_CELLS: u64 = 50_000_000;
/// LCS edit script between two sequences: the common start and end are matched first, then the
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
/// middle is deleted and inserted whole.
/// Computes the longest common subsequence (LCS) edit script (`Op::Same`, `Op::Del`, `Op::Ins`)
/// between sequences `a` and `b`.
///
/// Strips common prefix and suffix first, then iterates over windows capped at [`MAX_LCS`],
/// charging comparison cells against `budget`. Between windows, intermediate common prefixes
/// are trimmed to re-anchor alignment without table allocations. When the remaining cells
/// exceed `budget`, the unaligned tail degrades honestly as whole deletions and insertions.
pub(super) fn lcs_ops<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
let mut rem_a = ra.get(..ra.len().saturating_sub(suf)).unwrap_or_default();
let mut rem_b = rb.get(..rb.len().saturating_sub(suf)).unwrap_or_default();
let mut ops = vec![Op::Same; pre];
let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget {
(n, m) = (0, 0);
} else {
*budget -= cells;
}
let mut t = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
let v = if a.get(i) == b.get(j) {
t.get(i + 1).and_then(|r| r.get(j + 1)).copied().unwrap_or(0) + 1
} else {
t.get(i + 1).and_then(|r| r.get(j)).copied().unwrap_or(0).max(t.get(i).and_then(|r| r.get(j + 1)).copied().unwrap_or(0))
};
if let Some(c) = t.get_mut(i).and_then(|r| r.get_mut(j)) {
*c = v;
while !rem_a.is_empty() && !rem_b.is_empty() {
let p = rem_a.iter().zip(rem_b).take_while(|(x, y)| x == y).count();
if p > 0 {
ops.extend(std::iter::repeat_n(Op::Same, p));
rem_a = rem_a.get(p..).unwrap_or_default();
rem_b = rem_b.get(p..).unwrap_or_default();
if rem_a.is_empty() || rem_b.is_empty() {
break;
}
}
}
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if a.get(i) == b.get(j) {
ops.push(Op::Same);
i += 1;
j += 1;
} else if t.get(i + 1).and_then(|r| r.get(j)) >= t.get(i).and_then(|r| r.get(j + 1)) {
ops.push(Op::Del);
i += 1;
} else {
ops.push(Op::Ins);
j += 1;
let n = rem_a.len().min(MAX_LCS);
let m = rem_b.len().min(MAX_LCS);
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget || n == 0 || m == 0 {
break;
}
*budget = budget.saturating_sub(cells);
let stride = m.saturating_add(1);
let table_len = n.saturating_add(1).saturating_mul(stride);
let mut t = vec![0u32; table_len];
for i in (0..n).rev() {
let row = i.saturating_mul(stride);
let next_row = i.saturating_add(1).saturating_mul(stride);
for j in (0..m).rev() {
let v = if rem_a.get(i) == rem_b.get(j) {
t.get(next_row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0).saturating_add(1)
} else {
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
down.max(right)
};
if let Some(c) = t.get_mut(row.saturating_add(j)) {
*c = v;
}
}
}
let (mut i, mut j) = (0usize, 0usize);
while i < n && j < m {
let row = i.saturating_mul(stride);
let next_row = i.saturating_add(1).saturating_mul(stride);
if rem_a.get(i) == rem_b.get(j) {
ops.push(Op::Same);
i = i.saturating_add(1);
j = j.saturating_add(1);
} else {
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
if down >= right {
ops.push(Op::Del);
i = i.saturating_add(1);
} else {
ops.push(Op::Ins);
j = j.saturating_add(1);
}
}
}
rem_a = rem_a.get(i..).unwrap_or_default();
rem_b = rem_b.get(j..).unwrap_or_default();
}
ops.extend(std::iter::repeat_n(Op::Del, a.len() - i));
ops.extend(std::iter::repeat_n(Op::Ins, b.len() - j));
ops.extend(std::iter::repeat_n(Op::Del, rem_a.len()));
ops.extend(std::iter::repeat_n(Op::Ins, rem_b.len()));
ops.extend(std::iter::repeat_n(Op::Same, suf));
ops
}
@@ -410,15 +473,39 @@ fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) {
}
}
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph {
let (at, bt) = (a.plain_text(), b.plain_text());
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
/// Returns `true` if paragraph `p` contains more characters than `limit`.
///
/// Bounded to scan at most `limit + 1` characters without allocating unless inline objects
/// are present, avoiding O(n) traversal of large paragraphs.
fn para_exceeds_char_limit(p: &Paragraph, limit: usize) -> bool {
if p.objects.is_empty() {
p.text.chars().take(limit.saturating_add(1)).count() > limit
} else {
p.plain_text().chars().take(limit.saturating_add(1)).count() > limit
}
}
/// Diffs two matched paragraphs either at word granularity or at character granularity according
/// to `ctx.opts.characters`. When character-level diff is requested but either paragraph exceeds
/// [`MAX_CHAR_DIFF`] characters, falls back to word-level diff and increments `ctx.char_fallbacks`
/// so callers can discover the fallback.
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ctx: &mut DiffCtx<'_>) -> Paragraph {
let chars = if ctx.opts.characters {
if para_exceeds_char_limit(a, MAX_CHAR_DIFF) || para_exceeds_char_limit(b, MAX_CHAR_DIFF) {
*ctx.char_fallbacks = ctx.char_fallbacks.saturating_add(1);
false
} else {
true
}
} else {
false
};
let (ta, tb) = (tokens(a, chars), tokens(b, chars));
let key = |t: &Tok<'_>| match t {
Tok::Text(t) => Key::Text(opts.key(t)),
Tok::Text(t) => Key::Text(ctx.opts.key(t)),
Tok::Obj(o) => obj_key(doc, o),
};
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), budget);
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), ctx.budget);
// Only at character granularity: a matched whole word almost never happens by coincidence,
// but a matched single character very often does (digits, punctuation), so only there does
// a short interior match need folding back into its surrounding edit.
@@ -436,11 +523,11 @@ fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, o
j += 1;
}
Op::Del => {
put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() });
put(&mut p, ta.get(i), &CharProps { del: Some(ctx.del), ..base.clone() });
i += 1;
}
Op::Ins => {
put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() });
put(&mut p, tb.get(j), &CharProps { ins: Some(ctx.ins), ..base.clone() });
j += 1;
}
}
+560
View File
@@ -66,6 +66,309 @@ fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
assert_eq!(collapse_noise_matches(&ops), ops);
}
/// Verifies that `lcs_ops` chunks sequence alignment across multiple `MAX_LCS` windows,
/// replaying operations to prove exact reconstruction and asserting specific edit indices.
#[test]
fn lcs_ops_chunks_across_multiple_windows() {
// Two sequences of 9,000 elements (spanning three 4,000-element MAX_LCS windows).
// Element at 50 moves to 8,500. Substitutions at 0 and 8,999 zero the outer prefix/suffix.
let total = 9000;
let mut a: Vec<usize> = (0..total).collect();
let mut b = a.clone();
a[0] = 99999;
b[0] = 88888;
a[total - 1] = 77777;
b[total - 1] = 66666;
let moved = a[50];
b.remove(50);
b.insert(8500, moved);
let mut budget = u64::MAX;
let ops = lcs_ops(&a, &b, &mut budget);
// Replay ops against `a` and `b` to verify exact sequence reconstruction and order.
let mut ia = 0;
let mut ib = 0;
let mut dels_seen = Vec::new();
let mut inss_seen = Vec::new();
let mut reconstructed = Vec::new();
for op in &ops {
match op {
Op::Same => {
assert_eq!(a.get(ia), b.get(ib), "Same op must match elements at ia={ia}, ib={ib}");
if let Some(&val) = a.get(ia) {
reconstructed.push(val);
}
ia += 1;
ib += 1;
}
Op::Del => {
if let Some(&val) = a.get(ia) {
dels_seen.push((ia, val));
}
ia += 1;
}
Op::Ins => {
if let Some(&val) = b.get(ib) {
inss_seen.push((ib, val));
reconstructed.push(val);
}
ib += 1;
}
}
}
assert_eq!(ia, a.len());
assert_eq!(ib, b.len());
assert_eq!(reconstructed, b);
// Truth: 2 substitutions (indices 0 and 8999), 1 deletion at 50, 1 insertion at 8500.
assert_eq!(dels_seen, vec![(0, 99999), (50, 50), (8999, 77777)]);
assert_eq!(inss_seen, vec![(0, 88888), (8500, 50), (8999, 66666)]);
}
/// Verifies that `lcs_ops` forces successive full 4,000-element windows without
/// intermediate prefix trimming collapsing them, asserting exact budget exhaustion.
#[test]
fn lcs_ops_forces_successive_full_windows_without_intermediate_prefix_trim() {
// Force successive full 4,000-element windows without intermediate prefix trimming
// collapsing the middle into fewer tables.
// Length: 9,000 elements across 3 windows (4,000 + 4,000 + 1,000).
// Boundary substitutions at indices 0, 4000, 8000, and 8999 guarantee rem_a[0] != rem_b[0]
// at the beginning of each window, forcing intermediate prefix trim to find p == 0 so
// all three full DP tables must execute.
let total = 9000;
let mut a: Vec<usize> = (0..total).collect();
let mut b = a.clone();
a[0] = 11111;
b[0] = 22222;
a[4000] = 33333;
b[4000] = 44444;
a[8000] = 55555;
b[8000] = 66666;
a[total - 1] = 77777;
b[total - 1] = 88888;
// Budget for 3 full windows:
// Window 1: 4,000 * 4,000 = 16,000,000 cells
// Window 2: 4,000 * 4,000 = 16,000,000 cells
// Window 3: 1,000 * 1,000 = 1,000,000 cells
// Total cells = 33,000,000.
let mut budget = 33_000_000u64;
let ops = lcs_ops(&a, &b, &mut budget);
// Exact budget is fully exhausted (budget == 0), proving all 3 full DP tables were built.
assert_eq!(budget, 0, "all 3 successive windows must execute their DP tables without collapsing");
let mut ia = 0;
let mut ib = 0;
let mut reconstructed = Vec::new();
let mut dels_seen = Vec::new();
let mut inss_seen = Vec::new();
for op in &ops {
match op {
Op::Same => {
assert_eq!(a.get(ia), b.get(ib));
if let Some(&val) = a.get(ia) {
reconstructed.push(val);
}
ia += 1;
ib += 1;
}
Op::Del => {
if let Some(&val) = a.get(ia) {
dels_seen.push((ia, val));
}
ia += 1;
}
Op::Ins => {
if let Some(&val) = b.get(ib) {
inss_seen.push((ib, val));
reconstructed.push(val);
}
ib += 1;
}
}
}
assert_eq!(ia, a.len());
assert_eq!(ib, b.len());
assert_eq!(reconstructed, b);
assert_eq!(dels_seen, vec![(0, 11111), (4000, 33333), (8000, 55555), (8999, 77777)]);
assert_eq!(inss_seen, vec![(0, 22222), (4000, 44444), (8000, 66666), (8999, 88888)]);
// Also verify completely disjoint sequences across multiple windows with zero common items.
// Window 1: 4,000 * 4,000 = 16,000,000 cells (deletes 4,000 of a_diff)
// Window 2: 4,000 * 4,000 = 16,000,000 cells (deletes next 4,000 of a_diff)
// Window 3: 1,000 of a_diff * 4,000 of b_diff = 4,000,000 cells
// Total cells = 36,000,000.
let a_diff: Vec<usize> = (0..9000).collect();
let b_diff: Vec<usize> = (10_000..19_000).collect();
let mut budget_diff = 36_000_000u64;
let ops_diff = lcs_ops(&a_diff, &b_diff, &mut budget_diff);
assert_eq!(budget_diff, 0, "all 3 windows in disjoint comparison must exhaust exact 36,000,000 budget");
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Del).count(), 9000);
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Ins).count(), 9000);
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Same).count(), 0);
}
/// Tests `lcs_ops` budget behavior across edge cases: zero initial budget, exact-fit allocation,
/// one-short exhaustion, and multi-window exhaustion after the first chunk.
#[test]
fn lcs_ops_exhausted_budget_falls_back_whole() {
// 1. Budget 0 before any table: middle degrades whole, budget remains 0
let a = vec![1, 2, 3, 4, 5];
let b = vec![1, 8, 9, 5];
let mut budget = 0;
let ops = lcs_ops(&a, &b, &mut budget);
assert_eq!(ops, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
assert_eq!(budget, 0, "budget must remain 0");
// 2. Exact-fit: cells == budget
// Middle has 3 elements in a (2, 3, 4) and 2 in b (8, 9) -> 3 * 2 = 6 cells.
let mut budget_exact = 6;
let ops_exact = lcs_ops(&a, &b, &mut budget_exact);
assert_eq!(budget_exact, 0, "budget exactly exhausted to 0");
assert_eq!(ops_exact, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
// Exact-fit with shared elements in middle
let a_mid = vec![1, 2, 3, 5];
let b_mid = vec![1, 2, 9, 5];
// Middle is (3) vs (9): 1 * 1 = 1 cell
let mut budget_mid = 1;
let ops_mid = lcs_ops(&a_mid, &b_mid, &mut budget_mid);
assert_eq!(budget_mid, 0);
assert_eq!(ops_mid, vec![Op::Same, Op::Same, Op::Del, Op::Ins, Op::Same]);
// 3. One-short: budget == cells - 1
let mut budget_short = 5;
let ops_short = lcs_ops(&a, &b, &mut budget_short);
assert_eq!(budget_short, 5, "one-short budget must not be charged");
assert_eq!(ops_short, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
// 4. Multi-window: Exhaustion after the first window
// Two windows: window 1 has 4,000 elements (16,000,000 cells), window 2 has 500 elements (250,000 cells).
let mut a_multi: Vec<usize> = (0..4500).collect();
let mut b_multi = a_multi.clone();
a_multi[0] = 99999;
b_multi[0] = 88888;
a_multi[4000] = 77777;
b_multi[4000] = 66666;
a_multi[4499] = 55555;
b_multi[4499] = 44444;
// Case 4A: budget fits window 1 exactly (16,000,000), exhausts for window 2
let mut budget_w1_only = 16_000_000u64;
let ops_w1_only = lcs_ops(&a_multi, &b_multi, &mut budget_w1_only);
assert_eq!(budget_w1_only, 0, "budget spent on window 1, 0 remaining for window 2");
// Window 1 aligned via DP table, window 2 fell back to 500 Dels and 500 Inss
let tail_dels = ops_w1_only.iter().rev().skip(500).take(500).filter(|o| **o == Op::Del).count();
let tail_inss = ops_w1_only.iter().rev().take(500).filter(|o| **o == Op::Ins).count();
assert_eq!(tail_dels, 500);
assert_eq!(tail_inss, 500);
// Case 4B: budget fits both windows exactly: 16,250,000
let mut budget_both = 16_250_000u64;
let ops_both = lcs_ops(&a_multi, &b_multi, &mut budget_both);
assert_eq!(budget_both, 0, "both windows charged exactly to 0");
assert_eq!(ops_both.iter().filter(|o| **o == Op::Del).count(), 3);
assert_eq!(ops_both.iter().filter(|o| **o == Op::Ins).count(), 3);
// Case 4C: one short on window 2: 16_249_999
let mut budget_both_short = 16_249_999u64;
let _ = lcs_ops(&a_multi, &b_multi, &mut budget_both_short);
assert_eq!(budget_both_short, 249_999, "remaining budget must reflect window 1 spent and window 2 unspent");
}
/// Tests boundary conditions for `lcs_ops`: empty slices, one-sided empty, exact `MAX_LCS`,
/// `MAX_LCS + 1`, `2 * MAX_LCS`, and prefix-trim loop break paths.
#[test]
fn lcs_ops_boundary_cases_and_break_paths() {
// 1. Empty sequences
let mut b = u64::MAX;
assert_eq!(lcs_ops::<i32>(&[], &[], &mut b), Vec::<Op>::new());
// 2. One-sided empty
let items = [1, 2, 3];
assert_eq!(lcs_ops(&[], &items, &mut b), vec![Op::Ins, Op::Ins, Op::Ins]);
assert_eq!(lcs_ops(&items, &[], &mut b), vec![Op::Del, Op::Del, Op::Del]);
// 3. Exact MAX_LCS (4,000)
let len_max = 4000;
let mut a_max: Vec<usize> = (0..len_max).collect();
let mut b_max = a_max.clone();
a_max[0] = 99999;
b_max[0] = 88888;
a_max[len_max - 1] = 77777;
b_max[len_max - 1] = 66666;
let mut b_budget = u64::MAX;
let ops_max = lcs_ops(&a_max, &b_max, &mut b_budget);
assert_eq!(ops_max.len(), len_max + 2);
assert_eq!(ops_max.iter().filter(|o| **o == Op::Same).count(), len_max - 2);
// 4. Exact MAX_LCS + 1 (4,001)
let len_plus1 = 4001;
let mut a_p1: Vec<usize> = (0..len_plus1).collect();
let mut b_p1 = a_p1.clone();
a_p1[0] = 99999;
b_p1[0] = 88888;
a_p1[len_plus1 - 1] = 77777;
b_p1[len_plus1 - 1] = 66666;
let mut b_budget2 = u64::MAX;
let ops_p1 = lcs_ops(&a_p1, &b_p1, &mut b_budget2);
assert_eq!(ops_p1.len(), len_plus1 + 2);
assert_eq!(ops_p1.iter().filter(|o| **o == Op::Same).count(), len_plus1 - 2);
// 5. Exact 2 * MAX_LCS (8,000)
let len_2m = 8000;
let mut a_2m: Vec<usize> = (0..len_2m).collect();
let mut b_2m = a_2m.clone();
a_2m[0] = 11111;
b_2m[0] = 22222;
a_2m[4000] = 33333;
b_2m[4000] = 44444;
a_2m[len_2m - 1] = 55555;
b_2m[len_2m - 1] = 66666;
let mut b_budget3 = u64::MAX;
let ops_2m = lcs_ops(&a_2m, &b_2m, &mut b_budget3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Del).count(), 3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Ins).count(), 3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Same).count(), len_2m - 3);
// 6. Prefix-trim break paths inside the while loop:
// Case 6A: rem_a becomes empty after matching p elements
let mut a_break_a: Vec<usize> = (0..4005).collect();
let mut b_break_a: Vec<usize> = (0..4008).collect();
a_break_a[0] = 99999;
b_break_a[0] = 88888;
let mut b_break1 = u64::MAX;
let ops_break_a = lcs_ops(&a_break_a, &b_break_a, &mut b_break1);
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Del).count(), 1);
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Ins).count(), 4);
// Case 6B: rem_b becomes empty after matching p elements
let mut a_break_b: Vec<usize> = (0..4008).collect();
let mut b_break_b: Vec<usize> = (0..4005).collect();
a_break_b[0] = 99999;
b_break_b[0] = 88888;
let mut b_break2 = u64::MAX;
let ops_break_b = lcs_ops(&a_break_b, &b_break_b, &mut b_break2);
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Del).count(), 4);
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Ins).count(), 1);
// Case 6C: both become empty simultaneously after matching p elements
let mut a_break_both: Vec<usize> = (0..4005).collect();
let mut b_break_both: Vec<usize> = (0..4005).collect();
a_break_both[0] = 99999;
b_break_both[0] = 88888;
let mut b_break3 = u64::MAX;
let ops_break_both = lcs_ops(&a_break_both, &b_break_both, &mut b_break3);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Del).count(), 1);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Ins).count(), 1);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Same).count(), 4004);
}
#[test]
fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
// `collapse_noise_matches` only runs for character-level compares (wired up in
@@ -80,6 +383,145 @@ fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
);
}
#[test]
fn compare_character_level_surfaces_fallback_on_long_paragraphs() {
// Under MAX_CHAR_DIFF (2,000 characters), character-level diff engages normally:
// sub-word fragment is marked, characterFallback is 0, and notHonoured does not contain "level".
let original_short = "The quick brown fox jumps over the lazy dog. ".repeat(10);
let revised_short = original_short.replacen("jumps", "jumped", 1);
assert!(original_short.chars().count() < 2000);
let mut s = Session::new(Document::from_text(&original_short));
let r = s.run("review.compare", &json!({"text": revised_short, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"), "level should be honoured for short paragraphs");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "s", "character level marks only the sub-word suffix for short paragraphs");
assert_eq!(inserted, "ed", "character level marks only the sub-word suffix for short paragraphs");
// Above MAX_CHAR_DIFF (2,000 characters), character-level diff falls back to word-level diff.
// The fallback is surfaced in result metadata: characterFallback is incremented and
// notHonoured reports "level" rather than silently degrading without signal.
// In the output document, the fallback marks the whole word rather than sub-word fragments.
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let revised_long = original_long.replacen("jumps", "jumped", 1);
assert!(original_long.chars().count() > 2000);
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(not_honoured.iter().any(|v| v == "level"), "level should be listed in notHonoured when fallback occurs");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ", "long paragraph fallback marks the whole word");
assert_eq!(inserted, "jumped ", "long paragraph fallback marks the whole word");
}
#[test]
fn compare_character_level_exact_boundaries_at_limit() {
let prefix = "The quick brown fox jumps over the lazy dog. ".repeat(44); // 1980 chars
// Exactly MAX_CHAR_DIFF (2000) characters: character-level diff engages without fallback.
let original_2000 = format!("{prefix}1234567890123456789x");
let revised_2000 = format!("{prefix}1234567890123456789y");
assert_eq!(original_2000.chars().count(), 2000);
assert_eq!(revised_2000.chars().count(), 2000);
let mut s = Session::new(Document::from_text(&original_2000));
let r = s.run("review.compare", &json!({"text": revised_2000, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"), "2000 chars should not fall back");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "x", "character level marks single changed character at exactly 2000 chars");
assert_eq!(inserted, "y", "character level marks single changed character at exactly 2000 chars");
// Exactly 2001 characters (MAX_CHAR_DIFF + 1): triggers fallback to word-level diff.
let original_2001 = format!("{prefix}12345678901234567890x");
let revised_2001 = format!("{prefix}12345678901234567890y");
assert_eq!(original_2001.chars().count(), 2001);
assert_eq!(revised_2001.chars().count(), 2001);
let mut s = Session::new(Document::from_text(&original_2001));
let r = s.run("review.compare", &json!({"text": revised_2001, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(not_honoured.iter().any(|v| v == "level"), "2001 chars must trigger fallback");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "12345678901234567890x", "fallback marks whole word at 2001 chars");
assert_eq!(inserted, "12345678901234567890y", "fallback marks whole word at 2001 chars");
}
#[test]
fn compare_character_level_multiple_paragraphs_and_asymmetric_lengths() {
let short_a = "Short original text here.";
let short_b = "Short revised text here.";
let long_a = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let long_b = long_a.replacen("jumps", "jumped", 1);
let long_c = "Another long paragraph that exceeds two thousand chars. ".repeat(40);
let long_d = long_c.replacen("Another", "Different", 1);
// Document with 1 short modified paragraph and 1 long modified paragraph: fallback count is 1.
let doc_mixed_a = format!("{short_a}\n{long_a}");
let doc_mixed_b = format!("{short_b}\n{long_b}");
let mut s = Session::new(Document::from_text(&doc_mixed_a));
let r = s.run("review.compare", &json!({"text": doc_mixed_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
// Document with 2 long modified paragraphs: fallback count is 2.
let doc_2long_a = format!("{long_a}\n{long_c}");
let doc_2long_b = format!("{long_b}\n{long_d}");
let mut s = Session::new(Document::from_text(&doc_2long_a));
let r = s.run("review.compare", &json!({"text": doc_2long_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(2));
// Asymmetric lengths: original short, revised long (> 2000 chars) -> fallback count is 1.
let mut s = Session::new(Document::from_text(short_a));
let r = s.run("review.compare", &json!({"text": long_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
// Asymmetric lengths: original long (> 2000 chars), revised short -> fallback count is 1.
let mut s = Session::new(Document::from_text(&long_a));
let r = s.run("review.compare", &json!({"text": short_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
}
#[test]
fn compare_word_level_default_and_explicit_never_flag_fallback() {
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let revised_long = original_long.replacen("jumps", "jumped", 1);
assert!(original_long.chars().count() > 2000);
// Explicit "level": "word" on long paragraph: characterFallback is 0 and "level" not in notHonoured.
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long, "level": "word"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"));
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ");
assert_eq!(inserted, "jumped ");
// Default (no "level" param provided): behaves as word level, characterFallback is 0 and "level" not in notHonoured.
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"));
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ");
assert_eq!(inserted, "jumped ");
}
#[test]
fn compare_keeps_text_boxes_of_the_original() {
// Text boxes are anchored in body paragraphs: one after changed text, one alone in an
@@ -193,6 +635,124 @@ fn compare_bounds_total_diff_work() {
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
/// Verifies that moving a paragraph past `MAX_LCS` in a long document does not cascade into false
/// revisions in the tail, asserting exact changed block indices, text, and edit types.
#[test]
fn compare_lcs_window_move_past_cap_does_not_cascade_false_revisions() {
// Issue #6110: 4,600 blocks where prefix/suffix trim is zeroed (substitutions at blocks 0
// and 4599) and a record moves from index 100 to index 4400 (past MAX_LCS = 4000).
// Without chunking/continuation past the window, the tail misaligns and positional pairing
// marks ~202 paragraphs instead of the true 4 changes (substitutions at 0 and 4599,
// deletion at 100, insertion at 4400).
let total = 4600;
let mut orig_paras: Vec<String> = (0..total).map(|i| format!("Record {i} has alpha beta gamma payload.")).collect();
let mut rev_paras = orig_paras.clone();
// Zero out prefix trim: substitution at 0
orig_paras[0] = "Record 0 has alpha beta gamma original.".into();
rev_paras[0] = "Record 0 has alpha beta gamma revised.".into();
// Zero out suffix trim: substitution at 4599
orig_paras[total - 1] = format!("Record {} has alpha beta gamma original.", total - 1);
rev_paras[total - 1] = format!("Record {} has alpha beta gamma revised.", total - 1);
// Move record 100 to 4400
let moved = orig_paras[100].clone();
rev_paras.remove(100);
rev_paras.insert(4400, moved);
let original = orig_paras.join("\n");
let revised = rev_paras.join("\n");
let mut s = Session::new(Document::from_text(&original));
let r = s.run("review.compare", &json!({"text": revised})).unwrap();
// The true number of changed paragraphs is 4 (the two substitutions, the move's deletion,
// and the move's insertion). On unpatched code, window truncation misaligned the tail
// and marked ~202 paragraphs.
assert_eq!(r["changes"], 4, "move past MAX_LCS cap must not cascade into false revisions in the tail");
// In s.doc.body (total 4601 blocks: 4598 unchanged, 1 del at 100, 1 ins at 4401, 2 substituted at 0 and 4600):
let changed_blocks: Vec<(usize, String)> = s
.doc
.body
.iter()
.enumerate()
.filter_map(|(idx, b)| {
let p = b.as_para()?;
let has_change = p.mark.del.is_some() || p.mark.ins.is_some() || p.runs.iter().any(|r| r.props.del.is_some() || r.props.ins.is_some());
if has_change { Some((idx, p.plain_text())) } else { None }
})
.collect();
assert_eq!(changed_blocks.len(), 4);
assert_eq!(changed_blocks[0], (0, "Record 0 has alpha beta gamma original.revised.".into()));
assert_eq!(changed_blocks[1], (100, "Record 100 has alpha beta gamma payload.".into()));
assert_eq!(changed_blocks[2], (4401, "Record 100 has alpha beta gamma payload.".into()));
assert_eq!(changed_blocks[3], (4600, "Record 4599 has alpha beta gamma original.revised.".into()));
// Verify exact change types on those blocks:
let b0 = s.doc.body[0].as_para().unwrap();
assert!(b0.runs.iter().any(|r| r.props.del.is_some()) && b0.runs.iter().any(|r| r.props.ins.is_some()), "block 0 is text replacement");
let b100 = s.doc.body[100].as_para().unwrap();
assert!(b100.runs.iter().all(|r| r.props.del.is_some()) && b100.mark.del.is_some(), "block 100 is deleted whole");
let b4401 = s.doc.body[4401].as_para().unwrap();
assert!(b4401.runs.iter().all(|r| r.props.ins.is_some()) && b4401.mark.ins.is_some(), "block 4401 is inserted whole");
let b4600 = s.doc.body[4600].as_para().unwrap();
assert!(b4600.runs.iter().any(|r| r.props.del.is_some()) && b4600.runs.iter().any(|r| r.props.ins.is_some()), "block 4600 is text replacement");
s.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
/// Verifies that `mark_block` records paragraph mark revisions on whole-block deletions,
/// whole-block insertions, empty paragraphs, and ensures clean resolution via `review.acceptAll`.
#[test]
fn compare_mark_block_records_paragraph_mark_revisions_on_whole_block_changes() {
// Whole block delete/insert: test with differing paragraph count so whole-block paths trigger
let orig_multi = "Keep start.\nDelete me.\nKeep end.";
let rev_multi = "Keep start.\nKeep end.";
let mut s2 = Session::new(Document::from_text(orig_multi));
if let Some(Block::Para(p)) = s2.doc.body.get_mut(1).map(Arc::make_mut) {
p.mark.bold = Some(true);
}
s2.run("review.compare", &json!({"text": rev_multi})).unwrap();
// In s2.doc.body, block 1 was deleted whole.
// Verify that p.mark.del is recorded by mark_block and formatting is preserved.
let del_para = s2.doc.body[1].as_para().expect("block 1 must be paragraph");
assert!(del_para.mark.del.is_some(), "whole-block delete must record revision on paragraph mark");
assert_eq!(del_para.mark.bold, Some(true), "paragraph mark formatting must be preserved on deleted block");
// Accept all changes: deleted paragraph is removed
s2.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s2.doc.plain_text(StoryRef::Body), rev_multi);
// Test whole-block insertion
let mut s3 = Session::new(Document::from_text(rev_multi));
s3.run("review.compare", &json!({"text": orig_multi})).unwrap();
let ins_para = s3.doc.body[1].as_para().expect("block 1 must be paragraph");
assert!(ins_para.mark.ins.is_some(), "whole-block insert must record revision on paragraph mark");
s3.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s3.doc.plain_text(StoryRef::Body), orig_multi);
// Test empty paragraph deletion and insertion (len == 0, where only mark carries the revision)
let orig_empty = "Before.\n\nAfter.";
let rev_empty = "Before.\nAfter.";
let mut s4 = Session::new(Document::from_text(orig_empty));
s4.run("review.compare", &json!({"text": rev_empty})).unwrap();
let empty_del = s4.doc.body[1].as_para().expect("empty paragraph block");
assert_eq!(empty_del.len(), 0);
assert!(empty_del.mark.del.is_some(), "empty paragraph deletion must set mark.del");
s4.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s4.doc.plain_text(StoryRef::Body), rev_empty);
let mut s5 = Session::new(Document::from_text(rev_empty));
s5.run("review.compare", &json!({"text": orig_empty})).unwrap();
let empty_ins = s5.doc.body[1].as_para().expect("empty paragraph block");
assert_eq!(empty_ins.len(), 0);
assert!(empty_ins.mark.ins.is_some(), "empty paragraph insertion must set mark.ins");
s5.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s5.doc.plain_text(StoryRef::Body), orig_empty);
}
#[test]
fn macros_record_and_run() {
let mut s = Session::new(Document::new());
+27
View File
@@ -0,0 +1,27 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
@@ -17,6 +17,8 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: wordcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -18,6 +18,8 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: wordcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland