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}