Skip to main content

dsp_cli/model/
resource.rs

1//! Domain model for resource instances — `ResourceSummary`, `ResourcePage`,
2//! `ResourceDetail`, `ResourceVisibility`, `ResourceAccess`, `FieldValues`,
3//! `Value`, `ValueContent`, `DateValue`, `DatePoint`, and `FileValue`.
4//!
5//! These types cross the client/action boundary: `ResourcePage` is the raw
6//! per-page result returned by [`crate::client::DspClient::list_resources`];
7//! `ResourceSummary` is the per-row projection that flows through the action
8//! into the renderer. `ResourceDetail` carries the full envelope metadata
9//! returned by [`crate::client::DspClient::describe_resource`]. `FieldValues`
10//! and `ValueContent` carry
11//! the parsed field values (instance side), emitted when `--values` is set.
12
13/// A single resource instance, as returned by the list endpoint.
14///
15/// Fields are the envelope metadata only (label, IRI, ARK URL, creation date,
16/// last-modification date, resource type). Values are NOT fetched at list time
17/// — see D4 in the plan.
18///
19/// Uses the detailed representation: `ark_url`, `creation_date`, and
20/// `last_modified` are `Option<String>` for robustness; the list endpoint uses
21/// the complex schema so both dates populate (verified live on `dev` 2026-06-17).
22/// `last_modified` is additionally server-side optional — a resource that has
23/// never been modified will have none. The live test (Layer 5) makes hard
24/// assertions on the load-bearing fields.
25#[derive(Debug, Clone)]
26pub struct ResourceSummary {
27    /// Human-readable label assigned to this resource instance.
28    pub label: String,
29    /// Absolute IRI of this resource instance (its `@id`).
30    pub iri: String,
31    /// ARK URL for permanent citation, if present in the response.
32    pub ark_url: Option<String>,
33    /// RFC 3339 creation timestamp, if present in the response.
34    pub creation_date: Option<String>,
35    /// RFC 3339 last-modification timestamp, if present in the response.
36    /// Server-side optional — absent for resources that have never been modified.
37    pub last_modified: Option<String>,
38    /// Local name of the resource type (derived from `@type`).
39    pub resource_type: String,
40}
41
42/// Who can see a resource — derived from the resource's full access-control list.
43///
44/// Answers "is this resource public?". Classified by the highest access level
45/// granted to anonymous visitors or any logged-in user. The value is a
46/// translated dsp-cli domain term; raw permission codes never appear above the
47/// client boundary.
48#[derive(Debug, Clone, PartialEq)]
49pub enum ResourceVisibility {
50    /// The resource is publicly readable without authentication.
51    Public,
52    /// The resource is publicly accessible but only in a restricted view
53    /// (e.g. watermarked images). Full content requires authentication.
54    PublicRestricted,
55    /// The resource is visible to any logged-in user, but not to anonymous visitors.
56    LoggedInUsers,
57    /// The resource is only accessible to members of the project (or higher).
58    ProjectMembers,
59}
60
61impl ResourceVisibility {
62    /// Display string used uniformly across all output formats.
63    ///
64    /// Note: some variants include spaces and parentheses (e.g.
65    /// `"public (restricted view)"`), so this is not a bare lowercase token.
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            ResourceVisibility::Public => "public",
69            ResourceVisibility::PublicRestricted => "public (restricted view)",
70            ResourceVisibility::LoggedInUsers => "logged-in users",
71            ResourceVisibility::ProjectMembers => "project members only",
72        }
73    }
74}
75
76/// What the requesting caller can do with a resource.
77///
78/// Derived from the server's per-caller effective permission code. Answers
79/// "what can I do with this resource?" The value is a translated dsp-cli
80/// domain term; raw permission codes never appear above the client boundary.
81#[derive(Debug, Clone, PartialEq)]
82pub enum ResourceAccess {
83    /// The caller can view the resource in a restricted form only (e.g. a
84    /// low-resolution image). They cannot see the full content.
85    RestrictedView,
86    /// The caller can view the resource in full.
87    View,
88    /// The caller can view and modify the resource's values.
89    Edit,
90    /// The caller can view, modify, and delete the resource.
91    Delete,
92    /// The caller has full control over the resource (view, modify, delete,
93    /// and change permissions).
94    Manage,
95}
96
97impl ResourceAccess {
98    /// Display string used uniformly across all output formats.
99    pub fn as_str(&self) -> &'static str {
100        match self {
101            ResourceAccess::RestrictedView => "restricted view",
102            ResourceAccess::View => "view",
103            ResourceAccess::Edit => "edit",
104            ResourceAccess::Delete => "delete",
105            ResourceAccess::Manage => "manage",
106        }
107    }
108}
109
110/// Full envelope metadata for a single resource instance.
111///
112/// Returned by [`crate::client::DspClient::describe_resource`]. Contains the resource's
113/// identity, audit timestamps, ownership, and two translated permission facets
114/// (visibility and the caller's access level). The `values` field is populated
115/// only when `--values` is requested (Phase 8c); it is `None` for the default
116/// metadata-only mode (Phase 8b).
117#[derive(Debug, Clone)]
118pub struct ResourceDetail {
119    /// Human-readable label assigned to this resource instance.
120    pub label: String,
121    /// Absolute IRI of this resource instance.
122    pub iri: String,
123    /// Local name of the resource type (e.g. "Page").
124    pub resource_type: String,
125    /// ARK URL for permanent citation, if present in the response.
126    pub ark_url: Option<String>,
127    /// RFC 3339 creation timestamp, if present in the response.
128    pub creation_date: Option<String>,
129    /// RFC 3339 last-modification timestamp, if present in the response.
130    /// Absent for resources that have never been modified.
131    pub last_modified: Option<String>,
132    /// IRI of the project this resource belongs to, if present in the response.
133    pub attached_project: Option<String>,
134    /// IRI of the user who owns this resource, if present in the response.
135    pub owner: Option<String>,
136    /// Who can see this resource, derived from the resource's access-control list.
137    /// `None` when the ACL is absent or unparseable.
138    pub visibility: Option<ResourceVisibility>,
139    /// What the requesting caller can do with this resource.
140    /// `None` when the caller's effective permission is absent or unknown.
141    pub your_access: Option<ResourceAccess>,
142    /// Parsed field values for this resource instance.
143    ///
144    /// `None` when `--values` is not set (metadata-only mode — the default).
145    /// `Some` when `--values` is set; the vec may still be empty if the resource
146    /// has no readable user fields.
147    pub values: Option<Vec<FieldValues>>,
148}
149
150/// One value on a field, plus its optional per-value comment
151/// (`knora-api:valueHasComment`). The comment is free-text server-supplied
152/// annotation; `None` when the value has no comment (the common case).
153#[derive(Debug, Clone, PartialEq)]
154pub struct Value {
155    pub content: ValueContent,
156    pub comment: Option<String>,
157}
158
159impl From<ValueContent> for Value {
160    fn from(content: ValueContent) -> Self {
161        Value { content, comment: None }
162    }
163}
164
165/// One field on a resource and the value(s) it holds (instance side).
166///
167/// `name` is the field's local name (the `Value`-suffix is stripped on link
168/// properties, as in DSP-API property names like `isPartOfBookValue` →
169/// `isPartOfBook`). `label` is the server-supplied `rdfs:label` resolved from
170/// the defining data-model's ontology; `None` when the fetch failed or the
171/// field is a built-in. `values` is the list of parsed values for this field
172/// (multi-value fields have more than one entry; the list is never empty — a
173/// field with no readable values is omitted from the parent
174/// `ResourceDetail.values` vec).
175#[derive(Debug, Clone, PartialEq)]
176pub struct FieldValues {
177    /// Local field name (Value-suffix stripped for link properties).
178    pub name: String,
179    /// Human label from the defining data-model's ontology; `None` if unresolved
180    /// or the field is a system built-in.
181    pub label: Option<String>,
182    /// Parsed values carried by this field — at least one entry. Each entry
183    /// pairs a [`ValueContent`] with an optional per-value comment.
184    pub values: Vec<Value>,
185}
186
187/// The typed content of a single value (instance side).
188///
189/// Each variant corresponds to one entry in the value-type rendering matrix
190/// (dsp-cli/ADR-0013). Scalar arms hold a single extracted datum. `VocabularyItem` and `Link`
191/// additionally carry a resolved label (falling back to `None` on failure). `File`
192/// covers all file-representation types. `Raw` is the long-tail fallback for any
193/// value-type not in the named set — it never causes a hard error.
194#[derive(Debug, Clone, PartialEq)]
195pub enum ValueContent {
196    /// Plain or standoff text — standoff XML stripped by the client boundary.
197    Text(String),
198    /// Integer number.
199    Integer(i64),
200    /// Decimal number — stored as a string to preserve precision.
201    Decimal(String),
202    /// Boolean value.
203    Boolean(bool),
204    /// Calendar-aware date, possibly a range.
205    Date(DateValue),
206    /// Point-in-time timestamp string (ISO 8601).
207    Time(String),
208    /// URI string.
209    Uri(String),
210    /// Hex colour string (e.g. `"#ff0000"`).
211    Color(String),
212    /// GeoNames location code string.
213    Geoname(String),
214    /// Reference to a controlled-vocabulary list node.
215    VocabularyItem {
216        /// IRI of the list node.
217        node_iri: String,
218        /// Human label resolved from `/v2/node`; `None` when the fetch failed.
219        label: Option<String>,
220    },
221    /// Link to another resource instance.
222    Link {
223        /// IRI of the target resource.
224        target_iri: String,
225        /// `rdfs:label` of the target resource, embedded in the complex-schema
226        /// link value; `None` when absent.
227        target_label: Option<String>,
228    },
229    /// File representation value (still-image, moving-image, audio, document,
230    /// archive).
231    File(FileValue),
232    /// Long-tail fallback for value-types not covered by the named variants
233    /// (e.g. interval, geometry). Never a hard error.
234    Raw {
235        /// dsp-cli token for the value type, derived from the DSP-API `@type`
236        /// local name (lower-kebab form, e.g. `"interval"`).
237        value_type: String,
238        /// Best-effort text representation of the value datum.
239        text: String,
240    },
241}
242
243impl ValueContent {
244    /// Returns the dsp-cli kebab token for this value's type.
245    ///
246    /// Scalar arms return a `&'static str` literal. `File` borrows from the
247    /// inner `FileValue.value_type`. `Raw` borrows from the stored `value_type`
248    /// string. No allocation in any arm.
249    pub fn value_type_token(&self) -> &str {
250        match self {
251            ValueContent::Text(_) => "text",
252            ValueContent::Integer(_) => "integer",
253            ValueContent::Decimal(_) => "decimal",
254            ValueContent::Boolean(_) => "boolean",
255            ValueContent::Date(_) => "date",
256            ValueContent::Time(_) => "time",
257            ValueContent::Uri(_) => "uri",
258            ValueContent::Color(_) => "color",
259            ValueContent::Geoname(_) => "geoname",
260            ValueContent::VocabularyItem { .. } => "vocabulary-item",
261            ValueContent::Link { .. } => "link",
262            ValueContent::File(fv) => fv.value_type.as_token(),
263            ValueContent::Raw { value_type, .. } => value_type.as_str(),
264        }
265    }
266}
267
268/// Calendar-aware date value, optionally a range.
269///
270/// `start` and `end` are both present on every `DateValue`; a single-point date
271/// has `start == end` (field-by-field). The renderer collapses equal start/end to
272/// a single point display (`<point> (<Calendar>)`).
273#[derive(Debug, Clone, PartialEq)]
274pub struct DateValue {
275    /// Calendar system (e.g. `"GREGORIAN"`, `"JULIAN"`, `"ISLAMIC"`).
276    pub calendar: String,
277    /// Start of the date range (inclusive).
278    pub start: DatePoint,
279    /// End of the date range (inclusive). Equal to `start` for a single-point date.
280    pub end: DatePoint,
281}
282
283/// One endpoint of a date range, with optional precision.
284///
285/// Precision follows the fields present: year only → year precision; year + month
286/// → month precision; all three → day precision.
287#[derive(Debug, Clone, PartialEq)]
288pub struct DatePoint {
289    /// Year component. Negative for BCE years.
290    pub year: Option<i32>,
291    /// Month component (1–12), if present.
292    pub month: Option<u32>,
293    /// Day component (1–31), if present.
294    pub day: Option<u32>,
295    /// Era string (e.g. `"CE"`, `"BCE"`), if present in the response.
296    pub era: Option<String>,
297}
298
299/// File-representation value, covering all representation kinds.
300///
301/// `value_type` restricts to the five file kinds (`StillImage`, `MovingImage`,
302/// `Audio`, `Document`, `Archive`). `width` and `height` are only populated for
303/// `StillImage`; they are `None` for all other kinds.
304#[derive(Debug, Clone, PartialEq)]
305pub struct FileValue {
306    /// Representation kind — one of the five file variants of `ValueType`.
307    pub value_type: crate::model::resource_type::ValueType,
308    /// Original filename as stored by the server (e.g. `"image.jp2"`).
309    pub filename: String,
310    /// IIIF or download URL for the file.
311    pub url: String,
312    /// Image width in pixels; `Some` for `StillImage`, `None` otherwise.
313    pub width: Option<u32>,
314    /// Image height in pixels; `Some` for `StillImage`, `None` otherwise.
315    pub height: Option<u32>,
316}
317
318/// A single page of resource-list results from the DSP-API.
319///
320/// Returned by [`crate::client::DspClient::list_resources`]. The action accumulates pages
321/// for `--all` mode; for single-page mode the action reads exactly one.
322#[derive(Debug, Clone)]
323pub struct ResourcePage {
324    /// The resources on this page (may be empty — the empty-final-page case is normal).
325    pub resources: Vec<ResourceSummary>,
326    /// Whether the server reports more pages after this one.
327    ///
328    /// `false` when the field is absent or false in the response — there are no
329    /// further pages. `true` means the caller should fetch the next page.
330    pub may_have_more_results: bool,
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::model::resource_type::ValueType;
337
338    // ── ValueContent::value_type_token ───────────────────────────────────────
339
340    #[test]
341    fn value_type_token_text() {
342        assert_eq!(ValueContent::Text("hello".into()).value_type_token(), "text");
343    }
344
345    #[test]
346    fn value_type_token_integer() {
347        assert_eq!(ValueContent::Integer(42).value_type_token(), "integer");
348    }
349
350    #[test]
351    fn value_type_token_decimal() {
352        assert_eq!(ValueContent::Decimal("3.14".into()).value_type_token(), "decimal");
353    }
354
355    #[test]
356    fn value_type_token_boolean() {
357        assert_eq!(ValueContent::Boolean(true).value_type_token(), "boolean");
358    }
359
360    #[test]
361    fn value_type_token_date() {
362        let dv = DateValue {
363            calendar: "GREGORIAN".into(),
364            start: DatePoint {
365                year: Some(1489),
366                month: None,
367                day: None,
368                era: Some("CE".into()),
369            },
370            end: DatePoint {
371                year: Some(1489),
372                month: None,
373                day: None,
374                era: Some("CE".into()),
375            },
376        };
377        assert_eq!(ValueContent::Date(dv).value_type_token(), "date");
378    }
379
380    #[test]
381    fn value_type_token_time() {
382        assert_eq!(ValueContent::Time("2021-01-01T00:00:00Z".into()).value_type_token(), "time");
383    }
384
385    #[test]
386    fn value_type_token_uri() {
387        assert_eq!(ValueContent::Uri("https://example.com".into()).value_type_token(), "uri");
388    }
389
390    #[test]
391    fn value_type_token_color() {
392        assert_eq!(ValueContent::Color("#ff0000".into()).value_type_token(), "color");
393    }
394
395    #[test]
396    fn value_type_token_geoname() {
397        assert_eq!(ValueContent::Geoname("2661552".into()).value_type_token(), "geoname");
398    }
399
400    #[test]
401    fn value_type_token_vocabulary_item() {
402        assert_eq!(
403            ValueContent::VocabularyItem {
404                node_iri: "http://rdfh.ch/lists/0001/node1".into(),
405                label: Some("Leaf node".into()),
406            }
407            .value_type_token(),
408            "vocabulary-item"
409        );
410    }
411
412    #[test]
413    fn value_type_token_link() {
414        assert_eq!(
415            ValueContent::Link {
416                target_iri: "http://rdfh.ch/0803/res1".into(),
417                target_label: None,
418            }
419            .value_type_token(),
420            "link"
421        );
422    }
423
424    #[test]
425    fn value_type_token_file_still_image() {
426        let fv = FileValue {
427            value_type: ValueType::StillImage,
428            filename: "image.jp2".into(),
429            url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
430            width: Some(1200),
431            height: Some(800),
432        };
433        assert_eq!(ValueContent::File(fv).value_type_token(), "still-image");
434    }
435
436    #[test]
437    fn value_type_token_file_moving_image() {
438        let fv = FileValue {
439            value_type: ValueType::MovingImage,
440            filename: "video.mp4".into(),
441            url: "https://example.com/video.mp4".into(),
442            width: None,
443            height: None,
444        };
445        assert_eq!(ValueContent::File(fv).value_type_token(), "moving-image");
446    }
447
448    #[test]
449    fn value_type_token_file_audio() {
450        let fv = FileValue {
451            value_type: ValueType::Audio,
452            filename: "sound.wav".into(),
453            url: "https://example.com/sound.wav".into(),
454            width: None,
455            height: None,
456        };
457        assert_eq!(ValueContent::File(fv).value_type_token(), "audio");
458    }
459
460    #[test]
461    fn value_type_token_file_document() {
462        let fv = FileValue {
463            value_type: ValueType::Document,
464            filename: "doc.pdf".into(),
465            url: "https://example.com/doc.pdf".into(),
466            width: None,
467            height: None,
468        };
469        assert_eq!(ValueContent::File(fv).value_type_token(), "document");
470    }
471
472    #[test]
473    fn value_type_token_file_archive() {
474        let fv = FileValue {
475            value_type: ValueType::Archive,
476            filename: "data.zip".into(),
477            url: "https://example.com/data.zip".into(),
478            width: None,
479            height: None,
480        };
481        assert_eq!(ValueContent::File(fv).value_type_token(), "archive");
482    }
483
484    #[test]
485    fn value_type_token_raw() {
486        assert_eq!(
487            ValueContent::Raw { value_type: "interval".into(), text: "PT10S".into() }.value_type_token(),
488            "interval"
489        );
490    }
491
492    // ── FieldValues construction ──────────────────────────────────────────────
493
494    #[test]
495    fn field_values_construction_and_equality() {
496        let fv = FieldValues {
497            name: "hasTitle".into(),
498            label: Some("Title".into()),
499            values: vec![ValueContent::Text("Incunabula".into()).into()],
500        };
501        let cloned = fv.clone();
502        assert_eq!(fv, cloned);
503        assert_eq!(fv.name, "hasTitle");
504        assert_eq!(fv.label.as_deref(), Some("Title"));
505        assert_eq!(fv.values.len(), 1);
506    }
507
508    // ── DateValue / DatePoint construction ───────────────────────────────────
509
510    #[test]
511    fn date_value_point_equality() {
512        let pt = DatePoint {
513            year: Some(1489),
514            month: None,
515            day: None,
516            era: Some("CE".into()),
517        };
518        let dv = DateValue {
519            calendar: "GREGORIAN".into(),
520            start: pt.clone(),
521            end: pt.clone(),
522        };
523        assert_eq!(dv.start, dv.end, "single-point date must have start == end");
524    }
525
526    #[test]
527    fn date_value_range_not_equal() {
528        let start = DatePoint {
529            year: Some(1489),
530            month: None,
531            day: None,
532            era: Some("CE".into()),
533        };
534        let end = DatePoint {
535            year: Some(1490),
536            month: None,
537            day: None,
538            era: Some("CE".into()),
539        };
540        let dv = DateValue {
541            calendar: "GREGORIAN".into(),
542            start: start.clone(),
543            end: end.clone(),
544        };
545        assert_ne!(dv.start, dv.end, "range date must have start != end");
546    }
547}