use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RefState {
Live,
Dangling,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PageMapViewport {
pub x: f64,
pub y: f64,
pub zoom: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageMapNode {
pub id: String,
pub parent_id: Option<String>,
pub rank: f64,
pub ref_kind: String,
pub ref_id: String,
pub label: Option<String>,
pub status: String,
pub pinned: bool,
pub placed: bool,
pub collapsed: bool,
pub x: Option<f64>,
pub y: Option<f64>,
pub width: Option<f64>,
pub height: Option<f64>,
pub ref_state: RefState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageMapEdge {
pub id: String,
pub from_node: String,
pub to_node: String,
pub kind: String,
pub label: Option<String>,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageMapResponse {
pub page_id: String,
pub revision: i64,
pub map_schema: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub viewport: Option<PageMapViewport>,
pub nodes: Vec<PageMapNode>,
pub edges: Vec<PageMapEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateMapNodeRequest {
pub base_revision: i64,
#[serde(default)]
pub parent_id: Option<String>,
#[serde(default)]
pub ref_kind: Option<String>,
#[serde(default)]
pub ref_id: Option<String>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub rank: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatchMapNodeRequest {
pub base_revision: i64,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub label: Option<Option<String>>,
#[serde(default)]
pub pinned: Option<bool>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub rank: Option<f64>,
#[serde(default)]
pub parent_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteMapNodeRequest {
pub base_revision: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeMutationResponse {
pub revision: i64,
pub node: PageMapNode,
}
fn default_edge_kind() -> String {
"link".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateMapEdgeRequest {
pub base_revision: i64,
pub from_node: String,
pub to_node: String,
#[serde(default = "default_edge_kind")]
pub kind: String,
#[serde(default)]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PatchMapEdgeRequest {
pub base_revision: i64,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub label: Option<Option<String>>,
#[serde(default)]
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteMapEdgeRequest {
pub base_revision: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeMutationResponse {
pub revision: i64,
pub edge: PageMapEdge,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageMapNodeLayout {
pub node_id: String,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
#[serde(default)]
pub collapsed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PutPageMapLayoutRequest {
pub base_revision: i64,
#[serde(default)]
pub viewport: Option<PageMapViewport>,
#[serde(default)]
pub positions: Vec<PageMapNodeLayout>,
}
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
Ok(Some(Option::deserialize(deserializer)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ref_state_serializes_snake_case() {
assert_eq!(serde_json::to_string(&RefState::Live).unwrap(), "\"live\"");
assert_eq!(
serde_json::to_string(&RefState::Dangling).unwrap(),
"\"dangling\""
);
}
#[test]
fn double_option_label_distinguishes_absent_null_and_value() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default, deserialize_with = "deserialize_double_option")]
label: Option<Option<String>>,
}
let absent: Wrapper = serde_json::from_str("{}").unwrap();
assert_eq!(absent.label, None);
let cleared: Wrapper = serde_json::from_str(r#"{"label": null}"#).unwrap();
assert_eq!(cleared.label, Some(None));
let set: Wrapper = serde_json::from_str(r#"{"label": "custom"}"#).unwrap();
assert_eq!(set.label, Some(Some("custom".to_string())));
}
#[test]
fn patch_map_node_request_label_field_uses_double_option() {
let req: PatchMapNodeRequest =
serde_json::from_str(r#"{"base_revision": 1, "label": null}"#).unwrap();
assert_eq!(req.label, Some(None));
let req: PatchMapNodeRequest = serde_json::from_str(r#"{"base_revision": 1}"#).unwrap();
assert_eq!(req.label, None);
}
#[test]
fn create_map_edge_request_defaults_kind_to_link() {
let req: CreateMapEdgeRequest =
serde_json::from_str(r#"{"base_revision": 1, "from_node": "a", "to_node": "b"}"#)
.unwrap();
assert_eq!(req.kind, "link");
}
#[test]
fn page_map_response_omits_absent_viewport() {
let response = PageMapResponse {
page_id: "p1".to_string(),
revision: 0,
map_schema: 1,
viewport: None,
nodes: vec![],
edges: vec![],
};
let json = serde_json::to_string(&response).unwrap();
assert!(!json.contains("viewport"));
}
}