1use std::collections::HashMap;
13use std::hash::{Hash, Hasher};
14
15use crate::error::Result;
16use crate::simhash::{simhash, SimHashFingerprint};
17
18#[derive(Debug, Clone)]
20pub struct DeltaConfig {
21 pub similarity_threshold: f64,
25 pub context_lines: usize,
28}
29
30impl Default for DeltaConfig {
31 fn default() -> Self {
32 Self {
33 similarity_threshold: 0.6,
34 context_lines: 1,
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum DeltaOp {
42 Keep(usize),
44 Insert(Vec<String>),
46 Delete(usize),
48}
49
50#[derive(Debug, Clone)]
52pub struct DeltaResult {
53 pub delta_text: String,
55 pub lines_changed: usize,
57 pub lines_original: usize,
59 pub similarity: f64,
61}
62
63pub struct DeltaEncoder {
65 config: DeltaConfig,
66 fingerprint_index: HashMap<String, SimHashFingerprint>,
69}
70
71impl DeltaEncoder {
72 pub fn new() -> Self {
73 Self::with_config(DeltaConfig::default())
74 }
75
76 pub fn with_config(config: DeltaConfig) -> Self {
77 Self {
78 config,
79 fingerprint_index: HashMap::new(),
80 }
81 }
82
83 pub fn fingerprint(&self, text: &str) -> SimHashFingerprint {
85 simhash(text)
86 }
87
88 pub fn index_content(&mut self, id: &str, text: &str) {
90 let fp = simhash(text);
91 self.fingerprint_index.insert(id.to_string(), fp);
92 }
93
94 pub fn find_nearest(&self, text: &str, max_hamming_distance: u32) -> Option<&str> {
98 let fp = simhash(text);
99 let mut best_id: Option<&str> = None;
100 let mut best_dist = u32::MAX;
101
102 for (id, &indexed_fp) in &self.fingerprint_index {
103 let dist = fp.hamming_distance(&indexed_fp);
104 if dist < best_dist && dist <= max_hamming_distance {
105 best_dist = dist;
106 best_id = Some(id.as_str());
107 }
108 }
109
110 best_id
111 }
112
113 pub fn similarity(&self, old: &str, new: &str) -> f64 {
116 let fp_old = simhash(old);
118 let fp_new = simhash(new);
119 let simhash_sim = fp_old.estimated_similarity(&fp_new);
120
121 if simhash_sim < 0.3 {
123 return simhash_sim;
124 }
125
126 self.similarity_precise(old, new)
128 }
129
130 fn similarity_precise(&self, old: &str, new: &str) -> f64 {
132 let old_lines: Vec<&str> = old.lines().collect();
133 let new_lines: Vec<&str> = new.lines().collect();
134
135 if old_lines.is_empty() && new_lines.is_empty() {
136 return 1.0;
137 }
138 if old_lines.is_empty() || new_lines.is_empty() {
139 return 0.0;
140 }
141
142 let old_hashes: HashMap<u64, usize> = old_lines
144 .iter()
145 .map(|l| line_hash(l))
146 .fold(HashMap::new(), |mut acc, h| {
147 *acc.entry(h).or_insert(0) += 1;
148 acc
149 });
150
151 let new_hashes: HashMap<u64, usize> = new_lines
152 .iter()
153 .map(|l| line_hash(l))
154 .fold(HashMap::new(), |mut acc, h| {
155 *acc.entry(h).or_insert(0) += 1;
156 acc
157 });
158
159 let mut shared = 0usize;
161 for (hash, &old_count) in &old_hashes {
162 if let Some(&new_count) = new_hashes.get(hash) {
163 shared += old_count.min(new_count);
164 }
165 }
166
167 let total = old_lines.len().max(new_lines.len());
168 shared as f64 / total as f64
169 }
170
171 pub fn encode(&self, old: &str, new: &str, hash_prefix: &str) -> Result<Option<DeltaResult>> {
176 let sim = self.similarity(old, new);
177 if sim < self.config.similarity_threshold {
178 return Ok(None);
179 }
180
181 let old_lines: Vec<&str> = old.lines().collect();
182 let new_lines: Vec<&str> = new.lines().collect();
183
184 let ops = compute_diff(&old_lines, &new_lines);
185 let (delta_text, lines_changed) =
186 format_delta(&ops, &new_lines, hash_prefix, self.config.context_lines);
187
188 Ok(Some(DeltaResult {
189 delta_text,
190 lines_changed,
191 lines_original: old_lines.len(),
192 similarity: sim,
193 }))
194 }
195
196 pub fn should_delta(&self, old: &str, new: &str) -> bool {
198 let sim = self.similarity(old, new);
199 sim >= self.config.similarity_threshold && sim < 1.0
200 }
201}
202
203impl Default for DeltaEncoder {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209fn compute_diff<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<DeltaOp> {
213 let lcs = lcs_table(old, new);
216 let mut ops = Vec::new();
217 let mut i = old.len();
218 let mut j = new.len();
219
220 let mut raw_ops: Vec<RawOp> = Vec::new();
222 while i > 0 || j > 0 {
223 if i > 0 && j > 0 && old[i - 1] == new[j - 1] {
224 raw_ops.push(RawOp::Keep);
225 i -= 1;
226 j -= 1;
227 } else if j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]) {
228 raw_ops.push(RawOp::Insert(new[j - 1].to_string()));
229 j -= 1;
230 } else if i > 0 {
231 raw_ops.push(RawOp::Delete);
232 i -= 1;
233 }
234 }
235 raw_ops.reverse();
236
237 let mut keep_count = 0usize;
239 let mut del_count = 0usize;
240 let mut ins_buf: Vec<String> = Vec::new();
241
242 for op in raw_ops {
243 match op {
244 RawOp::Keep => {
245 if del_count > 0 {
246 ops.push(DeltaOp::Delete(del_count));
247 del_count = 0;
248 }
249 if !ins_buf.is_empty() {
250 ops.push(DeltaOp::Insert(std::mem::take(&mut ins_buf)));
251 }
252 keep_count += 1;
253 }
254 RawOp::Delete => {
255 if keep_count > 0 {
256 ops.push(DeltaOp::Keep(keep_count));
257 keep_count = 0;
258 }
259 if !ins_buf.is_empty() {
260 ops.push(DeltaOp::Insert(std::mem::take(&mut ins_buf)));
261 }
262 del_count += 1;
263 }
264 RawOp::Insert(line) => {
265 if keep_count > 0 {
266 ops.push(DeltaOp::Keep(keep_count));
267 keep_count = 0;
268 }
269 if del_count > 0 {
270 ops.push(DeltaOp::Delete(del_count));
271 del_count = 0;
272 }
273 ins_buf.push(line);
274 }
275 }
276 }
277 if keep_count > 0 {
279 ops.push(DeltaOp::Keep(keep_count));
280 }
281 if del_count > 0 {
282 ops.push(DeltaOp::Delete(del_count));
283 }
284 if !ins_buf.is_empty() {
285 ops.push(DeltaOp::Insert(ins_buf));
286 }
287
288 ops
289}
290
291#[derive(Debug)]
292enum RawOp {
293 Keep,
294 Delete,
295 Insert(String),
296}
297
298fn lcs_table(old: &[&str], new: &[&str]) -> Vec<Vec<usize>> {
300 let m = old.len();
301 let n = new.len();
302 let mut table = vec![vec![0usize; n + 1]; m + 1];
303
304 for i in 1..=m {
305 for j in 1..=n {
306 if old[i - 1] == new[j - 1] {
307 table[i][j] = table[i - 1][j - 1] + 1;
308 } else {
309 table[i][j] = table[i - 1][j].max(table[i][j - 1]);
310 }
311 }
312 }
313 table
314}
315
316fn format_delta(
318 ops: &[DeltaOp],
319 new_lines: &[&str],
320 hash_prefix: &str,
321 context_lines: usize,
322) -> (String, usize) {
323 let mut output = Vec::new();
324 output.push(format!("§delta:{hash_prefix}§"));
325
326 let mut line_num = 0usize;
327 let mut new_line = 0usize;
328 let mut lines_changed = 0usize;
329
330 for (i, op) in ops.iter().enumerate() {
331 match op {
332 DeltaOp::Keep(n) => {
333 let kept = &new_lines[new_line..new_line + n];
334 let lead = if i == 0 { 0 } else { context_lines.min(*n) };
335 let trail = if i + 1 == ops.len() { 0 } else { context_lines.min(n - lead) };
336 for line in &kept[..lead] {
337 output.push(format!(" {line}"));
338 }
339 let skip = n - lead - trail;
340 if skip > 0 {
341 output.push(format!(" @@ skip {skip} unchanged lines @@"));
342 }
343 for line in &kept[n - trail..] {
344 output.push(format!(" {line}"));
345 }
346 line_num += n;
347 new_line += n;
348 }
349 DeltaOp::Delete(n) => {
350 output.push(format!("-[{n} lines removed at L{}]", line_num + 1));
351 line_num += n;
352 lines_changed += n;
353 }
354 DeltaOp::Insert(lines) => {
355 for line in lines {
356 output.push(format!("+{line}"));
357 }
358 new_line += lines.len();
359 lines_changed += lines.len();
360 }
361 }
362 }
363
364 (output.join("\n"), lines_changed)
365}
366
367fn line_hash(line: &str) -> u64 {
369 let mut hasher = std::collections::hash_map::DefaultHasher::new();
370 line.hash(&mut hasher);
371 hasher.finish()
372}
373
374#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn test_identical_content_similarity_1() {
382 let enc = DeltaEncoder::new();
383 let text = "line 1\nline 2\nline 3\n";
384 assert!((enc.similarity(text, text) - 1.0).abs() < 1e-9);
385 }
386
387 #[test]
388 fn test_completely_different_similarity_0() {
389 let enc = DeltaEncoder::new();
390 let old = "aaa\nbbb\nccc\n";
391 let new = "xxx\nyyy\nzzz\n";
392 assert!(enc.similarity(old, new) < 0.01);
393 }
394
395 #[test]
396 fn test_small_edit_high_similarity() {
397 let enc = DeltaEncoder::new();
398 let old = "line 1\nline 2\nline 3\nline 4\nline 5\n";
399 let new = "line 1\nline 2\nline 3 modified\nline 4\nline 5\n";
400 let sim = enc.similarity(old, new);
401 assert!(sim > 0.7, "expected high similarity, got {sim}");
402 }
403
404 #[test]
405 fn test_encode_returns_none_below_threshold() {
406 let enc = DeltaEncoder::new();
407 let old = "aaa\nbbb\nccc\n";
408 let new = "xxx\nyyy\nzzz\n";
409 let result = enc.encode(old, new, "abc123").unwrap();
410 assert!(result.is_none());
411 }
412
413 #[test]
414 fn test_encode_returns_delta_for_small_edit() {
415 let enc = DeltaEncoder::new();
416 let old = "line 1\nline 2\nline 3\nline 4\nline 5\n";
417 let new = "line 1\nline 2\nline 3 modified\nline 4\nline 5\n";
418 let result = enc.encode(old, new, "abc123").unwrap();
419 assert!(result.is_some(), "expected delta for small edit");
420 let delta = result.unwrap();
421 assert!(delta.delta_text.starts_with("§delta:abc123§"));
422 assert!(delta.lines_changed > 0);
423 assert!(delta.similarity > 0.7);
424 }
425
426 #[test]
427 fn test_encode_identical_content() {
428 let enc = DeltaEncoder::new();
429 let text = "line 1\nline 2\nline 3\n";
430 let result = enc.encode(text, text, "abc123").unwrap();
431 if let Some(delta) = result {
434 assert_eq!(delta.lines_changed, 0);
435 }
436 }
438
439 #[test]
440 fn test_should_delta() {
441 let enc = DeltaEncoder::new();
442 let old = "line 1\nline 2\nline 3\nline 4\nline 5\n";
443 let new = "line 1\nline 2\nline 3 modified\nline 4\nline 5\n";
444 assert!(enc.should_delta(old, new));
445
446 assert!(!enc.should_delta(old, old));
448
449 assert!(!enc.should_delta("aaa\nbbb\n", "xxx\nyyy\n"));
451 }
452
453 #[test]
454 fn test_empty_inputs() {
455 let enc = DeltaEncoder::new();
456 assert!((enc.similarity("", "") - 1.0).abs() < 1e-9);
457 assert_eq!(enc.similarity("", "something"), 0.0);
458 assert_eq!(enc.similarity("something", ""), 0.0);
459 }
460
461 #[test]
462 fn test_delta_format_contains_header() {
463 let enc = DeltaEncoder::new();
464 let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
465 let new = "a\nb\nc\nX\ne\nf\ng\nh\ni\nj\n";
466 if let Some(delta) = enc.encode(old, new, "deadbeef").unwrap() {
467 assert!(delta.delta_text.contains("§delta:deadbeef§"));
468 }
469 }
470
471 #[test]
472 fn test_delta_numbers_lines_from_one_and_accounts_for_each() {
473 let enc = DeltaEncoder::new();
474 let old: String = (1..=20).map(|i| format!("row {i:02}\n")).collect();
475
476 let removed = old.replace("row 03\n", "");
477 let delta = enc.encode(&old, &removed, "f1").unwrap().unwrap();
478 assert_eq!(
479 delta.delta_text,
480 "§delta:f1§\n @@ skip 1 unchanged lines @@\n row 02\n-[1 lines removed at L3]\n row 04\n @@ skip 16 unchanged lines @@"
481 );
482
483 let edited = old.replace("row 10\n", "row ten\n");
484 let delta = enc.encode(&old, &edited, "f1").unwrap().unwrap();
485 assert_eq!(
486 delta.delta_text,
487 "§delta:f1§\n @@ skip 8 unchanged lines @@\n row 09\n-[1 lines removed at L10]\n+row ten\n row 11\n @@ skip 9 unchanged lines @@"
488 );
489
490 let appended = format!("{old}row 21\nrow 22\n");
491 let delta = enc.encode(&old, &appended, "f1").unwrap().unwrap();
492 assert_eq!(delta.delta_text, "§delta:f1§\n @@ skip 19 unchanged lines @@\n row 20\n+row 21\n+row 22");
493 }
494
495 #[test]
496 fn test_compute_diff_basic() {
497 let old = vec!["a", "b", "c"];
498 let new = vec!["a", "x", "c"];
499 let ops = compute_diff(&old, &new);
500 assert!(!ops.is_empty());
502 }
503
504 #[test]
505 fn test_compute_diff_insertion() {
506 let old = vec!["a", "c"];
507 let new = vec!["a", "b", "c"];
508 let ops = compute_diff(&old, &new);
509 assert!(!ops.is_empty());
510 }
511
512 #[test]
513 fn test_compute_diff_deletion() {
514 let old = vec!["a", "b", "c"];
515 let new = vec!["a", "c"];
516 let ops = compute_diff(&old, &new);
517 assert!(!ops.is_empty());
518 }
519
520 fn replay(old: &[String], delta: &str) -> Vec<String> {
521 let mut out = Vec::new();
522 let mut at = 0;
523 for line in delta.lines().skip(1) {
524 if let Some(rest) = line.strip_prefix(" @@ skip ") {
525 let n: usize = rest.split(' ').next().unwrap().parse().unwrap();
526 out.extend_from_slice(&old[at..at + n]);
527 at += n;
528 } else if let Some(rest) = line.strip_prefix("-[") {
529 let (n, pos) = rest.trim_end_matches(']').split_once(" lines removed at L").unwrap();
530 assert_eq!(pos.parse::<usize>().unwrap(), at + 1, "{delta}");
531 at += n.parse::<usize>().unwrap();
532 } else if let Some(added) = line.strip_prefix('+') {
533 out.push(added.to_string());
534 } else {
535 assert_eq!(line.strip_prefix(' '), Some(old[at].as_str()), "{delta}");
536 out.push(old[at].clone());
537 at += 1;
538 }
539 }
540 assert_eq!(at, old.len(), "{delta}");
541 out
542 }
543
544 use proptest::prelude::*;
547
548 proptest! {
549 #[test]
551 fn prop_similarity_bounded(
552 old in "[a-z\n]{5,100}",
553 new in "[a-z\n]{5,100}",
554 ) {
555 let enc = DeltaEncoder::new();
556 let sim = enc.similarity(&old, &new);
557 prop_assert!(sim >= 0.0 && sim <= 1.0, "similarity out of bounds: {sim}");
558 }
559
560 #[test]
562 fn prop_similarity_identity(
563 text in "[a-z ]{5,100}"
564 ) {
565 let enc = DeltaEncoder::new();
566 let sim = enc.similarity(&text, &text);
567 prop_assert!(
568 (sim - 1.0).abs() < 1e-9,
569 "identical content should have similarity 1.0, got {sim}"
570 );
571 }
572
573 #[test]
575 fn prop_delta_replays_to_the_new_text(
576 old in prop::collection::vec("[a-z]{1,6}", 3..30),
577 edits in prop::collection::vec((0usize..40, prop::option::of("[a-z]{1,6}")), 1..4),
578 ) {
579 let mut new = old.clone();
580 for (at, line) in edits {
581 let at = at % (new.len() + 1);
582 match line {
583 Some(line) => new.insert(at, line),
584 None if at < new.len() => {
585 new.remove(at);
586 }
587 None => {}
588 }
589 }
590 prop_assume!(!new.is_empty());
591 let enc = DeltaEncoder::new();
592 let (old_text, new_text) = (old.join("\n") + "\n", new.join("\n") + "\n");
593 if let Some(delta) = enc.encode(&old_text, &new_text, "p").unwrap() {
594 prop_assert_eq!(replay(&old, &delta.delta_text), new);
595 }
596 }
597
598 #[test]
600 fn prop_delta_header_present(
601 base in "[a-z]{3,10}\n[a-z]{3,10}\n[a-z]{3,10}\n[a-z]{3,10}\n[a-z]{3,10}\n",
602 ) {
603 let enc = DeltaEncoder::new();
604 let new = format!("{base}extra line\n");
606 if let Some(delta) = enc.encode(&base, &new, "test1234").unwrap() {
607 prop_assert!(
608 delta.delta_text.starts_with("§delta:test1234§"),
609 "delta should start with header"
610 );
611 }
612 }
613 }
614}