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}