Skip to main content

llmtxt_core/
diff.rs

1//! Line-based diff computation using LCS (Longest Common Subsequence).
2//!
3//! Provides both summary-only ([`compute_diff`]) and structured
4//! ([`structured_diff`]) diff output. The structured variant is the
5//! single source of truth for diff display across frontend, backend,
6//! and CLI consumers.
7
8#[cfg(feature = "wasm")]
9use wasm_bindgen::prelude::*;
10
11use crate::calculate_tokens;
12
13// ── Summary Diff ───────────────────────────────────────────────
14
15/// Result of computing a line-based diff between two texts.
16#[cfg_attr(feature = "wasm", wasm_bindgen)]
17#[derive(Debug, Clone)]
18pub struct DiffResult {
19    added_lines: u32,
20    removed_lines: u32,
21    added_tokens: u32,
22    removed_tokens: u32,
23}
24
25#[cfg_attr(feature = "wasm", wasm_bindgen)]
26impl DiffResult {
27    /// Number of lines added in the new text.
28    #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
29    pub fn added_lines(&self) -> u32 {
30        self.added_lines
31    }
32    /// Number of lines removed from the old text.
33    #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
34    pub fn removed_lines(&self) -> u32 {
35        self.removed_lines
36    }
37    /// Estimated tokens added.
38    #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
39    pub fn added_tokens(&self) -> u32 {
40        self.added_tokens
41    }
42    /// Estimated tokens removed.
43    #[cfg_attr(feature = "wasm", wasm_bindgen(getter))]
44    pub fn removed_tokens(&self) -> u32 {
45        self.removed_tokens
46    }
47}
48
49/// Build the LCS DP table for two line arrays.
50fn build_lcs_table(old_lines: &[&str], new_lines: &[&str]) -> Vec<Vec<u32>> {
51    let n = old_lines.len();
52    let m = new_lines.len();
53    let mut dp = vec![vec![0u32; m + 1]; n + 1];
54    for i in 1..=n {
55        for j in 1..=m {
56            if old_lines[i - 1] == new_lines[j - 1] {
57                dp[i][j] = dp[i - 1][j - 1] + 1;
58            } else {
59                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
60            }
61        }
62    }
63    dp
64}
65
66/// Compute a line-based diff between two texts.
67///
68/// Uses a hash-based LCS (Longest Common Subsequence) approach for
69/// O(n*m) comparison where n and m are line counts. Returns counts
70/// of added/removed lines and estimated token impact.
71#[cfg_attr(feature = "wasm", wasm_bindgen)]
72pub fn compute_diff(old_text: &str, new_text: &str) -> DiffResult {
73    let old_lines: Vec<&str> = old_text.lines().collect();
74    let new_lines: Vec<&str> = new_text.lines().collect();
75
76    let n = old_lines.len();
77    let m = new_lines.len();
78    let dp = build_lcs_table(&old_lines, &new_lines);
79
80    // Backtrack to find which lines were removed and which were added
81    let mut removed = Vec::new();
82    let mut added = Vec::new();
83    let mut i = n;
84    let mut j = m;
85
86    while i > 0 || j > 0 {
87        if i > 0 && j > 0 && old_lines[i - 1] == new_lines[j - 1] {
88            i -= 1;
89            j -= 1;
90        } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
91            added.push(new_lines[j - 1]);
92            j -= 1;
93        } else {
94            removed.push(old_lines[i - 1]);
95            i -= 1;
96        }
97    }
98
99    let added_tokens: u32 = added.iter().map(|l| calculate_tokens(l)).sum();
100    let removed_tokens: u32 = removed.iter().map(|l| calculate_tokens(l)).sum();
101
102    DiffResult {
103        added_lines: added.len() as u32,
104        removed_lines: removed.len() as u32,
105        added_tokens,
106        removed_tokens,
107    }
108}
109
110// ── Structured Diff ────────────────────────────────────────────
111
112/// A single line in a structured diff, with type and line numbers.
113#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
114pub struct StructuredDiffLine {
115    /// "context", "added", or "removed".
116    #[serde(rename = "type")]
117    pub line_type: String,
118    /// The text content of the line.
119    pub content: String,
120    /// Line number in the old text (null for added lines).
121    #[serde(rename = "oldLine")]
122    pub old_line: Option<u32>,
123    /// Line number in the new text (null for removed lines).
124    #[serde(rename = "newLine")]
125    pub new_line: Option<u32>,
126}
127
128/// Full structured diff result with lines and summary counts.
129#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
130pub struct StructuredDiffResult {
131    /// Interleaved diff lines with types and line numbers.
132    pub lines: Vec<StructuredDiffLine>,
133    /// Number of added lines.
134    #[serde(rename = "addedLineCount")]
135    pub added_line_count: u32,
136    /// Number of removed lines.
137    #[serde(rename = "removedLineCount")]
138    pub removed_line_count: u32,
139    /// Estimated tokens added.
140    #[serde(rename = "addedTokens")]
141    pub added_tokens: u32,
142    /// Estimated tokens removed.
143    #[serde(rename = "removedTokens")]
144    pub removed_tokens: u32,
145}
146
147/// Compute a structured line-level diff between two texts.
148///
149/// Returns a JSON-serialized [`StructuredDiffResult`] with interleaved
150/// context, added, and removed lines including line numbers for both
151/// old and new text. This is the single source of truth for diff display.
152///
153/// Uses the same LCS algorithm as [`compute_diff`] but produces full
154/// line-by-line output instead of just counts.
155#[cfg_attr(feature = "wasm", wasm_bindgen)]
156pub fn structured_diff(old_text: &str, new_text: &str) -> String {
157    let result = structured_diff_native(old_text, new_text);
158    serde_json::to_string(&result).unwrap_or_else(|_| {
159        r#"{"lines":[],"addedLineCount":0,"removedLineCount":0,"addedTokens":0,"removedTokens":0}"#
160            .to_string()
161    })
162}
163
164/// Native version of [`structured_diff`] returning a typed struct.
165pub fn structured_diff_native(old_text: &str, new_text: &str) -> StructuredDiffResult {
166    let old_lines: Vec<&str> = old_text.lines().collect();
167    let new_lines: Vec<&str> = new_text.lines().collect();
168
169    let n = old_lines.len();
170    let m = new_lines.len();
171    let dp = build_lcs_table(&old_lines, &new_lines);
172
173    // Backtrack from bottom-right, collecting entries in reverse
174    let mut entries: Vec<StructuredDiffLine> = Vec::new();
175    let mut i = n;
176    let mut j = m;
177
178    while i > 0 || j > 0 {
179        if i > 0 && j > 0 && old_lines[i - 1] == new_lines[j - 1] {
180            entries.push(StructuredDiffLine {
181                line_type: "context".to_string(),
182                content: old_lines[i - 1].to_string(),
183                old_line: Some(i as u32),
184                new_line: Some(j as u32),
185            });
186            i -= 1;
187            j -= 1;
188        } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
189            entries.push(StructuredDiffLine {
190                line_type: "added".to_string(),
191                content: new_lines[j - 1].to_string(),
192                old_line: None,
193                new_line: Some(j as u32),
194            });
195            j -= 1;
196        } else {
197            entries.push(StructuredDiffLine {
198                line_type: "removed".to_string(),
199                content: old_lines[i - 1].to_string(),
200                old_line: Some(i as u32),
201                new_line: None,
202            });
203            i -= 1;
204        }
205    }
206
207    // Reverse to get forward order
208    entries.reverse();
209
210    // Compute summary counts
211    let mut added_count: u32 = 0;
212    let mut removed_count: u32 = 0;
213    let mut added_tokens: u32 = 0;
214    let mut removed_tokens: u32 = 0;
215
216    for entry in &entries {
217        match entry.line_type.as_str() {
218            "added" => {
219                added_count += 1;
220                added_tokens += calculate_tokens(&entry.content);
221            }
222            "removed" => {
223                removed_count += 1;
224                removed_tokens += calculate_tokens(&entry.content);
225            }
226            _ => {}
227        }
228    }
229
230    StructuredDiffResult {
231        lines: entries,
232        added_line_count: added_count,
233        removed_line_count: removed_count,
234        added_tokens,
235        removed_tokens,
236    }
237}
238
239// ── Multi-way Diff ─────────────────────────────────────────────
240
241/// A single version variant at a divergent line position.
242#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
243pub struct MultiDiffVariant {
244    /// 0-based index into the versions array that was passed in.
245    #[serde(rename = "versionIndex")]
246    pub version_index: usize,
247    /// The content this version has at this line position.
248    pub content: String,
249}
250
251/// One line entry in a multi-way diff result.
252#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
253pub struct MultiDiffLine {
254    /// 1-based position in the padded line grid.
255    #[serde(rename = "lineNumber")]
256    pub line_number: usize,
257    /// "consensus" when all versions agree, "divergent" otherwise.
258    #[serde(rename = "type")]
259    pub line_type: String,
260    /// The most common variant's content (or the unanimous content for consensus lines).
261    pub content: String,
262    /// How many versions have `content` at this position.
263    pub agreement: usize,
264    /// Total number of versions (including the base).
265    pub total: usize,
266    /// All per-version contents when `line_type` is "divergent"; empty for "consensus".
267    pub variants: Vec<MultiDiffVariant>,
268}
269
270/// Aggregate statistics for a multi-way diff.
271#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
272pub struct MultiDiffStats {
273    #[serde(rename = "totalLines")]
274    pub total_lines: usize,
275    #[serde(rename = "consensusLines")]
276    pub consensus_lines: usize,
277    #[serde(rename = "divergentLines")]
278    pub divergent_lines: usize,
279    #[serde(rename = "consensusPercentage")]
280    pub consensus_percentage: f64,
281}
282
283/// Full result of a multi-way diff.
284#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
285pub struct MultiDiffResult {
286    /// Index of the base version (always 0, meaning `base` itself).
287    #[serde(rename = "baseVersion")]
288    pub base_version: usize,
289    /// Number of versions compared (base + additional versions).
290    #[serde(rename = "versionCount")]
291    pub version_count: usize,
292    pub lines: Vec<MultiDiffLine>,
293    pub stats: MultiDiffStats,
294}
295
296/// Compute a multi-way diff across a base version and up to 4 additional versions.
297///
298/// `base` is the base version content (typically v1).
299/// `versions_json` is a JSON array of strings (up to 4 entries) where each string
300/// is the full content of an additional version.
301///
302/// Uses LCS-based alignment via [`structured_diff_native`] so that insertions and
303/// deletions in any version do not cause downstream lines to appear divergent.
304/// Each base line is an anchor row; lines inserted by versions get their own rows
305/// tagged as "insertion".
306///
307/// Returns a [`MultiDiffResult`] or an error string.
308pub fn multi_way_diff_native(base: &str, versions_json: &str) -> Result<MultiDiffResult, String> {
309    let additional: Vec<String> =
310        serde_json::from_str(versions_json).map_err(|e| format!("Invalid versions JSON: {e}"))?;
311
312    if additional.len() > 4 {
313        return Err(format!(
314            "Too many versions: got {}, max is 4 additional (5 total)",
315            additional.len()
316        ));
317    }
318
319    let version_count = 1 + additional.len();
320    let base_lines: Vec<&str> = base.lines().collect();
321
322    if base_lines.is_empty() && additional.iter().all(|v| v.is_empty()) {
323        return Ok(MultiDiffResult {
324            base_version: 0,
325            version_count,
326            lines: vec![],
327            stats: MultiDiffStats {
328                total_lines: 0,
329                consensus_lines: 0,
330                divergent_lines: 0,
331                consensus_percentage: 100.0,
332            },
333        });
334    }
335
336    let n_base = base_lines.len();
337    let alignments: Vec<crate::diff_multi::VersionAlignment> = additional
338        .iter()
339        .map(|v| crate::diff_multi::align_version(base, v.as_str(), n_base))
340        .collect();
341
342    let grid = crate::diff_multi::build_aligned_grid(&base_lines, &alignments, version_count);
343    let (lines, stats) = crate::diff_multi::score_grid(&grid, version_count);
344
345    Ok(MultiDiffResult {
346        base_version: 0,
347        version_count,
348        lines,
349        stats,
350    })
351}
352
353/// JSON-returning wrapper for [`multi_way_diff_native`].
354///
355/// On success returns a JSON-serialised [`MultiDiffResult`].
356/// On error returns `{"error": "<message>"}`.
357pub fn multi_way_diff(base: &str, versions_json: &str) -> String {
358    match multi_way_diff_native(base, versions_json) {
359        Ok(result) => serde_json::to_string(&result)
360            .unwrap_or_else(|e| format!(r#"{{"error":"serialization failed: {e}"}}"#)),
361        Err(e) => format!(r#"{{"error":{}}}"#, serde_json::json!(e)),
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_compute_diff_identical() {
371        let text = "line 1\nline 2\nline 3";
372        let result = compute_diff(text, text);
373        assert_eq!(result.added_lines(), 0);
374        assert_eq!(result.removed_lines(), 0);
375        assert_eq!(result.added_tokens(), 0);
376        assert_eq!(result.removed_tokens(), 0);
377    }
378
379    #[test]
380    fn test_compute_diff_empty_to_content() {
381        let result = compute_diff("", "line 1\nline 2");
382        assert_eq!(result.added_lines(), 2);
383        assert_eq!(result.removed_lines(), 0);
384    }
385
386    #[test]
387    fn test_compute_diff_content_to_empty() {
388        let result = compute_diff("line 1\nline 2", "");
389        assert_eq!(result.added_lines(), 0);
390        assert_eq!(result.removed_lines(), 2);
391    }
392
393    #[test]
394    fn test_compute_diff_mixed_changes() {
395        let old = "line 1\nline 2\nline 3\nline 4";
396        let new = "line 1\nmodified 2\nline 3\nline 5\nline 6";
397        let result = compute_diff(old, new);
398        assert_eq!(result.removed_lines(), 2);
399        assert_eq!(result.added_lines(), 3);
400        assert!(result.added_tokens() > 0);
401        assert!(result.removed_tokens() > 0);
402    }
403
404    #[test]
405    fn test_compute_diff_tokens() {
406        let old = "short";
407        let new = "this is a much longer replacement line";
408        let result = compute_diff(old, new);
409        assert_eq!(result.removed_lines(), 1);
410        assert_eq!(result.added_lines(), 1);
411        assert_eq!(result.removed_tokens(), calculate_tokens("short"));
412        assert_eq!(
413            result.added_tokens(),
414            calculate_tokens("this is a much longer replacement line")
415        );
416    }
417
418    #[test]
419    fn test_structured_diff_identical() {
420        let text = "line 1\nline 2\nline 3";
421        let result = structured_diff_native(text, text);
422        assert_eq!(result.lines.len(), 3);
423        assert!(result.lines.iter().all(|l| l.line_type == "context"));
424        assert_eq!(result.added_line_count, 0);
425        assert_eq!(result.removed_line_count, 0);
426        assert_eq!(result.lines[0].old_line, Some(1));
427        assert_eq!(result.lines[0].new_line, Some(1));
428        assert_eq!(result.lines[2].old_line, Some(3));
429        assert_eq!(result.lines[2].new_line, Some(3));
430    }
431
432    #[test]
433    fn test_structured_diff_additions() {
434        let old = "line 1\nline 3";
435        let new = "line 1\nline 2\nline 3";
436        let result = structured_diff_native(old, new);
437        assert_eq!(result.added_line_count, 1);
438        assert_eq!(result.removed_line_count, 0);
439        let types: Vec<&str> = result.lines.iter().map(|l| l.line_type.as_str()).collect();
440        assert_eq!(types, vec!["context", "added", "context"]);
441        let added = &result.lines[1];
442        assert_eq!(added.content, "line 2");
443        assert_eq!(added.old_line, None);
444        assert_eq!(added.new_line, Some(2));
445    }
446
447    #[test]
448    fn test_structured_diff_removals() {
449        let old = "line 1\nline 2\nline 3";
450        let new = "line 1\nline 3";
451        let result = structured_diff_native(old, new);
452        assert_eq!(result.added_line_count, 0);
453        assert_eq!(result.removed_line_count, 1);
454        let types: Vec<&str> = result.lines.iter().map(|l| l.line_type.as_str()).collect();
455        assert_eq!(types, vec!["context", "removed", "context"]);
456        let removed = &result.lines[1];
457        assert_eq!(removed.content, "line 2");
458        assert_eq!(removed.old_line, Some(2));
459        assert_eq!(removed.new_line, None);
460    }
461
462    #[test]
463    fn test_structured_diff_mixed() {
464        let old = "line 1\nline 2\nline 3\nline 4";
465        let new = "line 1\nmodified 2\nline 3\nline 5";
466        let result = structured_diff_native(old, new);
467        assert_eq!(result.added_line_count, 2);
468        assert_eq!(result.removed_line_count, 2);
469        assert!(result.added_tokens > 0);
470        assert!(result.removed_tokens > 0);
471    }
472
473    #[test]
474    fn test_structured_diff_empty_to_content() {
475        let result = structured_diff_native("", "line 1\nline 2");
476        assert_eq!(result.added_line_count, 2);
477        assert_eq!(result.removed_line_count, 0);
478        assert!(result.lines.iter().all(|l| l.line_type == "added"));
479    }
480
481    #[test]
482    fn test_structured_diff_content_to_empty() {
483        let result = structured_diff_native("line 1\nline 2", "");
484        assert_eq!(result.added_line_count, 0);
485        assert_eq!(result.removed_line_count, 2);
486        assert!(result.lines.iter().all(|l| l.line_type == "removed"));
487    }
488
489    #[test]
490    fn test_structured_diff_json_serialization() {
491        let json = structured_diff("hello\n", "hello\nworld\n");
492        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
493        assert!(parsed["lines"].is_array());
494        assert_eq!(parsed["addedLineCount"], 1);
495        assert_eq!(parsed["removedLineCount"], 0);
496    }
497
498    // ── multi_way_diff tests ───────────────────────────────────────
499
500    #[test]
501    fn test_multi_way_diff_all_identical() {
502        let base = "line 1\nline 2\nline 3";
503        let v1 = "line 1\nline 2\nline 3";
504        let v2 = "line 1\nline 2\nline 3";
505        let versions_json = serde_json::to_string(&vec![v1, v2]).unwrap();
506        let result = multi_way_diff_native(base, &versions_json).unwrap();
507
508        assert_eq!(result.base_version, 0);
509        assert_eq!(result.version_count, 3);
510        assert_eq!(result.lines.len(), 3);
511        assert!(result.lines.iter().all(|l| l.line_type == "consensus"));
512        assert!(result.lines.iter().all(|l| l.agreement == 3));
513        assert!(result.lines.iter().all(|l| l.total == 3));
514        assert!(result.lines.iter().all(|l| l.variants.is_empty()));
515        assert_eq!(result.stats.consensus_lines, 3);
516        assert_eq!(result.stats.divergent_lines, 0);
517        assert_eq!(result.stats.total_lines, 3);
518        assert_eq!(result.stats.consensus_percentage, 100.0);
519    }
520
521    #[test]
522    fn test_multi_way_diff_one_divergent_line() {
523        // Same line count in all versions — only line 2 differs by content.
524        // LCS alignment: "alpha" and "gamma" are context; "beta" vs "BETA" diverges.
525        let base = "alpha\nbeta\ngamma";
526        let v1 = "alpha\nBETA\ngamma";
527        let v2 = "alpha\nBETA\ngamma";
528        let versions_json = serde_json::to_string(&vec![v1, v2]).unwrap();
529        let result = multi_way_diff_native(base, &versions_json).unwrap();
530
531        assert_eq!(result.version_count, 3);
532        assert_eq!(result.stats.consensus_lines, 2);
533        assert_eq!(result.stats.divergent_lines, 1);
534
535        // Line 1 (alpha) — consensus.
536        let line1 = &result.lines[0];
537        assert_eq!(line1.line_type, "consensus");
538        assert_eq!(line1.content, "alpha");
539        assert_eq!(line1.agreement, 3);
540
541        // Line 2 (beta / BETA) — divergent; majority (2 of 3) say "BETA".
542        let line2 = &result.lines[1];
543        assert_eq!(line2.line_type, "divergent");
544        assert_eq!(line2.content, "BETA");
545        assert_eq!(line2.agreement, 2);
546        assert_eq!(line2.total, 3);
547        assert_eq!(line2.variants.len(), 3);
548    }
549
550    #[test]
551    fn test_multi_way_diff_three_way_split() {
552        // Same line count; each version has a unique line 2 — no majority.
553        let base = "same\nbase_line2\nsame";
554        let v1 = "same\nv1_line2\nsame";
555        let v2 = "same\nv2_line2\nsame";
556        let versions_json = serde_json::to_string(&vec![v1, v2]).unwrap();
557        let result = multi_way_diff_native(base, &versions_json).unwrap();
558
559        let line2 = &result.lines[1];
560        assert_eq!(line2.line_type, "divergent");
561        assert_eq!(line2.agreement, 1); // each content appears exactly once
562        assert_eq!(line2.total, 3);
563        assert_eq!(line2.variants.len(), 3);
564    }
565
566    #[test]
567    fn test_multi_way_diff_different_lengths_lcs_aligned() {
568        // v1 appends "d", v2 removes "c"; LCS keeps a,b,c anchored.
569        let base = "a\nb\nc";
570        let v1 = "a\nb\nc\nd";
571        let v2 = "a\nb";
572        let versions_json = serde_json::to_string(&vec![v1, v2]).unwrap();
573        let result = multi_way_diff_native(base, &versions_json).unwrap();
574
575        // 3 base-line rows + 1 insertion row = 4 total.
576        assert_eq!(result.stats.total_lines, 4);
577        assert_eq!(result.stats.consensus_lines, 2);
578        assert_eq!(result.stats.divergent_lines, 1);
579
580        assert_eq!(result.lines[0].line_type, "consensus");
581        assert_eq!(result.lines[0].content, "a");
582        assert_eq!(result.lines[1].line_type, "consensus");
583        assert_eq!(result.lines[1].content, "b");
584
585        // c: base+v1 have it, v2 removed it.
586        assert_eq!(result.lines[2].line_type, "divergent");
587        assert_eq!(result.lines[2].content, "c");
588
589        // "d" inserted by v1.
590        assert_eq!(result.lines[3].line_type, "insertion");
591        assert_eq!(result.lines[3].content, "d");
592    }
593
594    #[test]
595    fn test_multi_way_diff_empty_base_and_versions() {
596        let versions_json = serde_json::to_string(&vec!["", ""]).unwrap();
597        let result = multi_way_diff_native("", &versions_json).unwrap();
598        assert_eq!(result.stats.total_lines, 0);
599        assert!(result.lines.is_empty());
600        assert_eq!(result.stats.consensus_percentage, 100.0);
601    }
602
603    #[test]
604    fn test_multi_way_diff_single_version() {
605        let base = "hello\nworld";
606        let v1 = "hello\nearth";
607        let versions_json = serde_json::to_string(&vec![v1]).unwrap();
608        let result = multi_way_diff_native(base, &versions_json).unwrap();
609
610        assert_eq!(result.version_count, 2);
611        assert_eq!(result.stats.total_lines, 2);
612        assert_eq!(result.lines[0].line_type, "consensus");
613        assert_eq!(result.lines[1].line_type, "divergent");
614        assert_eq!(result.lines[1].agreement, 1);
615        assert_eq!(result.lines[1].variants.len(), 2);
616    }
617
618    #[test]
619    fn test_multi_way_diff_rejects_too_many_versions() {
620        let versions: Vec<&str> = vec!["a", "b", "c", "d", "e"]; // 5 additional = 6 total
621        let versions_json = serde_json::to_string(&versions).unwrap();
622        let err = multi_way_diff_native("base", &versions_json).unwrap_err();
623        assert!(err.contains("Too many versions"));
624    }
625
626    #[test]
627    fn test_multi_way_diff_max_versions_accepted() {
628        // Exactly 4 additional versions (5 total) must be accepted.
629        let versions: Vec<&str> = vec!["a\nb", "a\nc", "a\nd", "a\ne"];
630        let versions_json = serde_json::to_string(&versions).unwrap();
631        let result = multi_way_diff_native("a\nb", &versions_json).unwrap();
632        assert_eq!(result.version_count, 5);
633    }
634
635    #[test]
636    fn test_multi_way_diff_json_output_shape() {
637        let base = "x\ny";
638        let v1 = "x\nz";
639        let versions_json = serde_json::to_string(&vec![v1]).unwrap();
640        let json = multi_way_diff(base, &versions_json);
641        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
642
643        assert!(parsed["lines"].is_array());
644        assert_eq!(parsed["baseVersion"], 0);
645        assert_eq!(parsed["versionCount"], 2);
646        assert!(parsed["stats"]["totalLines"].is_number());
647        assert!(parsed["stats"]["consensusLines"].is_number());
648        assert!(parsed["stats"]["divergentLines"].is_number());
649        assert!(parsed["stats"]["consensusPercentage"].is_number());
650    }
651
652    #[test]
653    fn test_multi_way_diff_line_numbers_are_one_based() {
654        let base = "a\nb\nc";
655        let v1 = "a\nb\nc";
656        let versions_json = serde_json::to_string(&vec![v1]).unwrap();
657        let result = multi_way_diff_native(base, &versions_json).unwrap();
658
659        for (idx, line) in result.lines.iter().enumerate() {
660            assert_eq!(line.line_number, idx + 1);
661        }
662    }
663
664    #[test]
665    fn test_multi_way_diff_consensus_percentage_rounding() {
666        // 2 consensus lines out of 3 base lines = 66.666...% → rounds to 66.7.
667        let base = "a\nb\nc";
668        let v1 = "a\nX\nc";
669        let versions_json = serde_json::to_string(&vec![v1]).unwrap();
670        let result = multi_way_diff_native(base, &versions_json).unwrap();
671        assert_eq!(result.stats.consensus_percentage, 66.7);
672    }
673
674    // ── New LCS-alignment behavior tests ──────────────────────────
675
676    #[test]
677    fn test_multi_way_diff_insertion_after_first_line() {
678        // V1 inserts X,Y after A: positional diff wrongly marks B..E divergent;
679        // LCS-aligned diff correctly marks them consensus with X,Y as insertions.
680        let base = "A\nB\nC\nD\nE";
681        let v1 = "A\nX\nY\nB\nC\nD\nE";
682        let versions_json = serde_json::to_string(&vec![v1]).unwrap();
683        let result = multi_way_diff_native(base, &versions_json).unwrap();
684
685        assert_eq!(result.stats.total_lines, 7); // 5 base + 2 insertions
686        assert_eq!(result.stats.consensus_lines, 5);
687        assert_eq!(result.stats.divergent_lines, 0);
688        assert_eq!(result.stats.consensus_percentage, 100.0);
689        assert_eq!(result.lines[0].content, "A");
690        assert_eq!(result.lines[1].line_type, "insertion");
691        assert_eq!(result.lines[1].content, "X");
692        assert_eq!(result.lines[2].line_type, "insertion");
693        assert_eq!(result.lines[2].content, "Y");
694        for (i, expected) in ["B", "C", "D", "E"].iter().enumerate() {
695            assert_eq!(result.lines[3 + i].line_type, "consensus");
696            assert_eq!(result.lines[3 + i].content, *expected);
697        }
698    }
699
700    #[test]
701    fn test_multi_way_diff_deletion_aligned() {
702        // V1 removes C: A,B,D,E consensus; C is divergent (base has it, v1 removed it).
703        let base = "A\nB\nC\nD\nE";
704        let v1 = "A\nB\nD\nE";
705        let versions_json = serde_json::to_string(&vec![v1]).unwrap();
706        let result = multi_way_diff_native(base, &versions_json).unwrap();
707
708        assert_eq!(result.stats.total_lines, 5);
709        assert_eq!(result.stats.consensus_lines, 4);
710        assert_eq!(result.stats.divergent_lines, 1);
711        let row_c = result
712            .lines
713            .iter()
714            .find(|l| l.line_type == "divergent")
715            .expect("expected one divergent row for C");
716        assert_eq!(row_c.content, "C");
717        for line in result.lines.iter().filter(|l| l.line_type != "divergent") {
718            assert_eq!(line.line_type, "consensus");
719        }
720    }
721
722    #[test]
723    fn test_multi_way_diff_mixed_insertions_two_versions() {
724        // V1 inserts X after A; V2 inserts Y after B. A,B,C must be consensus.
725        let base = "A\nB\nC";
726        let v1 = "A\nX\nB\nC";
727        let v2 = "A\nB\nY\nC";
728        let versions_json = serde_json::to_string(&vec![v1, v2]).unwrap();
729        let result = multi_way_diff_native(base, &versions_json).unwrap();
730
731        assert_eq!(result.stats.total_lines, 5); // 3 base + 2 insertions
732        assert_eq!(result.stats.consensus_lines, 3);
733        assert_eq!(result.stats.divergent_lines, 0);
734        assert_eq!(result.stats.consensus_percentage, 100.0);
735
736        let insertions: Vec<&MultiDiffLine> = result
737            .lines
738            .iter()
739            .filter(|l| l.line_type == "insertion")
740            .collect();
741        assert_eq!(insertions.len(), 2);
742
743        let insertion_contents: std::collections::HashSet<&str> =
744            insertions.iter().map(|l| l.content.as_str()).collect();
745        assert!(insertion_contents.contains("X"));
746        assert!(insertion_contents.contains("Y"));
747
748        for line in result.lines.iter().filter(|l| l.line_type != "insertion") {
749            assert_eq!(line.line_type, "consensus");
750        }
751    }
752
753    #[test]
754    fn test_multi_way_diff_markdown_section_insertion() {
755        // V1 adds a new section in the middle; all 7 base lines stay consensus.
756        let base = "# Title\n\nFirst paragraph.\n\n# Conclusion\n\nEnd.";
757        let v1 = "# Title\n\nFirst paragraph.\n\n# New Section\n\nMiddle content.\n\n# Conclusion\n\nEnd.";
758        let versions_json = serde_json::to_string(&vec![v1]).unwrap();
759        let result = multi_way_diff_native(base, &versions_json).unwrap();
760
761        // Base has 7 lines; v1 inserts 4 lines between "First paragraph." and "# Conclusion":
762        // "# New Section", "" (blank), "Middle content.", "" (blank).
763        assert_eq!(result.stats.consensus_lines, 7);
764        assert_eq!(result.stats.divergent_lines, 0);
765        assert_eq!(result.stats.consensus_percentage, 100.0);
766        let insertion_lines: Vec<&MultiDiffLine> = result
767            .lines
768            .iter()
769            .filter(|l| l.line_type == "insertion")
770            .collect();
771        assert_eq!(insertion_lines.len(), 4);
772        let contents: Vec<&str> = insertion_lines.iter().map(|l| l.content.as_str()).collect();
773        assert!(contents.contains(&"# New Section"));
774        assert!(contents.contains(&"Middle content."));
775    }
776}