mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:31:54 -06:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c8599edc | ||
|
|
7296cf64de | ||
|
|
a8db235e05 | ||
|
|
4706420e94 | ||
|
|
ae7ae89eb2 | ||
|
|
1327d20055 | ||
|
|
7013411a2f | ||
|
|
411a4915a1 | ||
|
|
5574b32600 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user