dsp_cli/model/structure.rs
1//! Data-model structure domain shape — shared across the client → action boundary.
2//!
3//! Types here are the dsp-cli vocabulary for data-model structure data,
4//! surfaced by `dsp vre data-model structure`. DSP-API wire types live inside
5//! `src/client/http.rs` and are never exposed above the client layer. See
6//! ADR-0001 and ADR-0008.
7//!
8//! Key CONTEXT.md vocabulary: structure, relation. Wire deserialization
9//! (DSP-API `rdfs:subClassOf`, `owl:Restriction`, `knora-api:objectType`,
10//! `knora-api:isLinkProperty`, CURIE prefix lookups, etc.) stays inside
11//! `src/client/http.rs`. No serde derive: wire deserialization stays in
12//! `src/client/http.rs`.
13
14use std::fmt;
15
16/// The structure of a data-model — the set of relations between its resource-types.
17///
18/// A describe-shaped DETAIL struct (like `DataModelDetail`), passed directly to
19/// the renderer. Carries the baseline data-model name (for prose headers and
20/// cross-data-model tagging) and the full sorted relation list. No `serde` derive:
21/// wire deserialization stays in `src/client/http.rs`. See ADR-0001 / ADR-0008
22/// and the CONTEXT.md "Structure" / "Relation" entries.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct DataModelStructure {
25 /// Baseline data-model name (e.g. `beol`). Used as the prose header and as
26 /// the baseline when the renderer tags cross-data-model targets (`[to <dm>]`):
27 /// `target_data_model == Some(x) && x != data_model` → emit `[to x]`.
28 pub data_model: String,
29 /// All relations of the data-model, sorted per D6 by `(source, kind, field, target)`.
30 /// The client sorts; the action filters by `is_builtin` unless `--include-builtins`.
31 pub relations: Vec<Relation>,
32}
33
34/// A directed edge between two resource-types in (or out of) a data-model.
35///
36/// Two kinds: a **link relation** (a link field on the source resource-type
37/// points to the target; the field name labels the relation — `field` is `Some`)
38/// and an **inheritance relation** (the source extends the target as a superclass
39/// — `field` is `None`). Carries a `target_data_model` tag for cross-data-model
40/// targets (mirroring `Field.data_model` in the target direction) and an
41/// `is_builtin` flag whose meaning is asymmetric by kind (see below).
42///
43/// **Invariants** (asserted in unit tests; not enforced structurally — same
44/// precedent as `Field.link_target` in `resource_type.rs`):
45/// - (a) `field.is_some()` iff `kind == RelationKind::Link`.
46/// - (b) `target_data_model == None` whenever the target is in a system namespace
47/// (`knora-api`, `knora-base`, etc.).
48///
49/// No `serde` derive: wire deserialization stays in `src/client/http.rs`. See
50/// ADR-0001 / ADR-0008 and the CONTEXT.md "Relation" entry.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Relation {
53 /// Source resource-type local name (IRI fragment, e.g. `letter`).
54 pub source: String,
55 /// Target resource-type or superclass local name (IRI fragment, e.g. `person`
56 /// or `writtenSource`).
57 pub target: String,
58 /// Whether this is a link relation or an inheritance relation.
59 pub kind: RelationKind,
60 /// The link-field local name that labels this relation (e.g. `hasSender`).
61 /// `Some` iff `kind == RelationKind::Link`; `None` for inheritance relations.
62 /// This invariant is asserted in unit tests — the type does not enforce it
63 /// structurally.
64 pub field: Option<String>,
65 /// The target resource-type's data-model name (the CURIE prefix of the
66 /// target's `@id`). `None` when the target's prefix is a system namespace
67 /// (`knora-api`, `knora-base`, etc.); `Some(prefix)` otherwise. The renderer
68 /// emits `[to <dm>]` iff `Some(x)` and `x != DataModelStructure.data_model`.
69 /// Mirrors `Field.data_model` (which tags the source direction for
70 /// cross-data-model fields). This invariant is asserted in unit tests.
71 pub target_data_model: Option<String>,
72 /// Builtin flag — semantics are **asymmetric** by kind:
73 /// - `Link` → `true` iff the link **field's** CURIE prefix is a system namespace.
74 /// A project-defined link field pointing to a built-in target is `false`
75 /// (shown by default), because the *field* is project-defined.
76 /// - `Inherits` → `true` iff the **superclass** (target) CURIE prefix is a
77 /// system namespace (e.g. `Resource`, `StillImageRepresentation`).
78 ///
79 /// The action filters `!r.is_builtin` by default; `--include-builtins` shows all.
80 pub is_builtin: bool,
81}
82
83/// The kind of a relation — link or inheritance.
84///
85/// `Display` writes `"link"` / `"inherits"`. Derives `Ord` + `PartialOrd` with
86/// **`Link` declared before `Inherits`**, so derived `Ord` puts link relations
87/// before inherits relations for the same source under D6's tuple sort
88/// `(source, kind, field, target)`. `Copy` / `Eq` cover invariant assertions.
89/// No `serde` derive.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
91pub enum RelationKind {
92 /// A link field on the source resource-type points to the target resource-type.
93 /// The field name labels the relation. Display: `"link"`.
94 ///
95 /// **Variant order is load-bearing**: `Link` is declared before `Inherits` so
96 /// that derived `Ord` gives `Link < Inherits`, making link relations sort before
97 /// inherits relations for the same source (D6).
98 Link,
99 /// The source resource-type extends the target as a superclass. Display: `"inherits"`.
100 Inherits,
101}
102
103impl fmt::Display for RelationKind {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 RelationKind::Link => f.write_str("link"),
107 RelationKind::Inherits => f.write_str("inherits"),
108 }
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 // --- Construction, equality, clone round-trip ---
117
118 #[test]
119 fn data_model_structure_full_construction_and_equality() {
120 let structure = DataModelStructure {
121 data_model: "beol".into(),
122 relations: vec![
123 Relation {
124 source: "letter".into(),
125 target: "Book".into(),
126 kind: RelationKind::Link,
127 field: Some("cites".into()),
128 target_data_model: Some("biblio".into()),
129 is_builtin: false,
130 },
131 Relation {
132 source: "letter".into(),
133 target: "writtenSource".into(),
134 kind: RelationKind::Inherits,
135 field: None,
136 target_data_model: None,
137 is_builtin: false,
138 },
139 ],
140 };
141 let cloned = structure.clone();
142 assert_eq!(structure, cloned);
143 assert_eq!(structure.data_model, "beol");
144 assert_eq!(structure.relations.len(), 2);
145 }
146
147 #[test]
148 fn data_model_structure_empty_relations() {
149 let structure = DataModelStructure {
150 data_model: "minimal".into(),
151 relations: vec![],
152 };
153 let cloned = structure.clone();
154 assert_eq!(structure, cloned);
155 assert_eq!(structure.data_model, "minimal");
156 assert!(structure.relations.is_empty());
157 }
158
159 #[test]
160 fn relation_link_full_construction_and_equality() {
161 // A link relation with a field label and a cross-data-model target_data_model.
162 let rel = Relation {
163 source: "letter".into(),
164 target: "Book".into(),
165 kind: RelationKind::Link,
166 field: Some("cites".into()),
167 target_data_model: Some("biblio".into()),
168 is_builtin: false,
169 };
170 let cloned = rel.clone();
171 assert_eq!(rel, cloned);
172 assert_eq!(rel.source, "letter");
173 assert_eq!(rel.target, "Book");
174 assert_eq!(rel.kind, RelationKind::Link);
175 assert_eq!(rel.field.as_deref(), Some("cites"));
176 assert_eq!(rel.target_data_model.as_deref(), Some("biblio"));
177 assert!(!rel.is_builtin);
178 }
179
180 #[test]
181 fn relation_inherits_construction_and_equality() {
182 // An inherits relation: field is None, target_data_model is None (in-model).
183 let rel = Relation {
184 source: "letter".into(),
185 target: "writtenSource".into(),
186 kind: RelationKind::Inherits,
187 field: None,
188 target_data_model: None,
189 is_builtin: false,
190 };
191 let cloned = rel.clone();
192 assert_eq!(rel, cloned);
193 assert_eq!(rel.source, "letter");
194 assert_eq!(rel.target, "writtenSource");
195 assert_eq!(rel.kind, RelationKind::Inherits);
196 assert!(rel.field.is_none());
197 assert!(rel.target_data_model.is_none());
198 assert!(!rel.is_builtin);
199 }
200
201 // --- RelationKind Display ---
202
203 #[test]
204 fn relation_kind_display_link() {
205 assert_eq!(RelationKind::Link.to_string(), "link");
206 }
207
208 #[test]
209 fn relation_kind_display_inherits() {
210 assert_eq!(RelationKind::Inherits.to_string(), "inherits");
211 }
212
213 // --- RelationKind ordering: Link < Inherits (D6 load-bearing) ---
214
215 #[test]
216 fn relation_kind_ordering_link_before_inherits() {
217 assert!(
218 RelationKind::Link < RelationKind::Inherits,
219 "Link must sort before Inherits (D6: link before inherits for the same source)"
220 );
221 }
222
223 #[test]
224 fn relation_kind_ordering_inherits_not_less_than_link() {
225 assert!(RelationKind::Inherits >= RelationKind::Link);
226 }
227
228 // --- Invariant (a): field.is_some() iff kind == Link, both directions ---
229
230 #[test]
231 fn link_relation_has_field_some() {
232 // A Link relation MUST carry field == Some(...).
233 let rel = Relation {
234 source: "letter".into(),
235 target: "person".into(),
236 kind: RelationKind::Link,
237 field: Some("hasSender".into()),
238 target_data_model: None,
239 is_builtin: false,
240 };
241 assert_eq!(rel.kind, RelationKind::Link);
242 assert!(
243 rel.field.is_some(),
244 "a Link relation must have field == Some(...)"
245 );
246 }
247
248 #[test]
249 fn non_link_relation_has_field_none() {
250 // An Inherits relation MUST carry field == None.
251 let rel = Relation {
252 source: "letter".into(),
253 target: "writtenSource".into(),
254 kind: RelationKind::Inherits,
255 field: None,
256 target_data_model: None,
257 is_builtin: false,
258 };
259 assert_ne!(rel.kind, RelationKind::Link);
260 assert!(
261 rel.field.is_none(),
262 "a non-Link relation must have field == None"
263 );
264 }
265
266 // --- Asymmetric is_builtin cases ---
267
268 #[test]
269 fn project_link_field_to_builtin_target_is_not_builtin() {
270 // A project-defined link field pointing to a built-in target is is_builtin=false
271 // (shown by default), because is_builtin is keyed off the FIELD's prefix for
272 // link relations, not the target's prefix. target_data_model is None because the
273 // target's prefix is system (invariant b).
274 let rel = Relation {
275 source: "letter".into(),
276 target: "Resource".into(),
277 kind: RelationKind::Link,
278 field: Some("hasRelation".into()),
279 target_data_model: None, // system target → None (invariant b)
280 is_builtin: false, // project field → not builtin
281 };
282 assert_eq!(rel.kind, RelationKind::Link);
283 assert!(
284 !rel.is_builtin,
285 "project link field to built-in target is is_builtin=false"
286 );
287 assert!(
288 rel.target_data_model.is_none(),
289 "system target → target_data_model must be None"
290 );
291 }
292
293 #[test]
294 fn inherits_relation_to_system_super_is_builtin() {
295 // An inherits relation to a system superclass (e.g. Resource) is is_builtin=true
296 // because is_builtin is keyed off the TARGET's prefix for inheritance relations.
297 // target_data_model is None because the target's prefix is system (invariant b).
298 let rel = Relation {
299 source: "letter".into(),
300 target: "Resource".into(),
301 kind: RelationKind::Inherits,
302 field: None,
303 target_data_model: None, // system target → None (invariant b)
304 is_builtin: true, // system superclass → builtin
305 };
306 assert_eq!(rel.kind, RelationKind::Inherits);
307 assert!(
308 rel.is_builtin,
309 "inherits relation to system superclass is is_builtin=true"
310 );
311 assert!(
312 rel.target_data_model.is_none(),
313 "system target → target_data_model must be None"
314 );
315 }
316
317 // --- Invariant (b): Some-direction — project/sibling target → target_data_model == Some ---
318
319 #[test]
320 fn non_system_target_has_target_data_model_some() {
321 // Invariant (b) Some-direction: a relation whose target is in a project or sibling
322 // namespace (non-system) MUST carry target_data_model == Some(prefix).
323 // This complements the existing None-direction tests (system targets above) and
324 // documents both directions of the invariant at the model level.
325
326 // In-model link: target is in the same DM ("beol") → Some("beol").
327 let in_model_link = Relation {
328 source: "letter".into(),
329 target: "person".into(),
330 kind: RelationKind::Link,
331 field: Some("hasSender".into()),
332 target_data_model: Some("beol".into()), // project target → Some (invariant b)
333 is_builtin: false,
334 };
335 assert!(
336 in_model_link.target_data_model.is_some(),
337 "in-model link target must have target_data_model == Some(...)"
338 );
339 assert_eq!(
340 in_model_link.target_data_model.as_deref(),
341 Some("beol"),
342 "in-model target_data_model must equal the baseline DM name"
343 );
344
345 // Cross-model link: target is in a sibling DM ("biblio") → Some("biblio").
346 let cross_model_link = Relation {
347 source: "letter".into(),
348 target: "Book".into(),
349 kind: RelationKind::Link,
350 field: Some("cites".into()),
351 target_data_model: Some("biblio".into()), // sibling DM target → Some (invariant b)
352 is_builtin: false,
353 };
354 assert!(
355 cross_model_link.target_data_model.is_some(),
356 "cross-model link target must have target_data_model == Some(...)"
357 );
358 assert_eq!(
359 cross_model_link.target_data_model.as_deref(),
360 Some("biblio"),
361 "cross-model target_data_model must equal the sibling DM name"
362 );
363 // Crucially: it is NOT None.
364 assert_ne!(
365 cross_model_link.target_data_model, None,
366 "non-system target must NOT have target_data_model == None"
367 );
368 }
369}