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//! 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}