1use anyhow::{Context, Result};
58use yrs::updates::decoder::Decode;
59use yrs::{Doc, GetString, ReadTxn, Text, TextRef, Transact, Update};
60
61const TEXT_KEY: &str = "content";
62
63pub struct CrdtDoc {
65 doc: Doc,
66}
67
68impl CrdtDoc {
69 pub fn from_text(content: &str) -> Self {
71 let doc = Doc::new();
72 let text = doc.get_or_insert_text(TEXT_KEY);
73 let mut txn = doc.transact_mut();
74 text.insert(&mut txn, 0, content);
75 drop(txn);
76 CrdtDoc { doc }
77 }
78
79 pub fn to_text(&self) -> String {
81 let text = self.doc.get_or_insert_text(TEXT_KEY);
82 let txn = self.doc.transact();
83 text.get_string(&txn)
84 }
85
86 #[allow(dead_code)] pub fn apply_edit(&self, offset: u32, delete_len: u32, insert: &str) {
89 let text = self.doc.get_or_insert_text(TEXT_KEY);
90 let mut txn = self.doc.transact_mut();
91 if delete_len > 0 {
92 text.remove_range(&mut txn, offset, delete_len);
93 }
94 if !insert.is_empty() {
95 text.insert(&mut txn, offset, insert);
96 }
97 }
98
99 pub fn encode_state(&self) -> Vec<u8> {
101 let txn = self.doc.transact();
102 txn.encode_state_as_update_v1(&yrs::StateVector::default())
103 }
104
105 pub fn decode_state(bytes: &[u8]) -> Result<Self> {
107 let doc = Doc::new();
108 let update = Update::decode_v1(bytes)
109 .map_err(|e| anyhow::anyhow!("failed to decode CRDT state: {}", e))?;
110 let mut txn = doc.transact_mut();
111 txn.apply_update(update)
112 .map_err(|e| anyhow::anyhow!("failed to apply CRDT update: {}", e))?;
113 drop(txn);
114 Ok(CrdtDoc { doc })
115 }
116}
117
118pub fn merge(base_state: Option<&[u8]>, ours_text: &str, theirs_text: &str) -> Result<String> {
128 if ours_text == theirs_text {
130 eprintln!("[crdt] ours == theirs, skipping merge");
131 return Ok(ours_text.to_string());
132 }
133
134 let base_doc = if let Some(bytes) = base_state {
136 CrdtDoc::decode_state(bytes)
137 .context("failed to decode base CRDT state")?
138 } else {
139 CrdtDoc::from_text("")
140 };
141 let mut base_text = base_doc.to_text();
142
143 eprintln!(
144 "[crdt] merge: base_len={} ours_len={} theirs_len={}",
145 base_text.len(),
146 ours_text.len(),
147 theirs_text.len()
148 );
149
150 let ours_common = common_prefix_len(&base_text, ours_text);
155 let theirs_common = common_prefix_len(&base_text, theirs_text);
156 let base_len = base_text.len();
157
158 if base_len > 0
159 && (ours_common as f64 / base_len as f64) < 0.5
160 && (theirs_common as f64 / base_len as f64) < 0.5
161 {
162 eprintln!(
163 "[crdt] Stale CRDT base detected (common prefix: ours={}%, theirs={}%). Using ours as base.",
164 (ours_common * 100) / base_len,
165 (theirs_common * 100) / base_len
166 );
167 base_text = ours_text.to_string();
168 }
169
170 let mutual_prefix = common_prefix_len(ours_text, theirs_text);
187 if mutual_prefix > base_text.len() {
188 let snap = &ours_text[..mutual_prefix];
195 let snapped = match snap.rfind('\n') {
196 Some(pos) if pos >= base_text.len() => pos + 1,
197 _ => base_text.len(), };
199 if snapped > base_text.len() {
200 eprintln!(
201 "[crdt] Advancing base to shared prefix (base_len={} → {})",
202 base_text.len(),
203 snapped
204 );
205 base_text = ours_text[..snapped].to_string();
206 }
207 }
208
209 let ours_ops = compute_edit_ops(&base_text, ours_text);
211 let theirs_ops = compute_edit_ops(&base_text, theirs_text);
212
213 let base_encoded = if base_text == base_doc.to_text() {
216 base_doc.encode_state()
217 } else {
218 CrdtDoc::from_text(&base_text).encode_state()
219 };
220
221 let ours_doc = Doc::with_client_id(1);
225 {
226 let update = Update::decode_v1(&base_encoded)
227 .map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
228 let mut txn = ours_doc.transact_mut();
229 txn.apply_update(update)
230 .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
231 }
232
233 let theirs_doc = Doc::with_client_id(2);
234 {
235 let update = Update::decode_v1(&base_encoded)
236 .map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
237 let mut txn = theirs_doc.transact_mut();
238 txn.apply_update(update)
239 .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
240 }
241
242 {
244 let text = ours_doc.get_or_insert_text(TEXT_KEY);
245 let mut txn = ours_doc.transact_mut();
246 apply_ops(&text, &mut txn, &ours_ops);
247 }
248
249 {
251 let text = theirs_doc.get_or_insert_text(TEXT_KEY);
252 let mut txn = theirs_doc.transact_mut();
253 apply_ops(&text, &mut txn, &theirs_ops);
254 }
255
256 let ours_sv = {
258 let txn = ours_doc.transact();
259 txn.state_vector()
260 };
261 let theirs_update = {
262 let txn = theirs_doc.transact();
263 txn.encode_state_as_update_v1(&ours_sv)
264 };
265 {
266 let update = Update::decode_v1(&theirs_update)
267 .map_err(|e| anyhow::anyhow!("decode error: {}", e))?;
268 let mut txn = ours_doc.transact_mut();
269 txn.apply_update(update)
270 .map_err(|e| anyhow::anyhow!("apply error: {}", e))?;
271 }
272
273 let merged = {
277 let text = ours_doc.get_or_insert_text(TEXT_KEY);
278 let txn = ours_doc.transact();
279 text.get_string(&txn)
280 };
281
282 Ok(dedup_adjacent_blocks(&merged))
284}
285
286pub fn dedup_adjacent_blocks(text: &str) -> String {
294 let blocks: Vec<&str> = text.split("\n\n").collect();
295 if blocks.len() < 2 {
296 return text.to_string();
297 }
298
299 let mut result: Vec<&str> = Vec::with_capacity(blocks.len());
300 for block in &blocks {
301 let trimmed = block.trim();
302 let non_empty_lines = trimmed.lines().filter(|l| !l.trim().is_empty()).count();
304 if non_empty_lines >= 2
305 && let Some(prev) = result.last()
306 && prev.trim() == trimmed
307 {
308 eprintln!("[crdt] dedup: removed duplicate block ({} lines)", non_empty_lines);
309 continue;
310 }
311 result.push(*block);
312 }
313
314 result.join("\n\n")
315}
316
317pub fn compact(state: &[u8]) -> Result<Vec<u8>> {
319 let doc = CrdtDoc::decode_state(state)?;
320 Ok(doc.encode_state())
321}
322
323fn common_prefix_len(a: &str, b: &str) -> usize {
325 a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
326}
327
328#[derive(Debug)]
330enum EditOp {
331 Retain(u32),
332 Delete(u32),
333 Insert(String),
334}
335
336fn compute_edit_ops(from: &str, to: &str) -> Vec<EditOp> {
338 use similar::{ChangeTag, TextDiff};
339
340 let diff = TextDiff::from_lines(from, to);
341 let mut ops = Vec::new();
342
343 for change in diff.iter_all_changes() {
344 match change.tag() {
345 ChangeTag::Equal => {
346 let len = change.value().len() as u32;
347 if let Some(EditOp::Retain(n)) = ops.last_mut() {
348 *n += len;
349 } else {
350 ops.push(EditOp::Retain(len));
351 }
352 }
353 ChangeTag::Delete => {
354 let len = change.value().len() as u32;
355 if let Some(EditOp::Delete(n)) = ops.last_mut() {
356 *n += len;
357 } else {
358 ops.push(EditOp::Delete(len));
359 }
360 }
361 ChangeTag::Insert => {
362 let s = change.value().to_string();
363 if let Some(EditOp::Insert(existing)) = ops.last_mut() {
364 existing.push_str(&s);
365 } else {
366 ops.push(EditOp::Insert(s));
367 }
368 }
369 }
370 }
371
372 ops
373}
374
375fn apply_ops(text: &TextRef, txn: &mut yrs::TransactionMut<'_>, ops: &[EditOp]) {
377 let mut cursor: u32 = 0;
378 for op in ops {
379 match op {
380 EditOp::Retain(n) => cursor += n,
381 EditOp::Delete(n) => {
382 text.remove_range(txn, cursor, *n);
383 }
385 EditOp::Insert(s) => {
386 text.insert(txn, cursor, s);
387 cursor += s.len() as u32;
388 }
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn roundtrip_text() {
399 let content = "Hello, world!\nLine two.\n";
400 let doc = CrdtDoc::from_text(content);
401 assert_eq!(doc.to_text(), content);
402 }
403
404 #[test]
405 fn roundtrip_encode_decode() {
406 let content = "Some document content.\n";
407 let doc = CrdtDoc::from_text(content);
408 let encoded = doc.encode_state();
409 let decoded = CrdtDoc::decode_state(&encoded).unwrap();
410 assert_eq!(decoded.to_text(), content);
411 }
412
413 #[test]
414 fn apply_edit_insert() {
415 let doc = CrdtDoc::from_text("Hello world");
416 doc.apply_edit(5, 0, ",");
417 assert_eq!(doc.to_text(), "Hello, world");
418 }
419
420 #[test]
421 fn apply_edit_delete() {
422 let doc = CrdtDoc::from_text("Hello, world");
423 doc.apply_edit(5, 1, "");
424 assert_eq!(doc.to_text(), "Hello world");
425 }
426
427 #[test]
428 fn apply_edit_replace() {
429 let doc = CrdtDoc::from_text("Hello world");
430 doc.apply_edit(6, 5, "Rust");
431 assert_eq!(doc.to_text(), "Hello Rust");
432 }
433
434 #[test]
435 fn concurrent_append_merge_no_conflict() {
436 let base = "# Document\n\nBase content.\n";
437 let base_doc = CrdtDoc::from_text(base);
438 let base_state = base_doc.encode_state();
439
440 let ours = format!("{base}## Agent\n\nAgent response.\n");
441 let theirs = format!("{base}## User\n\nUser addition.\n");
442
443 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
444
445 assert!(merged.contains("Agent response."), "missing agent text");
447 assert!(merged.contains("User addition."), "missing user text");
448 assert!(merged.contains("Base content."), "missing base text");
449 assert!(!merged.contains("<<<<<<<"));
451 assert!(!merged.contains(">>>>>>>"));
452 }
453
454 #[test]
455 fn concurrent_insert_same_position() {
456 let base = "Line 1\nLine 3\n";
457 let base_doc = CrdtDoc::from_text(base);
458 let base_state = base_doc.encode_state();
459
460 let ours = "Line 1\nAgent line\nLine 3\n";
461 let theirs = "Line 1\nUser line\nLine 3\n";
462
463 let merged = merge(Some(&base_state), ours, theirs).unwrap();
464
465 assert!(merged.contains("Agent line"), "missing agent insertion");
467 assert!(merged.contains("User line"), "missing user insertion");
468 assert!(merged.contains("Line 1"), "missing line 1");
469 assert!(merged.contains("Line 3"), "missing line 3");
470 }
471
472 #[test]
473 fn merge_no_base_state() {
474 let ours = "Agent wrote this.\n";
476 let theirs = "User wrote this.\n";
477
478 let merged = merge(None, ours, theirs).unwrap();
479
480 assert!(merged.contains("Agent wrote this."));
481 assert!(merged.contains("User wrote this."));
482 }
483
484 #[test]
485 fn compact_preserves_content() {
486 let doc = CrdtDoc::from_text("Hello");
487 doc.apply_edit(5, 0, " world");
488 doc.apply_edit(11, 0, "!");
489
490 let state = doc.encode_state();
491 let compacted = compact(&state).unwrap();
492 let restored = CrdtDoc::decode_state(&compacted).unwrap();
493
494 assert_eq!(restored.to_text(), "Hello world!");
495 assert!(compacted.len() <= state.len());
496 }
497
498 #[test]
499 fn compact_reduces_size_after_edits() {
500 let doc = CrdtDoc::from_text("aaaa");
501 for i in 0..20 {
503 let c = ((b'a' + (i % 26)) as char).to_string();
504 doc.apply_edit(0, 1, &c);
505 }
506 let state = doc.encode_state();
507 let compacted = compact(&state).unwrap();
508 let restored = CrdtDoc::decode_state(&compacted).unwrap();
509 assert_eq!(restored.to_text(), doc.to_text());
510 }
511
512 #[test]
513 fn empty_document() {
514 let doc = CrdtDoc::from_text("");
515 assert_eq!(doc.to_text(), "");
516
517 let encoded = doc.encode_state();
518 let decoded = CrdtDoc::decode_state(&encoded).unwrap();
519 assert_eq!(decoded.to_text(), "");
520 }
521
522 #[test]
523 fn decode_invalid_bytes_errors() {
524 let result = CrdtDoc::decode_state(&[0xff, 0xfe, 0xfd]);
525 assert!(result.is_err());
526 }
527
528 #[test]
529 fn merge_identical_texts() {
530 let base = "Same content.\n";
531 let base_doc = CrdtDoc::from_text(base);
532 let state = base_doc.encode_state();
533
534 let merged = merge(Some(&state), base, base).unwrap();
535 assert_eq!(merged, base);
536 }
537
538 #[test]
539 fn merge_one_side_unchanged() {
540 let base = "Original.\n";
541 let base_doc = CrdtDoc::from_text(base);
542 let state = base_doc.encode_state();
543
544 let ours = "Original.\nAgent added.\n";
545 let merged = merge(Some(&state), ours, base).unwrap();
546 assert_eq!(merged, ours);
547 }
548
549 #[test]
560 fn merge_stale_base_no_duplicate_user_prompt() {
561 let base_content = "\
563## Assistant
564
565Previous response content.
566
567Committed and pushed.
568
569";
570 let base_doc = CrdtDoc::from_text(base_content);
571 let base_state = base_doc.encode_state();
572
573 let user_prompt = "\
575Opening a video a shows video a.
576Closing video a then opening video b start video b but video b is hidden.
577Closing video b then reopening video b starts and shows video b. video b is visible.
578";
579
580 let ours = format!("\
582{}{}### Re: Close A → Open B still hidden
583
584Added explicit height and visibility reset.
585
586Committed and pushed.
587
588", base_content, user_prompt);
589
590 let theirs = format!("\
592{}{}
593", base_content, user_prompt);
594
595 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
596
597 let prompt_count = merged.matches("Opening a video a shows video a.").count();
599 assert_eq!(
600 prompt_count, 1,
601 "User prompt duplicated! Appeared {} times in:\n{}",
602 prompt_count, merged
603 );
604
605 assert!(
607 merged.contains("### Re: Close A → Open B still hidden"),
608 "Agent response missing from merge:\n{}", merged
609 );
610 }
611
612 #[test]
615 fn merge_stale_base_same_insertion_both_sides() {
616 let base_content = "Line 1\nLine 2\n";
617 let base_doc = CrdtDoc::from_text(base_content);
618 let base_state = base_doc.encode_state();
619
620 let shared_addition = "User typed this.\n";
622 let ours = format!("{}{}Agent response.\n", base_content, shared_addition);
623 let theirs = format!("{}{}", base_content, shared_addition);
624
625 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
626
627 let count = merged.matches("User typed this.").count();
628 assert_eq!(
629 count, 1,
630 "Shared text duplicated! Appeared {} times in:\n{}",
631 count, merged
632 );
633 assert!(merged.contains("Agent response."), "Agent text missing:\n{}", merged);
634 }
635
636 #[test]
652 fn merge_no_character_interleaving() {
653 let base = "# Doc\n\nPrevious content.\n\n";
655 let base_doc = CrdtDoc::from_text(base);
656 let base_state = base_doc.encode_state();
657
658 let ours = "# Doc\n\nPrevious content.\n\n*Compacted. Content archived to*\n";
660 let theirs = "# Doc\n\nPrevious content.\n\n**Soft-bristle brush only**\n";
662
663 let merged = merge(Some(&base_state), ours, theirs).unwrap();
664
665 assert!(
667 merged.contains("*Compacted. Content archived to*"),
668 "Agent text should be contiguous (not interleaved). Got:\n{}",
669 merged
670 );
671 assert!(
672 merged.contains("**Soft-bristle brush only**"),
673 "User text should be contiguous (not interleaved). Got:\n{}",
674 merged
675 );
676 }
677
678 #[test]
681 fn merge_concurrent_same_line_no_garbling() {
682 let base = "Some base text\n";
683 let base_doc = CrdtDoc::from_text(base);
684 let base_state = base_doc.encode_state();
685
686 let ours = "Agent wrote this line\n";
688 let theirs = "User wrote different text\n";
689
690 let merged = merge(Some(&base_state), ours, theirs).unwrap();
691
692 let has_agent_contiguous = merged.contains("Agent wrote this line");
694 let has_user_contiguous = merged.contains("User wrote different text");
695
696 assert!(
697 has_agent_contiguous || has_user_contiguous,
698 "At least one side should have contiguous text (no char interleaving). Got:\n{}",
699 merged
700 );
701 }
702
703 #[test]
719 fn merge_replace_vs_append_no_interleaving() {
720 let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
722 let footer = "\n<!-- /agent:exchange -->\n";
723
724 let old_exchange = "\
726### Committed, Pushed & Released
727
728**project (v0.1.0):**
729- Committed initial implementation
730- Tagged v0.1.0 and pushed
731
732Add a README.md to the project.
733Also add AGENTS.md with a symlink CLAUDE.md
734
735**sub-project:**
736- Committed fix + SPEC.md
737- Pushed to remote
738";
739 let stale_base = format!("{header}{old_exchange}{footer}");
740 let stale_state = CrdtDoc::from_text(&stale_base).encode_state();
741
742 let _baseline = stale_base.clone();
745
746 let agent_exchange = "\
748### Done
749
750Added to project and pushed:
751
752- **README.md** — overview, usage, design notes
753- **AGENTS.md** — architecture, key decisions, commands, related projects
754- **CLAUDE.md** → symlink to AGENTS.md
755
756All committed and pushed.
757";
758 let ours = format!("{header}{agent_exchange}{footer}");
759
760 let theirs_exchange = "\
764### Committed, Pushed & Released
765
766**project (v0.1.0):**
767- Committed initial implementation
768- Tagged v0.1.0 and pushed
769
770Add a README.md to the project.
771Also add AGENTS.md with a symlink CLAUDE.md
772
773Please add tests.
774Please comprehensively test adherence to the spec.
775
776**sub-project:**
777- Committed fix + SPEC.md
778- Pushed to remote
779";
780 let theirs = format!("{header}{theirs_exchange}{footer}");
781
782 let merged = merge(Some(&stale_state), &ours, &theirs).unwrap();
784
785 assert!(
787 merged.contains("- **AGENTS.md** — architecture, key decisions, commands, related projects"),
788 "Agent text garbled (mid-word split). Got:\n{}", merged
789 );
790
791 assert!(
793 merged.contains("Please add tests."),
794 "User addition missing. Got:\n{}", merged
795 );
796
797 assert!(
799 !merged.contains("key deAdd") && !merged.contains("key de\n"),
800 "Old content interleaved into agent text. Got:\n{}", merged
801 );
802 }
803
804 #[test]
807 fn merge_replace_vs_append_with_baseline_base() {
808 let header = "---\nagent_doc_format: template\n---\n\n# Title\n\n<!-- agent:exchange -->\n";
809 let footer = "\n<!-- /agent:exchange -->\n";
810
811 let old_exchange = "\
812### Previous Response
813
814Old content here.
815
816Add a README.md to the project.
817Also add AGENTS.md with a symlink CLAUDE.md
818";
819 let baseline = format!("{header}{old_exchange}{footer}");
820
821 let agent_exchange = "\
823### Done
824
825- **README.md** — overview, usage, design notes
826- **AGENTS.md** — architecture, key decisions, commands, related projects
827- **CLAUDE.md** → symlink to AGENTS.md
828
829All committed and pushed.
830";
831 let ours = format!("{header}{agent_exchange}{footer}");
832
833 let user_addition = "\nPlease add tests.\n";
835 let theirs = format!("{header}{old_exchange}{user_addition}{footer}");
836
837 let baseline_state = CrdtDoc::from_text(&baseline).encode_state();
839 let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
840
841 assert!(
843 merged.contains("key decisions, commands, related projects"),
844 "Agent text garbled. Got:\n{}", merged
845 );
846
847 assert!(
849 merged.contains("Please add tests."),
850 "User addition missing. Got:\n{}", merged
851 );
852 }
853
854 #[test]
861 fn merge_streaming_concurrent_edit_preserves_formatting() {
862 let base = "commit and push all rappstack packages.\n\n";
864 let base_doc = CrdtDoc::from_text(base);
865 let base_state = base_doc.encode_state();
866
867 let ours = "\
869commit and push all rappstack packages.
870
871### Re: commit and push
872
873*Compacted. Content archived to `docs/`*
874
875Done — all packages pushed.
876";
877
878 let theirs = "\
880commit and push all rappstack packages.
881
882**Soft-bristle brush only**
883";
884
885 let merged = merge(Some(&base_state), ours, theirs).unwrap();
886
887 assert!(
889 merged.contains("*Compacted. Content archived to `docs/`*"),
890 "Agent formatting broken. Got:\n{}",
891 merged
892 );
893 assert!(
895 merged.contains("**Soft-bristle brush only**"),
896 "User formatting broken. Got:\n{}",
897 merged
898 );
899 assert!(
901 !merged.contains("*C*C") && !merged.contains("**Sot"),
902 "Character interleaving detected. Got:\n{}",
903 merged
904 );
905 }
906
907 #[test]
914 fn merge_replace_vs_insert_no_interleaving() {
915 let header = "---\nagent_doc_format: template\nagent_doc_write: crdt\n---\n\n# Document Title\n\nSome preamble text that both sides share.\nThis provides enough common prefix to avoid stale detection.\n\n<!-- agent:exchange -->\n";
916 let footer = "<!-- /agent:exchange -->\n";
917
918 let old_exchange = "Line one of old content\nLine two of old content\nLine three of old content\n";
919 let baseline = format!("{header}{old_exchange}{footer}");
920 let baseline_doc = CrdtDoc::from_text(&baseline);
921 let baseline_state = baseline_doc.encode_state();
922
923 let agent_exchange = "Completely new line one\nCompletely new line two\nCompletely new line three\nCompletely new line four\n";
925 let ours = format!("{header}{agent_exchange}{footer}");
926
927 let theirs = format!("{header}Line one of old content\nUser inserted this line\nLine two of old content\nLine three of old content\n{footer}");
929
930 let merged = merge(Some(&baseline_state), &ours, &theirs).unwrap();
931
932 assert!(
934 merged.contains("Completely new line one"),
935 "Agent line 1 missing or garbled. Got:\n{}", merged
936 );
937 assert!(
938 merged.contains("Completely new line two"),
939 "Agent line 2 missing or garbled. Got:\n{}", merged
940 );
941
942 assert!(
944 merged.contains("User inserted this line"),
945 "User insertion missing. Got:\n{}", merged
946 );
947
948 assert!(
950 !merged.contains("CompleteUser") && !merged.contains("Complete\nUser"),
951 "Character interleaving detected. Got:\n{}", merged
952 );
953 }
954
955 #[test]
958 fn reorder_agent_before_human_at_append_boundary() {
959 let base = "# Document\n\nBase content.\n";
960 let base_doc = CrdtDoc::from_text(base);
961 let base_state = base_doc.encode_state();
962
963 let ours = format!("{base}### Agent Response\n\nAgent wrote this.\n");
965 let theirs = format!("{base}User added this line.\n");
967
968 let merged = merge(Some(&base_state), &ours, &theirs).unwrap();
969
970 assert!(merged.contains("Agent wrote this."), "missing agent text");
972 assert!(merged.contains("User added this line."), "missing user text");
973 assert!(merged.contains("Base content."), "missing base text");
974
975 let agent_pos = merged.find("Agent wrote this.").unwrap();
977 let human_pos = merged.find("User added this line.").unwrap();
978 assert!(
979 agent_pos < human_pos,
980 "Agent content should appear before human content.\nAgent pos: {}, Human pos: {}\nMerged:\n{}",
981 agent_pos, human_pos, merged
982 );
983 }
984
985 #[test]
990 fn dedup_removes_identical_adjacent_blocks() {
991 let text = "### Re: Question\nAnswer here.\n\n### Re: Question\nAnswer here.\n\nDifferent block.";
992 let result = dedup_adjacent_blocks(text);
993 assert_eq!(result.matches("### Re: Question").count(), 1);
994 assert!(result.contains("Different block."));
995 }
996
997 #[test]
998 fn dedup_preserves_different_adjacent_blocks() {
999 let text = "### Re: First\nAnswer one.\n\n### Re: Second\nAnswer two.";
1000 let result = dedup_adjacent_blocks(text);
1001 assert!(result.contains("### Re: First"));
1002 assert!(result.contains("### Re: Second"));
1003 }
1004
1005 #[test]
1006 fn dedup_ignores_short_repeated_lines() {
1007 let text = "---\n\n---\n\nContent.";
1009 let result = dedup_adjacent_blocks(text);
1010 assert_eq!(result, text);
1011 }
1012
1013 #[test]
1014 fn dedup_handles_empty_text() {
1015 assert_eq!(dedup_adjacent_blocks(""), "");
1016 }
1017
1018 #[test]
1019 fn dedup_no_change_when_no_duplicates() {
1020 let text = "Block A\nLine 2.\n\nBlock B\nLine 2.";
1021 let result = dedup_adjacent_blocks(text);
1022 assert_eq!(result, text);
1023 }
1024}