Skip to main content

alizarin_core/graph/
descriptors.rs

1//! Resource descriptor types and configuration.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Descriptor function UUID (from Arches)
7pub const DESCRIPTOR_FUNCTION_ID: &str = "60000000-0000-0000-0000-000000000001";
8
9/// Descriptors for resource display
10#[derive(Clone, Debug, Serialize, Deserialize, Default)]
11pub struct StaticResourceDescriptors {
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub name: Option<String>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub map_popup: Option<String>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub description: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub slug: Option<String>,
20}
21
22impl StaticResourceDescriptors {
23    /// Check if all descriptors are empty
24    pub fn is_empty(&self) -> bool {
25        self.name.is_none()
26            && self.map_popup.is_none()
27            && self.description.is_none()
28            && self.slug.is_none()
29    }
30
31    /// Create empty descriptors
32    pub fn empty() -> Self {
33        Self::default()
34    }
35}
36
37/// Configuration for a single descriptor type (name, description, map_popup)
38///
39/// The default descriptor function stores a single `nodegroup_id` because all
40/// placeholders must come from the same nodegroup.  Non-default functions (such
41/// as the Multi-card Resource Descriptor) may reference nodes across multiple
42/// nodegroups, in which case `nodegroup_ids` is populated instead.
43#[derive(Clone, Debug, Serialize, Deserialize)]
44pub struct DescriptorTypeConfig {
45    /// Single nodegroup (default descriptor function).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub nodegroup_id: Option<String>,
48    /// Multiple nodegroups (non-default descriptor functions).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub nodegroup_ids: Option<Vec<String>>,
51    pub string_template: String,
52}
53
54impl DescriptorTypeConfig {
55    /// Return all nodegroup IDs referenced by this config.
56    pub fn all_nodegroup_ids(&self) -> Vec<&str> {
57        if let Some(ids) = &self.nodegroup_ids {
58            ids.iter().map(|s| s.as_str()).collect()
59        } else if let Some(id) = &self.nodegroup_id {
60            vec![id.as_str()]
61        } else {
62            vec![]
63        }
64    }
65
66    /// Whether this config spans multiple nodegroups.
67    pub fn is_multi_nodegroup(&self) -> bool {
68        self.nodegroup_ids.as_ref().is_some_and(|ids| ids.len() > 1)
69    }
70}
71
72/// Complete descriptor configuration from functions_x_graphs
73#[derive(Clone, Debug, Serialize, Deserialize)]
74pub struct DescriptorConfig {
75    pub descriptor_types: HashMap<String, DescriptorTypeConfig>,
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::graph::{IndexedGraph, StaticGraph, StaticTile};
82
83    /// Build a minimal graph via JSON deserialization with descriptor function config
84    fn build_test_graph(descriptor_types: Vec<(&str, &str, &str)>) -> IndexedGraph {
85        let dt: HashMap<String, serde_json::Value> = descriptor_types
86            .into_iter()
87            .map(|(dtype, ng_id, template)| {
88                (
89                    dtype.to_string(),
90                    serde_json::json!({
91                        "nodegroup_id": ng_id,
92                        "string_template": template,
93                    }),
94                )
95            })
96            .collect();
97
98        let graph_json = serde_json::json!({
99            "graphid": "test-graph",
100            "name": {"en": "Test Graph"},
101            "root": {
102                "nodeid": "root-id",
103                "name": "Root",
104                "datatype": "semantic",
105                "graph_id": "test-graph"
106            },
107            "nodes": [
108                {
109                    "nodeid": "root-id",
110                    "name": "Root",
111                    "datatype": "semantic",
112                    "graph_id": "test-graph"
113                },
114                {
115                    "nodeid": "name-node-id",
116                    "name": "Name",
117                    "alias": "name",
118                    "datatype": "string",
119                    "nodegroup_id": "name-ng",
120                    "graph_id": "test-graph"
121                },
122                {
123                    "nodeid": "slug-node-id",
124                    "name": "Slug",
125                    "alias": "slug",
126                    "datatype": "string",
127                    "nodegroup_id": "slug-ng",
128                    "graph_id": "test-graph"
129                }
130            ],
131            "nodegroups": [
132                { "nodegroupid": "name-ng", "cardinality": "1" },
133                { "nodegroupid": "slug-ng", "cardinality": "1" }
134            ],
135            "edges": [
136                { "domainnode_id": "root-id", "rangenode_id": "name-node-id" },
137                { "domainnode_id": "root-id", "rangenode_id": "slug-node-id" }
138            ],
139            "functions_x_graphs": [
140                {
141                    "config": { "descriptor_types": dt },
142                    "function_id": DESCRIPTOR_FUNCTION_ID,
143                    "graph_id": "test-graph",
144                    "id": "fxg-1"
145                }
146            ]
147        });
148
149        let graph: StaticGraph = serde_json::from_value(graph_json).expect("test graph JSON");
150        IndexedGraph::new(graph)
151    }
152
153    fn make_tile(nodegroup_id: &str, node_id: &str, value: &str) -> StaticTile {
154        let mut tile = StaticTile::new_empty(nodegroup_id.to_string());
155        tile.resourceinstance_id = "res-1".to_string();
156        tile.tileid = Some("tile-1".to_string());
157        tile.data.insert(
158            node_id.to_string(),
159            serde_json::json!({"en": {"value": value, "direction": "ltr"}}),
160        );
161        tile
162    }
163
164    #[test]
165    fn test_build_descriptors_slug() {
166        let indexed = build_test_graph(vec![
167            ("name", "name-ng", "<Name>"),
168            ("slug", "slug-ng", "<Slug>"),
169        ]);
170
171        let tiles = vec![
172            make_tile("name-ng", "name-node-id", "My Resource"),
173            make_tile("slug-ng", "slug-node-id", "My Resource"),
174        ];
175
176        let descriptors = indexed.build_descriptors(&tiles);
177
178        assert_eq!(descriptors.name.as_deref(), Some("My Resource"));
179        assert_eq!(descriptors.slug.as_deref(), Some("my-resource"));
180        assert_eq!(descriptors.description, None);
181        assert_eq!(descriptors.map_popup, None);
182    }
183
184    #[test]
185    fn test_build_descriptors_slug_absent_is_none() {
186        let indexed = build_test_graph(vec![("name", "name-ng", "<Name>")]);
187
188        let tiles = vec![make_tile("name-ng", "name-node-id", "My Resource")];
189
190        let descriptors = indexed.build_descriptors(&tiles);
191
192        assert_eq!(descriptors.name.as_deref(), Some("My Resource"));
193        assert!(descriptors.slug.is_none());
194    }
195
196    #[test]
197    fn test_descriptors_is_empty() {
198        let d = StaticResourceDescriptors::default();
199        assert!(d.is_empty());
200
201        let d = StaticResourceDescriptors {
202            slug: Some("test".to_string()),
203            ..Default::default()
204        };
205        assert!(!d.is_empty());
206    }
207
208    #[test]
209    fn test_descriptors_serde_roundtrip_with_slug() {
210        let d = StaticResourceDescriptors {
211            name: Some("Test".to_string()),
212            slug: Some("test-slug".to_string()),
213            description: None,
214            map_popup: None,
215        };
216        let json = serde_json::to_string(&d).unwrap();
217        let parsed: StaticResourceDescriptors = serde_json::from_str(&json).unwrap();
218        assert_eq!(parsed.slug.as_deref(), Some("test-slug"));
219        assert_eq!(parsed.name.as_deref(), Some("Test"));
220    }
221
222    #[test]
223    fn test_descriptors_deserialize_without_slug_is_backwards_compatible() {
224        let json = r#"{"name": "Test"}"#;
225        let parsed: StaticResourceDescriptors = serde_json::from_str(json).unwrap();
226        assert_eq!(parsed.name.as_deref(), Some("Test"));
227        assert!(parsed.slug.is_none());
228    }
229
230    #[test]
231    fn test_build_descriptors_multi_nodegroup() {
232        // Build a graph with a non-default descriptor function using nodegroup_ids
233        let graph_json = serde_json::json!({
234            "graphid": "test-graph",
235            "name": {"en": "Test Graph"},
236            "root": {
237                "nodeid": "root-id",
238                "name": "Root",
239                "datatype": "semantic",
240                "graph_id": "test-graph"
241            },
242            "nodes": [
243                {
244                    "nodeid": "root-id",
245                    "name": "Root",
246                    "datatype": "semantic",
247                    "graph_id": "test-graph"
248                },
249                {
250                    "nodeid": "name-node-id",
251                    "name": "Name",
252                    "alias": "name",
253                    "datatype": "string",
254                    "nodegroup_id": "name-ng",
255                    "graph_id": "test-graph"
256                },
257                {
258                    "nodeid": "desc-node-id",
259                    "name": "Description",
260                    "alias": "description",
261                    "datatype": "string",
262                    "nodegroup_id": "desc-ng",
263                    "graph_id": "test-graph"
264                }
265            ],
266            "nodegroups": [
267                { "nodegroupid": "name-ng", "cardinality": "1" },
268                { "nodegroupid": "desc-ng", "cardinality": "1" }
269            ],
270            "edges": [
271                { "domainnode_id": "root-id", "rangenode_id": "name-node-id" },
272                { "domainnode_id": "root-id", "rangenode_id": "desc-node-id" }
273            ],
274            "functions_x_graphs": [
275                {
276                    "config": {
277                        "descriptor_types": {
278                            "name": {
279                                "nodegroup_ids": ["name-ng", "desc-ng"],
280                                "string_template": "<Name> - <Description>"
281                            }
282                        }
283                    },
284                    "function_id": "00b2d15a-fda0-4578-b79a-784e4138664b",
285                    "graph_id": "test-graph",
286                    "id": "fxg-1"
287                }
288            ]
289        });
290
291        let graph: StaticGraph = serde_json::from_value(graph_json).expect("test graph JSON");
292        let indexed = IndexedGraph::new(graph);
293
294        let tiles = vec![
295            make_tile("name-ng", "name-node-id", "Heritage Item 42"),
296            make_tile("desc-ng", "desc-node-id", "A fine building"),
297        ];
298
299        let descriptors = indexed.build_descriptors(&tiles);
300        assert_eq!(
301            descriptors.name.as_deref(),
302            Some("Heritage Item 42 - A fine building")
303        );
304    }
305}