Skip to main content

wenlan_types/
page_map.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Wire types for the Page Map API (stage 2). See
3//! docs/superpowers/plans/2026-07-18-page-map-api-spec.md for the full
4//! contract. Mirrors the row shapes in
5//! `crates/wenlan-core/src/db/page_map.rs`, adding the read-time computed
6//! `ref_state` field the daemon derives at GET time (never stored).
7
8use serde::{Deserialize, Serialize};
9
10/// Whether a node's backing object (memory/entity/page/section) still
11/// resolves. Computed at read time by the server; never persisted.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum RefState {
15    Live,
16    Dangling,
17}
18
19/// `page_maps.viewport`, wire shape (stored server-side as a JSON string).
20#[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/// A node in the map, as returned to clients.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PageMapNode {
30    pub id: String,
31    /// `None` only for the root.
32    pub parent_id: Option<String>,
33    pub rank: f64,
34    pub ref_kind: String,
35    pub ref_id: String,
36    /// Map-local display override; `None` = render from the backing object.
37    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/// An edge in the map, as returned to clients.
50#[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/// Full map read: `GET /api/pages/{id}/map` and `PUT .../map/layout`, which
61/// also returns the whole (now-updated) map.
62#[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/// `POST /api/pages/{id}/map/nodes`. `ref_kind`/`ref_id` are `Option` on the
74/// wire (rather than required strings) so a missing ref can be reported as a
75/// 400 by the handler rather than whatever status axum's own JSON-rejection
76/// happens to pick for a missing field.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CreateMapNodeRequest {
79    pub base_revision: i64,
80    /// `None` = attach under the map's root (resolved server-side; lets the
81    /// very first node on a brand-new map be created without the client
82    /// already knowing the root's freshly-minted id).
83    #[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/// `PATCH /api/pages/{id}/map/nodes/{node_id}`. `label` is a nested
96/// `Option` so a patch can distinguish "don't touch the label" (key absent)
97/// from "clear the override back to NULL" (key present, value `null`) —
98/// see `deserialize_double_option` below.
99#[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/// `DELETE /api/pages/{id}/map/nodes/{node_id}` (and the edge equivalent
115/// below) — a conditional write like every other map mutation, so it still
116/// carries `base_revision` even though it has no other fields.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct DeleteMapNodeRequest {
119    pub base_revision: i64,
120}
121
122/// The shared mutation envelope for node writes: the new revision plus the
123/// touched row, so the client can chain edits without a round-trip.
124#[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/// `POST /api/pages/{id}/map/edges`.
135#[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/// `PATCH /api/pages/{id}/map/edges/{edge_id}` — accept/dismiss/relabel only
147/// (no from/to/kind changes, per the route table).
148#[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/// The shared mutation envelope for edge writes.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct EdgeMutationResponse {
165    pub revision: i64,
166    pub edge: PageMapEdge,
167}
168
169/// One placed node in a `PUT .../map/layout` write.
170#[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/// `PUT /api/pages/{id}/map/layout`.
182#[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
191/// Distinguishes "field omitted" (`None`, leave unchanged) from "field
192/// present with value `null`" (`Some(None)`, clear the override) for a
193/// `label` PATCH — serde's derived `Option<Option<T>>` collapses both to
194/// `None` by default (a JSON `null` short-circuits at the outer `Option`),
195/// so the nested option is deserialized by hand here. This is the same ~3
196/// line recipe the `serde_with::rust::double_option` crate ships; hand-
197/// rolled so wenlan-types stays serde + serde_json + anyhow only.
198fn 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}