Skip to main content

oxihuman_export/
diff_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Diff/patch format export for config changes.
6
7/// A single diff entry (changed line).
8#[allow(dead_code)]
9#[derive(Debug, Clone)]
10pub struct DiffEntry {
11    pub line_number: usize,
12    pub operation: DiffOp,
13    pub content: String,
14}
15
16/// Diff operation type.
17#[allow(dead_code)]
18#[derive(Debug, Clone, PartialEq)]
19pub enum DiffOp {
20    Add,
21    Remove,
22    Context,
23}
24
25impl DiffOp {
26    pub fn symbol(&self) -> char {
27        match self {
28            DiffOp::Add => '+',
29            DiffOp::Remove => '-',
30            DiffOp::Context => ' ',
31        }
32    }
33}
34
35/// A diff between two text files.
36#[allow(dead_code)]
37pub struct DiffExport {
38    pub source_name: String,
39    pub target_name: String,
40    pub entries: Vec<DiffEntry>,
41}
42
43impl DiffExport {
44    #[allow(dead_code)]
45    pub fn new(source: &str, target: &str) -> Self {
46        Self {
47            source_name: source.to_string(),
48            target_name: target.to_string(),
49            entries: Vec::new(),
50        }
51    }
52}
53
54/// Compute a simple line-by-line diff between two strings.
55#[allow(dead_code)]
56pub fn compute_diff(source: &str, target: &str) -> DiffExport {
57    let mut diff = DiffExport::new(source, target);
58    let src_lines: Vec<&str> = source.lines().collect();
59    let tgt_lines: Vec<&str> = target.lines().collect();
60    let max = src_lines.len().max(tgt_lines.len());
61    for i in 0..max {
62        let src = src_lines.get(i).copied();
63        let tgt = tgt_lines.get(i).copied();
64        match (src, tgt) {
65            (Some(s), Some(t)) if s == t => {
66                diff.entries.push(DiffEntry {
67                    line_number: i + 1,
68                    operation: DiffOp::Context,
69                    content: s.to_string(),
70                });
71            }
72            (Some(s), Some(t)) => {
73                diff.entries.push(DiffEntry {
74                    line_number: i + 1,
75                    operation: DiffOp::Remove,
76                    content: s.to_string(),
77                });
78                diff.entries.push(DiffEntry {
79                    line_number: i + 1,
80                    operation: DiffOp::Add,
81                    content: t.to_string(),
82                });
83            }
84            (Some(s), None) => {
85                diff.entries.push(DiffEntry {
86                    line_number: i + 1,
87                    operation: DiffOp::Remove,
88                    content: s.to_string(),
89                });
90            }
91            (None, Some(t)) => {
92                diff.entries.push(DiffEntry {
93                    line_number: i + 1,
94                    operation: DiffOp::Add,
95                    content: t.to_string(),
96                });
97            }
98            (None, None) => {}
99        }
100    }
101    diff
102}
103
104/// Serialize diff to unified diff format string.
105#[allow(dead_code)]
106pub fn export_diff_unified(diff: &DiffExport) -> String {
107    let mut out = format!("--- {}\n+++ {}\n", diff.source_name, diff.target_name);
108    for e in &diff.entries {
109        out.push(e.operation.symbol());
110        out.push_str(&e.content);
111        out.push('\n');
112    }
113    out
114}
115
116/// Count of additions.
117#[allow(dead_code)]
118pub fn addition_count(diff: &DiffExport) -> usize {
119    diff.entries
120        .iter()
121        .filter(|e| e.operation == DiffOp::Add)
122        .count()
123}
124
125/// Count of removals.
126#[allow(dead_code)]
127pub fn removal_count(diff: &DiffExport) -> usize {
128    diff.entries
129        .iter()
130        .filter(|e| e.operation == DiffOp::Remove)
131        .count()
132}
133
134/// Whether the two texts are identical (no changes).
135#[allow(dead_code)]
136pub fn is_identical(diff: &DiffExport) -> bool {
137    !diff.entries.iter().any(|e| e.operation != DiffOp::Context)
138}
139
140/// Total changed lines (add + remove).
141#[allow(dead_code)]
142pub fn changed_line_count(diff: &DiffExport) -> usize {
143    addition_count(diff) + removal_count(diff)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn identical_texts_no_changes() {
152        let diff = compute_diff("hello\nworld", "hello\nworld");
153        assert!(is_identical(&diff));
154    }
155
156    #[test]
157    fn changed_line_count_one_change() {
158        let diff = compute_diff("a\nb\nc", "a\nX\nc");
159        assert_eq!(changed_line_count(&diff), 2);
160    }
161
162    #[test]
163    fn addition_count_correct() {
164        let diff = compute_diff("a\nb", "a\nb\nc");
165        assert_eq!(addition_count(&diff), 1);
166    }
167
168    #[test]
169    fn removal_count_correct() {
170        let diff = compute_diff("a\nb\nc", "a\nb");
171        assert_eq!(removal_count(&diff), 1);
172    }
173
174    #[test]
175    fn unified_diff_starts_with_header() {
176        let diff = compute_diff("old", "new");
177        let out = export_diff_unified(&diff);
178        assert!(out.starts_with("---"));
179    }
180
181    #[test]
182    fn unified_diff_contains_plus() {
183        let diff = compute_diff("a", "b");
184        let out = export_diff_unified(&diff);
185        assert!(out.contains('+'));
186    }
187
188    #[test]
189    fn diff_op_symbol_correct() {
190        assert_eq!(DiffOp::Add.symbol(), '+');
191        assert_eq!(DiffOp::Remove.symbol(), '-');
192        assert_eq!(DiffOp::Context.symbol(), ' ');
193    }
194
195    #[test]
196    fn empty_strings_no_entries() {
197        let diff = compute_diff("", "");
198        assert_eq!(diff.entries.len(), 0);
199    }
200
201    #[test]
202    fn source_target_names_stored() {
203        let diff = DiffExport::new("old.cfg", "new.cfg");
204        assert_eq!(diff.source_name, "old.cfg");
205        assert_eq!(diff.target_name, "new.cfg");
206    }
207
208    #[test]
209    fn context_lines_counted() {
210        let diff = compute_diff("a\nb\nc", "a\nX\nc");
211        let ctx = diff
212            .entries
213            .iter()
214            .filter(|e| e.operation == DiffOp::Context)
215            .count();
216        assert_eq!(ctx, 2);
217    }
218}