Skip to main content

dsp_cli/model/
resource_type.rs

1//! Resource-type domain shape — shared across the client → action boundary.
2//!
3//! Types here are the dsp-cli vocabulary for resource-type detail data,
4//! surfaced by `dsp vre resource-type describe`. DSP-API wire types
5//! live inside `src/client/http.rs` and are never exposed above the client
6//! layer. See ADR-0001 and ADR-0008.
7//!
8//! Key CONTEXT.md vocabulary: resource-type, field, value-type, cardinality,
9//! representation. Wire deserialization (DSP-API `owl:Restriction`,
10//! `owl:onProperty`, `knora-api:objectType`, `rdfs:subClassOf`, etc.) stays
11//! inside `src/client/http.rs`.
12
13use std::fmt;
14
15/// The full detail of a resource-type, as surfaced by `dsp vre resource-type describe`.
16///
17/// The describe projection: identity (`name` + `iri`), server-supplied `label`,
18/// the resource-type's own `data_model` name (the baseline for cross-DM field
19/// tagging and the prose `Data-model:` header), an optional `representation`
20/// kind (for asset types), the project/external superclass local names
21/// (`super_types`), and the full field list. No `serde` derive: wire
22/// deserialization stays in `src/client/http.rs`. See ADR-0001 / ADR-0008
23/// and the CONTEXT.md "Resource Type" / "Field" / "Cardinality" /
24/// "Representation" entries.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ResourceTypeDetail {
27    /// Short name of the resource-type (e.g. `manuscript`), derived from the IRI fragment.
28    pub name: String,
29    /// Full IRI of the resource-type
30    /// (e.g. `http://api.dasch.swiss/ontology/0801/beol/v2#manuscript`).
31    pub iri: String,
32    /// Server-supplied human label (`rdfs:label`), if any.
33    pub label: Option<String>,
34    /// Short name of the resource-type's own data-model (e.g. `beol`). Used as
35    /// the prose `Data-model:` header line and as the baseline when the renderer
36    /// tags cross-DM fields (`[from <dm>]`). Derived from the data-model IRI at
37    /// the client boundary — not a wire field.
38    pub data_model: String,
39    /// Representation kind, if the resource-type is a file representation
40    /// (still-image, moving-image, audio, document, archive, text). Detected from
41    /// the presence of the corresponding `knora-api` file-value restriction in the
42    /// class's flattened `owl:Restriction` set (Decision 5 — transitive-safe;
43    /// see the plan). `None` for non-asset resource-types.
44    pub representation: Option<Representation>,
45    /// Project and external (non-system) superclass local names. Derived from the
46    /// non-Restriction `{"@id":…}` entries in `rdfs:subClassOf` (Decision 6).
47    /// Excludes `knora-api` / system supers. Empty if the type extends only system
48    /// classes or has no explicit non-system superclass.
49    pub super_types: Vec<String>,
50    /// Full field list. Includes both project-defined and built-in (system)
51    /// fields; the action layer filters built-ins unless `--include-builtins` is
52    /// set. Sorted by `salsah-gui:guiOrder` then by name at the client boundary.
53    pub fields: Vec<Field>,
54}
55
56/// A field belonging to a resource-type, as surfaced by `dsp vre resource-type describe`.
57///
58/// Carries identity (`name` + `iri`), server-supplied `label`, the field's
59/// `value_type` (see `ValueType`), an optional `link_target` (the target
60/// resource-type local name, `Some` iff `value_type == ValueType::Link`),
61/// `cardinality`, a flag marking system (built-in) fields, and the source
62/// `data_model` name (for cross-DM tagging). No `serde` derive: wire
63/// deserialization stays in `src/client/http.rs`. See ADR-0001 / ADR-0008
64/// and the CONTEXT.md "Field" / "Value Type" / "Cardinality" entries.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Field {
67    /// Short name of the field (e.g. `hasTitle`), derived from the property IRI.
68    pub name: String,
69    /// Full IRI of the field's property
70    /// (e.g. `http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle`).
71    pub iri: String,
72    /// Server-supplied human label (`rdfs:label`), if any. `None` for system
73    /// built-in fields whose property node is not fetched, and for fields left
74    /// best-effort after a failed sibling-ontology fetch.
75    pub label: Option<String>,
76    /// The kind of value this field holds (text, integer, link, …).
77    pub value_type: ValueType,
78    /// The local name of the link target resource-type (e.g. `Book`, `person`).
79    /// `Some` iff `value_type == ValueType::Link`; `None` for all other value
80    /// types. This invariant is asserted in unit tests — the type does not
81    /// enforce it structurally.
82    pub link_target: Option<String>,
83    /// Cardinality constraint: how many values the field may / must carry.
84    pub cardinality: Cardinality,
85    /// `true` iff the field's property CURIE prefix is a system namespace
86    /// (`knora-api`, `knora-base`, `rdf`, `rdfs`, `owl`, `salsah-gui`,
87    /// `standoff`, `xsd`). System fields are hidden by default; revealed with
88    /// `--include-builtins` (Decision 3).
89    pub is_builtin: bool,
90    /// Source data-model name for the field's property. `Some` with the
91    /// data-model short name for project-defined and cross-DM fields
92    /// (e.g. `Some("biblio")` for a `biblio:` property on a `beol` class);
93    /// `None` for system built-ins (system-namespace prefix). The renderer
94    /// emits a `[from <dm>]` tag when `Some(x)` and `x` differs from
95    /// `ResourceTypeDetail.data_model` (Decision 10).
96    pub data_model: Option<String>,
97}
98
99/// The kind of value a field holds — the dsp-cli vocabulary for DSP-API's
100/// `knora-api:objectType`.
101///
102/// Named variants cover all 16 value types from the CONTEXT.md "Value Type"
103/// entry. `Other(String)` provides graceful degradation for any `objectType`
104/// outside this set (e.g. `GeomValue`, `IntervalValue`, `TextFileValue`) — the
105/// string is a kebab-cased local name derived by the client at the ADR-0001
106/// boundary. `Display` writes the kebab string; `Other(s)` writes `s` verbatim
107/// (the client builds the kebab form). Does NOT derive `Copy` (has `Other(String)`).
108/// No `serde` derive.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum ValueType {
111    /// Plain text (`knora-api:TextValue`).
112    Text,
113    /// Integer number (`knora-api:IntValue`).
114    Integer,
115    /// Decimal number (`knora-api:DecimalValue`).
116    Decimal,
117    /// Boolean (`knora-api:BooleanValue`).
118    Boolean,
119    /// Calendar date (`knora-api:DateValue`).
120    Date,
121    /// Point in time (`knora-api:TimeValue`).
122    Time,
123    /// URI (`knora-api:UriValue`).
124    Uri,
125    /// Color value (`knora-api:ColorValue`).
126    Color,
127    /// Geonames location reference (`knora-api:GeonameValue`).
128    Geoname,
129    /// Reference to a list node (`knora-api:ListValue`).
130    ListItem,
131    /// Link to another resource (`knora-api:isLinkProperty`; objectType is the
132    /// target resource class, not a `…Value` type).
133    Link,
134    /// Still-image file (`knora-api:StillImageFileValue`).
135    StillImage,
136    /// Moving-image file (`knora-api:MovingImageFileValue`).
137    MovingImage,
138    /// Audio file (`knora-api:AudioFileValue`).
139    Audio,
140    /// Document file (`knora-api:DocumentFileValue`).
141    Document,
142    /// Archive file (`knora-api:ArchiveFileValue`).
143    Archive,
144    /// Any objectType not covered by the 16 named variants (e.g. `geom`,
145    /// `interval`, `text-file`). The string is already in kebab form — `Display`
146    /// writes it verbatim.
147    Other(String),
148}
149
150/// Cardinality constraint on a field — how many values may / must be supplied.
151///
152/// Maps directly to DSP-API's `owl:cardinality` / `owl:minCardinality` /
153/// `owl:maxCardinality` triple (Decision 1 — only `0`/`1` bounds are emitted).
154/// Derives `Copy` (fieldless). `Display` produces the CONTEXT.md canonical
155/// notation (`1`, `0-1`, `0-n`, `1-n`), matching dsp-tools' native data-model
156/// format. No `serde` derive.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum Cardinality {
159    /// Exactly one value required (`owl:cardinality 1`). Display: `"1"`.
160    One,
161    /// At most one value, may be absent (`owl:maxCardinality 1`). Display: `"0-1"`.
162    ZeroOrOne,
163    /// Any number of values, may be absent (`owl:minCardinality 0`). Display: `"0-n"`.
164    ZeroOrMore,
165    /// At least one value required (`owl:minCardinality 1`). Display: `"1-n"`.
166    OneOrMore,
167}
168
169/// Representation kind of a resource-type — what kind of file it holds.
170///
171/// Detected from the presence of the corresponding `knora-api` file-value
172/// property restriction in the class's flattened `owl:Restriction` set (Decision
173/// 5 — transitive-safe). Only present on resource-types that are file
174/// representations; non-asset types carry `None` on `ResourceTypeDetail`.
175/// Derives `Copy` (fieldless). `Display` produces a kebab string. No `serde`
176/// derive. `ValueType` and `Representation` overlap semantically (both carry
177/// still-image / … variants) but are independent types — field-level vs
178/// resource-type-level.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum Representation {
181    /// Still-image representation (`knora-api:hasStillImageFileValue`). Display: `"still-image"`.
182    StillImage,
183    /// Moving-image representation (`knora-api:hasMovingImageFileValue`). Display: `"moving-image"`.
184    MovingImage,
185    /// Audio representation (`knora-api:hasAudioFileValue`). Display: `"audio"`.
186    Audio,
187    /// Document representation (`knora-api:hasDocumentFileValue`). Display: `"document"`.
188    Document,
189    /// Archive representation (`knora-api:hasArchiveFileValue`). Display: `"archive"`.
190    Archive,
191    /// Text representation (`knora-api:hasTextFileValue`). Display: `"text"`.
192    Text,
193}
194
195impl fmt::Display for Cardinality {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        match self {
198            Cardinality::One => f.write_str("1"),
199            Cardinality::ZeroOrOne => f.write_str("0-1"),
200            Cardinality::ZeroOrMore => f.write_str("0-n"),
201            Cardinality::OneOrMore => f.write_str("1-n"),
202        }
203    }
204}
205
206impl fmt::Display for Representation {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        match self {
209            Representation::StillImage => f.write_str("still-image"),
210            Representation::MovingImage => f.write_str("moving-image"),
211            Representation::Audio => f.write_str("audio"),
212            Representation::Document => f.write_str("document"),
213            Representation::Archive => f.write_str("archive"),
214            Representation::Text => f.write_str("text"),
215        }
216    }
217}
218
219impl ValueType {
220    /// Returns the canonical dsp-cli kebab token for this value type.
221    ///
222    /// Named variants return a `&'static str` literal. `Other(s)` borrows `s`
223    /// directly — the client already built the kebab form. Matches `Display`
224    /// output; prefer `as_token` when you need a `&str` without allocating.
225    pub fn as_token(&self) -> &str {
226        match self {
227            ValueType::Text => "text",
228            ValueType::Integer => "integer",
229            ValueType::Decimal => "decimal",
230            ValueType::Boolean => "boolean",
231            ValueType::Date => "date",
232            ValueType::Time => "time",
233            ValueType::Uri => "uri",
234            ValueType::Color => "color",
235            ValueType::Geoname => "geoname",
236            ValueType::ListItem => "list-item",
237            ValueType::Link => "link",
238            ValueType::StillImage => "still-image",
239            ValueType::MovingImage => "moving-image",
240            ValueType::Audio => "audio",
241            ValueType::Document => "document",
242            ValueType::Archive => "archive",
243            ValueType::Other(s) => s.as_str(),
244        }
245    }
246}
247
248impl fmt::Display for ValueType {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        // Route through `as_token` so the two remain in sync.
251        f.write_str(self.as_token())
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    // --- Construction, equality, clone round-trip ---
260
261    #[test]
262    fn resource_type_detail_full_construction_and_equality() {
263        let detail = ResourceTypeDetail {
264            name: "manuscript".into(),
265            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
266            label: Some("Manuscript".into()),
267            data_model: "beol".into(),
268            representation: Some(Representation::StillImage),
269            super_types: vec!["writtenSource".into()],
270            fields: vec![Field {
271                name: "hasTitle".into(),
272                iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle".into(),
273                label: Some("Title".into()),
274                value_type: ValueType::Text,
275                link_target: None,
276                cardinality: Cardinality::OneOrMore,
277                is_builtin: false,
278                data_model: Some("beol".into()),
279            }],
280        };
281        let cloned = detail.clone();
282        assert_eq!(detail, cloned);
283        assert_eq!(detail.name, "manuscript");
284        assert_eq!(
285            detail.iri,
286            "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript"
287        );
288        assert_eq!(detail.label.as_deref(), Some("Manuscript"));
289        assert_eq!(detail.data_model, "beol");
290        assert_eq!(detail.representation, Some(Representation::StillImage));
291        assert_eq!(detail.super_types, vec!["writtenSource"]);
292        assert_eq!(detail.fields.len(), 1);
293    }
294
295    #[test]
296    fn resource_type_detail_minimal_none_variants() {
297        let detail = ResourceTypeDetail {
298            name: "Thing".into(),
299            iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Thing".into(),
300            label: None,
301            data_model: "minimal".into(),
302            representation: None,
303            super_types: vec![],
304            fields: vec![],
305        };
306        let cloned = detail.clone();
307        assert_eq!(detail, cloned);
308        assert_eq!(detail.label, None);
309        assert_eq!(detail.representation, None);
310        assert!(detail.super_types.is_empty());
311        assert!(detail.fields.is_empty());
312    }
313
314    #[test]
315    fn field_full_construction_and_equality() {
316        let field = Field {
317            name: "hasAuthor".into(),
318            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
319            label: Some("Author".into()),
320            value_type: ValueType::Link,
321            link_target: Some("person".into()),
322            cardinality: Cardinality::ZeroOrMore,
323            is_builtin: false,
324            data_model: Some("beol".into()),
325        };
326        let cloned = field.clone();
327        assert_eq!(field, cloned);
328        assert_eq!(field.name, "hasAuthor");
329        assert_eq!(
330            field.iri,
331            "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor"
332        );
333        assert_eq!(field.label.as_deref(), Some("Author"));
334        assert_eq!(field.value_type, ValueType::Link);
335        assert_eq!(field.link_target.as_deref(), Some("person"));
336        assert_eq!(field.cardinality, Cardinality::ZeroOrMore);
337        assert!(!field.is_builtin);
338        assert_eq!(field.data_model.as_deref(), Some("beol"));
339    }
340
341    #[test]
342    fn field_minimal_none_variants() {
343        let field = Field {
344            name: "arkUrl".into(),
345            iri: "http://api.knora.org/ontology/knora-api/v2#arkUrl".into(),
346            label: None,
347            value_type: ValueType::Uri,
348            link_target: None,
349            cardinality: Cardinality::One,
350            is_builtin: true,
351            data_model: None,
352        };
353        let cloned = field.clone();
354        assert_eq!(field, cloned);
355        assert_eq!(field.label, None);
356        assert_eq!(field.link_target, None);
357        assert!(field.is_builtin);
358        assert_eq!(field.data_model, None);
359    }
360
361    // --- Display assertions for Cardinality (all 4 variants) ---
362
363    #[test]
364    fn cardinality_display_one() {
365        assert_eq!(Cardinality::One.to_string(), "1");
366    }
367
368    #[test]
369    fn cardinality_display_zero_or_one() {
370        assert_eq!(Cardinality::ZeroOrOne.to_string(), "0-1");
371    }
372
373    #[test]
374    fn cardinality_display_zero_or_more() {
375        assert_eq!(Cardinality::ZeroOrMore.to_string(), "0-n");
376    }
377
378    #[test]
379    fn cardinality_display_one_or_more() {
380        assert_eq!(Cardinality::OneOrMore.to_string(), "1-n");
381    }
382
383    // --- Display assertions for Representation (all 6 variants) ---
384
385    #[test]
386    fn representation_display_still_image() {
387        assert_eq!(Representation::StillImage.to_string(), "still-image");
388    }
389
390    #[test]
391    fn representation_display_moving_image() {
392        assert_eq!(Representation::MovingImage.to_string(), "moving-image");
393    }
394
395    #[test]
396    fn representation_display_audio() {
397        assert_eq!(Representation::Audio.to_string(), "audio");
398    }
399
400    #[test]
401    fn representation_display_document() {
402        assert_eq!(Representation::Document.to_string(), "document");
403    }
404
405    #[test]
406    fn representation_display_archive() {
407        assert_eq!(Representation::Archive.to_string(), "archive");
408    }
409
410    #[test]
411    fn representation_display_text() {
412        assert_eq!(Representation::Text.to_string(), "text");
413    }
414
415    // --- Display assertions for ValueType (all 16 named + Other) ---
416
417    #[test]
418    fn value_type_display_text() {
419        assert_eq!(ValueType::Text.to_string(), "text");
420    }
421
422    #[test]
423    fn value_type_display_integer() {
424        assert_eq!(ValueType::Integer.to_string(), "integer");
425    }
426
427    #[test]
428    fn value_type_display_decimal() {
429        assert_eq!(ValueType::Decimal.to_string(), "decimal");
430    }
431
432    #[test]
433    fn value_type_display_boolean() {
434        assert_eq!(ValueType::Boolean.to_string(), "boolean");
435    }
436
437    #[test]
438    fn value_type_display_date() {
439        assert_eq!(ValueType::Date.to_string(), "date");
440    }
441
442    #[test]
443    fn value_type_display_time() {
444        assert_eq!(ValueType::Time.to_string(), "time");
445    }
446
447    #[test]
448    fn value_type_display_uri() {
449        assert_eq!(ValueType::Uri.to_string(), "uri");
450    }
451
452    #[test]
453    fn value_type_display_color() {
454        assert_eq!(ValueType::Color.to_string(), "color");
455    }
456
457    #[test]
458    fn value_type_display_geoname() {
459        assert_eq!(ValueType::Geoname.to_string(), "geoname");
460    }
461
462    #[test]
463    fn value_type_display_list_item() {
464        assert_eq!(ValueType::ListItem.to_string(), "list-item");
465    }
466
467    #[test]
468    fn value_type_display_link() {
469        assert_eq!(ValueType::Link.to_string(), "link");
470    }
471
472    #[test]
473    fn value_type_display_still_image() {
474        assert_eq!(ValueType::StillImage.to_string(), "still-image");
475    }
476
477    #[test]
478    fn value_type_display_moving_image() {
479        assert_eq!(ValueType::MovingImage.to_string(), "moving-image");
480    }
481
482    #[test]
483    fn value_type_display_audio() {
484        assert_eq!(ValueType::Audio.to_string(), "audio");
485    }
486
487    #[test]
488    fn value_type_display_document() {
489        assert_eq!(ValueType::Document.to_string(), "document");
490    }
491
492    #[test]
493    fn value_type_display_archive() {
494        assert_eq!(ValueType::Archive.to_string(), "archive");
495    }
496
497    #[test]
498    fn value_type_display_other_verbatim() {
499        // Other(s) writes s verbatim — the client builds the kebab form.
500        assert_eq!(
501            ValueType::Other("text-file".into()).to_string(),
502            "text-file"
503        );
504    }
505
506    // --- ValueType::as_token matches Display for all variants ---
507
508    /// `as_token` returns the correct kebab string for a representative of each
509    /// named variant, and matches `Display` output exactly.
510    #[test]
511    fn value_type_as_token_matches_display_named_variants() {
512        let cases = [
513            ValueType::Text,
514            ValueType::Integer,
515            ValueType::Decimal,
516            ValueType::Boolean,
517            ValueType::Date,
518            ValueType::Time,
519            ValueType::Uri,
520            ValueType::Color,
521            ValueType::Geoname,
522            ValueType::ListItem,
523            ValueType::Link,
524            ValueType::StillImage,
525            ValueType::MovingImage,
526            ValueType::Audio,
527            ValueType::Document,
528            ValueType::Archive,
529        ];
530        for vt in &cases {
531            assert_eq!(
532                vt.as_token(),
533                vt.to_string(),
534                "as_token must match Display for {:?}",
535                vt
536            );
537        }
538    }
539
540    #[test]
541    fn value_type_as_token_still_image() {
542        assert_eq!(ValueType::StillImage.as_token(), "still-image");
543    }
544
545    #[test]
546    fn value_type_as_token_list_item() {
547        assert_eq!(ValueType::ListItem.as_token(), "list-item");
548    }
549
550    #[test]
551    fn value_type_as_token_moving_image() {
552        assert_eq!(ValueType::MovingImage.as_token(), "moving-image");
553    }
554
555    #[test]
556    fn value_type_as_token_other_borrows_string() {
557        let vt = ValueType::Other("geom".into());
558        // as_token borrows from the inner String; matches Display.
559        assert_eq!(vt.as_token(), "geom");
560        assert_eq!(vt.as_token(), vt.to_string());
561    }
562
563    // --- Link ⇔ link_target invariant, both directions ---
564
565    #[test]
566    fn link_field_has_link_target_some() {
567        // A Field with value_type == Link MUST carry link_target == Some(...).
568        let field = Field {
569            name: "hasAuthor".into(),
570            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
571            label: Some("Author".into()),
572            value_type: ValueType::Link,
573            link_target: Some("person".into()),
574            cardinality: Cardinality::ZeroOrMore,
575            is_builtin: false,
576            data_model: Some("beol".into()),
577        };
578        assert_eq!(field.value_type, ValueType::Link);
579        assert!(
580            field.link_target.is_some(),
581            "a Link field must have link_target == Some(...)"
582        );
583    }
584
585    #[test]
586    fn non_link_field_has_link_target_none() {
587        // A Field with value_type != Link MUST carry link_target == None.
588        let field = Field {
589            name: "hasTitle".into(),
590            iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle".into(),
591            label: Some("Title".into()),
592            value_type: ValueType::Text,
593            link_target: None,
594            cardinality: Cardinality::OneOrMore,
595            is_builtin: false,
596            data_model: Some("beol".into()),
597        };
598        assert_ne!(field.value_type, ValueType::Link);
599        assert!(
600            field.link_target.is_none(),
601            "a non-Link field must have link_target == None"
602        );
603    }
604}