Skip to main content

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}