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 dsp-cli/ADR-0001 and dsp-cli/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 dsp-cli/ADR-0001 / dsp-cli/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 dsp-cli/ADR-0001 / dsp-cli/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 dsp-cli/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 VocabularyItem,
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:
189 /// `"moving-image"`.
190 MovingImage,
191 /// Audio representation (`knora-api:hasAudioFileValue`). Display: `"audio"`.
192 Audio,
193 /// Document representation (`knora-api:hasDocumentFileValue`). Display: `"document"`.
194 Document,
195 /// Archive representation (`knora-api:hasArchiveFileValue`). Display: `"archive"`.
196 Archive,
197 /// Text representation (`knora-api:hasTextFileValue`). Display: `"text"`.
198 Text,
199}
200
201impl fmt::Display for Cardinality {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 match self {
204 Cardinality::One => f.write_str("1"),
205 Cardinality::ZeroOrOne => f.write_str("0-1"),
206 Cardinality::ZeroOrMore => f.write_str("0-n"),
207 Cardinality::OneOrMore => f.write_str("1-n"),
208 }
209 }
210}
211
212impl fmt::Display for Representation {
213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 match self {
215 Representation::StillImage => f.write_str("still-image"),
216 Representation::MovingImage => f.write_str("moving-image"),
217 Representation::Audio => f.write_str("audio"),
218 Representation::Document => f.write_str("document"),
219 Representation::Archive => f.write_str("archive"),
220 Representation::Text => f.write_str("text"),
221 }
222 }
223}
224
225impl ValueType {
226 /// Returns the canonical dsp-cli kebab token for this value type.
227 ///
228 /// Named variants return a `&'static str` literal. `Other(s)` borrows `s`
229 /// directly — the client already built the kebab form. Matches `Display`
230 /// output; prefer `as_token` when you need a `&str` without allocating.
231 pub fn as_token(&self) -> &str {
232 match self {
233 ValueType::Text => "text",
234 ValueType::Integer => "integer",
235 ValueType::Decimal => "decimal",
236 ValueType::Boolean => "boolean",
237 ValueType::Date => "date",
238 ValueType::Time => "time",
239 ValueType::Uri => "uri",
240 ValueType::Color => "color",
241 ValueType::Geoname => "geoname",
242 ValueType::VocabularyItem => "vocabulary-item",
243 ValueType::Link => "link",
244 ValueType::StillImage => "still-image",
245 ValueType::MovingImage => "moving-image",
246 ValueType::Audio => "audio",
247 ValueType::Document => "document",
248 ValueType::Archive => "archive",
249 ValueType::Other(s) => s.as_str(),
250 }
251 }
252}
253
254impl fmt::Display for ValueType {
255 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256 // Route through `as_token` so the two remain in sync.
257 f.write_str(self.as_token())
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 // --- Construction, equality, clone round-trip ---
266
267 #[test]
268 fn resource_type_detail_full_construction_and_equality() {
269 let detail = ResourceTypeDetail {
270 name: "manuscript".into(),
271 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
272 label: Some("Manuscript".into()),
273 data_model: "beol".into(),
274 representation: Some(Representation::StillImage),
275 super_types: vec!["writtenSource".into()],
276 fields: vec![Field {
277 name: "hasTitle".into(),
278 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle".into(),
279 label: Some("Title".into()),
280 value_type: ValueType::Text,
281 link_target: None,
282 cardinality: Cardinality::OneOrMore,
283 is_builtin: false,
284 data_model: Some("beol".into()),
285 }],
286 count: None,
287 };
288 let cloned = detail.clone();
289 assert_eq!(detail, cloned);
290 assert_eq!(detail.name, "manuscript");
291 assert_eq!(detail.iri, "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript");
292 assert_eq!(detail.label.as_deref(), Some("Manuscript"));
293 assert_eq!(detail.data_model, "beol");
294 assert_eq!(detail.representation, Some(Representation::StillImage));
295 assert_eq!(detail.super_types, vec!["writtenSource"]);
296 assert_eq!(detail.fields.len(), 1);
297 }
298
299 #[test]
300 fn resource_type_detail_minimal_none_variants() {
301 let detail = ResourceTypeDetail {
302 name: "Thing".into(),
303 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Thing".into(),
304 label: None,
305 data_model: "minimal".into(),
306 representation: None,
307 super_types: vec![],
308 fields: vec![],
309 count: None,
310 };
311 let cloned = detail.clone();
312 assert_eq!(detail, cloned);
313 assert_eq!(detail.label, None);
314 assert_eq!(detail.representation, None);
315 assert!(detail.super_types.is_empty());
316 assert!(detail.fields.is_empty());
317 }
318
319 #[test]
320 fn field_full_construction_and_equality() {
321 let field = Field {
322 name: "hasAuthor".into(),
323 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
324 label: Some("Author".into()),
325 value_type: ValueType::Link,
326 link_target: Some("person".into()),
327 cardinality: Cardinality::ZeroOrMore,
328 is_builtin: false,
329 data_model: Some("beol".into()),
330 };
331 let cloned = field.clone();
332 assert_eq!(field, cloned);
333 assert_eq!(field.name, "hasAuthor");
334 assert_eq!(field.iri, "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor");
335 assert_eq!(field.label.as_deref(), Some("Author"));
336 assert_eq!(field.value_type, ValueType::Link);
337 assert_eq!(field.link_target.as_deref(), Some("person"));
338 assert_eq!(field.cardinality, Cardinality::ZeroOrMore);
339 assert!(!field.is_builtin);
340 assert_eq!(field.data_model.as_deref(), Some("beol"));
341 }
342
343 #[test]
344 fn field_minimal_none_variants() {
345 let field = Field {
346 name: "arkUrl".into(),
347 iri: "http://api.knora.org/ontology/knora-api/v2#arkUrl".into(),
348 label: None,
349 value_type: ValueType::Uri,
350 link_target: None,
351 cardinality: Cardinality::One,
352 is_builtin: true,
353 data_model: None,
354 };
355 let cloned = field.clone();
356 assert_eq!(field, cloned);
357 assert_eq!(field.label, None);
358 assert_eq!(field.link_target, None);
359 assert!(field.is_builtin);
360 assert_eq!(field.data_model, None);
361 }
362
363 // --- Display assertions for Cardinality (all 4 variants) ---
364
365 #[test]
366 fn cardinality_display_one() {
367 assert_eq!(Cardinality::One.to_string(), "1");
368 }
369
370 #[test]
371 fn cardinality_display_zero_or_one() {
372 assert_eq!(Cardinality::ZeroOrOne.to_string(), "0-1");
373 }
374
375 #[test]
376 fn cardinality_display_zero_or_more() {
377 assert_eq!(Cardinality::ZeroOrMore.to_string(), "0-n");
378 }
379
380 #[test]
381 fn cardinality_display_one_or_more() {
382 assert_eq!(Cardinality::OneOrMore.to_string(), "1-n");
383 }
384
385 // --- Display assertions for Representation (all 6 variants) ---
386
387 #[test]
388 fn representation_display_still_image() {
389 assert_eq!(Representation::StillImage.to_string(), "still-image");
390 }
391
392 #[test]
393 fn representation_display_moving_image() {
394 assert_eq!(Representation::MovingImage.to_string(), "moving-image");
395 }
396
397 #[test]
398 fn representation_display_audio() {
399 assert_eq!(Representation::Audio.to_string(), "audio");
400 }
401
402 #[test]
403 fn representation_display_document() {
404 assert_eq!(Representation::Document.to_string(), "document");
405 }
406
407 #[test]
408 fn representation_display_archive() {
409 assert_eq!(Representation::Archive.to_string(), "archive");
410 }
411
412 #[test]
413 fn representation_display_text() {
414 assert_eq!(Representation::Text.to_string(), "text");
415 }
416
417 // --- Display assertions for ValueType (all 16 named + Other) ---
418
419 #[test]
420 fn value_type_display_text() {
421 assert_eq!(ValueType::Text.to_string(), "text");
422 }
423
424 #[test]
425 fn value_type_display_integer() {
426 assert_eq!(ValueType::Integer.to_string(), "integer");
427 }
428
429 #[test]
430 fn value_type_display_decimal() {
431 assert_eq!(ValueType::Decimal.to_string(), "decimal");
432 }
433
434 #[test]
435 fn value_type_display_boolean() {
436 assert_eq!(ValueType::Boolean.to_string(), "boolean");
437 }
438
439 #[test]
440 fn value_type_display_date() {
441 assert_eq!(ValueType::Date.to_string(), "date");
442 }
443
444 #[test]
445 fn value_type_display_time() {
446 assert_eq!(ValueType::Time.to_string(), "time");
447 }
448
449 #[test]
450 fn value_type_display_uri() {
451 assert_eq!(ValueType::Uri.to_string(), "uri");
452 }
453
454 #[test]
455 fn value_type_display_color() {
456 assert_eq!(ValueType::Color.to_string(), "color");
457 }
458
459 #[test]
460 fn value_type_display_geoname() {
461 assert_eq!(ValueType::Geoname.to_string(), "geoname");
462 }
463
464 #[test]
465 fn value_type_display_vocabulary_item() {
466 assert_eq!(ValueType::VocabularyItem.to_string(), "vocabulary-item");
467 }
468
469 #[test]
470 fn value_type_display_link() {
471 assert_eq!(ValueType::Link.to_string(), "link");
472 }
473
474 #[test]
475 fn value_type_display_still_image() {
476 assert_eq!(ValueType::StillImage.to_string(), "still-image");
477 }
478
479 #[test]
480 fn value_type_display_moving_image() {
481 assert_eq!(ValueType::MovingImage.to_string(), "moving-image");
482 }
483
484 #[test]
485 fn value_type_display_audio() {
486 assert_eq!(ValueType::Audio.to_string(), "audio");
487 }
488
489 #[test]
490 fn value_type_display_document() {
491 assert_eq!(ValueType::Document.to_string(), "document");
492 }
493
494 #[test]
495 fn value_type_display_archive() {
496 assert_eq!(ValueType::Archive.to_string(), "archive");
497 }
498
499 #[test]
500 fn value_type_display_other_verbatim() {
501 // Other(s) writes s verbatim — the client builds the kebab form.
502 assert_eq!(ValueType::Other("text-file".into()).to_string(), "text-file");
503 }
504
505 // --- ValueType::as_token matches Display for all variants ---
506
507 /// `as_token` returns the correct kebab string for a representative of each
508 /// named variant, and matches `Display` output exactly.
509 #[test]
510 fn value_type_as_token_matches_display_named_variants() {
511 let cases = [
512 ValueType::Text,
513 ValueType::Integer,
514 ValueType::Decimal,
515 ValueType::Boolean,
516 ValueType::Date,
517 ValueType::Time,
518 ValueType::Uri,
519 ValueType::Color,
520 ValueType::Geoname,
521 ValueType::VocabularyItem,
522 ValueType::Link,
523 ValueType::StillImage,
524 ValueType::MovingImage,
525 ValueType::Audio,
526 ValueType::Document,
527 ValueType::Archive,
528 ];
529 for vt in &cases {
530 assert_eq!(vt.as_token(), vt.to_string(), "as_token must match Display for {:?}", vt);
531 }
532 }
533
534 #[test]
535 fn value_type_as_token_still_image() {
536 assert_eq!(ValueType::StillImage.as_token(), "still-image");
537 }
538
539 #[test]
540 fn value_type_as_token_vocabulary_item() {
541 assert_eq!(ValueType::VocabularyItem.as_token(), "vocabulary-item");
542 }
543
544 #[test]
545 fn value_type_as_token_moving_image() {
546 assert_eq!(ValueType::MovingImage.as_token(), "moving-image");
547 }
548
549 #[test]
550 fn value_type_as_token_other_borrows_string() {
551 let vt = ValueType::Other("geom".into());
552 // as_token borrows from the inner String; matches Display.
553 assert_eq!(vt.as_token(), "geom");
554 assert_eq!(vt.as_token(), vt.to_string());
555 }
556
557 // --- Link ⇔ link_target invariant, both directions ---
558
559 #[test]
560 fn link_field_has_link_target_some() {
561 // A Field with value_type == Link MUST carry link_target == Some(...).
562 let field = Field {
563 name: "hasAuthor".into(),
564 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
565 label: Some("Author".into()),
566 value_type: ValueType::Link,
567 link_target: Some("person".into()),
568 cardinality: Cardinality::ZeroOrMore,
569 is_builtin: false,
570 data_model: Some("beol".into()),
571 };
572 assert_eq!(field.value_type, ValueType::Link);
573 assert!(field.link_target.is_some(), "a Link field must have link_target == Some(...)");
574 }
575
576 #[test]
577 fn non_link_field_has_link_target_none() {
578 // A Field with value_type != Link MUST carry link_target == None.
579 let field = Field {
580 name: "hasTitle".into(),
581 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle".into(),
582 label: Some("Title".into()),
583 value_type: ValueType::Text,
584 link_target: None,
585 cardinality: Cardinality::OneOrMore,
586 is_builtin: false,
587 data_model: Some("beol".into()),
588 };
589 assert_ne!(field.value_type, ValueType::Link);
590 assert!(field.link_target.is_none(), "a non-Link field must have link_target == None");
591 }
592}