1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum RefState {
15 Live,
16 Dangling,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
21pub struct PageMapViewport {
22 pub x: f64,
23 pub y: f64,
24 pub zoom: f64,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PageMapNode {
30 pub id: String,
31 pub parent_id: Option<String>,
33 pub rank: f64,
34 pub ref_kind: String,
35 pub ref_id: String,
36 pub label: Option<String>,
38 pub status: String,
39 pub pinned: bool,
40 pub placed: bool,
41 pub collapsed: bool,
42 pub x: Option<f64>,
43 pub y: Option<f64>,
44 pub width: Option<f64>,
45 pub height: Option<f64>,
46 pub ref_state: RefState,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PageMapEdge {
52 pub id: String,
53 pub from_node: String,
54 pub to_node: String,
55 pub kind: String,
56 pub label: Option<String>,
57 pub status: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct PageMapResponse {
64 pub page_id: String,
65 pub revision: i64,
66 pub map_schema: i64,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub viewport: Option<PageMapViewport>,
69 pub nodes: Vec<PageMapNode>,
70 pub edges: Vec<PageMapEdge>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CreateMapNodeRequest {
79 pub base_revision: i64,
80 #[serde(default)]
84 pub parent_id: Option<String>,
85 #[serde(default)]
86 pub ref_kind: Option<String>,
87 #[serde(default)]
88 pub ref_id: Option<String>,
89 #[serde(default)]
90 pub label: Option<String>,
91 #[serde(default)]
92 pub rank: f64,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PatchMapNodeRequest {
101 pub base_revision: i64,
102 #[serde(default, deserialize_with = "deserialize_double_option")]
103 pub label: Option<Option<String>>,
104 #[serde(default)]
105 pub pinned: Option<bool>,
106 #[serde(default)]
107 pub status: Option<String>,
108 #[serde(default)]
109 pub rank: Option<f64>,
110 #[serde(default)]
111 pub parent_id: Option<String>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct DeleteMapNodeRequest {
119 pub base_revision: i64,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct NodeMutationResponse {
126 pub revision: i64,
127 pub node: PageMapNode,
128}
129
130fn default_edge_kind() -> String {
131 "link".to_string()
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct CreateMapEdgeRequest {
137 pub base_revision: i64,
138 pub from_node: String,
139 pub to_node: String,
140 #[serde(default = "default_edge_kind")]
141 pub kind: String,
142 #[serde(default)]
143 pub label: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct PatchMapEdgeRequest {
150 pub base_revision: i64,
151 #[serde(default, deserialize_with = "deserialize_double_option")]
152 pub label: Option<Option<String>>,
153 #[serde(default)]
154 pub status: Option<String>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct DeleteMapEdgeRequest {
159 pub base_revision: i64,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct EdgeMutationResponse {
165 pub revision: i64,
166 pub edge: PageMapEdge,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct PageMapNodeLayout {
172 pub node_id: String,
173 pub x: f64,
174 pub y: f64,
175 pub width: f64,
176 pub height: f64,
177 #[serde(default)]
178 pub collapsed: bool,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct PutPageMapLayoutRequest {
184 pub base_revision: i64,
185 #[serde(default)]
186 pub viewport: Option<PageMapViewport>,
187 #[serde(default)]
188 pub positions: Vec<PageMapNodeLayout>,
189}
190
191fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
199where
200 D: serde::Deserializer<'de>,
201 T: serde::Deserialize<'de>,
202{
203 Ok(Some(Option::deserialize(deserializer)?))
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn ref_state_serializes_snake_case() {
212 assert_eq!(serde_json::to_string(&RefState::Live).unwrap(), "\"live\"");
213 assert_eq!(
214 serde_json::to_string(&RefState::Dangling).unwrap(),
215 "\"dangling\""
216 );
217 }
218
219 #[test]
220 fn double_option_label_distinguishes_absent_null_and_value() {
221 #[derive(Deserialize)]
222 struct Wrapper {
223 #[serde(default, deserialize_with = "deserialize_double_option")]
224 label: Option<Option<String>>,
225 }
226
227 let absent: Wrapper = serde_json::from_str("{}").unwrap();
228 assert_eq!(absent.label, None);
229
230 let cleared: Wrapper = serde_json::from_str(r#"{"label": null}"#).unwrap();
231 assert_eq!(cleared.label, Some(None));
232
233 let set: Wrapper = serde_json::from_str(r#"{"label": "custom"}"#).unwrap();
234 assert_eq!(set.label, Some(Some("custom".to_string())));
235 }
236
237 #[test]
238 fn patch_map_node_request_label_field_uses_double_option() {
239 let req: PatchMapNodeRequest =
240 serde_json::from_str(r#"{"base_revision": 1, "label": null}"#).unwrap();
241 assert_eq!(req.label, Some(None));
242
243 let req: PatchMapNodeRequest = serde_json::from_str(r#"{"base_revision": 1}"#).unwrap();
244 assert_eq!(req.label, None);
245 }
246
247 #[test]
248 fn create_map_edge_request_defaults_kind_to_link() {
249 let req: CreateMapEdgeRequest =
250 serde_json::from_str(r#"{"base_revision": 1, "from_node": "a", "to_node": "b"}"#)
251 .unwrap();
252 assert_eq!(req.kind, "link");
253 }
254
255 #[test]
256 fn page_map_response_omits_absent_viewport() {
257 let response = PageMapResponse {
258 page_id: "p1".to_string(),
259 revision: 0,
260 map_schema: 1,
261 viewport: None,
262 nodes: vec![],
263 edges: vec![],
264 };
265 let json = serde_json::to_string(&response).unwrap();
266 assert!(!json.contains("viewport"));
267 }
268}