dsp_cli/model/vocabulary.rs
1//! Vocabulary domain shapes — shared across the client → action → render
2//! boundary for `dsp vre vocabulary list` / `describe` (DSP-API "list" /
3//! "list node").
4//!
5//! Types here are the dsp-cli vocabulary for DSP-API's list/list-node wire
6//! shapes. Wire (de)serialization stays in `src/client/http.rs`; nothing
7//! here derives `serde`. See ADR-0001 and ADR-0008.
8
9/// One language-tagged string, as DSP-API returns it in a list's `labels` /
10/// `comments`. ALL languages are kept (plan 034 D4) — no preferred-language
11/// collapsing anywhere in this crate. `language` is `None` for DSP-API's
12/// untagged `PlainStringLiteralV2` variant (D9b).
13///
14/// Same shape as [`ProjectDescription`]; deliberately not consolidated with
15/// it — see the plan's BACKLOG note. `language` stays `Option<String>`
16/// rather than a closed `Language` enum, even though D9 proves the server's
17/// tag set is closed at exactly `{de, en, fr, it, rm}`: mirroring the
18/// existing `ProjectDescription` precedent means an unexpected tag stays a
19/// value the CLI can still display, rather than becoming a hard parse error
20/// (`ServerError`). Reads should degrade, not refuse.
21///
22/// [`ProjectDescription`]: crate::model::ProjectDescription
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct LocalizedText {
25 /// The label/comment text.
26 pub value: String,
27 /// BCP-47-shaped language tag, if the server provides one (e.g. `"en"`).
28 pub language: Option<String>,
29}
30
31/// Identity + labels of a vocabulary or one of its nodes. No children, no
32/// counts — those live on [`Vocabulary`] / [`VocabularyNode`] / [`VocabularyTree`].
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct VocabularyHeader {
35 /// The vocabulary's or node's IRI (e.g. `http://rdfh.ch/lists/0838/…`).
36 pub iri: String,
37 /// Human-readable short name, if the server supplies one.
38 pub name: Option<String>,
39 /// Language-tagged labels (D4: all languages kept, no preference rule).
40 pub labels: Vec<LocalizedText>,
41 /// Language-tagged comments (D7: carried, out of the default column set).
42 pub comments: Vec<LocalizedText>,
43}
44
45/// A vocabulary as shown by `dsp vre vocabulary list` — the lean list-index
46/// projection.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Vocabulary {
49 pub header: VocabularyHeader,
50 /// `Some` only when `--count` fetched this vocabulary's tree; `None`
51 /// when a per-tree fetch failed (disclosed to the caller, not silently
52 /// dropped). Counts nodes strictly BELOW the root — the root itself is
53 /// never a node.
54 pub node_count: Option<usize>,
55 /// `Some` only under `--count`. Deepest level; the root's direct
56 /// children sit at level 1.
57 pub depth: Option<usize>,
58}
59
60/// One node in a vocabulary's tree, as returned by the DSP-API
61/// (position-sorted by the server; the client re-sorts defensively).
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct VocabularyNode {
64 pub header: VocabularyHeader,
65 /// Raw, 0-based DSP-API `position` among siblings. NOT the 1-based
66 /// outline `number` the renderer derives (D10) — kept unchanged here so
67 /// `--columns position` still shows the untranslated wire value.
68 pub position: i32,
69 pub children: Vec<VocabularyNode>,
70}
71
72/// What the CLIENT returns from `describe_vocabulary`: a faithful,
73/// position-ordered tree plus the facts the wire response carried. Holds NO
74/// rendered-counts and no render decisions — structurally, since it has no
75/// such fields, the client cannot express them even by accident. [`VocabularyDetail`]
76/// is where those decisions live (Step 3's layer-ownership split).
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct VocabularyTree {
79 pub root: VocabularyHeader,
80 /// The WHOLE tree, always — never pruned. Absolute `number` (D10) and
81 /// `path` (D11) are derived from each node's ancestor chain, which
82 /// pruning would destroy.
83 pub children: Vec<VocabularyNode>,
84 /// From `listinfo.projectIri`, which a root response ALWAYS carries — so
85 /// this is never absent and the cross-project guard never has to fail
86 /// open.
87 pub project_iri: String,
88 /// Set when the address that was resolved turned out to be a NODE, not a
89 /// root: the client is the layer that saw the `Node` response shape
90 /// (D2), so it is the layer that records this.
91 pub requested_node: Option<String>,
92}
93
94/// What the ACTION hands the renderer for `describe`: the tree plus the
95/// render decisions only the action can make, because only it sees `--subtree`.
96///
97/// The split is deliberate and is what makes the layer ownership structural
98/// rather than a convention: `describe_vocabulary` (the client) returns a
99/// bare [`VocabularyTree`], so the client has no `node_count` field to fill
100/// in even by accident.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct VocabularyDetail {
103 pub tree: VocabularyTree,
104 /// Set by the action from `--subtree` (D14): the node IRI to narrow
105 /// RENDERING to. `None` renders the whole vocabulary.
106 pub subtree_of: Option<String>,
107 /// Nodes RENDERED (honouring `subtree_of`). Equals the tabular data-row
108 /// count in both whole-vocabulary and `--subtree` mode (D15). Computed
109 /// by the action via [`VocabularyTree::count_and_depth`].
110 pub node_count: usize,
111 /// Deepest level of what is RENDERED (a root's direct children sit at
112 /// level 1). Under `--subtree` this is branch-relative — the addressed
113 /// node itself is level 1 of its own branch, so a leaf renders `1 node ·
114 /// 1 level`. The per-node `depth` COLUMN the renderer derives separately
115 /// stays absolute, so it never disagrees with `number`'s segment count.
116 pub depth: usize,
117}
118
119impl VocabularyTree {
120 /// Count nodes and measure depth at or below the node named by `from`
121 /// (the whole tree when `from` is `None`).
122 ///
123 /// Shared derivation used by BOTH `list --count` and `describe`, so one
124 /// walk has one implementation — the thing that makes the D15 invariant
125 /// hold (`node_count` equals the rendered data-row count in both modes).
126 ///
127 /// - `from: None` — whole-vocabulary mode. The root itself is never a
128 /// node, so it is not counted; `depth` treats the root's direct
129 /// children as level 1.
130 /// - `from: Some(iri)` — branch mode (`--subtree`). The addressed node
131 /// IS included, as the top of its own branch (D14b): a leaf node
132 /// yields `(1, 1)` — one node, one level, because the node itself
133 /// occupies level 1 of the branch, its children level 2, and so on.
134 /// If `iri` does not address any node in this tree, returns `(0, 0)`
135 /// (the action layer validates that `--subtree`'s address names a node
136 /// present in the tree before calling this, so that case should not
137 /// arise from user input — this is a defensive default, not
138 /// user-facing behaviour).
139 ///
140 /// This is not on the untrusted-input parse path (that's
141 /// `src/client/http.rs`, which must walk iteratively) — it walks a tree
142 /// that already survived deserialisation, so plain recursion is fine;
143 /// real data tops out at 9 levels.
144 pub fn count_and_depth(&self, from: Option<&str>) -> (usize, usize) {
145 match from {
146 None => {
147 let mut count = 0;
148 let mut depth = 0;
149 for child in &self.children {
150 let (child_count, child_height) = subtree_stats(child);
151 count += child_count;
152 depth = depth.max(child_height);
153 }
154 (count, depth)
155 }
156 Some(iri) => match find_node(&self.children, iri) {
157 Some(node) => subtree_stats(node),
158 None => (0, 0),
159 },
160 }
161 }
162}
163
164/// Node count and height of the subtree rooted at `node`, INCLUSIVE of
165/// `node` itself: a leaf returns `(1, 1)`.
166fn subtree_stats(node: &VocabularyNode) -> (usize, usize) {
167 let mut count = 1;
168 let mut max_child_height = 0;
169 for child in &node.children {
170 let (child_count, child_height) = subtree_stats(child);
171 count += child_count;
172 max_child_height = max_child_height.max(child_height);
173 }
174 (count, 1 + max_child_height)
175}
176
177/// Depth-first search for the node whose header IRI equals `iri`, anywhere
178/// in `nodes` or their descendants.
179fn find_node<'a>(nodes: &'a [VocabularyNode], iri: &str) -> Option<&'a VocabularyNode> {
180 for node in nodes {
181 if node.header.iri == iri {
182 return Some(node);
183 }
184 if let Some(found) = find_node(&node.children, iri) {
185 return Some(found);
186 }
187 }
188 None
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 fn header(iri: &str) -> VocabularyHeader {
196 VocabularyHeader {
197 iri: iri.to_string(),
198 name: Some(iri.to_string()),
199 labels: vec![LocalizedText {
200 value: iri.to_string(),
201 language: Some("en".into()),
202 }],
203 comments: vec![],
204 }
205 }
206
207 fn leaf(iri: &str, position: i32) -> VocabularyNode {
208 VocabularyNode {
209 header: header(iri),
210 position,
211 children: vec![],
212 }
213 }
214
215 /// Builds:
216 /// ```text
217 /// root
218 /// 1 node1 (leaf)
219 /// 2 node2
220 /// 2.1 node2a (leaf)
221 /// 2.2 node2b
222 /// 2.2.1 node2b1 (leaf)
223 /// ```
224 /// 5 nodes total; deepest level is 3 (node2 -> node2b -> node2b1).
225 fn fixture_tree() -> VocabularyTree {
226 let node2b1 = leaf("node2b1", 0);
227 let node2b = VocabularyNode {
228 header: header("node2b"),
229 position: 1,
230 children: vec![node2b1],
231 };
232 let node2a = leaf("node2a", 0);
233 let node2 = VocabularyNode {
234 header: header("node2"),
235 position: 1,
236 children: vec![node2a, node2b],
237 };
238 let node1 = leaf("node1", 0);
239 VocabularyTree {
240 root: header("root"),
241 children: vec![node1, node2],
242 project_iri: "http://rdfh.ch/projects/0001".into(),
243 requested_node: None,
244 }
245 }
246
247 #[test]
248 fn count_and_depth_whole_tree() {
249 let tree = fixture_tree();
250 let (count, depth) = tree.count_and_depth(None);
251 // 5 real nodes; root itself is not counted.
252 assert_eq!(count, 5);
253 // node1 is level 1; node2/node2a level 1/2; node2b1 level 3.
254 assert_eq!(depth, 3);
255 }
256
257 #[test]
258 fn count_and_depth_subtree_of_non_root_branch() {
259 let tree = fixture_tree();
260 // node2's own branch: node2, node2a, node2b, node2b1 = 4 nodes.
261 let (count, depth) = tree.count_and_depth(Some("node2"));
262 assert_eq!(count, 4);
263 // node2 itself is level 1 of its branch, node2b1 is level 3.
264 assert_eq!(depth, 3);
265 }
266
267 #[test]
268 fn count_and_depth_subtree_of_leaf_yields_one_node_one_level() {
269 let tree = fixture_tree();
270 // D14b: the addressed node is included, as the top of its branch.
271 let (count, depth) = tree.count_and_depth(Some("node2a"));
272 assert_eq!(count, 1);
273 assert_eq!(depth, 1);
274 }
275
276 #[test]
277 fn count_and_depth_subtree_of_intermediate_branch() {
278 let tree = fixture_tree();
279 // node2b's branch: node2b, node2b1 = 2 nodes, 2 levels.
280 let (count, depth) = tree.count_and_depth(Some("node2b"));
281 assert_eq!(count, 2);
282 assert_eq!(depth, 2);
283 }
284
285 #[test]
286 fn count_and_depth_unknown_iri_returns_zero() {
287 let tree = fixture_tree();
288 let (count, depth) = tree.count_and_depth(Some("does-not-exist"));
289 assert_eq!(count, 0);
290 assert_eq!(depth, 0);
291 }
292
293 #[test]
294 fn position_ordering_is_preserved_as_given() {
295 // This model does no sorting itself (the client sorts defensively by
296 // `position` before building the tree); this test just confirms the
297 // field and `Vec` order carry through unchanged.
298 let children = vec![leaf("a", 0), leaf("b", 1), leaf("c", 2)];
299 let parent = VocabularyNode {
300 header: header("parent"),
301 position: 0,
302 children,
303 };
304 assert_eq!(parent.children[0].header.iri, "a");
305 assert_eq!(parent.children[0].position, 0);
306 assert_eq!(parent.children[1].header.iri, "b");
307 assert_eq!(parent.children[1].position, 1);
308 assert_eq!(parent.children[2].header.iri, "c");
309 assert_eq!(parent.children[2].position, 2);
310 }
311
312 /// D15: `node_count` equals the rendered data-row count in BOTH
313 /// whole-vocabulary and `--subtree` mode. Here we hand-count the fixture
314 /// tree's nodes for each mode and assert `count_and_depth` agrees.
315 #[test]
316 fn d15_node_count_matches_manually_counted_rendered_rows() {
317 let tree = fixture_tree();
318
319 // Whole-vocabulary mode: every real node is a rendered row.
320 let whole_vocabulary_rows = ["node1", "node2", "node2a", "node2b", "node2b1"];
321 let (whole_count, _) = tree.count_and_depth(None);
322 assert_eq!(whole_count, whole_vocabulary_rows.len());
323
324 // `--subtree`-equivalent mode narrowed to node2: node2 and its
325 // descendants are the rendered rows.
326 let subtree_rows = ["node2", "node2a", "node2b", "node2b1"];
327 let (subtree_count, _) = tree.count_and_depth(Some("node2"));
328 assert_eq!(subtree_count, subtree_rows.len());
329
330 // `--subtree`-equivalent mode narrowed to a leaf: exactly one
331 // rendered row — the leaf itself.
332 let leaf_rows = ["node2a"];
333 let (leaf_count, _) = tree.count_and_depth(Some("node2a"));
334 assert_eq!(leaf_count, leaf_rows.len());
335 }
336
337 #[test]
338 fn localized_text_construction_and_equality() {
339 let a = LocalizedText {
340 value: "Period".into(),
341 language: Some("en".into()),
342 };
343 let b = a.clone();
344 assert_eq!(a, b);
345 assert_eq!(a.value, "Period");
346 assert_eq!(a.language.as_deref(), Some("en"));
347 }
348
349 #[test]
350 fn localized_text_untagged() {
351 let a = LocalizedText {
352 value: "untagged".into(),
353 language: None,
354 };
355 assert_eq!(a.language, None);
356 }
357
358 #[test]
359 fn vocabulary_node_count_none_before_count_flag() {
360 let vocab = Vocabulary {
361 header: header("vocab"),
362 node_count: None,
363 depth: None,
364 };
365 assert_eq!(vocab.node_count, None);
366 assert_eq!(vocab.depth, None);
367 }
368
369 #[test]
370 fn vocabulary_tree_requested_node_default_none() {
371 let tree = fixture_tree();
372 assert_eq!(tree.requested_node, None);
373 assert_eq!(tree.project_iri, "http://rdfh.ch/projects/0001");
374 }
375
376 #[test]
377 fn vocabulary_detail_construction_and_equality() {
378 let tree = fixture_tree();
379 let (node_count, depth) = tree.count_and_depth(None);
380 let detail = VocabularyDetail {
381 tree: tree.clone(),
382 subtree_of: None,
383 node_count,
384 depth,
385 };
386 let cloned = detail.clone();
387 assert_eq!(detail, cloned);
388 assert_eq!(detail.node_count, 5);
389 assert_eq!(detail.depth, 3);
390 assert_eq!(detail.subtree_of, None);
391 }
392}