Skip to main content

oxihuman_core/
patch_generator.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Generate unified diff patches.
6
7/// A hunk in a unified diff.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct DiffHunk {
10    pub old_start: usize,
11    pub old_count: usize,
12    pub new_start: usize,
13    pub new_count: usize,
14    pub lines: Vec<String>,
15}
16
17/// A unified diff patch.
18#[derive(Debug, Clone, Default)]
19pub struct UnifiedPatch {
20    pub old_file: String,
21    pub new_file: String,
22    pub hunks: Vec<DiffHunk>,
23}
24
25impl UnifiedPatch {
26    pub fn new(old_file: &str, new_file: &str) -> Self {
27        UnifiedPatch {
28            old_file: old_file.to_string(),
29            new_file: new_file.to_string(),
30            hunks: Vec::new(),
31        }
32    }
33
34    pub fn add_hunk(&mut self, hunk: DiffHunk) {
35        self.hunks.push(hunk);
36    }
37
38    pub fn is_empty(&self) -> bool {
39        self.hunks.is_empty()
40    }
41}
42
43/// Generate a unified patch from old/new line slices.
44pub fn generate_patch(old_file: &str, new_file: &str, old: &[&str], new: &[&str]) -> UnifiedPatch {
45    let mut patch = UnifiedPatch::new(old_file, new_file);
46    if old == new {
47        return patch;
48    }
49    let mut hunk_lines = Vec::new();
50    for line in old {
51        hunk_lines.push(format!("-{}", line));
52    }
53    for line in new {
54        hunk_lines.push(format!("+{}", line));
55    }
56    patch.add_hunk(DiffHunk {
57        old_start: 1,
58        old_count: old.len(),
59        new_start: 1,
60        new_count: new.len(),
61        lines: hunk_lines,
62    });
63    patch
64}
65
66/// Serialize a patch to unified diff format string.
67pub fn serialize_patch(patch: &UnifiedPatch) -> String {
68    let mut out = String::new();
69    out.push_str(&format!("--- {}\n", patch.old_file));
70    out.push_str(&format!("+++ {}\n", patch.new_file));
71    for hunk in &patch.hunks {
72        out.push_str(&format!(
73            "@@ -{},{} +{},{} @@\n",
74            hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
75        ));
76        for line in &hunk.lines {
77            out.push_str(line);
78            out.push('\n');
79        }
80    }
81    out
82}
83
84/// Count total changed lines in a patch.
85pub fn total_changed_lines(patch: &UnifiedPatch) -> usize {
86    patch
87        .hunks
88        .iter()
89        .flat_map(|h| h.lines.iter())
90        .filter(|l| l.starts_with('+') || l.starts_with('-'))
91        .count()
92}
93
94/// Apply a patch to old lines to produce new lines (stub).
95pub fn apply_patch_stub(old: &[&str], patch: &UnifiedPatch) -> Vec<String> {
96    if patch.is_empty() {
97        return old.iter().map(|s| s.to_string()).collect();
98    }
99    /* Stub: extract '+' lines from patch */
100    patch
101        .hunks
102        .iter()
103        .flat_map(|h| {
104            h.lines
105                .iter()
106                .filter(|l| l.starts_with('+'))
107                .map(|l| l[1..].to_string())
108        })
109        .collect()
110}
111
112/// Return true if patch is a no-op (empty).
113pub fn is_identity_patch(patch: &UnifiedPatch) -> bool {
114    patch.is_empty()
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_empty_patch_for_same_files() {
123        let p = generate_patch("a.txt", "b.txt", &["x"], &["x"]);
124        assert!(p.is_empty());
125    }
126
127    #[test]
128    fn test_patch_has_hunk_for_diff() {
129        let p = generate_patch("a.txt", "b.txt", &["x"], &["y"]);
130        assert!(!p.is_empty());
131        assert_eq!(p.hunks.len(), 1);
132    }
133
134    #[test]
135    fn test_serialize_contains_header() {
136        let p = generate_patch("old.txt", "new.txt", &["a"], &["b"]);
137        let s = serialize_patch(&p);
138        assert!(s.contains("--- old.txt"));
139        assert!(s.contains("+++ new.txt"));
140    }
141
142    #[test]
143    fn test_total_changed_lines() {
144        let p = generate_patch("a", "b", &["x"], &["y"]);
145        assert_eq!(total_changed_lines(&p), 2); /* one delete, one insert */
146    }
147
148    #[test]
149    fn test_apply_patch_stub() {
150        let p = generate_patch("a", "b", &["old"], &["new"]);
151        let result = apply_patch_stub(&["old"], &p);
152        assert_eq!(result, vec!["new".to_string()]);
153    }
154
155    #[test]
156    fn test_identity_patch() {
157        let p = generate_patch("a", "b", &["same"], &["same"]);
158        assert!(is_identity_patch(&p));
159    }
160
161    #[test]
162    fn test_patch_new_default() {
163        let p = UnifiedPatch::default();
164        assert!(p.is_empty());
165    }
166
167    #[test]
168    fn test_hunk_line_counts() {
169        let p = generate_patch("f", "g", &["a", "b"], &["c"]);
170        assert_eq!(p.hunks[0].old_count, 2);
171        assert_eq!(p.hunks[0].new_count, 1);
172    }
173
174    #[test]
175    fn test_serialize_empty_patch() {
176        let p = UnifiedPatch::new("a", "b");
177        let s = serialize_patch(&p);
178        assert!(s.contains("--- a"));
179    }
180
181    #[test]
182    fn test_apply_empty_patch() {
183        let p = UnifiedPatch::new("a", "b");
184        let result = apply_patch_stub(&["line1"], &p);
185        assert_eq!(result, vec!["line1".to_string()]);
186    }
187}