Skip to main content

oxihuman_core/
edit_script.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Myers diff edit script generator.
6//!
7//! Produces a minimal sequence of edit operations (keep / delete / insert)
8//! that transforms one slice of lines into another using the Myers O(ND)
9//! algorithm skeleton.
10
11/// A single edit operation in a diff script.
12#[derive(Debug, Clone, PartialEq)]
13pub enum EditOp {
14    Keep(String),
15    Delete(String),
16    Insert(String),
17}
18
19/// A complete edit script (list of [`EditOp`]).
20#[derive(Debug, Clone)]
21pub struct EditScript {
22    pub ops: Vec<EditOp>,
23}
24
25impl EditScript {
26    pub fn new() -> Self {
27        Self { ops: Vec::new() }
28    }
29
30    pub fn push(&mut self, op: EditOp) {
31        self.ops.push(op);
32    }
33
34    pub fn len(&self) -> usize {
35        self.ops.len()
36    }
37
38    pub fn is_empty(&self) -> bool {
39        self.ops.is_empty()
40    }
41
42    pub fn keep_count(&self) -> usize {
43        self.ops
44            .iter()
45            .filter(|o| matches!(o, EditOp::Keep(_)))
46            .count()
47    }
48
49    pub fn delete_count(&self) -> usize {
50        self.ops
51            .iter()
52            .filter(|o| matches!(o, EditOp::Delete(_)))
53            .count()
54    }
55
56    pub fn insert_count(&self) -> usize {
57        self.ops
58            .iter()
59            .filter(|o| matches!(o, EditOp::Insert(_)))
60            .count()
61    }
62}
63
64impl Default for EditScript {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70/// Build an edit script transforming `old` lines into `new` lines.
71///
72/// Uses a simple LCS-based greedy approach (stub quality, not full Myers).
73pub fn build_edit_script(old: &[&str], new: &[&str]) -> EditScript {
74    let mut script = EditScript::new();
75    let n = old.len();
76    let m = new.len();
77    let mut i = 0;
78    let mut j = 0;
79    while i < n && j < m {
80        if old[i] == new[j] {
81            script.push(EditOp::Keep(old[i].to_string()));
82            i += 1;
83            j += 1;
84        } else {
85            script.push(EditOp::Delete(old[i].to_string()));
86            script.push(EditOp::Insert(new[j].to_string()));
87            i += 1;
88            j += 1;
89        }
90    }
91    while i < n {
92        script.push(EditOp::Delete(old[i].to_string()));
93        i += 1;
94    }
95    while j < m {
96        script.push(EditOp::Insert(new[j].to_string()));
97        j += 1;
98    }
99    script
100}
101
102/// Apply an edit script and return the resulting lines.
103pub fn apply_edit_script(script: &EditScript) -> Vec<String> {
104    script
105        .ops
106        .iter()
107        .filter_map(|op| match op {
108            EditOp::Keep(s) | EditOp::Insert(s) => Some(s.clone()),
109            EditOp::Delete(_) => None,
110        })
111        .collect()
112}
113
114/// Count the edit distance (number of non-keep ops) in an edit script.
115pub fn edit_distance_from_script(script: &EditScript) -> usize {
116    script
117        .ops
118        .iter()
119        .filter(|o| !matches!(o, EditOp::Keep(_)))
120        .count()
121}
122
123/// Serialize an edit script to a human-readable diff string.
124pub fn script_to_diff_string(script: &EditScript) -> String {
125    let mut out = String::new();
126    for op in &script.ops {
127        match op {
128            EditOp::Keep(s) => {
129                out.push(' ');
130                out.push_str(s);
131                out.push('\n');
132            }
133            EditOp::Delete(s) => {
134                out.push('-');
135                out.push_str(s);
136                out.push('\n');
137            }
138            EditOp::Insert(s) => {
139                out.push('+');
140                out.push_str(s);
141                out.push('\n');
142            }
143        }
144    }
145    out
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_empty_inputs() {
154        let script = build_edit_script(&[], &[]);
155        assert!(script.is_empty());
156    }
157
158    #[test]
159    fn test_identical_lines_all_keep() {
160        let old = ["a", "b", "c"];
161        let script = build_edit_script(&old, &old);
162        assert_eq!(script.keep_count(), 3);
163        assert_eq!(script.delete_count(), 0);
164        assert_eq!(script.insert_count(), 0);
165    }
166
167    #[test]
168    fn test_all_deleted() {
169        let old = ["x", "y"];
170        let script = build_edit_script(&old, &[]);
171        assert_eq!(script.delete_count(), 2);
172        assert_eq!(script.insert_count(), 0);
173    }
174
175    #[test]
176    fn test_all_inserted() {
177        let new = ["x", "y"];
178        let script = build_edit_script(&[], &new);
179        assert_eq!(script.insert_count(), 2);
180        assert_eq!(script.delete_count(), 0);
181    }
182
183    #[test]
184    fn test_apply_gives_new_lines() {
185        let old = ["a", "b"];
186        let new = ["a", "c"];
187        let script = build_edit_script(&old, &new);
188        let result = apply_edit_script(&script);
189        assert_eq!(result, vec!["a", "c"]);
190    }
191
192    #[test]
193    fn test_edit_distance_nonzero() {
194        let old = ["a"];
195        let new = ["b"];
196        let script = build_edit_script(&old, &new);
197        assert!(edit_distance_from_script(&script) > 0);
198    }
199
200    #[test]
201    fn test_script_to_diff_string_contains_plus_minus() {
202        let old = ["hello"];
203        let new = ["world"];
204        let script = build_edit_script(&old, &new);
205        let diff = script_to_diff_string(&script);
206        assert!(diff.contains('-'));
207        assert!(diff.contains('+'));
208    }
209
210    #[test]
211    fn test_len_and_is_empty() {
212        let mut s = EditScript::new();
213        assert!(s.is_empty());
214        s.push(EditOp::Keep("x".into()));
215        assert_eq!(s.len(), 1);
216    }
217
218    #[test]
219    fn test_default() {
220        let s = EditScript::default();
221        assert!(s.is_empty());
222    }
223}