Skip to main content

docgen_diff/
types.rs

1//! Core diff data model — a faithful port of `types.ts`.
2//!
3//! The load-bearing parity property is the **JSON shape**: camelCase fields,
4//! lowercase enum tags, and `oldPath`/`blocks` omitted when absent. These
5//! mirror the original SvelteKit payloads byte-for-byte so the P3 navigation
6//! island (and any consumer of the shipped JSON) reads identical data.
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum DocDiffLineKind {
13    Context,
14    Added,
15    Removed,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum DocDiffBlockKind {
21    Context,
22    Added,
23    Removed,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum DocDiffFileStatus {
29    Added,
30    Modified,
31    Deleted,
32    Renamed,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum DocDiffTimelinePointKind {
38    Commit,
39    Worktree,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct DocDiffLine {
45    pub kind: DocDiffLineKind,
46    pub old_line: Option<u32>,
47    pub new_line: Option<u32>,
48    pub text: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct DocDiffHunk {
54    pub old_start: u32,
55    pub old_lines: u32,
56    pub new_start: u32,
57    pub new_lines: u32,
58    pub lines: Vec<DocDiffLine>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct DocDiffBlock {
64    pub id: String,
65    pub kind: DocDiffBlockKind,
66    pub raw: String,
67    pub html: String,
68    pub old_index: Option<usize>,
69    pub new_index: Option<usize>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct DocDiffFile {
75    pub path: String,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub old_path: Option<String>,
78    pub status: DocDiffFileStatus,
79    pub added_lines: u32,
80    pub removed_lines: u32,
81    pub hunks: Vec<DocDiffHunk>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub blocks: Option<Vec<DocDiffBlock>>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "lowercase")]
88pub enum DocDiffFileTreeNode {
89    Group {
90        id: String,
91        label: String,
92        children: Vec<DocDiffFileTreeNode>,
93    },
94    // Field-level camelCase must be set on the variant: the enum-level
95    // `rename_all` renames only the variant tags, not struct-variant fields. The
96    // original TS file-tree nodes use `oldPath`/`addedLines`/`removedLines`.
97    #[serde(rename_all = "camelCase")]
98    File {
99        id: String,
100        label: String,
101        path: String,
102        #[serde(skip_serializing_if = "Option::is_none")]
103        old_path: Option<String>,
104        status: DocDiffFileStatus,
105        added_lines: u32,
106        removed_lines: u32,
107    },
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct DocDiffTimelinePoint {
113    pub id: String,
114    pub kind: DocDiffTimelinePointKind,
115    pub hash: Option<String>,
116    pub short_hash: String,
117    pub subject: String,
118    pub author: Option<String>,
119    /// RFC3339 string, parity with the TS ISO date.
120    pub date: Option<String>,
121    pub base_ref: String,
122    pub head_ref: String,
123    pub files: Vec<DocDiffFile>,
124    pub file_tree: Vec<DocDiffFileTreeNode>,
125    pub total_added_lines: u32,
126    pub total_removed_lines: u32,
127    pub warnings: Vec<String>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct DocDiffReport {
133    /// "build-history" in P2.
134    pub mode: String,
135    pub base_ref: String,
136    pub head_ref: String,
137    pub generated_at: String,
138    pub timeline: Vec<DocDiffTimelinePoint>,
139    pub selected_point_id: Option<String>,
140    pub selected_file_path: Option<String>,
141    pub files: Vec<DocDiffFile>,
142    pub total_added_lines: u32,
143    pub total_removed_lines: u32,
144    pub warnings: Vec<String>,
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn line_kind_serializes_lowercase() {
153        assert_eq!(
154            serde_json::to_string(&DocDiffLineKind::Added).unwrap(),
155            r#""added""#
156        );
157    }
158
159    #[test]
160    fn file_status_serializes_lowercase() {
161        assert_eq!(
162            serde_json::to_string(&DocDiffFileStatus::Renamed).unwrap(),
163            r#""renamed""#
164        );
165    }
166
167    #[test]
168    fn file_omits_old_path_and_blocks_when_absent() {
169        let f = DocDiffFile {
170            path: "docs/a.md".into(),
171            old_path: None,
172            status: DocDiffFileStatus::Modified,
173            added_lines: 1,
174            removed_lines: 2,
175            hunks: vec![],
176            blocks: None,
177        };
178        let v = serde_json::to_string(&f).unwrap();
179        assert!(!v.contains("oldPath"));
180        assert!(!v.contains("blocks"));
181        assert!(v.contains(r#""addedLines":1"#));
182        assert!(v.contains(r#""removedLines":2"#));
183    }
184
185    #[test]
186    fn file_tree_node_uses_type_tag() {
187        let n = DocDiffFileTreeNode::Group {
188            id: "docs/dev".into(),
189            label: "dev".into(),
190            children: vec![],
191        };
192        let v = serde_json::to_string(&n).unwrap();
193        assert!(v.contains(r#""type":"group""#));
194    }
195
196    #[test]
197    fn file_tree_file_node_omits_old_path_when_absent() {
198        let n = DocDiffFileTreeNode::File {
199            id: "docs/a.md".into(),
200            label: "a.md".into(),
201            path: "docs/a.md".into(),
202            old_path: None,
203            status: DocDiffFileStatus::Added,
204            added_lines: 3,
205            removed_lines: 0,
206        };
207        let v = serde_json::to_string(&n).unwrap();
208        assert!(v.contains(r#""type":"file""#));
209        assert!(!v.contains("oldPath"));
210        // Fields must be camelCase (parity with the TS file-tree node), not the
211        // Rust snake_case — the diff island reads `addedLines`/`removedLines`.
212        assert!(v.contains(r#""addedLines":3"#));
213        assert!(v.contains(r#""removedLines":0"#));
214        assert!(!v.contains("added_lines"));
215    }
216}