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