use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sem_core::model::change::ChangeType;
use sem_core::model::identity::match_entities;
use weave_core::binding::replace_at_word_boundaries;
use weave_core::conflict::EntityConflict;
use weave_core::MergeResult;
use crate::parsers::entities_of;
use crate::wire::{conflict_advice, Finding, Sides};
pub(crate) const OPS_SCHEMA_VERSION: &str = "1.0.0";
pub const FILE_KEY: &str = "(file)";
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PatchError {
#[error(
"base mismatch: the ops were extracted against sha256 {expected}, \
but the supplied base hashes to {actual}"
)]
BaseMismatch {
expected: String,
actual: String,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Op {
Added,
Edited,
Renamed,
RenameEdited,
Deleted,
Unchanged,
Split,
Merged,
Copied,
Moved,
}
impl Op {
pub fn wire(self) -> &'static str {
match self {
Op::Added => "added",
Op::Edited => "edited",
Op::Renamed => "renamed",
Op::RenameEdited => "rename_edited",
Op::Deleted => "deleted",
Op::Unchanged => "unchanged",
Op::Split => "split",
Op::Merged => "merged",
Op::Copied => "copied",
Op::Moved => "moved",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum OpsDocError {
#[error("not a weave ops document: {source}")]
Malformed {
#[source]
source: serde_json::Error,
},
#[error("schema_version {found} is a major this build does not know (it implements 1.x)")]
UnknownMajor { found: String },
}
pub fn parse_ops_doc(text: &str) -> Result<OpsDoc, OpsDocError> {
let doc: OpsDoc =
serde_json::from_str(text).map_err(|source| OpsDocError::Malformed { source })?;
if !doc.schema_version.starts_with("1.") {
return Err(OpsDocError::UnknownMajor {
found: doc.schema_version,
});
}
Ok(doc)
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct OpsDoc {
pub schema_version: String,
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub base: Option<BaseRef>,
pub exact: bool,
pub ops: Vec<PatchOp>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BaseRef {
pub sha256: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub inline: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PatchOp {
pub op: Op,
pub entity: String,
pub entity_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub separator: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub fallback: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
pub(crate) fn sha256_hex(content: &str) -> String {
let mut h = Sha256::new();
h.update(content.as_bytes());
format!("{:x}", h.finalize())
}
#[derive(Debug, Clone)]
struct Region {
name: String,
start: usize,
end: usize,
}
fn is_lead_line(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("//")
|| t.starts_with('#')
|| t.starts_with("/*")
|| t.starts_with('*')
|| t.starts_with('@')
|| t.starts_with("--")
|| t.starts_with(";;")
}
fn regions_of(path: &str, content: &str) -> Vec<Region> {
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
let mut regions: Vec<Region> = Vec::new();
let mut floor = 0usize; for e in entities_of(path, content) {
let start0 = e.start_line.saturating_sub(1).min(total);
let end0 = e.end_line.min(total);
if end0 <= start0 {
continue;
}
let mut bundled = start0;
while bundled > floor && is_lead_line(lines[bundled - 1]) {
bundled -= 1;
}
regions.push(Region {
name: e.name.clone(),
start: bundled,
end: end0,
});
floor = end0;
}
regions
}
fn slice(lines: &[&str], start: usize, end: usize) -> String {
if start >= end {
return String::new();
}
let mut s = lines[start..end].join("\n");
s.push('\n');
s
}
pub fn extract(path: &str, base: &str, changed: &str, embed_base: bool) -> OpsDoc {
let base_ents = entities_of(path, base);
let changed_ents = entities_of(path, changed);
let changed_lines: Vec<&str> = changed.lines().collect();
let changed_regions = regions_of(path, changed);
let region_text = |name: &str| -> Option<String> {
changed_regions
.iter()
.find(|r| r.name == name)
.map(|r| slice(&changed_lines, r.start, r.end))
};
let mut ops: Vec<PatchOp> = Vec::new();
let changes = match_entities(&base_ents, &changed_ents, path, None, None, None).changes;
let to_base_name: std::collections::HashMap<String, String> = changes
.iter()
.filter(|c| c.change_type == ChangeType::Renamed)
.filter_map(|c| {
c.old_entity_name
.clone()
.map(|old| (c.entity_name.clone(), old))
})
.collect();
for c in &changes {
let name = c.entity_name.clone();
match c.change_type {
ChangeType::Added => {
let (after, separator) = anchor_for(&changed_regions, &changed_lines, &name);
let after = after.map(|a| to_base_name.get(&a).cloned().unwrap_or(a));
ops.push(PatchOp {
op: Op::Added,
entity: name.clone(),
entity_type: c.entity_type.clone(),
from: None,
to: None,
content: region_text(&name),
after,
separator,
fallback: false,
});
}
ChangeType::Deleted => ops.push(PatchOp {
op: Op::Deleted,
entity: name.clone(),
entity_type: c.entity_type.clone(),
from: None,
to: None,
content: None,
after: None,
separator: None,
fallback: false,
}),
ChangeType::Renamed => {
let old = c.old_entity_name.clone().unwrap_or_else(|| name.clone());
let new_text = region_text(&name);
let base_text = base_ents
.iter()
.find(|e| e.name == old)
.map(|_| region_slice(path, base, &old))
.unwrap_or_default();
let unrenamed = new_text
.as_deref()
.map(|t| replace_at_word_boundaries(t, &name, &old))
.unwrap_or_default();
let op = if !base_text.is_empty() && unrenamed == base_text {
Op::Renamed
} else {
Op::RenameEdited
};
ops.push(PatchOp {
op,
entity: old.clone(),
entity_type: c.entity_type.clone(),
from: Some(old),
to: Some(name.clone()),
content: new_text,
after: None,
separator: None,
fallback: false,
});
}
ChangeType::Modified | ChangeType::Moved | ChangeType::Reordered => ops.push(PatchOp {
op: Op::Edited,
entity: name.clone(),
entity_type: c.entity_type.clone(),
from: None,
to: None,
content: region_text(&name),
after: None,
separator: None,
fallback: false,
}),
}
}
let touched: std::collections::HashSet<&str> = changes
.iter()
.map(|c| c.entity_name.as_str())
.chain(changes.iter().filter_map(|c| c.old_entity_name.as_deref()))
.collect();
let base_regions = regions_of(path, base);
let base_lines: Vec<&str> = base.lines().collect();
for r in &base_regions {
if touched.contains(r.name.as_str()) {
continue;
}
let Some(new_text) = region_text(&r.name) else {
continue;
};
let old_text = slice(&base_lines, r.start, r.end);
if old_text != new_text {
let entity_type = base_ents
.iter()
.find(|e| e.name == r.name)
.map(|e| e.entity_type.clone())
.unwrap_or_else(|| "unknown".to_string());
ops.push(PatchOp {
op: Op::Edited,
entity: r.name.clone(),
entity_type,
from: None,
to: None,
content: Some(new_text),
after: None,
separator: None,
fallback: false,
});
}
}
ops.sort_by_key(|o| match o.op {
Op::Deleted => 0,
Op::Added => 2,
_ => 1,
});
let mut doc = OpsDoc {
schema_version: OPS_SCHEMA_VERSION.to_string(),
file: path.to_string(),
base: Some(BaseRef {
sha256: sha256_hex(base),
inline: embed_base.then(|| base.to_string()),
}),
exact: true,
ops,
};
let replay = apply_ops(base, path, &doc);
if replay.content != changed {
doc.exact = false;
doc.ops.push(PatchOp {
op: Op::Edited,
entity: FILE_KEY.into(),
entity_type: "file".into(),
from: None,
to: None,
content: Some(changed.to_string()),
after: None,
separator: None,
fallback: true,
});
}
doc
}
fn region_slice(path: &str, content: &str, name: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
regions_of(path, content)
.iter()
.find(|r| r.name == name)
.map(|r| slice(&lines, r.start, r.end))
.unwrap_or_default()
}
fn anchor_for(regions: &[Region], lines: &[&str], name: &str) -> (Option<String>, Option<String>) {
let Some(idx) = regions.iter().position(|r| r.name == name) else {
return (None, None);
};
if idx > 0 {
let prev = ®ions[idx - 1];
let gap = slice(lines, prev.end, regions[idx].start);
(Some(prev.name.clone()), Some(gap))
} else {
let gap = regions
.get(idx + 1)
.map(|next| slice(lines, regions[idx].end, next.start))
.unwrap_or_default();
(None, Some(gap))
}
}
struct Edit {
order: usize,
start: usize,
end: usize,
replacement: Vec<String>,
}
impl Edit {
fn is_insertion(&self) -> bool {
self.start == self.end
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct NaiveApply {
pub content: String,
pub unapplied: Vec<String>,
}
pub(crate) fn apply_ops(source: &str, path: &str, doc: &OpsDoc) -> NaiveApply {
if let Some(op) = doc
.ops
.iter()
.rev()
.find(|o| o.fallback && o.entity == FILE_KEY)
{
return NaiveApply {
content: op.content.clone().unwrap_or_default(),
unapplied: Vec::new(),
};
}
let lines: Vec<&str> = source.lines().collect();
let trailing_newline = source.is_empty() || source.ends_with('\n');
let regions = regions_of(path, source);
let find = |name: &str| regions.iter().position(|r| r.name == name);
let mut edits: Vec<Edit> = Vec::new();
let mut unapplied: Vec<String> = Vec::new();
let to_lines = |s: &str| -> Vec<String> { s.lines().map(|l| l.to_string()).collect() };
let mut push = |start: usize, end: usize, replacement: Vec<String>| {
edits.push(Edit {
order: edits.len(),
start,
end,
replacement,
})
};
for op in &doc.ops {
match op.op {
Op::Deleted => match find(&op.entity) {
Some(i) => {
let r = ®ions[i];
let (start, end) = match regions.get(i + 1) {
Some(next) => (r.start, next.start),
None if i > 0 => (regions[i - 1].end, r.end),
None => (r.start, r.end),
};
push(start, end, Vec::new());
}
None => unapplied.push(format!("deleted {}", op.entity)),
},
Op::Edited | Op::Renamed | Op::RenameEdited => match find(&op.entity) {
Some(i) => {
let r = ®ions[i];
push(
r.start,
r.end,
to_lines(op.content.as_deref().unwrap_or("")),
);
}
None => unapplied.push(format!("{} {}", op.op.wire(), op.entity)),
},
Op::Added => {
let text = op.content.clone().unwrap_or_default();
let sep = op.separator.clone().unwrap_or_default();
let (at, payload) = match &op.after {
Some(anchor) => match find(anchor) {
Some(i) => (regions[i].end, format!("{sep}{text}")),
None => {
unapplied
.push(format!("added {} (anchor `{anchor}` absent)", op.entity));
(lines.len(), format!("\n{text}"))
}
},
None => (0, format!("{text}{sep}")),
};
push(at, at, to_lines(&payload));
}
Op::Unchanged | Op::Split | Op::Merged | Op::Copied | Op::Moved => {}
}
}
edits.sort_by(|a, b| {
b.start
.cmp(&a.start)
.then(a.is_insertion().cmp(&b.is_insertion()))
.then(b.order.cmp(&a.order))
});
let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
for e in edits {
let start = e.start.min(out.len());
let end = e.end.clamp(start, out.len());
out.splice(start..end, e.replacement);
}
let mut content = out.join("\n");
if trailing_newline && !content.is_empty() {
content.push('\n');
}
NaiveApply { content, unapplied }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyMode {
FastForward,
ThreeWay,
NoBase,
}
impl ApplyMode {
pub fn wire(self) -> &'static str {
match self {
ApplyMode::FastForward => "fast_forward",
ApplyMode::ThreeWay => "three_way",
ApplyMode::NoBase => "no_base",
}
}
}
#[derive(Debug, Clone)]
pub struct ApplyReport {
pub content: String,
pub clean: bool,
pub mode: ApplyMode,
pub findings: Vec<Finding>,
pub unapplied: Vec<String>,
}
pub fn apply(
doc: &OpsDoc,
target: &str,
target_path: &str,
declared_base: Option<&str>,
host: &weave_core::host::Host,
) -> Result<ApplyReport, PatchError> {
let base: Option<String> = declared_base
.map(|s| s.to_string())
.or_else(|| doc.base.as_ref().and_then(|b| b.inline.clone()));
if let (Some(b), Some(r)) = (base.as_deref(), doc.base.as_ref()) {
let actual = sha256_hex(b);
if actual != r.sha256 {
return Err(PatchError::BaseMismatch {
expected: r.sha256.clone(),
actual,
});
}
}
let Some(base) = base else {
let naive = apply_ops(target, target_path, doc);
return Ok(ApplyReport {
content: naive.content,
clean: true,
mode: ApplyMode::NoBase,
findings: Vec::new(),
unapplied: naive.unapplied,
});
};
let theirs = apply_ops(&base, target_path, doc);
if base == target {
return Ok(ApplyReport {
content: theirs.content,
clean: true,
mode: ApplyMode::FastForward,
findings: Vec::new(),
unapplied: theirs.unapplied,
});
}
let result = weave_core::entity_merge_fmt(
&base,
target,
&theirs.content,
target_path,
&weave_core::MarkerFormat::default(),
host,
);
let findings = findings_of(&result);
Ok(ApplyReport {
clean: result.is_clean(),
content: result.content,
mode: ApplyMode::ThreeWay,
findings,
unapplied: theirs.unapplied,
})
}
pub(crate) fn findings_of(result: &MergeResult) -> Vec<Finding> {
result.conflicts.iter().map(finding_of_conflict).collect()
}
fn finding_of_conflict(c: &EntityConflict) -> Finding {
let (kind, suggestion) = conflict_advice(c, Sides::PATCH);
let mut f = Finding::derived("CONFLICT", &c.entity_name, &c.entity_type);
f.source = "conflict".to_string();
f.kind = Some(kind.to_string());
f.base = c.base_content.clone();
f.ours = c.ours_content.clone();
f.theirs = c.theirs_content.clone();
f.complexity = Some(c.complexity.to_string());
f.suggestion = Some(suggestion);
f
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: &str = "\
def alpha(x):
return x + 1
def beta(x):
return x * 2
def gamma(x):
return x - 3
";
fn roundtrip(base: &str, changed: &str) -> OpsDoc {
let doc = extract("m.py", base, changed, true);
let out =
apply(&doc, base, "m.py", None, &weave_core::host::Host::default()).expect("apply");
assert_eq!(out.content, changed, "round trip must be byte-exact");
doc
}
#[test]
fn extract_then_apply_reproduces_an_edit_exactly_at_entity_granularity() {
let changed = BASE.replace("return x * 2", "return x * 22");
let doc = roundtrip(BASE, &changed);
assert!(doc.exact, "an in-body edit is expressible as one op");
assert_eq!(doc.ops.len(), 1);
assert_eq!(doc.ops[0].op, Op::Edited);
assert_eq!(doc.ops[0].entity, "beta");
}
#[test]
fn extract_then_apply_reproduces_a_rename_exactly() {
let changed = BASE.replace("def beta(x):", "def bravo(x):");
let doc = roundtrip(BASE, &changed);
assert!(doc.exact);
assert_eq!(doc.ops[0].op, Op::Renamed);
assert_eq!(doc.ops[0].from.as_deref(), Some("beta"));
assert_eq!(doc.ops[0].to.as_deref(), Some("bravo"));
}
#[test]
fn a_rename_that_also_edits_the_body_degrades_to_delete_plus_add_under_the_current_matcher() {
let changed = BASE
.replace("def beta(x):", "def bravo(x):")
.replace("return x * 2", "return x * 22");
let doc = roundtrip(BASE, &changed);
let mut kinds: Vec<&str> = doc.ops.iter().map(|o| o.op.wire()).collect();
kinds.sort_unstable();
assert_eq!(kinds, vec!["added", "deleted"]);
assert!(doc.exact, "still byte-exact, just coarser than it could be");
}
#[test]
fn a_hand_authored_rename_edited_op_applies() {
let doc = OpsDoc {
schema_version: OPS_SCHEMA_VERSION.into(),
file: "m.py".into(),
base: None,
exact: true,
ops: vec![PatchOp {
op: Op::RenameEdited,
entity: "beta".into(),
entity_type: "function".into(),
from: Some("beta".into()),
to: Some("bravo".into()),
content: Some("def bravo(x):\n return x * 22\n".into()),
after: None,
separator: None,
fallback: false,
}],
};
let out = apply_ops(BASE, "m.py", &doc);
assert!(out.unapplied.is_empty());
assert!(out.content.contains("def bravo(x):\n return x * 22"));
assert!(!out.content.contains("def beta"));
assert!(out.content.contains("def alpha") && out.content.contains("def gamma"));
}
#[test]
fn extract_then_apply_reproduces_a_deletion_exactly() {
let changed = "\
def alpha(x):
return x + 1
def gamma(x):
return x - 3
";
let doc = roundtrip(BASE, changed);
assert!(doc.exact, "deleting an entity takes its gap with it");
assert_eq!(doc.ops[0].op, Op::Deleted);
assert_eq!(doc.ops[0].entity, "beta");
}
#[test]
fn extract_then_apply_reproduces_an_addition_exactly() {
let changed = format!("{BASE}\n\ndef delta(x):\n return x / 4\n");
let doc = roundtrip(BASE, &changed);
assert!(doc.exact);
let added = doc
.ops
.iter()
.find(|o| o.op == Op::Added)
.expect("added op");
assert_eq!(added.entity, "delta");
assert_eq!(added.after.as_deref(), Some("gamma"));
}
#[test]
fn extract_then_apply_reproduces_all_four_op_kinds_at_once() {
let changed = "\
def alpha(x):
return x + 100
def bravo(x):
return x * 2
def delta(x):
return x / 4
";
let doc = roundtrip(BASE, changed);
let mut kinds: Vec<&str> = doc.ops.iter().map(|o| o.op.wire()).collect();
kinds.sort_unstable();
assert_eq!(kinds, vec!["added", "deleted", "edited", "renamed"]);
assert!(
doc.exact,
"the whole change is expressible as ops: {doc:#?}"
);
}
#[test]
fn a_docstring_edit_is_an_op_on_the_entity_it_documents() {
let base = "# lead comment\ndef alpha(x):\n return x\n";
let changed = "# better comment\ndef alpha(x):\n return x\n";
let doc = roundtrip(base, changed);
assert!(doc.exact, "leading comments bundle into the entity");
assert_eq!(doc.ops[0].entity, "alpha");
}
#[test]
fn a_change_outside_every_entity_is_reported_as_inexact_and_still_round_trips() {
let changed = format!("{BASE}\nprint(alpha(1))\n");
let doc = roundtrip(BASE, &changed);
assert!(
!doc.exact,
"weave must not claim entity granularity it lacks"
);
let last = doc.ops.last().unwrap();
assert!(last.fallback);
assert_eq!(last.entity, FILE_KEY);
}
#[test]
fn ops_land_cleanly_on_a_target_with_a_disjoint_edit() {
let changed = BASE.replace("return x * 2", "return x * 22");
let doc = extract("m.py", BASE, &changed, true);
let target = BASE.replace("return x - 3", "return x - 33");
let out = apply(
&doc,
&target,
"m.py",
None,
&weave_core::host::Host::default(),
)
.expect("apply");
assert_eq!(out.mode, ApplyMode::ThreeWay, "the target drifted");
assert!(out.clean, "disjoint entities compose");
assert!(
out.content.contains("return x * 22"),
"the patch's edit landed"
);
assert!(
out.content.contains("return x - 33"),
"the target's edit survived"
);
assert!(out.findings.is_empty());
}
#[test]
fn a_deletion_lands_on_a_drifted_target() {
let changed = BASE.replace("def beta(x):\n return x * 2\n\n\n", "");
let doc = extract("m.py", BASE, &changed, true);
let target = BASE.replace("return x + 1", "return x + 11");
let out = apply(
&doc,
&target,
"m.py",
None,
&weave_core::host::Host::default(),
)
.expect("apply");
assert!(out.clean, "{:#?}", out.findings);
assert!(!out.content.contains("def beta"));
assert!(out.content.contains("return x + 11"));
}
#[test]
fn divergent_renames_conflict_rather_than_pick_a_winner() {
let changed = BASE.replace("def beta(x):", "def bravo(x):");
let doc = extract("m.py", BASE, &changed, true);
let target = BASE.replace("def beta(x):", "def charlie(x):");
let out = apply(
&doc,
&target,
"m.py",
None,
&weave_core::host::Host::default(),
)
.expect("apply");
assert!(!out.clean, "weave must refuse to guess between two names");
assert_eq!(out.mode, ApplyMode::ThreeWay);
let f = out.findings.first().expect("a CONFLICT finding");
assert_eq!(f.class, "CONFLICT");
assert_eq!(f.source, "conflict");
assert_eq!(f.kind.as_deref(), Some("rename_rename"));
assert!(f.suggestion.as_ref().unwrap().contains("call site"));
assert!(
out.content.contains("<<<<<<<") || out.content.contains("======="),
"a conflict renders as markers, not as a silent choice:\n{}",
out.content
);
}
#[test]
fn both_sides_editing_one_body_conflicts() {
let changed = BASE.replace("return x * 2", "return x * 22");
let doc = extract("m.py", BASE, &changed, true);
let target = BASE.replace("return x * 2", "return x * 222");
let out = apply(
&doc,
&target,
"m.py",
None,
&weave_core::host::Host::default(),
)
.expect("apply");
assert!(!out.clean);
assert_eq!(out.findings[0].kind.as_deref(), Some("both_modified"));
}
#[test]
fn a_malformed_ops_document_is_refused_with_a_matchable_error() {
let good = r#"{
"schema_version": "1.0.0",
"file": "m.py",
"exact": true,
"ops": [{"op": "edited", "entity": "alpha", "entity_type": "function",
"content": "def alpha():\n return 2\n"}]
}"#;
let doc = parse_ops_doc(good).expect("a well-formed document");
assert_eq!(doc.ops[0].op, Op::Edited);
assert!(matches!(
parse_ops_doc("this is not json"),
Err(OpsDocError::Malformed { .. })
));
let missing = r#"{"schema_version": "1.0.0", "file": "m.py", "ops": []}"#;
assert!(matches!(
parse_ops_doc(missing),
Err(OpsDocError::Malformed { .. })
));
let typo = good.replace("\"entity\":", "\"entitiy\":");
assert!(
matches!(parse_ops_doc(&typo), Err(OpsDocError::Malformed { .. })),
"a misspelled field is not a document this build accepts"
);
let unknown_verb = good.replace("\"edited\"", "\"eddited\"");
assert!(matches!(
parse_ops_doc(&unknown_verb),
Err(OpsDocError::Malformed { .. })
));
let future = good.replace("1.0.0", "2.0.0");
match parse_ops_doc(&future) {
Err(OpsDocError::UnknownMajor { found }) => assert_eq!(found, "2.0.0"),
other => panic!("expected UnknownMajor, got {other:?}"),
}
}
#[test]
fn a_reserved_verb_decodes_and_changes_nothing() {
let doc = r#"{
"schema_version": "1.0.0",
"file": "m.py",
"exact": true,
"ops": [{"op": "moved", "entity": "alpha", "entity_type": "function"}]
}"#;
let doc = parse_ops_doc(doc).expect("reserved verbs are accepted");
assert_eq!(doc.ops[0].op, Op::Moved);
let out = apply_ops(BASE, "m.py", &doc);
assert_eq!(out.content, BASE, "a reserved op is not acted on");
}
#[test]
fn a_base_that_is_not_the_ops_base_is_refused_not_merged() {
let changed = BASE.replace("return x * 2", "return x * 22");
let doc = extract("m.py", BASE, &changed, false);
let wrong_base = BASE.replace("def alpha(x):", "def alfa(x):");
let err = apply(
&doc,
BASE,
"m.py",
Some(&wrong_base),
&weave_core::host::Host::default(),
)
.unwrap_err();
let PatchError::BaseMismatch { expected, actual } = err;
assert_eq!(expected, doc.base.as_ref().expect("base ref").sha256);
assert_eq!(actual, sha256_hex(&wrong_base));
assert_ne!(expected, actual);
}
#[test]
fn with_no_base_available_the_target_stands_in_and_the_mode_says_so() {
let changed = BASE.replace("return x * 2", "return x * 22");
let doc = extract("m.py", BASE, &changed, false); let out =
apply(&doc, BASE, "m.py", None, &weave_core::host::Host::default()).expect("apply");
assert_eq!(out.mode, ApplyMode::NoBase);
assert_eq!(out.content, changed);
}
#[test]
fn an_op_whose_entity_is_absent_is_reported_rather_than_dropped_silently() {
let changed = BASE.replace("return x * 2", "return x * 22");
let doc = extract("m.py", BASE, &changed, false);
let target = "def alpha(x):\n return x + 1\n"; let out = apply(
&doc,
target,
"m.py",
None,
&weave_core::host::Host::default(),
)
.expect("apply");
assert_eq!(out.unapplied, vec!["edited beta"]);
}
#[test]
fn v1_never_emits_unchanged_or_the_reserved_relational_ops() {
let changed = BASE.replace("return x * 2", "return x * 22");
let doc = extract("m.py", BASE, &changed, true);
for op in &doc.ops {
assert!(
["added", "edited", "renamed", "rename_edited", "deleted"].contains(&op.op.wire()),
"unexpected op `{}`",
op.op.wire()
);
}
}
#[test]
fn a_consumer_tolerates_reserved_relational_ops_without_choking() {
let mut doc = extract("m.py", BASE, BASE, true);
doc.ops.push(PatchOp {
op: Op::Moved,
entity: "beta".into(),
entity_type: "function".into(),
from: None,
to: None,
content: None,
after: None,
separator: None,
fallback: false,
});
let out = apply_ops(BASE, "m.py", &doc);
assert_eq!(out.content, BASE, "a reserved op is accepted and ignored");
}
#[test]
fn the_document_round_trips_through_json() {
let changed = BASE.replace("return x * 2", "return x * 22");
let doc = extract("m.py", BASE, &changed, true);
let json = serde_json::to_string(&doc).unwrap();
let back: OpsDoc = serde_json::from_str(&json).unwrap();
assert_eq!(doc, back);
}
}