Skip to main content

tokmd_types/
diff.rs

1//! Diff receipt DTOs.
2//!
3//! This module owns the serde-stable structures emitted by `tokmd diff`.
4//! Public consumers should continue using the root-level re-exports from
5//! `tokmd_types`.
6
7use serde::{Deserialize, Serialize};
8
9use crate::ToolInfo;
10
11/// A row in the diff output showing changes for a single language.
12///
13/// # Examples
14///
15/// ```
16/// use tokmd_types::DiffRow;
17///
18/// let row = DiffRow {
19///     lang: "Rust".to_string(),
20///     old_code: 1000, new_code: 1200, delta_code: 200,
21///     old_lines: 1500, new_lines: 1800, delta_lines: 300,
22///     old_files: 10,   new_files: 12,   delta_files: 2,
23///     old_bytes: 40000, new_bytes: 48000, delta_bytes: 8000,
24///     old_tokens: 10000, new_tokens: 12000, delta_tokens: 2000,
25/// };
26/// assert_eq!(row.delta_code, (row.new_code as i64) - (row.old_code as i64));
27/// ```
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct DiffRow {
30    pub lang: String,
31    pub old_code: usize,
32    pub new_code: usize,
33    pub delta_code: i64,
34    pub old_lines: usize,
35    pub new_lines: usize,
36    pub delta_lines: i64,
37    pub old_files: usize,
38    pub new_files: usize,
39    pub delta_files: i64,
40    pub old_bytes: usize,
41    pub new_bytes: usize,
42    pub delta_bytes: i64,
43    pub old_tokens: usize,
44    pub new_tokens: usize,
45    pub delta_tokens: i64,
46}
47
48/// Aggregate totals for the diff.
49///
50/// # Examples
51///
52/// ```
53/// use tokmd_types::DiffTotals;
54///
55/// // Default is all zeros
56/// let totals = DiffTotals::default();
57/// assert_eq!(totals.delta_code, 0);
58/// assert_eq!(totals.delta_files, 0);
59/// ```
60#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
61pub struct DiffTotals {
62    pub old_code: usize,
63    pub new_code: usize,
64    pub delta_code: i64,
65    pub old_lines: usize,
66    pub new_lines: usize,
67    pub delta_lines: i64,
68    pub old_files: usize,
69    pub new_files: usize,
70    pub delta_files: i64,
71    pub old_bytes: usize,
72    pub new_bytes: usize,
73    pub delta_bytes: i64,
74    pub old_tokens: usize,
75    pub new_tokens: usize,
76    pub delta_tokens: i64,
77}
78
79/// JSON receipt for diff output with envelope metadata.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct DiffReceipt {
82    pub schema_version: u32,
83    pub generated_at_ms: u128,
84    pub tool: ToolInfo,
85    pub mode: String,
86    pub from_source: String,
87    pub to_source: String,
88    pub diff_rows: Vec<DiffRow>,
89    pub totals: DiffTotals,
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    fn sample_diff_row() -> DiffRow {
97        DiffRow {
98            lang: "Rust".to_string(),
99            old_code: 100,
100            new_code: 150,
101            delta_code: 50,
102            old_lines: 200,
103            new_lines: 260,
104            delta_lines: 60,
105            old_files: 5,
106            new_files: 7,
107            delta_files: 2,
108            old_bytes: 5_000,
109            new_bytes: 6_500,
110            delta_bytes: 1_500,
111            old_tokens: 1_200,
112            new_tokens: 1_700,
113            delta_tokens: 500,
114        }
115    }
116
117    fn sample_diff_totals() -> DiffTotals {
118        DiffTotals {
119            old_code: 1_000,
120            new_code: 1_200,
121            delta_code: 200,
122            old_lines: 1_500,
123            new_lines: 1_800,
124            delta_lines: 300,
125            old_files: 10,
126            new_files: 12,
127            delta_files: 2,
128            old_bytes: 40_000,
129            new_bytes: 48_000,
130            delta_bytes: 8_000,
131            old_tokens: 10_000,
132            new_tokens: 12_000,
133            delta_tokens: 2_000,
134        }
135    }
136
137    // ── DiffRow ──────────────────────────────────────────────────────
138    #[test]
139    fn diff_row_serde_roundtrip() {
140        let row = sample_diff_row();
141        let json = serde_json::to_string(&row).expect("serialize");
142        let back: DiffRow = serde_json::from_str(&json).expect("deserialize");
143        assert_eq!(back, row);
144    }
145
146    #[test]
147    fn diff_row_field_names_stable() {
148        let row = sample_diff_row();
149        let value = serde_json::to_value(&row).expect("to_value");
150        for key in [
151            "lang",
152            "old_code",
153            "new_code",
154            "delta_code",
155            "old_lines",
156            "new_lines",
157            "delta_lines",
158            "old_files",
159            "new_files",
160            "delta_files",
161            "old_bytes",
162            "new_bytes",
163            "delta_bytes",
164            "old_tokens",
165            "new_tokens",
166            "delta_tokens",
167        ] {
168            assert!(
169                value.get(key).is_some(),
170                "missing expected field `{key}` in DiffRow JSON: {value}"
171            );
172        }
173    }
174
175    // ── DiffTotals ───────────────────────────────────────────────────
176    #[test]
177    fn diff_totals_serde_roundtrip() {
178        let totals = sample_diff_totals();
179        let json = serde_json::to_string(&totals).expect("serialize");
180        let back: DiffTotals = serde_json::from_str(&json).expect("deserialize");
181        assert_eq!(back, totals);
182    }
183
184    #[test]
185    fn diff_totals_default_is_all_zero() {
186        let totals = DiffTotals::default();
187        let value = serde_json::to_value(&totals).expect("to_value");
188        for key in [
189            "old_code",
190            "new_code",
191            "delta_code",
192            "old_lines",
193            "new_lines",
194            "delta_lines",
195            "old_files",
196            "new_files",
197            "delta_files",
198            "old_bytes",
199            "new_bytes",
200            "delta_bytes",
201            "old_tokens",
202            "new_tokens",
203            "delta_tokens",
204        ] {
205            assert_eq!(
206                value[key], 0,
207                "expected default DiffTotals.{key} to serialize to 0, got {}",
208                value[key]
209            );
210        }
211    }
212
213    #[test]
214    fn diff_totals_negative_deltas_roundtrip() {
215        let totals = DiffTotals {
216            old_code: 200,
217            new_code: 50,
218            delta_code: -150,
219            old_files: 8,
220            new_files: 4,
221            delta_files: -4,
222            ..DiffTotals::default()
223        };
224        let json = serde_json::to_string(&totals).expect("serialize");
225        let back: DiffTotals = serde_json::from_str(&json).expect("deserialize");
226        assert_eq!(back, totals);
227        assert_eq!(back.delta_code, -150);
228        assert_eq!(back.delta_files, -4);
229    }
230
231    // ── DiffReceipt ──────────────────────────────────────────────────
232    #[test]
233    fn diff_receipt_serde_roundtrip() {
234        let receipt = DiffReceipt {
235            schema_version: crate::SCHEMA_VERSION,
236            generated_at_ms: 1_700_000_000_000,
237            tool: ToolInfo {
238                name: "tokmd".into(),
239                version: "1.2.3".into(),
240            },
241            mode: "diff".into(),
242            from_source: "old.json".into(),
243            to_source: "new.json".into(),
244            diff_rows: vec![sample_diff_row()],
245            totals: sample_diff_totals(),
246        };
247
248        let json = serde_json::to_string(&receipt).expect("serialize");
249        let value: serde_json::Value = serde_json::from_str(&json).expect("to value");
250        // Field stability check
251        for key in [
252            "schema_version",
253            "generated_at_ms",
254            "tool",
255            "mode",
256            "from_source",
257            "to_source",
258            "diff_rows",
259            "totals",
260        ] {
261            assert!(
262                value.get(key).is_some(),
263                "missing expected field `{key}` in DiffReceipt JSON"
264            );
265        }
266
267        let back: DiffReceipt = serde_json::from_str(&json).expect("deserialize");
268        assert_eq!(back.schema_version, receipt.schema_version);
269        assert_eq!(back.mode, "diff");
270        assert_eq!(back.from_source, "old.json");
271        assert_eq!(back.to_source, "new.json");
272        assert_eq!(back.diff_rows.len(), 1);
273        assert_eq!(back.diff_rows[0], receipt.diff_rows[0]);
274        assert_eq!(back.totals, receipt.totals);
275    }
276
277    #[test]
278    fn diff_receipt_preserves_row_order() {
279        let mut rows = Vec::new();
280        for (i, lang) in ["Rust", "Go", "C++", "Python"].iter().enumerate() {
281            let mut row = sample_diff_row();
282            row.lang = (*lang).to_string();
283            row.delta_code = i as i64;
284            rows.push(row);
285        }
286        let receipt = DiffReceipt {
287            schema_version: crate::SCHEMA_VERSION,
288            generated_at_ms: 0,
289            tool: ToolInfo::default(),
290            mode: "diff".into(),
291            from_source: "a".into(),
292            to_source: "b".into(),
293            diff_rows: rows.clone(),
294            totals: DiffTotals::default(),
295        };
296        let json = serde_json::to_string(&receipt).expect("serialize");
297        let back: DiffReceipt = serde_json::from_str(&json).expect("deserialize");
298        let back_langs: Vec<_> = back.diff_rows.iter().map(|r| r.lang.as_str()).collect();
299        assert_eq!(back_langs, vec!["Rust", "Go", "C++", "Python"]);
300    }
301}