Skip to main content

weave_cli/
patch.rs

1//! `weave patch` — the WRITE side of the agent contract.
2//!
3//! The read side (`weave-findings.schema.json`) tells an agent what a merge
4//! *did*. This is the symmetric half: how an agent says what it *wants*, in the
5//! same vocabulary, over the same keys.
6//!
7//! # Why ops rather than a diff
8//!
9//! A textual diff is a statement about line numbers, and line numbers are the
10//! first thing to rot. `extract` emits a typed op per entity, drawn from a
11//! fixed set: `added`, `edited`, `renamed`, `rename_edited`,
12//! `deleted`. An op names an *entity*, so
13//! it survives everything that moved around it.
14//!
15//! # apply is a merge, not a patch
16//!
17//! The interesting case is a target that has **drifted** from the base the ops
18//! were extracted against. `git apply` answers that with fuzz and rejects;
19//! weave answers it with the merge it already knows how to do:
20//!
21//! ```text
22//!   apply(ops, target) = entity_merge(
23//!       base   = the base the ops were extracted from,
24//!       ours   = target,                      // what is actually on disk
25//!       theirs = naive_apply(ops, base),      // what the ops intended
26//!   )
27//! ```
28//!
29//! This formulation is what makes drift honest. `theirs` is the author's intent
30//! reconstructed *in the author's own world*; `ours` is reality; `base` is the
31//! common ancestor they actually share. Anything else — applying ops straight to
32//! a drifted target — silently resolves a three-way situation with two-way
33//! information, and that is exactly where patch tools lose edits.
34//!
35//! When the ops carry no base, or the target *is* the base, the merge degenerates
36//! (`ours == base` ⟹ result is `theirs`) and `apply` short-circuits to the naive
37//! application. That degeneracy falls out of 3-way merge itself, not a special
38//! case bolted on — and it keeps the naive result byte-for-byte.
39//!
40//! # The exactness escape hatch
41//!
42//! `extract` **verifies itself**: it applies the ops it just produced back onto
43//! the base and compares with the changed file. When entity granularity cannot
44//! express the change (a rewritten module docstring, reordered entities, a
45//! changed shebang), it says so — `exact: false` — and appends a whole-file
46//! fallback op. The read side has no counterpart — a line-level fallback
47//! merge returns an empty audit trail there, so it reports nothing. The
48//! round trip then still holds byte-for-byte, and the document is honest about
49//! having lost entity granularity instead of quietly dropping the difference.
50
51use serde::{Deserialize, Serialize};
52use sha2::{Digest, Sha256};
53
54use sem_core::model::change::ChangeType;
55use sem_core::model::identity::match_entities;
56use weave_core::binding::replace_at_word_boundaries;
57use weave_core::conflict::EntityConflict;
58use weave_core::MergeResult;
59
60use crate::parsers::entities_of;
61use crate::wire::{conflict_advice, Finding, Sides};
62
63/// Semver of the ops wire format (`schema/weave-ops.schema.json`).
64pub(crate) const OPS_SCHEMA_VERSION: &str = "1.0.0";
65
66/// The entity key used for a whole-file fallback op. Mirrors the read side's
67/// convention for findings that are about the file rather than an entity.
68pub const FILE_KEY: &str = "(file)";
69
70/// Why an ops document could not be applied.
71///
72/// One refusal today, and it is the one worth naming: the caller supplied a
73/// base that is provably not the one the ops were extracted from. It used to
74/// be a `String` carrying both hashes inside a sentence, so the only way to
75/// tell this refusal from any other was to read the prose. Both operands now
76/// travel as fields, and a caller can decide (re-extract? drop the base? stop?)
77/// by matching.
78#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
79pub enum PatchError {
80    /// The supplied base does not hash to what the ops were extracted against.
81    #[error(
82        "base mismatch: the ops were extracted against sha256 {expected}, \
83         but the supplied base hashes to {actual}"
84    )]
85    BaseMismatch {
86        /// The digest the document declares.
87        expected: String,
88        /// The digest the supplied base actually has.
89        actual: String,
90    },
91}
92
93// ---------------------------------------------------------------------------
94// Wire format
95// ---------------------------------------------------------------------------
96
97/// The op alphabet, exactly as `schema/weave-ops.schema.json` enumerates it.
98///
99/// It used to be a `String`, so a document saying `"op": "eddited"` decoded
100/// without complaint and then fell through this crate's `match` into the
101/// "edited-ish" arm — an unknown verb silently treated as a known one. Being
102/// an enum makes the whole vocabulary a parsing question, answered once, at the
103/// boundary, before anything acts on it.
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum Op {
107    /// The entity is new in the changed file.
108    Added,
109    /// The entity's text changed.
110    Edited,
111    /// The entity was renamed, its text otherwise unchanged.
112    Renamed,
113    /// Renamed and edited in one step.
114    RenameEdited,
115    /// The entity is gone from the changed file.
116    Deleted,
117    /// Carries no intent; a v1 producer never emits it.
118    Unchanged,
119    /// Reserved: relational, correlating several entity keys, which a per-key
120    /// matcher cannot express. A v1 consumer accepts them and acts on none —
121    /// which is a thing this type can now state rather than a thing the
122    /// `match` below happens to do.
123    Split,
124    /// Reserved. See [`Op::Split`].
125    Merged,
126    /// Reserved. See [`Op::Split`].
127    Copied,
128    /// Reserved. See [`Op::Split`].
129    Moved,
130}
131
132impl Op {
133    /// The verb as the wire format spells it — the one place the mapping
134    /// lives, so a diagnostic and the JSON cannot drift apart.
135    pub fn wire(self) -> &'static str {
136        match self {
137            Op::Added => "added",
138            Op::Edited => "edited",
139            Op::Renamed => "renamed",
140            Op::RenameEdited => "rename_edited",
141            Op::Deleted => "deleted",
142            Op::Unchanged => "unchanged",
143            Op::Split => "split",
144            Op::Merged => "merged",
145            Op::Copied => "copied",
146            Op::Moved => "moved",
147        }
148    }
149}
150
151/// Why an ops document could not be read.
152///
153/// `weave patch apply` used to answer both of these with a `format!` string,
154/// so "this is not JSON", "this is JSON but not an ops document" and "this is
155/// an ops document from a future major" were one type. The first two are a
156/// caller's typo; the third means "upgrade weave", and a caller cannot act on
157/// that difference by reading a sentence.
158#[derive(Debug, thiserror::Error)]
159pub enum OpsDocError {
160    /// The bytes did not decode into an ops document: not JSON, a field of the
161    /// wrong type, a required field missing, an op outside the vocabulary, or
162    /// a field the schema does not define.
163    #[error("not a weave ops document: {source}")]
164    Malformed {
165        #[source]
166        source: serde_json::Error,
167    },
168
169    /// A major version this build does not implement.
170    #[error("schema_version {found} is a major this build does not know (it implements 1.x)")]
171    UnknownMajor { found: String },
172}
173
174/// Decode an ops document, or say which way it was not one.
175///
176/// The one entry point: nothing else in this crate turns bytes into an
177/// [`OpsDoc`], and no `serde_json::Value` survives past this line.
178pub fn parse_ops_doc(text: &str) -> Result<OpsDoc, OpsDocError> {
179    let doc: OpsDoc =
180        serde_json::from_str(text).map_err(|source| OpsDocError::Malformed { source })?;
181    if !doc.schema_version.starts_with("1.") {
182        return Err(OpsDocError::UnknownMajor {
183            found: doc.schema_version,
184        });
185    }
186    Ok(doc)
187}
188
189/// `deny_unknown_fields` here and on the two types below is not strictness for
190/// its own sake: `schema/weave-ops.schema.json` says `additionalProperties:
191/// false` at all three levels, and until now this decoder accepted documents
192/// its own published schema rejects. A misspelled `entitiy` used to be
193/// discarded in silence.
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
195#[serde(deny_unknown_fields)]
196pub struct OpsDoc {
197    pub schema_version: String,
198    /// The path the ops were extracted from. Its extension selects the parser;
199    /// `apply` may target a different path entirely.
200    pub file: String,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub base: Option<BaseRef>,
203    /// True when applying these ops to the declared base reproduces the changed
204    /// file byte-for-byte at entity granularity. False means a whole-file
205    /// fallback op is present and granularity was lost.
206    pub exact: bool,
207    pub ops: Vec<PatchOp>,
208}
209
210/// What the ops were extracted against.
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212#[serde(deny_unknown_fields)]
213pub struct BaseRef {
214    /// SHA-256 of the base content. Lets `apply` refuse a base that is not the
215    /// one the ops were computed from, instead of merging against a lie.
216    pub sha256: String,
217    /// The base itself. Optional and usually large; when present the ops are
218    /// self-contained and `apply` can do the real three-way merge with no help.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub inline: Option<String>,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
224#[serde(deny_unknown_fields)]
225pub struct PatchOp {
226    /// Which of the op vocabulary's actions this is.
227    pub op: Op,
228    /// The key the op is about: the entity's name **in the base**, so a rename
229    /// can still be located in a target that has not seen it yet.
230    pub entity: String,
231    pub entity_type: String,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub from: Option<String>,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub to: Option<String>,
236    /// The entity's full new text. Absent for `deleted`.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub content: Option<String>,
239    /// For `added`: the entity this one follows in the changed file. Absent
240    /// means "at the top of the file". A missing anchor in the target is not an
241    /// error — the entity is appended, and the op is reported as placed by
242    /// fallback.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub after: Option<String>,
245    /// For `added`: the whitespace/comment gap that separated this entity from
246    /// its anchor, so an insertion does not weld two definitions together.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub separator: Option<String>,
249    /// True on the whole-file escape hatch (`entity == "(file)"`): weave could
250    /// not express the change at entity granularity and is shipping the file.
251    #[serde(default, skip_serializing_if = "is_false")]
252    pub fallback: bool,
253}
254
255fn is_false(b: &bool) -> bool {
256    !*b
257}
258
259pub(crate) fn sha256_hex(content: &str) -> String {
260    let mut h = Sha256::new();
261    h.update(content.as_bytes());
262    format!("{:x}", h.finalize())
263}
264
265// ---------------------------------------------------------------------------
266// Regions: an entity plus the comments that belong to it
267// ---------------------------------------------------------------------------
268
269/// A top-level entity's line span, 0-based, `[start, end)`.
270#[derive(Debug, Clone)]
271struct Region {
272    name: String,
273    start: usize,
274    end: usize,
275}
276
277/// Is this line a comment or decorator that belongs to the definition below it?
278fn is_lead_line(line: &str) -> bool {
279    let t = line.trim_start();
280    t.starts_with("//")
281        || t.starts_with('#')
282        || t.starts_with("/*")
283        || t.starts_with('*')
284        || t.starts_with('@')
285        || t.starts_with("--")
286        || t.starts_with(";;")
287}
288
289/// Split `content` into top-level entity regions, bundling each entity's
290/// leading comment/decorator block into it.
291///
292/// Bundling matches what weave-core's reconstruction does, and it is not
293/// cosmetic: without it, editing a function's docstring is not expressible as an
294/// op on that function, and every docstring edit would fall back to whole-file.
295fn regions_of(path: &str, content: &str) -> Vec<Region> {
296    let lines: Vec<&str> = content.lines().collect();
297    let total = lines.len();
298    let mut regions: Vec<Region> = Vec::new();
299    let mut floor = 0usize; // no region may start before this
300    for e in entities_of(path, content) {
301        let start0 = e.start_line.saturating_sub(1).min(total);
302        let end0 = e.end_line.min(total);
303        if end0 <= start0 {
304            continue;
305        }
306        let mut bundled = start0;
307        while bundled > floor && is_lead_line(lines[bundled - 1]) {
308            bundled -= 1;
309        }
310        regions.push(Region {
311            name: e.name.clone(),
312            start: bundled,
313            end: end0,
314        });
315        floor = end0;
316    }
317    regions
318}
319
320fn slice(lines: &[&str], start: usize, end: usize) -> String {
321    if start >= end {
322        return String::new();
323    }
324    let mut s = lines[start..end].join("\n");
325    s.push('\n');
326    s
327}
328
329// ---------------------------------------------------------------------------
330// extract
331// ---------------------------------------------------------------------------
332
333/// Compute the ops that turn `base` into `changed`.
334///
335/// `embed_base` inlines the base snapshot, making the document self-contained —
336/// which is what lets `apply` run a real three-way merge against a drifted
337/// target with nothing but the ops file.
338pub fn extract(path: &str, base: &str, changed: &str, embed_base: bool) -> OpsDoc {
339    let base_ents = entities_of(path, base);
340    let changed_ents = entities_of(path, changed);
341
342    let changed_lines: Vec<&str> = changed.lines().collect();
343    let changed_regions = regions_of(path, changed);
344    let region_text = |name: &str| -> Option<String> {
345        changed_regions
346            .iter()
347            .find(|r| r.name == name)
348            .map(|r| slice(&changed_lines, r.start, r.end))
349    };
350
351    let mut ops: Vec<PatchOp> = Vec::new();
352    let changes = match_entities(&base_ents, &changed_ents, path, None, None, None).changes;
353
354    // Ops are keyed by BASE names, because that is what `apply` can still find
355    // in a target that has not seen the patch. An anchor named after a renamed
356    // entity has to be translated back, or the insertion loses its place.
357    let to_base_name: std::collections::HashMap<String, String> = changes
358        .iter()
359        .filter(|c| c.change_type == ChangeType::Renamed)
360        .filter_map(|c| {
361            c.old_entity_name
362                .clone()
363                .map(|old| (c.entity_name.clone(), old))
364        })
365        .collect();
366
367    for c in &changes {
368        let name = c.entity_name.clone();
369        match c.change_type {
370            ChangeType::Added => {
371                let (after, separator) = anchor_for(&changed_regions, &changed_lines, &name);
372                let after = after.map(|a| to_base_name.get(&a).cloned().unwrap_or(a));
373                ops.push(PatchOp {
374                    op: Op::Added,
375                    entity: name.clone(),
376                    entity_type: c.entity_type.clone(),
377                    from: None,
378                    to: None,
379                    content: region_text(&name),
380                    after,
381                    separator,
382                    fallback: false,
383                });
384            }
385            ChangeType::Deleted => ops.push(PatchOp {
386                op: Op::Deleted,
387                entity: name.clone(),
388                entity_type: c.entity_type.clone(),
389                from: None,
390                to: None,
391                content: None,
392                after: None,
393                separator: None,
394                fallback: false,
395            }),
396            ChangeType::Renamed => {
397                let old = c.old_entity_name.clone().unwrap_or_else(|| name.clone());
398                let new_text = region_text(&name);
399                // Pure rename vs rename+edit: substitute the old name back and
400                // see whether the base body returns — decide on text, not a guess.
401                let base_text = base_ents
402                    .iter()
403                    .find(|e| e.name == old)
404                    .map(|_| region_slice(path, base, &old))
405                    .unwrap_or_default();
406                let unrenamed = new_text
407                    .as_deref()
408                    .map(|t| replace_at_word_boundaries(t, &name, &old))
409                    .unwrap_or_default();
410                let op = if !base_text.is_empty() && unrenamed == base_text {
411                    Op::Renamed
412                } else {
413                    Op::RenameEdited
414                };
415                ops.push(PatchOp {
416                    op,
417                    entity: old.clone(),
418                    entity_type: c.entity_type.clone(),
419                    from: Some(old),
420                    to: Some(name.clone()),
421                    content: new_text,
422                    after: None,
423                    separator: None,
424                    fallback: false,
425                });
426            }
427            // Modified, and the positional changes weave cannot express as
428            // anything finer. `edited` is the honest description; if position
429            // actually mattered, self-verification below will catch it.
430            ChangeType::Modified | ChangeType::Moved | ChangeType::Reordered => ops.push(PatchOp {
431                op: Op::Edited,
432                entity: name.clone(),
433                entity_type: c.entity_type.clone(),
434                from: None,
435                to: None,
436                content: region_text(&name),
437                after: None,
438                separator: None,
439                fallback: false,
440            }),
441        }
442    }
443
444    // The matcher compares entity *bodies*, so a change confined to an entity's
445    // leading comment block registers as no change at all. The region does know
446    // — and a docstring edit that silently vanished from a patch would be worse
447    // than one that fell back to whole-file. Sweep for it explicitly.
448    let touched: std::collections::HashSet<&str> = changes
449        .iter()
450        .map(|c| c.entity_name.as_str())
451        .chain(changes.iter().filter_map(|c| c.old_entity_name.as_deref()))
452        .collect();
453    let base_regions = regions_of(path, base);
454    let base_lines: Vec<&str> = base.lines().collect();
455    for r in &base_regions {
456        if touched.contains(r.name.as_str()) {
457            continue;
458        }
459        let Some(new_text) = region_text(&r.name) else {
460            continue;
461        };
462        let old_text = slice(&base_lines, r.start, r.end);
463        if old_text != new_text {
464            let entity_type = base_ents
465                .iter()
466                .find(|e| e.name == r.name)
467                .map(|e| e.entity_type.clone())
468                .unwrap_or_else(|| "unknown".to_string());
469            ops.push(PatchOp {
470                op: Op::Edited,
471                entity: r.name.clone(),
472                entity_type,
473                from: None,
474                to: None,
475                content: Some(new_text),
476                after: None,
477                separator: None,
478                fallback: false,
479            });
480        }
481    }
482
483    // Deletions first, then edits, then additions: additions anchor on entities
484    // that must still be findable when they are placed.
485    ops.sort_by_key(|o| match o.op {
486        Op::Deleted => 0,
487        Op::Added => 2,
488        _ => 1,
489    });
490
491    let mut doc = OpsDoc {
492        schema_version: OPS_SCHEMA_VERSION.to_string(),
493        file: path.to_string(),
494        base: Some(BaseRef {
495            sha256: sha256_hex(base),
496            inline: embed_base.then(|| base.to_string()),
497        }),
498        exact: true,
499        ops,
500    };
501
502    // ---- Self-verification. The document does not get to claim what it has
503    // not demonstrated.
504    let replay = apply_ops(base, path, &doc);
505    if replay.content != changed {
506        doc.exact = false;
507        doc.ops.push(PatchOp {
508            op: Op::Edited,
509            entity: FILE_KEY.into(),
510            entity_type: "file".into(),
511            from: None,
512            to: None,
513            content: Some(changed.to_string()),
514            after: None,
515            separator: None,
516            fallback: true,
517        });
518    }
519    doc
520}
521
522fn region_slice(path: &str, content: &str, name: &str) -> String {
523    let lines: Vec<&str> = content.lines().collect();
524    regions_of(path, content)
525        .iter()
526        .find(|r| r.name == name)
527        .map(|r| slice(&lines, r.start, r.end))
528        .unwrap_or_default()
529}
530
531/// Where a newly added entity sits, expressed relative to its neighbours rather
532/// than to a line number.
533fn anchor_for(regions: &[Region], lines: &[&str], name: &str) -> (Option<String>, Option<String>) {
534    let Some(idx) = regions.iter().position(|r| r.name == name) else {
535        return (None, None);
536    };
537    if idx > 0 {
538        let prev = &regions[idx - 1];
539        let gap = slice(lines, prev.end, regions[idx].start);
540        (Some(prev.name.clone()), Some(gap))
541    } else {
542        // First in the file: the gap that follows it is what keeps it apart
543        // from whatever came first before.
544        let gap = regions
545            .get(idx + 1)
546            .map(|next| slice(lines, regions[idx].end, next.start))
547            .unwrap_or_default();
548        (None, Some(gap))
549    }
550}
551
552// ---------------------------------------------------------------------------
553// naive application — "what the ops intended, in the author's own world"
554// ---------------------------------------------------------------------------
555
556/// A pending line-range rewrite, expressed over the ORIGINAL line indices so
557/// that ops stay order-independent.
558struct Edit {
559    /// Position in the ops document; only used to break ties between two
560    /// insertions at the same line.
561    order: usize,
562    start: usize,
563    end: usize,
564    replacement: Vec<String>,
565}
566
567impl Edit {
568    fn is_insertion(&self) -> bool {
569        self.start == self.end
570    }
571}
572
573#[derive(Debug, Clone, PartialEq)]
574pub(crate) struct NaiveApply {
575    pub content: String,
576    /// Ops whose target entity was not present. Never silent: an op that did
577    /// not land is reported, because "applied nothing" and "applied everything"
578    /// must not look the same.
579    pub unapplied: Vec<String>,
580}
581
582/// Apply ops to `source` positionally, with no merge. This is the `theirs`
583/// synthesis step of `apply`, and on its own it is only correct when `source`
584/// is the base the ops were extracted from.
585pub(crate) fn apply_ops(source: &str, path: &str, doc: &OpsDoc) -> NaiveApply {
586    // The whole-file escape hatch wins outright.
587    if let Some(op) = doc
588        .ops
589        .iter()
590        .rev()
591        .find(|o| o.fallback && o.entity == FILE_KEY)
592    {
593        return NaiveApply {
594            content: op.content.clone().unwrap_or_default(),
595            unapplied: Vec::new(),
596        };
597    }
598
599    let lines: Vec<&str> = source.lines().collect();
600    let trailing_newline = source.is_empty() || source.ends_with('\n');
601    let regions = regions_of(path, source);
602    let find = |name: &str| regions.iter().position(|r| r.name == name);
603
604    let mut edits: Vec<Edit> = Vec::new();
605    let mut unapplied: Vec<String> = Vec::new();
606    let to_lines = |s: &str| -> Vec<String> { s.lines().map(|l| l.to_string()).collect() };
607    let mut push = |start: usize, end: usize, replacement: Vec<String>| {
608        edits.push(Edit {
609            order: edits.len(),
610            start,
611            end,
612            replacement,
613        })
614    };
615
616    for op in &doc.ops {
617        match op.op {
618            Op::Deleted => match find(&op.entity) {
619                Some(i) => {
620                    let r = &regions[i];
621                    // Take the entity *and* the gap that follows it, so removing
622                    // a definition does not leave its blank lines behind. When
623                    // it is last, take the gap before it instead.
624                    let (start, end) = match regions.get(i + 1) {
625                        Some(next) => (r.start, next.start),
626                        None if i > 0 => (regions[i - 1].end, r.end),
627                        None => (r.start, r.end),
628                    };
629                    push(start, end, Vec::new());
630                }
631                None => unapplied.push(format!("deleted {}", op.entity)),
632            },
633            Op::Edited | Op::Renamed | Op::RenameEdited => match find(&op.entity) {
634                Some(i) => {
635                    let r = &regions[i];
636                    push(
637                        r.start,
638                        r.end,
639                        to_lines(op.content.as_deref().unwrap_or("")),
640                    );
641                }
642                None => unapplied.push(format!("{} {}", op.op.wire(), op.entity)),
643            },
644            Op::Added => {
645                let text = op.content.clone().unwrap_or_default();
646                let sep = op.separator.clone().unwrap_or_default();
647                let (at, payload) = match &op.after {
648                    Some(anchor) => match find(anchor) {
649                        Some(i) => (regions[i].end, format!("{sep}{text}")),
650                        // Anchor gone: append rather than drop. Reported, so a
651                        // caller knows placement was a fallback.
652                        None => {
653                            unapplied
654                                .push(format!("added {} (anchor `{anchor}` absent)", op.entity));
655                            (lines.len(), format!("\n{text}"))
656                        }
657                    },
658                    None => (0, format!("{text}{sep}")),
659                };
660                push(at, at, to_lines(&payload));
661            }
662            // `unchanged` and the reserved relational ops: accepted, acted on
663            // by nobody. Now spelled as the variants they are, so adding a
664            // verb to the vocabulary makes this arm a compile error rather
665            // than a silent no-op.
666            Op::Unchanged | Op::Split | Op::Merged | Op::Copied | Op::Moved => {}
667        }
668    }
669
670    // Apply back-to-front so earlier line indices stay valid. Two tie-breaks
671    // matter at a shared position: a removal/replacement must go before an
672    // insertion anchored at the same line (otherwise the insertion lands inside
673    // the range about to be removed), and insertions among themselves are
674    // applied in reverse document order so they come out in document order.
675    edits.sort_by(|a, b| {
676        b.start
677            .cmp(&a.start)
678            .then(a.is_insertion().cmp(&b.is_insertion()))
679            .then(b.order.cmp(&a.order))
680    });
681
682    let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
683    for e in edits {
684        let start = e.start.min(out.len());
685        let end = e.end.clamp(start, out.len());
686        out.splice(start..end, e.replacement);
687    }
688
689    let mut content = out.join("\n");
690    if trailing_newline && !content.is_empty() {
691        content.push('\n');
692    }
693    NaiveApply { content, unapplied }
694}
695
696// ---------------------------------------------------------------------------
697// apply — the three-way formulation
698// ---------------------------------------------------------------------------
699
700#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701pub enum ApplyMode {
702    /// `ours == base`: the three-way merge degenerates to `theirs`.
703    FastForward,
704    /// The target drifted from the base; a real entity merge decided it.
705    ThreeWay,
706    /// No base was available, so the target was treated as one. Drift cannot be
707    /// detected in this mode — it is stated, not hidden.
708    NoBase,
709}
710
711impl ApplyMode {
712    pub fn wire(self) -> &'static str {
713        match self {
714            ApplyMode::FastForward => "fast_forward",
715            ApplyMode::ThreeWay => "three_way",
716            ApplyMode::NoBase => "no_base",
717        }
718    }
719}
720
721#[derive(Debug, Clone)]
722pub struct ApplyReport {
723    pub content: String,
724    pub clean: bool,
725    pub mode: ApplyMode,
726    pub findings: Vec<Finding>,
727    pub unapplied: Vec<String>,
728}
729
730/// Apply `doc` to `target`.
731///
732/// `declared_base` is the base the caller supplied out of band; the document's
733/// own `base.inline` is used when it has one. With neither, the target stands in
734/// for the base and the result is a naive application (`NoBase`).
735///
736/// Errors only when the caller supplied a base that is provably not the one the
737/// ops were computed from — merging against the wrong ancestor produces a
738/// confidently wrong answer, which is the one outcome worth refusing.
739pub fn apply(
740    doc: &OpsDoc,
741    target: &str,
742    target_path: &str,
743    declared_base: Option<&str>,
744    host: &weave_core::host::Host,
745) -> Result<ApplyReport, PatchError> {
746    let base: Option<String> = declared_base
747        .map(|s| s.to_string())
748        .or_else(|| doc.base.as_ref().and_then(|b| b.inline.clone()));
749
750    if let (Some(b), Some(r)) = (base.as_deref(), doc.base.as_ref()) {
751        let actual = sha256_hex(b);
752        if actual != r.sha256 {
753            return Err(PatchError::BaseMismatch {
754                expected: r.sha256.clone(),
755                actual,
756            });
757        }
758    }
759
760    let Some(base) = base else {
761        let naive = apply_ops(target, target_path, doc);
762        return Ok(ApplyReport {
763            content: naive.content,
764            clean: true,
765            mode: ApplyMode::NoBase,
766            findings: Vec::new(),
767            unapplied: naive.unapplied,
768        });
769    };
770
771    let theirs = apply_ops(&base, target_path, doc);
772
773    if base == target {
774        // ours == base ⟹ the three-way merge is theirs, by definition. Taking
775        // the shortcut keeps the round trip byte-exact instead of subjecting an
776        // unambiguous result to reconstruction.
777        return Ok(ApplyReport {
778            content: theirs.content,
779            clean: true,
780            mode: ApplyMode::FastForward,
781            findings: Vec::new(),
782            unapplied: theirs.unapplied,
783        });
784    }
785
786    let result = weave_core::entity_merge_fmt(
787        &base,
788        target,
789        &theirs.content,
790        target_path,
791        &weave_core::MarkerFormat::default(),
792        host,
793    );
794    let findings = findings_of(&result);
795    Ok(ApplyReport {
796        clean: result.is_clean(),
797        content: result.content,
798        mode: ApplyMode::ThreeWay,
799        findings,
800        unapplied: theirs.unapplied,
801    })
802}
803
804/// Conflicts of a merge, in the read side's finding vocabulary.
805///
806/// The kind-and-advice mapping is `crate::wire::conflict_advice`, shared with
807/// `weave-mcp`'s findings document; the only thing this producer chooses is
808/// what to call the two sides.
809pub(crate) fn findings_of(result: &MergeResult) -> Vec<Finding> {
810    result.conflicts.iter().map(finding_of_conflict).collect()
811}
812
813fn finding_of_conflict(c: &EntityConflict) -> Finding {
814    let (kind, suggestion) = conflict_advice(c, Sides::PATCH);
815
816    let mut f = Finding::derived("CONFLICT", &c.entity_name, &c.entity_type);
817    f.source = "conflict".to_string();
818    f.kind = Some(kind.to_string());
819    f.base = c.base_content.clone();
820    f.ours = c.ours_content.clone();
821    f.theirs = c.theirs_content.clone();
822    f.complexity = Some(c.complexity.to_string());
823    f.suggestion = Some(suggestion);
824    f
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    const BASE: &str = "\
832def alpha(x):
833    return x + 1
834
835
836def beta(x):
837    return x * 2
838
839
840def gamma(x):
841    return x - 3
842";
843
844    fn roundtrip(base: &str, changed: &str) -> OpsDoc {
845        let doc = extract("m.py", base, changed, true);
846        let out =
847            apply(&doc, base, "m.py", None, &weave_core::host::Host::default()).expect("apply");
848        assert_eq!(out.content, changed, "round trip must be byte-exact");
849        doc
850    }
851
852    // ---- the round-trip property ----------------------------------------
853
854    #[test]
855    fn extract_then_apply_reproduces_an_edit_exactly_at_entity_granularity() {
856        let changed = BASE.replace("return x * 2", "return x * 22");
857        let doc = roundtrip(BASE, &changed);
858        assert!(doc.exact, "an in-body edit is expressible as one op");
859        assert_eq!(doc.ops.len(), 1);
860        assert_eq!(doc.ops[0].op, Op::Edited);
861        assert_eq!(doc.ops[0].entity, "beta");
862    }
863
864    #[test]
865    fn extract_then_apply_reproduces_a_rename_exactly() {
866        let changed = BASE.replace("def beta(x):", "def bravo(x):");
867        let doc = roundtrip(BASE, &changed);
868        assert!(doc.exact);
869        assert_eq!(doc.ops[0].op, Op::Renamed);
870        assert_eq!(doc.ops[0].from.as_deref(), Some("beta"));
871        assert_eq!(doc.ops[0].to.as_deref(), Some("bravo"));
872    }
873
874    /// **A known matcher gap, pinned deliberately.**
875    ///
876    /// Findings are only as good as the matcher they're built on: a rename that
877    /// also rewrites the body should still pair across the rename, but
878    /// sem-core's matcher does not pair it here, so `extract` sees a delete and
879    /// an add, and that is what it emits.
880    ///
881    /// It is emitted honestly rather than papered over with a local
882    /// similarity heuristic: `apply` synthesises `theirs` and hands it to
883    /// `entity_merge`, which runs the *same* matcher again — so a locally
884    /// invented pairing would be undone downstream while making this document
885    /// disagree with weave-core about what happened. The round trip is still
886    /// exact; the cost is fidelity against a drifted target, where a
887    /// rename+edit degrades to modify/delete instead of rename/modify.
888    #[test]
889    fn a_rename_that_also_edits_the_body_degrades_to_delete_plus_add_under_the_current_matcher() {
890        let changed = BASE
891            .replace("def beta(x):", "def bravo(x):")
892            .replace("return x * 2", "return x * 22");
893        let doc = roundtrip(BASE, &changed);
894        let mut kinds: Vec<&str> = doc.ops.iter().map(|o| o.op.wire()).collect();
895        kinds.sort_unstable();
896        assert_eq!(kinds, vec!["added", "deleted"]);
897        assert!(doc.exact, "still byte-exact, just coarser than it could be");
898    }
899
900    /// `rename_edited` stays in the vocabulary and stays *applicable*: a
901    /// hand-authored op (or a future matcher that pairs it) must land.
902    #[test]
903    fn a_hand_authored_rename_edited_op_applies() {
904        let doc = OpsDoc {
905            schema_version: OPS_SCHEMA_VERSION.into(),
906            file: "m.py".into(),
907            base: None,
908            exact: true,
909            ops: vec![PatchOp {
910                op: Op::RenameEdited,
911                entity: "beta".into(),
912                entity_type: "function".into(),
913                from: Some("beta".into()),
914                to: Some("bravo".into()),
915                content: Some("def bravo(x):\n    return x * 22\n".into()),
916                after: None,
917                separator: None,
918                fallback: false,
919            }],
920        };
921        let out = apply_ops(BASE, "m.py", &doc);
922        assert!(out.unapplied.is_empty());
923        assert!(out.content.contains("def bravo(x):\n    return x * 22"));
924        assert!(!out.content.contains("def beta"));
925        assert!(out.content.contains("def alpha") && out.content.contains("def gamma"));
926    }
927
928    #[test]
929    fn extract_then_apply_reproduces_a_deletion_exactly() {
930        let changed = "\
931def alpha(x):
932    return x + 1
933
934
935def gamma(x):
936    return x - 3
937";
938        let doc = roundtrip(BASE, changed);
939        assert!(doc.exact, "deleting an entity takes its gap with it");
940        assert_eq!(doc.ops[0].op, Op::Deleted);
941        assert_eq!(doc.ops[0].entity, "beta");
942    }
943
944    #[test]
945    fn extract_then_apply_reproduces_an_addition_exactly() {
946        let changed = format!("{BASE}\n\ndef delta(x):\n    return x / 4\n");
947        let doc = roundtrip(BASE, &changed);
948        assert!(doc.exact);
949        let added = doc
950            .ops
951            .iter()
952            .find(|o| o.op == Op::Added)
953            .expect("added op");
954        assert_eq!(added.entity, "delta");
955        assert_eq!(added.after.as_deref(), Some("gamma"));
956    }
957
958    #[test]
959    fn extract_then_apply_reproduces_all_four_op_kinds_at_once() {
960        let changed = "\
961def alpha(x):
962    return x + 100
963
964
965def bravo(x):
966    return x * 2
967
968
969def delta(x):
970    return x / 4
971";
972        let doc = roundtrip(BASE, changed);
973        let mut kinds: Vec<&str> = doc.ops.iter().map(|o| o.op.wire()).collect();
974        kinds.sort_unstable();
975        assert_eq!(kinds, vec!["added", "deleted", "edited", "renamed"]);
976        assert!(
977            doc.exact,
978            "the whole change is expressible as ops: {doc:#?}"
979        );
980    }
981
982    #[test]
983    fn a_docstring_edit_is_an_op_on_the_entity_it_documents() {
984        let base = "# lead comment\ndef alpha(x):\n    return x\n";
985        let changed = "# better comment\ndef alpha(x):\n    return x\n";
986        let doc = roundtrip(base, changed);
987        assert!(doc.exact, "leading comments bundle into the entity");
988        assert_eq!(doc.ops[0].entity, "alpha");
989    }
990
991    // ---- the escape hatch ------------------------------------------------
992
993    #[test]
994    fn a_change_outside_every_entity_is_reported_as_inexact_and_still_round_trips() {
995        // The trailing module-level statement belongs to no entity.
996        let changed = format!("{BASE}\nprint(alpha(1))\n");
997        let doc = roundtrip(BASE, &changed);
998        assert!(
999            !doc.exact,
1000            "weave must not claim entity granularity it lacks"
1001        );
1002        let last = doc.ops.last().unwrap();
1003        assert!(last.fallback);
1004        assert_eq!(last.entity, FILE_KEY);
1005    }
1006
1007    // ---- drift ------------------------------------------------------------
1008
1009    #[test]
1010    fn ops_land_cleanly_on_a_target_with_a_disjoint_edit() {
1011        // The patch edits beta …
1012        let changed = BASE.replace("return x * 2", "return x * 22");
1013        let doc = extract("m.py", BASE, &changed, true);
1014        // … while the target has independently edited gamma.
1015        let target = BASE.replace("return x - 3", "return x - 33");
1016
1017        let out = apply(
1018            &doc,
1019            &target,
1020            "m.py",
1021            None,
1022            &weave_core::host::Host::default(),
1023        )
1024        .expect("apply");
1025        assert_eq!(out.mode, ApplyMode::ThreeWay, "the target drifted");
1026        assert!(out.clean, "disjoint entities compose");
1027        assert!(
1028            out.content.contains("return x * 22"),
1029            "the patch's edit landed"
1030        );
1031        assert!(
1032            out.content.contains("return x - 33"),
1033            "the target's edit survived"
1034        );
1035        assert!(out.findings.is_empty());
1036    }
1037
1038    #[test]
1039    fn a_deletion_lands_on_a_drifted_target() {
1040        let changed = BASE.replace("def beta(x):\n    return x * 2\n\n\n", "");
1041        let doc = extract("m.py", BASE, &changed, true);
1042        let target = BASE.replace("return x + 1", "return x + 11");
1043        let out = apply(
1044            &doc,
1045            &target,
1046            "m.py",
1047            None,
1048            &weave_core::host::Host::default(),
1049        )
1050        .expect("apply");
1051        assert!(out.clean, "{:#?}", out.findings);
1052        assert!(!out.content.contains("def beta"));
1053        assert!(out.content.contains("return x + 11"));
1054    }
1055
1056    // ---- honest conflict --------------------------------------------------
1057
1058    #[test]
1059    fn divergent_renames_conflict_rather_than_pick_a_winner() {
1060        let changed = BASE.replace("def beta(x):", "def bravo(x):");
1061        let doc = extract("m.py", BASE, &changed, true);
1062        // The target renamed the same entity to something else.
1063        let target = BASE.replace("def beta(x):", "def charlie(x):");
1064
1065        let out = apply(
1066            &doc,
1067            &target,
1068            "m.py",
1069            None,
1070            &weave_core::host::Host::default(),
1071        )
1072        .expect("apply");
1073        assert!(!out.clean, "weave must refuse to guess between two names");
1074        assert_eq!(out.mode, ApplyMode::ThreeWay);
1075        let f = out.findings.first().expect("a CONFLICT finding");
1076        assert_eq!(f.class, "CONFLICT");
1077        assert_eq!(f.source, "conflict");
1078        assert_eq!(f.kind.as_deref(), Some("rename_rename"));
1079        assert!(f.suggestion.as_ref().unwrap().contains("call site"));
1080        assert!(
1081            out.content.contains("<<<<<<<") || out.content.contains("======="),
1082            "a conflict renders as markers, not as a silent choice:\n{}",
1083            out.content
1084        );
1085    }
1086
1087    #[test]
1088    fn both_sides_editing_one_body_conflicts() {
1089        let changed = BASE.replace("return x * 2", "return x * 22");
1090        let doc = extract("m.py", BASE, &changed, true);
1091        let target = BASE.replace("return x * 2", "return x * 222");
1092        let out = apply(
1093            &doc,
1094            &target,
1095            "m.py",
1096            None,
1097            &weave_core::host::Host::default(),
1098        )
1099        .expect("apply");
1100        assert!(!out.clean);
1101        assert_eq!(out.findings[0].kind.as_deref(), Some("both_modified"));
1102    }
1103
1104    // ---- the decode boundary -----------------------------------------------
1105
1106    /// Every way an ops document can fail to be one, refused by name.
1107    ///
1108    /// The point is not that these are rejected — three of the four were
1109    /// rejected before, as prose. It is that a caller can tell which happened
1110    /// without reading a sentence, and that the two that were *accepted*
1111    /// (an unknown field, an unknown verb) no longer are.
1112    #[test]
1113    fn a_malformed_ops_document_is_refused_with_a_matchable_error() {
1114        let good = r#"{
1115            "schema_version": "1.0.0",
1116            "file": "m.py",
1117            "exact": true,
1118            "ops": [{"op": "edited", "entity": "alpha", "entity_type": "function",
1119                     "content": "def alpha():\n    return 2\n"}]
1120        }"#;
1121        let doc = parse_ops_doc(good).expect("a well-formed document");
1122        assert_eq!(doc.ops[0].op, Op::Edited);
1123
1124        // Not JSON at all.
1125        assert!(matches!(
1126            parse_ops_doc("this is not json"),
1127            Err(OpsDocError::Malformed { .. })
1128        ));
1129
1130        // JSON, but missing a field the schema requires.
1131        let missing = r#"{"schema_version": "1.0.0", "file": "m.py", "ops": []}"#;
1132        assert!(matches!(
1133            parse_ops_doc(missing),
1134            Err(OpsDocError::Malformed { .. })
1135        ));
1136
1137        // A field the schema does not define. `entitiy` used to be discarded
1138        // in silence and the op applied to an entity named "".
1139        let typo = good.replace("\"entity\":", "\"entitiy\":");
1140        assert!(
1141            matches!(parse_ops_doc(&typo), Err(OpsDocError::Malformed { .. })),
1142            "a misspelled field is not a document this build accepts"
1143        );
1144
1145        // A verb outside the vocabulary. `eddited` used to decode and then
1146        // fall through the apply match into the "everything else" arm.
1147        let unknown_verb = good.replace("\"edited\"", "\"eddited\"");
1148        assert!(matches!(
1149            parse_ops_doc(&unknown_verb),
1150            Err(OpsDocError::Malformed { .. })
1151        ));
1152
1153        // A major this build does not implement — a different thing entirely,
1154        // and the only one of the five a caller fixes by upgrading weave.
1155        let future = good.replace("1.0.0", "2.0.0");
1156        match parse_ops_doc(&future) {
1157            Err(OpsDocError::UnknownMajor { found }) => assert_eq!(found, "2.0.0"),
1158            other => panic!("expected UnknownMajor, got {other:?}"),
1159        }
1160    }
1161
1162    /// The reserved relational verbs decode and are acted on by nobody — the
1163    /// schema's own rule for a v1 consumer, now stated in the type.
1164    #[test]
1165    fn a_reserved_verb_decodes_and_changes_nothing() {
1166        let doc = r#"{
1167            "schema_version": "1.0.0",
1168            "file": "m.py",
1169            "exact": true,
1170            "ops": [{"op": "moved", "entity": "alpha", "entity_type": "function"}]
1171        }"#;
1172        let doc = parse_ops_doc(doc).expect("reserved verbs are accepted");
1173        assert_eq!(doc.ops[0].op, Op::Moved);
1174        let out = apply_ops(BASE, "m.py", &doc);
1175        assert_eq!(out.content, BASE, "a reserved op is not acted on");
1176    }
1177
1178    // ---- base integrity ---------------------------------------------------
1179
1180    #[test]
1181    fn a_base_that_is_not_the_ops_base_is_refused_not_merged() {
1182        let changed = BASE.replace("return x * 2", "return x * 22");
1183        let doc = extract("m.py", BASE, &changed, false);
1184        let wrong_base = BASE.replace("def alpha(x):", "def alfa(x):");
1185        let err = apply(
1186            &doc,
1187            BASE,
1188            "m.py",
1189            Some(&wrong_base),
1190            &weave_core::host::Host::default(),
1191        )
1192        .unwrap_err();
1193        // The refusal is matchable, and both digests travel with it — the
1194        // caller does not have to re-hash anything to report what happened.
1195        let PatchError::BaseMismatch { expected, actual } = err;
1196        assert_eq!(expected, doc.base.as_ref().expect("base ref").sha256);
1197        assert_eq!(actual, sha256_hex(&wrong_base));
1198        assert_ne!(expected, actual);
1199    }
1200
1201    #[test]
1202    fn with_no_base_available_the_target_stands_in_and_the_mode_says_so() {
1203        let changed = BASE.replace("return x * 2", "return x * 22");
1204        let doc = extract("m.py", BASE, &changed, false); // no inline base
1205        let out =
1206            apply(&doc, BASE, "m.py", None, &weave_core::host::Host::default()).expect("apply");
1207        assert_eq!(out.mode, ApplyMode::NoBase);
1208        assert_eq!(out.content, changed);
1209    }
1210
1211    #[test]
1212    fn an_op_whose_entity_is_absent_is_reported_rather_than_dropped_silently() {
1213        let changed = BASE.replace("return x * 2", "return x * 22");
1214        let doc = extract("m.py", BASE, &changed, false);
1215        let target = "def alpha(x):\n    return x + 1\n"; // no beta at all
1216        let out = apply(
1217            &doc,
1218            target,
1219            "m.py",
1220            None,
1221            &weave_core::host::Host::default(),
1222        )
1223        .expect("apply");
1224        assert_eq!(out.unapplied, vec!["edited beta"]);
1225    }
1226
1227    // ---- vocabulary -------------------------------------------------------
1228
1229    #[test]
1230    fn v1_never_emits_unchanged_or_the_reserved_relational_ops() {
1231        let changed = BASE.replace("return x * 2", "return x * 22");
1232        let doc = extract("m.py", BASE, &changed, true);
1233        for op in &doc.ops {
1234            assert!(
1235                ["added", "edited", "renamed", "rename_edited", "deleted"].contains(&op.op.wire()),
1236                "unexpected op `{}`",
1237                op.op.wire()
1238            );
1239        }
1240    }
1241
1242    #[test]
1243    fn a_consumer_tolerates_reserved_relational_ops_without_choking() {
1244        let mut doc = extract("m.py", BASE, BASE, true);
1245        doc.ops.push(PatchOp {
1246            op: Op::Moved,
1247            entity: "beta".into(),
1248            entity_type: "function".into(),
1249            from: None,
1250            to: None,
1251            content: None,
1252            after: None,
1253            separator: None,
1254            fallback: false,
1255        });
1256        let out = apply_ops(BASE, "m.py", &doc);
1257        assert_eq!(out.content, BASE, "a reserved op is accepted and ignored");
1258    }
1259
1260    #[test]
1261    fn the_document_round_trips_through_json() {
1262        let changed = BASE.replace("return x * 2", "return x * 22");
1263        let doc = extract("m.py", BASE, &changed, true);
1264        let json = serde_json::to_string(&doc).unwrap();
1265        let back: OpsDoc = serde_json::from_str(&json).unwrap();
1266        assert_eq!(doc, back);
1267    }
1268}