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