1use 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
63pub(crate) const OPS_SCHEMA_VERSION: &str = "1.0.0";
65
66pub const FILE_KEY: &str = "(file)";
69
70#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
79pub enum PatchError {
80 #[error(
82 "base mismatch: the ops were extracted against sha256 {expected}, \
83 but the supplied base hashes to {actual}"
84 )]
85 BaseMismatch {
86 expected: String,
88 actual: String,
90 },
91}
92
93#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum Op {
107 Added,
109 Edited,
111 Renamed,
113 RenameEdited,
115 Deleted,
117 Unchanged,
119 Split,
124 Merged,
126 Copied,
128 Moved,
130}
131
132impl Op {
133 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#[derive(Debug, thiserror::Error)]
159pub enum OpsDocError {
160 #[error("not a weave ops document: {source}")]
164 Malformed {
165 #[source]
166 source: serde_json::Error,
167 },
168
169 #[error("schema_version {found} is a major this build does not know (it implements 1.x)")]
171 UnknownMajor { found: String },
172}
173
174pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
195#[serde(deny_unknown_fields)]
196pub struct OpsDoc {
197 pub schema_version: String,
198 pub file: String,
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub base: Option<BaseRef>,
203 pub exact: bool,
207 pub ops: Vec<PatchOp>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
212#[serde(deny_unknown_fields)]
213pub struct BaseRef {
214 pub sha256: String,
217 #[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 pub op: Op,
228 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 #[serde(skip_serializing_if = "Option::is_none")]
238 pub content: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub after: Option<String>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub separator: Option<String>,
249 #[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#[derive(Debug, Clone)]
271struct Region {
272 name: String,
273 start: usize,
274 end: usize,
275}
276
277fn 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
289fn 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; 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
329pub 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 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 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 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 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 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 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
531fn 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 = ®ions[idx - 1];
539 let gap = slice(lines, prev.end, regions[idx].start);
540 (Some(prev.name.clone()), Some(gap))
541 } else {
542 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
552struct Edit {
559 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 pub unapplied: Vec<String>,
580}
581
582pub(crate) fn apply_ops(source: &str, path: &str, doc: &OpsDoc) -> NaiveApply {
586 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 = ®ions[i];
621 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 = ®ions[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 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 Op::Unchanged | Op::Split | Op::Merged | Op::Copied | Op::Moved => {}
667 }
668 }
669
670 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701pub enum ApplyMode {
702 FastForward,
704 ThreeWay,
706 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
730pub 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 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
804pub(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 #[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 #[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 #[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 #[test]
994 fn a_change_outside_every_entity_is_reported_as_inexact_and_still_round_trips() {
995 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 #[test]
1010 fn ops_land_cleanly_on_a_target_with_a_disjoint_edit() {
1011 let changed = BASE.replace("return x * 2", "return x * 22");
1013 let doc = extract("m.py", BASE, &changed, true);
1014 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 #[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 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 #[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 assert!(matches!(
1126 parse_ops_doc("this is not json"),
1127 Err(OpsDocError::Malformed { .. })
1128 ));
1129
1130 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 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 let unknown_verb = good.replace("\"edited\"", "\"eddited\"");
1148 assert!(matches!(
1149 parse_ops_doc(&unknown_verb),
1150 Err(OpsDocError::Malformed { .. })
1151 ));
1152
1153 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 #[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 #[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 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); 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"; 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 #[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}