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//! dsp-cli/ADR-0001 and dsp-cli/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 dsp-cli/ADR-0001 / dsp-cli/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 (`knora-api`,
47/// `knora-base`, etc.).
48///
49/// No `serde` derive: wire deserialization stays in `src/client/http.rs`. See
50/// dsp-cli/ADR-0001 / dsp-cli/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. A
74 /// project-defined link field pointing to a built-in target is `false` (shown by default),
75 /// because the *field* is project-defined.
76 /// - `Inherits` → `true` iff the **superclass** (target) CURIE prefix is a system namespace
77 /// (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 { data_model: "minimal".into(), relations: vec![] };
150 let cloned = structure.clone();
151 assert_eq!(structure, cloned);
152 assert_eq!(structure.data_model, "minimal");
153 assert!(structure.relations.is_empty());
154 }
155
156 #[test]
157 fn relation_link_full_construction_and_equality() {
158 // A link relation with a field label and a cross-data-model target_data_model.
159 let rel = Relation {
160 source: "letter".into(),
161 target: "Book".into(),
162 kind: RelationKind::Link,
163 field: Some("cites".into()),
164 target_data_model: Some("biblio".into()),
165 is_builtin: false,
166 };
167 let cloned = rel.clone();
168 assert_eq!(rel, cloned);
169 assert_eq!(rel.source, "letter");
170 assert_eq!(rel.target, "Book");
171 assert_eq!(rel.kind, RelationKind::Link);
172 assert_eq!(rel.field.as_deref(), Some("cites"));
173 assert_eq!(rel.target_data_model.as_deref(), Some("biblio"));
174 assert!(!rel.is_builtin);
175 }
176
177 #[test]
178 fn relation_inherits_construction_and_equality() {
179 // An inherits relation: field is None, target_data_model is None (in-model).
180 let rel = Relation {
181 source: "letter".into(),
182 target: "writtenSource".into(),
183 kind: RelationKind::Inherits,
184 field: None,
185 target_data_model: None,
186 is_builtin: false,
187 };
188 let cloned = rel.clone();
189 assert_eq!(rel, cloned);
190 assert_eq!(rel.source, "letter");
191 assert_eq!(rel.target, "writtenSource");
192 assert_eq!(rel.kind, RelationKind::Inherits);
193 assert!(rel.field.is_none());
194 assert!(rel.target_data_model.is_none());
195 assert!(!rel.is_builtin);
196 }
197
198 // --- RelationKind Display ---
199
200 #[test]
201 fn relation_kind_display_link() {
202 assert_eq!(RelationKind::Link.to_string(), "link");
203 }
204
205 #[test]
206 fn relation_kind_display_inherits() {
207 assert_eq!(RelationKind::Inherits.to_string(), "inherits");
208 }
209
210 // --- RelationKind ordering: Link < Inherits (D6 load-bearing) ---
211
212 #[test]
213 fn relation_kind_ordering_link_before_inherits() {
214 assert!(
215 RelationKind::Link < RelationKind::Inherits,
216 "Link must sort before Inherits (D6: link before inherits for the same source)"
217 );
218 }
219
220 #[test]
221 fn relation_kind_ordering_inherits_not_less_than_link() {
222 assert!(RelationKind::Inherits >= RelationKind::Link);
223 }
224
225 // --- Invariant (a): field.is_some() iff kind == Link, both directions ---
226
227 #[test]
228 fn link_relation_has_field_some() {
229 // A Link relation MUST carry field == Some(...).
230 let rel = Relation {
231 source: "letter".into(),
232 target: "person".into(),
233 kind: RelationKind::Link,
234 field: Some("hasSender".into()),
235 target_data_model: None,
236 is_builtin: false,
237 };
238 assert_eq!(rel.kind, RelationKind::Link);
239 assert!(rel.field.is_some(), "a Link relation must have field == Some(...)");
240 }
241
242 #[test]
243 fn non_link_relation_has_field_none() {
244 // An Inherits relation MUST carry field == None.
245 let rel = Relation {
246 source: "letter".into(),
247 target: "writtenSource".into(),
248 kind: RelationKind::Inherits,
249 field: None,
250 target_data_model: None,
251 is_builtin: false,
252 };
253 assert_ne!(rel.kind, RelationKind::Link);
254 assert!(rel.field.is_none(), "a non-Link relation must have field == None");
255 }
256
257 // --- Asymmetric is_builtin cases ---
258
259 #[test]
260 fn project_link_field_to_builtin_target_is_not_builtin() {
261 // A project-defined link field pointing to a built-in target is is_builtin=false
262 // (shown by default), because is_builtin is keyed off the FIELD's prefix for
263 // link relations, not the target's prefix. target_data_model is None because the
264 // target's prefix is system (invariant b).
265 let rel = Relation {
266 source: "letter".into(),
267 target: "Resource".into(),
268 kind: RelationKind::Link,
269 field: Some("hasRelation".into()),
270 target_data_model: None, // system target → None (invariant b)
271 is_builtin: false, // project field → not builtin
272 };
273 assert_eq!(rel.kind, RelationKind::Link);
274 assert!(!rel.is_builtin, "project link field to built-in target is is_builtin=false");
275 assert!(
276 rel.target_data_model.is_none(),
277 "system target → target_data_model must be None"
278 );
279 }
280
281 #[test]
282 fn inherits_relation_to_system_super_is_builtin() {
283 // An inherits relation to a system superclass (e.g. Resource) is is_builtin=true
284 // because is_builtin is keyed off the TARGET's prefix for inheritance relations.
285 // target_data_model is None because the target's prefix is system (invariant b).
286 let rel = Relation {
287 source: "letter".into(),
288 target: "Resource".into(),
289 kind: RelationKind::Inherits,
290 field: None,
291 target_data_model: None, // system target → None (invariant b)
292 is_builtin: true, // system superclass → builtin
293 };
294 assert_eq!(rel.kind, RelationKind::Inherits);
295 assert!(rel.is_builtin, "inherits relation to system superclass is is_builtin=true");
296 assert!(
297 rel.target_data_model.is_none(),
298 "system target → target_data_model must be None"
299 );
300 }
301
302 // --- Invariant (b): Some-direction — project/sibling target → target_data_model == Some ---
303
304 #[test]
305 fn non_system_target_has_target_data_model_some() {
306 // Invariant (b) Some-direction: a relation whose target is in a project or sibling
307 // namespace (non-system) MUST carry target_data_model == Some(prefix).
308 // This complements the existing None-direction tests (system targets above) and
309 // documents both directions of the invariant at the model level.
310
311 // In-model link: target is in the same DM ("beol") → Some("beol").
312 let in_model_link = Relation {
313 source: "letter".into(),
314 target: "person".into(),
315 kind: RelationKind::Link,
316 field: Some("hasSender".into()),
317 target_data_model: Some("beol".into()), // project target → Some (invariant b)
318 is_builtin: false,
319 };
320 assert!(
321 in_model_link.target_data_model.is_some(),
322 "in-model link target must have target_data_model == Some(...)"
323 );
324 assert_eq!(
325 in_model_link.target_data_model.as_deref(),
326 Some("beol"),
327 "in-model target_data_model must equal the baseline DM name"
328 );
329
330 // Cross-model link: target is in a sibling DM ("biblio") → Some("biblio").
331 let cross_model_link = Relation {
332 source: "letter".into(),
333 target: "Book".into(),
334 kind: RelationKind::Link,
335 field: Some("cites".into()),
336 target_data_model: Some("biblio".into()), // sibling DM target → Some (invariant b)
337 is_builtin: false,
338 };
339 assert!(
340 cross_model_link.target_data_model.is_some(),
341 "cross-model link target must have target_data_model == Some(...)"
342 );
343 assert_eq!(
344 cross_model_link.target_data_model.as_deref(),
345 Some("biblio"),
346 "cross-model target_data_model must equal the sibling DM name"
347 );
348 // Crucially: it is NOT None.
349 assert_ne!(
350 cross_model_link.target_data_model, None,
351 "non-system target must NOT have target_data_model == None"
352 );
353 }
354}