Skip to main content

dsp_cli/client/
http.rs

1//! HTTP `DspClient` implementation — real `reqwest::blocking` client.
2//!
3//! ## DSP-API login endpoint (confirmed from AuthenticationEndpointsV2.scala)
4//!
5//! **URL**: `POST {server}/v2/authentication`
6//!
7//! **Request body**: JSON with one identifier key (`email`, `username`, or `iri`)
8//! plus a `password` key. The CLI auto-detects the identifier type from the
9//! `--user` value and sends the matching key: an `http(s)://` prefix is treated
10//! as a user IRI, a value containing `@` as an email address, and anything else
11//! as a username. See `identifier_key` for the heuristic.
12//!
13//! Example (email): `{ "email": "<value>", "password": "<password>" }`
14//!
15//! **Response body (200)**:
16//! ```json
17//! { "token": "<jwt-string>" }
18//! ```
19//! Token only — no `user`, no `expires_at` in the response. The user identity
20//! is echoed back from the `--user` argument; expiry is extracted from the JWT
21//! via `extract_exp`.
22//!
23//! **Error status codes**: 401 for bad credentials (treat 401 and 403 alike).
24//!
25//! **Credentials travel in the POST JSON body**, not in the URL or in any header
26//! that reqwest's built-in debug logging captures (i.e. not Authorization header).
27
28use std::io::{Read, Write};
29use std::time::Duration;
30
31use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
32
33use std::collections::{HashMap, HashSet};
34
35use crate::client::DspClient;
36use crate::client::builtins::builtin_field_value_type;
37use crate::client::jwt::extract_exp;
38use crate::diagnostic::Diagnostic;
39use crate::model::auth::LoginResponse;
40use crate::model::resource::{DatePoint, DateValue, FieldValues, FileValue, Value, ValueContent};
41use crate::model::{
42    Cardinality, CreateDumpOutcome, DataModel, DataModelDetail, DataModelStructure,
43    DataModelSummary, DumpStatus, DumpTask, Field, Project, ProjectDescription, ProjectDetail,
44    ProjectRef, ProjectStatus, Relation, RelationKind, Representation, ResourceAccess,
45    ResourceDetail, ResourcePage, ResourceSummary, ResourceTypeDetail, ResourceTypeSummary,
46    ResourceVisibility, ValueType,
47};
48
49// ---------------------------------------------------------------------------
50// Private wire DTOs (boundary translation — ADR-0001)
51// ---------------------------------------------------------------------------
52
53/// Private DSP-API wire type for the login response.
54///
55/// Stays inside this module — the boundary translation to `LoginResponse`
56/// (dsp-cli vocabulary) happens below (ADR-0001).
57#[derive(serde::Deserialize)]
58struct LoginApiResponse {
59    token: String,
60}
61
62/// DSP-API response envelope for a single-project lookup.
63///
64/// `GET /admin/projects/shortcode/{sc}` | `/shortname/{n}` | `/iri/{enc-iri}`
65/// all return `{ "project": { "id": "…", "shortcode": "…", "shortname": "…", … } }`.
66/// Only the fields the CLI needs are extracted here (private, ADR-0001 boundary).
67#[derive(serde::Deserialize)]
68struct ProjectGetApiResponse {
69    project: ProjectApiDto,
70}
71
72#[derive(serde::Deserialize)]
73struct ProjectApiDto {
74    id: String,
75    shortcode: String,
76    shortname: String,
77}
78
79/// DSP-API wire type for a dump/export task status response.
80///
81/// JSON wire shape: `{ "id": "…", "status": "in_progress"|"completed"|"failed",
82/// "errorMessage": "…", "createdAt": "…" }` (camelCase on the wire; optional
83/// fields may be absent). This DTO is private to `http.rs` — the translation to
84/// `DumpTask` (dsp-cli vocabulary) happens in `into_dump_task`. See ADR-0001.
85#[derive(serde::Deserialize)]
86struct DataTaskStatusApiResponse {
87    id: String,
88    status: String,
89    #[serde(default, rename = "errorMessage")]
90    error_message: Option<String>,
91    /// Raw RFC 3339 timestamp string from the wire. Kept as `Option<String>`
92    /// (not `Option<DateTime<Utc>>`) so that a present-but-malformed timestamp
93    /// does NOT fail the entire body parse — we parse it best-effort in
94    /// `into_dump_task`, and fall back to `None` on failure.
95    #[serde(default, rename = "createdAt")]
96    created_at: Option<String>,
97}
98
99/// DSP-API V3 error envelope for 409 conflict responses.
100///
101/// Shape on the wire:
102/// ```json
103/// { "errors": [{ "code": "export_exists", "details": { "id": "…", "projectIri": "…" } }] }
104/// ```
105/// Private to `http.rs` — the `export_exists` accessor is the only path above this module.
106#[derive(serde::Deserialize)]
107struct V3ErrorBody {
108    #[serde(default)]
109    errors: Vec<V3ErrorItem>,
110}
111
112#[derive(serde::Deserialize)]
113struct V3ErrorItem {
114    code: String,
115    #[serde(default)]
116    details: std::collections::HashMap<String, String>,
117}
118
119/// One ontology's resource-class instance counts from the resource-counts
120/// endpoint.
121///
122/// `GET /v3/projects/{enc(project_iri)}/resourcesPerOntology` returns a
123/// top-level JSON array of these — not wrapped in an envelope object. Only
124/// `classesAndCount` is modelled; the sibling `ontology` object (iri/label/
125/// comment) is dropped by serde since `resource_counts` flattens across
126/// ontologies (ADR-0001: translation stays in this module).
127#[derive(serde::Deserialize)]
128struct OntologyAndResourceClassesDto {
129    #[serde(rename = "classesAndCount", default)]
130    classes_and_count: Vec<ClassAndCountDto>,
131}
132
133/// One resource-class's instance count within an ontology, from the
134/// resource-counts endpoint.
135///
136/// `itemCount` counts non-deleted resources but is NOT permission-filtered —
137/// see the `resource_counts` trait doc for why this differs from
138/// `list_resources`.
139#[derive(serde::Deserialize)]
140struct ClassAndCountDto {
141    #[serde(rename = "resourceClass")]
142    resource_class: ResourceClassRefDto,
143    #[serde(rename = "itemCount")]
144    item_count: u64,
145}
146
147/// Bare resource-class reference from the resource-counts endpoint — only the
148/// IRI is needed.
149#[derive(serde::Deserialize)]
150struct ResourceClassRefDto {
151    iri: String,
152}
153
154/// DSP-API response envelope for the project list endpoint.
155///
156/// `GET /admin/projects` returns `{ "projects": [ … ] }`. Only the fields the
157/// CLI needs for `project list` are extracted here (private, ADR-0001 boundary).
158#[derive(serde::Deserialize)]
159struct ProjectsListApiResponse {
160    projects: Vec<ProjectListItemDto>,
161}
162
163/// One project item from the `GET /admin/projects` response.
164///
165/// Only the fields needed for `project list` are modelled — serde ignores the
166/// rest (description, keywords, licences, …) by default. See ADR-0001.
167#[derive(serde::Deserialize)]
168struct ProjectListItemDto {
169    id: String,
170    shortname: String,
171    shortcode: String,
172    #[serde(default)]
173    longname: Option<String>,
174    /// `status` has NO `#[serde(default)]`: a missing `status` field is a
175    /// server-contract change and MUST fail parse loudly (→ `ServerError`),
176    /// not silently default to `false`. This mirrors the deliberate care in
177    /// [`DataTaskStatusApiResponse::status`] and is intentional. Document any
178    /// future change to this decision with an ADR amendment.
179    status: bool,
180    #[serde(default)]
181    ontologies: Vec<String>,
182}
183
184/// Private DSP-API wire type for the RICH single-project lookup (describe).
185///
186/// Distinct from `ProjectApiDto` (resolve_project's lean projection) so each
187/// caller owns its own parse contract. Boundary-private (ADR-0001).
188///
189/// Note: this endpoint is the SAME as resolve_project's (`/admin/projects/…`)
190/// but describe parses more fields. A separate DTO keeps parse contracts
191/// independent and avoids breaking resolve_project fixtures that omit `status`.
192#[derive(serde::Deserialize)]
193struct ProjectDetailApiResponse {
194    project: ProjectDetailApiDto,
195}
196
197#[derive(serde::Deserialize)]
198struct ProjectDetailApiDto {
199    id: String,
200    shortcode: String,
201    shortname: String,
202    #[serde(default)]
203    longname: Option<String>,
204    /// No `#[serde(default)]`: a missing `status` is a server-contract change
205    /// and must fail parse loudly (→ `ServerError`), mirroring `ProjectListItemDto`.
206    status: bool,
207    #[serde(default)]
208    description: Vec<ProjectDescriptionDto>,
209    #[serde(default)]
210    keywords: Vec<String>,
211    #[serde(default)]
212    ontologies: Vec<String>,
213}
214
215#[derive(serde::Deserialize)]
216struct ProjectDescriptionDto {
217    value: String,
218    #[serde(default)]
219    language: Option<String>,
220}
221
222/// Wire shape of `GET /v2/ontologies/metadata/{iri}`. JSON-LD returns either a
223/// `@graph` array (multiple ontologies), a flattened single object (one
224/// ontology, NO `@graph`), or `{}` (none). The flattened-single fields are
225/// captured at the top level and reconciled in code. (`@graph` and the
226/// flattened fields are mutually exclusive in practice; if both ever appear,
227/// `@graph` wins — see the reconciliation comment.)
228// Note on `#[serde(default)]`: an `Option<T>` field already deserializes a
229// MISSING key to `None` without `default`, so it is omitted on the plain
230// `Option` fields below. It is kept ONLY on `last_modification_date` as
231// belt-and-braces (see `LastModDto` for the precise absent-vs-malformed
232// semantics).
233#[derive(serde::Deserialize)]
234struct OntologyMetadataResponse {
235    #[serde(rename = "@graph")]
236    graph: Option<Vec<OntologyMetadataDto>>,
237    // Flattened single-ontology case (present only when `@graph` is absent):
238    #[serde(rename = "@id")]
239    id: Option<String>,
240    #[serde(rename = "rdfs:label")]
241    label: Option<String>,
242    #[serde(rename = "knora-api:lastModificationDate", default)]
243    last_modification_date: Option<LastModDto>,
244}
245
246#[derive(serde::Deserialize)]
247struct OntologyMetadataDto {
248    #[serde(rename = "@id")]
249    id: String,
250    #[serde(rename = "rdfs:label")]
251    label: Option<String>,
252    #[serde(rename = "knora-api:lastModificationDate", default)]
253    last_modification_date: Option<LastModDto>,
254}
255
256/// `knora-api:lastModificationDate` is `{"@value": "...", "@type": "..."}`.
257/// Typed as `Option<LastModDto>` with `#[serde(default)]` on the containing
258/// fields: an ABSENT `knora-api:lastModificationDate` key deserializes to
259/// `None` (the `Option` default — `#[serde(default)]` is belt-and-braces here).
260/// A PRESENT but malformed value (e.g. missing `@value`) will fail the parse
261/// and surface as a `ServerError` — it is NOT silently dropped. This is
262/// consistent with this codebase's fail-loud-on-contract-violation stance.
263#[derive(serde::Deserialize)]
264struct LastModDto {
265    #[serde(rename = "@value")]
266    value: String,
267}
268
269/// Wire shape of `GET /v2/ontologies/allentities/{iri}`. A flat JSON-LD doc:
270/// the ontology node's fields at top level, a `@graph` of all entities, and a
271/// `@context` prefix map. `allLanguages` is off, so labels are plain strings.
272#[derive(serde::Deserialize)]
273struct OntologyAllEntitiesResponse {
274    #[serde(rename = "@id")]
275    id: String,
276    #[serde(rename = "rdfs:label")]
277    label: Option<String>,
278    #[serde(rename = "knora-api:lastModificationDate", default)]
279    last_modification_date: Option<LastModDto>,
280    #[serde(rename = "@graph", default)]
281    graph: Vec<OntologyEntityDto>,
282    // Prefix → namespace. Typed as Value (NOT String) on purpose: a JSON-LD
283    // `@context` may legitimately carry object-valued term definitions
284    // (`"term": {"@id": …, "@type": …}`) we don't consume; a `String`-typed map
285    // would make serde FAIL the whole parse on such an entry. We extract only the
286    // string-valued prefix entries we need. (Live beol context is all strings, but
287    // this keeps a richer context from breaking the read — a deliberate
288    // robustness-over-strictness call, see Risks.)
289    #[serde(rename = "@context", default)]
290    context: HashMap<String, serde_json::Value>,
291}
292
293/// One `@graph` entity. Only resource-types are consumed; `is_resource_class`
294/// is absent on non-resource nodes (→ false via `default`). `label` is a plain
295/// string or absent (allLanguages off).
296///
297/// ADDITIVE extension for `describe_resource_type` (Step 2, task 016): all new
298/// fields are `Option` / `#[serde(default)]` so `describe_data_model` (which
299/// shares this struct) continues to parse without change — see R1 in the plan.
300/// - `sub_class_of`: heterogeneous list of superclass refs + `owl:Restriction`s;
301///   typed as `Vec<serde_json::Value>` because the mix of shapes (bare `@id` vs
302///   restriction object with integer cardinality) defeats a single `#[derive]`
303///   struct (R9).
304/// - `object_type`: `knora-api:objectType` → inner `{"@id": "…"}` object.
305/// - `is_link_property`: `knora-api:isLinkProperty` (link fields).
306/// - `is_link_value_property`: `knora-api:isLinkValueProperty` (reification twin; dropped).
307/// - `is_resource_property`: `knora-api:isResourceProperty`.
308/// - `gui_order`: `salsah-gui:guiOrder` on restriction nodes (only meaningful
309///   inside `sub_class_of` items, but also present on property nodes for some
310///   ontologies; captured here for completeness and parsed from `sub_class_of`
311///   elements directly in the classifier).
312#[derive(serde::Deserialize)]
313struct OntologyEntityDto {
314    #[serde(rename = "@id")]
315    id: String,
316    #[serde(rename = "rdfs:label")]
317    label: Option<String>,
318    #[serde(rename = "knora-api:isResourceClass", default)]
319    is_resource_class: bool,
320    /// `rdfs:subClassOf` — heterogeneous list of superclass refs and restrictions.
321    /// Typed as `Vec<serde_json::Value>` (R9). `#[serde(default)]` so nodes that
322    /// lack this field (property nodes, most non-resource-class nodes) parse to
323    /// an empty vec rather than failing.
324    #[serde(rename = "rdfs:subClassOf", default)]
325    sub_class_of: Vec<serde_json::Value>,
326    /// `knora-api:objectType` → `{"@id": "…"}`. Present on property nodes to
327    /// identify the value type (or link-target resource class).
328    #[serde(rename = "knora-api:objectType")]
329    object_type: Option<ObjectTypeDto>,
330    /// `knora-api:isLinkProperty` — present (true) on link-property nodes.
331    #[serde(rename = "knora-api:isLinkProperty", default)]
332    is_link_property: bool,
333    /// `knora-api:isLinkValueProperty` — present (true) on reification twin nodes.
334    /// These are dropped from the field list (Decision 7 / R-twin).
335    #[serde(rename = "knora-api:isLinkValueProperty", default)]
336    is_link_value_property: bool,
337    /// `knora-api:isResourceProperty` — present (true) on resource-property nodes.
338    #[serde(rename = "knora-api:isResourceProperty", default)]
339    is_resource_property: bool,
340}
341
342/// Inner object for `knora-api:objectType: {"@id": "…"}`.
343#[derive(serde::Deserialize, Clone)]
344struct ObjectTypeDto {
345    #[serde(rename = "@id")]
346    id: String,
347}
348
349/// Borrowed view of an `export_exists` conflict's details.
350///
351/// Private to `http.rs` — exposing it would leak wire vocabulary ("export",
352/// "projectIri") above the client layer, violating ADR-0001.
353struct ExportExists<'a> {
354    /// `errors[].details.id`, if present.
355    id: Option<&'a str>,
356    /// `errors[].details.projectIri`, if present.
357    project_iri: Option<&'a str>,
358}
359
360impl V3ErrorBody {
361    /// Extract the `export_exists` conflict details, if present.
362    ///
363    /// Returns `None` if no error with `code == "export_exists"` is present.
364    /// The returned `ExportExists` fields are each `Option` — callers are
365    /// responsible for fail-closed handling of absent `id` or `projectIri`.
366    fn export_exists(&self) -> Option<ExportExists<'_>> {
367        self.errors
368            .iter()
369            .find(|e| e.code == "export_exists")
370            .map(|e| ExportExists {
371                id: e.details.get("id").map(String::as_str),
372                project_iri: e.details.get("projectIri").map(String::as_str),
373            })
374    }
375}
376
377impl DataTaskStatusApiResponse {
378    /// Translate the API wire response to a [`DumpTask`].
379    ///
380    /// Status string mapping:
381    /// - `"in_progress"` → [`DumpStatus::InProgress`]
382    /// - `"completed"`   → [`DumpStatus::Completed`]
383    /// - `"failed"`      → [`DumpStatus::Failed`]
384    /// - anything else   → `ServerError` (unknown status from the server)
385    ///
386    /// **Single truncation point**: `error_message` is capped to ≤500 chars
387    /// here before being stored in `DumpTask.error_message`. The Step 8 poll-loop
388    /// `Failed` branch relies on this invariant — do not bypass this method when
389    /// constructing `DumpTask` values from server responses.
390    fn into_dump_task(self) -> Result<DumpTask, Diagnostic> {
391        // Validate the server-supplied id as it enters the domain model, so an
392        // invalid id never travels into a DumpTask (defence in depth — the
393        // URL-building sites also validate before embedding it).
394        validate_dump_id(&self.id)?;
395
396        let status = match self.status.as_str() {
397            "in_progress" => DumpStatus::InProgress,
398            "completed" => DumpStatus::Completed,
399            "failed" => DumpStatus::Failed,
400            other => {
401                return Err(Diagnostic::ServerError(format!(
402                    "server returned unknown dump status: '{other}'"
403                )));
404            }
405        };
406
407        // Truncate error_message to ≤500 chars. Log the TRUNCATED value at
408        // TRACE — raw is server-controlled and may be very large; bounding the
409        // trace output keeps TRACE logs predictable.
410        let error_message = self.error_message.map(|raw| {
411            let truncated = if raw.chars().count() > 500 {
412                raw.chars().take(500).collect::<String>()
413            } else {
414                raw
415            };
416            tracing::trace!("dump task error_message (truncated): {}", truncated);
417            truncated
418        });
419
420        // Parse created_at best-effort: a present-but-malformed timestamp must
421        // NOT fail the whole parse — created_at is display-only, never load-bearing.
422        let created_at = self.created_at.and_then(|s| {
423            match chrono::DateTime::parse_from_rfc3339(&s) {
424                Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
425                Err(_) => {
426                    tracing::debug!(raw = %s, "dump task createdAt could not be parsed as RFC3339; using None");
427                    None
428                }
429            }
430        });
431
432        Ok(DumpTask {
433            id: self.id,
434            status,
435            error_message,
436            created_at,
437        })
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Private helpers
443// ---------------------------------------------------------------------------
444
445/// DSP-API's `POST /v2/authentication` discriminates the user identifier by
446/// which JSON key is present. dsp-cli takes one `--user` value and infers the
447/// key: an `http(s)://` prefix → user IRI; an `@` → email; otherwise username.
448fn identifier_key(user: &str) -> &'static str {
449    if user.starts_with("http://") || user.starts_with("https://") {
450        "iri"
451    } else if user.contains('@') {
452        "email"
453    } else {
454        "username"
455    }
456}
457
458/// Classifies a `project` string so `resolve_project` can build the right URL.
459///
460/// Priority:
461/// 1. Starts with `http://` or `https://` → `Iri`.
462/// 2. Matches `^[0-9A-Fa-f]{4}$` exactly → `Shortcode`.
463/// 3. Anything else → `Shortname`.
464///
465/// **Overlap note**: a 4-hex-letter string (e.g. `beef`) is classified as
466/// `Shortcode` even though it could theoretically be a shortname. This is
467/// intentional and documented in the plan (risks §6).
468enum ProjectIdent<'a> {
469    Iri(&'a str),
470    Shortcode(&'a str),
471    Shortname(&'a str),
472}
473
474fn classify(project: &str) -> ProjectIdent<'_> {
475    if project.starts_with("http://") || project.starts_with("https://") {
476        ProjectIdent::Iri(project)
477    } else if project.len() == 4 && project.chars().all(|c| c.is_ascii_hexdigit()) {
478        ProjectIdent::Shortcode(project)
479    } else {
480        ProjectIdent::Shortname(project)
481    }
482}
483
484/// Percent-encode an IRI for safe insertion as a single URL path segment.
485///
486/// Uses `NON_ALPHANUMERIC` — encodes every character that is not `[A-Za-z0-9]`,
487/// including `/`, `:`, `?`, `#`, `[`, `]`, `@`, and sub-delimiters. Over-encoding
488/// the unreserved chars (`-._~`) is harmless; the Tapir server decodes the segment.
489fn enc(iri: &str) -> String {
490    utf8_percent_encode(iri, NON_ALPHANUMERIC).to_string()
491}
492
493/// Maps an unexpected HTTP status to a `Diagnostic`.
494///
495/// Reused by `resolve_project`, the ontology reads (`fetch_allentities` and the
496/// data-model / resource-type endpoints), and the dump methods to keep
497/// unexpected-status handling DRY. `401`/`403` map to `AuthRequired` (exit 3,
498/// ADR-0012) with a re-authenticate hint — the common case is a cached token
499/// that has expired (surfaced by `dsp auth status`), which previously fell
500/// through to a bare "unexpected status 401" runtime error. Endpoints needing a
501/// tailored auth message (e.g. the dump commands' "system-administrator token"
502/// wording) keep their own explicit `401 | 403` arm and never reach this
503/// fallback for those statuses; the `login` method retains fully inline handling.
504fn map_unexpected_status(status: reqwest::StatusCode, url: &str) -> Diagnostic {
505    if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
506        // ADR-0007: treat 401 and 403 alike. A read refused here is usually a
507        // missing or expired cached token, so point the user at re-authentication
508        // rather than emitting a bare runtime error.
509        Diagnostic::AuthRequired(
510            "your token may be missing, expired, or lack permission — run \
511             `dsp auth login` to (re)authenticate"
512                .into(),
513        )
514    } else if status.is_server_error() {
515        Diagnostic::ServerError(format!("server returned {status} for {url}"))
516    } else {
517        Diagnostic::ServerError(format!("unexpected status {status} for {url}"))
518    }
519}
520
521/// Validate that a `dump_id` returned by the server is URL-safe.
522///
523/// DSP-API returns dump IDs in URL-safe base64 form (`[A-Za-z0-9_-]+`). Before
524/// inserting a dump_id verbatim into a URL path segment we verify it matches
525/// this shape — a malformed id from a rogue or buggy server must not silently
526/// corrupt the URL. Percent-encoding is intentionally NOT applied (that would
527/// mangle valid `-`/`_` characters).
528fn validate_dump_id(id: &str) -> Result<(), Diagnostic> {
529    if id.is_empty()
530        || id.len() > 256 // equivalent to char count: the validated charset is ASCII-only ([A-Za-z0-9_-])
531        || !id
532            .chars()
533            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
534    {
535        // Cap the displayed portion to avoid leaking a huge server-controlled string.
536        let preview: String = id.chars().take(40).collect();
537        let suffix = if id.chars().count() > 40 { "…" } else { "" };
538        return Err(Diagnostic::ServerError(format!(
539            "server returned an invalid dump id: '{preview}{suffix}'"
540        )));
541    }
542    Ok(())
543}
544
545/// Build the URL for a single-project lookup by identifier.
546///
547/// Returns the URL string only — the conditional-bearer logic and the
548/// `is_safe_shortcode` guard stay in their respective callers.
549/// Used by both `resolve_project` and `describe_project`.
550fn project_lookup_url(base: &str, project: &str) -> String {
551    match classify(project) {
552        ProjectIdent::Shortcode(code) => {
553            format!("{base}/admin/projects/shortcode/{code}")
554        }
555        ProjectIdent::Shortname(name) => {
556            format!("{base}/admin/projects/shortname/{name}")
557        }
558        ProjectIdent::Iri(iri) => {
559            format!("{base}/admin/projects/iri/{}", enc(iri))
560        }
561    }
562}
563
564/// Check whether a project shortcode is a safe filename component.
565///
566/// DSP shortcodes are 4 hex digits (e.g. `0001`, `ABCD`). This function is
567/// generous — it accepts any non-empty ASCII-alphanumeric string up to 32 chars
568/// — so it admits the real shortcode space without being fragile. It MUST reject
569/// any value containing `/`, `\`, `..`, or an absolute path prefix, because
570/// `default_output_path` builds a file path from the shortcode via
571/// `PathBuf::join`. A leading `/` or `..` component would escape the intended
572/// directory.
573fn is_safe_shortcode(s: &str) -> bool {
574    !s.is_empty() && s.len() <= 32 && s.chars().all(|c| c.is_ascii_alphanumeric())
575}
576
577/// Strip an IRI/CURIE to its local name (after the last `#`, `/`, or `:`).
578///
579/// rsplit always yields at least one element (even on `""`), so `unwrap_or` is
580/// a no-panic guard rather than a live fallback.
581fn local_name(id: &str) -> &str {
582    id.rsplit(['#', '/', ':']).next().unwrap_or(id)
583}
584
585/// Resolve a class `@id` into `(resource-type name, full IRI)`.
586///
587/// - `name` = the local part: the segment after the last `#`, `/`, or `:`.
588/// - `iri`  = CURIE `prefix:local` expanded via `@context` (when `local` is not
589///   `//…`, i.e. not a scheme separator, and `prefix` is a known context prefix);
590///   otherwise the `@id` verbatim (covers full IRIs, unknown prefixes, no-colon).
591///
592/// Total — no `unwrap`/`panic`. One code path handles CURIE, full-IRI, and
593/// degenerate input without a dedicated `contains("://")` branch (the fallback
594/// naturally covers full IRIs).
595fn expand_class_id(id: &str, prefixes: &HashMap<String, String>) -> (String, String) {
596    let name = local_name(id).to_string();
597    let iri = match id.split_once(':') {
598        Some((prefix, local)) if !local.starts_with("//") => prefixes
599            .get(prefix)
600            .map(|ns| format!("{ns}{local}"))
601            .unwrap_or_else(|| id.to_string()),
602        _ => id.to_string(), // scheme://… , no colon, or unknown prefix
603    };
604    (name, iri)
605}
606
607/// Derive a data-model name from its DSP-API ontology IRI.
608///
609/// `http://…/ontology/0801/beol/v2` → `beol`. Robust to a trailing slash and a
610/// missing `/v2` suffix. Empty input → empty name (server contract trusted; an
611/// empty ontology IRI degrades silently to an empty name, treated as benign).
612///
613/// The `rsplit('/').next()` branch always yields `Some` — `rsplit` on `""` yields
614/// one empty element — so the `unwrap_or` is structurally unreachable. It is kept
615/// as a no-panic guard.
616///
617/// `pub(crate)` so `builtins.rs` can call it to assert name↔IRI consistency.
618pub(crate) fn data_model_name_from_iri(iri: &str) -> String {
619    let t = iri.trim_end_matches('/');
620    let t = t.strip_suffix("/v2").unwrap_or(t);
621    t.rsplit('/').next().unwrap_or(t).to_string()
622}
623
624// ---------------------------------------------------------------------------
625// describe_resource_type helpers
626// ---------------------------------------------------------------------------
627
628/// System namespace prefixes (Decision 3). A field whose property CURIE prefix
629/// is in this set is a built-in field (hidden by default; revealed with
630/// `--include-builtins`). Project fields from a sibling data-model have a
631/// project-specific prefix and are NOT in this set, so they show by default.
632const SYSTEM_PREFIXES: &[&str] = &[
633    "knora-api",
634    "knora-base",
635    "rdf",
636    "rdfs",
637    "owl",
638    "salsah-gui",
639    "standoff",
640    "xsd",
641];
642
643/// `knora-api` file-value property local names that signal the representation kind.
644///
645/// Matched against `owl:onProperty @id` local names in restrictions. The first
646/// match deterministically selects the representation (Decision 5, R8).
647const FILE_VALUE_PROPS: &[(&str, Representation)] = &[
648    ("hasStillImageFileValue", Representation::StillImage),
649    ("hasMovingImageFileValue", Representation::MovingImage),
650    ("hasAudioFileValue", Representation::Audio),
651    ("hasDocumentFileValue", Representation::Document),
652    ("hasArchiveFileValue", Representation::Archive),
653    ("hasTextFileValue", Representation::Text),
654];
655
656/// Maximum number of distinct sibling ontologies to fetch (Decision 9 / R5).
657/// Defends against a pathological or hostile `@context` with many prefixes.
658const MAX_SIBLING_FETCHES: usize = 16;
659
660/// Check whether a CURIE prefix belongs to a system (built-in) namespace.
661///
662/// Returns `true` iff `prefix` is in [`SYSTEM_PREFIXES`]. Used to decide
663/// `is_builtin` for a field (Decision 3) and to skip system fields from the
664/// sibling-fetch loop (R5).
665fn is_system_prefix(prefix: &str) -> bool {
666    SYSTEM_PREFIXES.contains(&prefix)
667}
668
669/// Map a DSP-API `objectType` local name to a [`ValueType`].
670///
671/// Named variants for all 16 known types; `Other(kebab)` for anything else
672/// (e.g. `GeomValue`, `IntervalValue`, `TextFileValue`). The `Other` string is
673/// derived by: strip trailing `Value` (if present), kebab-case by inserting `-`
674/// only before an uppercase letter that follows a lowercase (so runs of
675/// consecutive uppercase stay together), then lowercase the whole.
676///
677/// Examples: `TextValue`→`text`, `URIValue`→`uri`, `GeoNameValue`→`geo-name`,
678/// `GeomValue`→`geom`. Display-only robustness.
679fn map_object_type_to_value_type(local: &str) -> ValueType {
680    match local {
681        "TextValue" => ValueType::Text,
682        "IntValue" => ValueType::Integer,
683        "DecimalValue" => ValueType::Decimal,
684        "BooleanValue" => ValueType::Boolean,
685        "DateValue" => ValueType::Date,
686        "TimeValue" => ValueType::Time,
687        "UriValue" => ValueType::Uri,
688        "ColorValue" => ValueType::Color,
689        "GeonameValue" => ValueType::Geoname,
690        "ListValue" => ValueType::ListItem,
691        "StillImageFileValue" => ValueType::StillImage,
692        "MovingImageFileValue" => ValueType::MovingImage,
693        "AudioFileValue" => ValueType::Audio,
694        "DocumentFileValue" => ValueType::Document,
695        "ArchiveFileValue" => ValueType::Archive,
696        other => ValueType::Other(object_type_to_kebab(other)),
697    }
698}
699
700/// Convert an objectType local name to a kebab-cased string for `Other`.
701///
702/// Algorithm: strip trailing `Value` suffix (if present), then kebab-case
703/// by inserting `-` only before an uppercase letter that follows a lowercase
704/// (consecutive-uppercase runs stay together — so `URIValue`→`uri`, not
705/// `u-r-i`). Then lowercase the whole. See the plan's CamelCase splitter rule.
706fn object_type_to_kebab(local: &str) -> String {
707    // Strip trailing `Value` suffix if present.
708    let base = local.strip_suffix("Value").unwrap_or(local);
709
710    // Insert `-` before an uppercase letter that follows a lowercase letter.
711    // Consecutive uppercase sequences (e.g. "URI") are NOT split.
712    let mut result = String::with_capacity(base.len() + 4);
713    let chars: Vec<char> = base.chars().collect();
714    for (i, &ch) in chars.iter().enumerate() {
715        if i > 0 && ch.is_uppercase() {
716            // Insert dash only when the immediately preceding char is lowercase.
717            if chars[i - 1].is_lowercase() {
718                result.push('-');
719            }
720        }
721        result.push(ch);
722    }
723    result.to_lowercase()
724}
725
726/// Decode an `owl:Restriction` element's cardinality fields into a [`Cardinality`].
727///
728/// Decision 1 / the decode table: DSP-API only ever emits `owl:cardinality`=1,
729/// `owl:minCardinality`∈{0,1}, or `owl:maxCardinality`=1. Any other shape
730/// (absent key, value>1) degrades to `ZeroOrMore` with a `tracing::warn!`.
731fn decode_cardinality(restriction: &serde_json::Value) -> Cardinality {
732    // Helper to read an integer from a serde_json::Value.
733    let as_u64 =
734        |key: &str| -> Option<u64> { restriction.get(key).and_then(serde_json::Value::as_u64) };
735
736    if let Some(v) = as_u64("owl:cardinality") {
737        if v == 1 {
738            return Cardinality::One;
739        }
740        tracing::warn!(
741            value = v,
742            "owl:cardinality had unexpected value (expected 1); falling back to ZeroOrMore"
743        );
744        return Cardinality::ZeroOrMore;
745    }
746
747    if let Some(v) = as_u64("owl:maxCardinality") {
748        if v == 1 {
749            return Cardinality::ZeroOrOne;
750        }
751        tracing::warn!(
752            value = v,
753            "owl:maxCardinality had unexpected value (expected 1); falling back to ZeroOrMore"
754        );
755        return Cardinality::ZeroOrMore;
756    }
757
758    if let Some(v) = as_u64("owl:minCardinality") {
759        return match v {
760            0 => Cardinality::ZeroOrMore,
761            1 => Cardinality::OneOrMore,
762            other => {
763                tracing::warn!(
764                    value = other,
765                    "owl:minCardinality had unexpected value (expected 0 or 1); falling back to ZeroOrMore"
766                );
767                Cardinality::ZeroOrMore
768            }
769        };
770    }
771
772    tracing::warn!("owl:Restriction has no recognized cardinality key; falling back to ZeroOrMore");
773    Cardinality::ZeroOrMore
774}
775
776/// Detect the representation kind from the set of restriction `onProperty` local names.
777///
778/// Matches against [`FILE_VALUE_PROPS`] in order; returns the first hit.
779/// Must be called BEFORE filtering built-in fields (the file-value props are
780/// `knora-api:` prefixed → `is_builtin = true` → filtered in default mode).
781fn detect_representation(restriction_prop_locals: &[&str]) -> Option<Representation> {
782    for local in restriction_prop_locals {
783        for (file_val_local, repr) in FILE_VALUE_PROPS {
784            if local == file_val_local {
785                return Some(*repr);
786            }
787        }
788    }
789    None
790}
791
792/// Extract the CURIE prefix from an `@id` string (the part before the first `:`
793/// that is not followed by `//`). Returns `None` for full IRIs or bare names.
794fn curie_prefix(id: &str) -> Option<&str> {
795    id.split_once(':')
796        .filter(|(_, local)| !local.starts_with("//"))
797        .map(|(prefix, _)| prefix)
798}
799
800// ---------------------------------------------------------------------------
801// Resource list DTOs (boundary translation — ADR-0001)
802// ---------------------------------------------------------------------------
803
804/// Top-level DTO for `GET /v2/resources` responses.
805///
806/// The endpoint returns three structural forms of JSON-LD:
807///
808/// - **Many results**: `{ "@graph": [ { "@id": "…", "@type": "…", … }, … ], "knora-api:mayHaveMoreResults": … }`
809/// - **Single result**: `{ "@id": "…", "@type": "…", … }` — no `@graph`, but `@id` IS present
810/// - **Empty result**: `{}` — no `@graph`, no `@id`
811///
812/// The distinction between single and empty is carried by `@id` presence (rev: D3 R3).
813/// `graph` handles the many case; the single-node fields (`id`, `type_field`, etc.)
814/// handle the one case; absence of both signals empty.
815#[derive(serde::Deserialize)]
816struct ResourceListDto {
817    /// Present for the "many" case: an array of resource nodes.
818    #[serde(rename = "@graph", default)]
819    graph: Option<Vec<ResourceNodeDto>>,
820
821    /// Present for the "single" case (and absent for empty/many).
822    #[serde(rename = "@id", default)]
823    id: Option<String>,
824
825    /// `@type` for the single-node case. May be a bare string IRI or an array;
826    /// typed as `Value` to match the `node_dto_to_summary` signature uniformly.
827    #[serde(rename = "@type", default)]
828    type_field: Option<serde_json::Value>,
829
830    /// `rdfs:label` for the single-node case.
831    #[serde(rename = "rdfs:label", default)]
832    label: Option<serde_json::Value>,
833
834    /// `knora-api:arkUrl` for the single-node case.
835    #[serde(rename = "knora-api:arkUrl", default)]
836    ark_url: Option<serde_json::Value>,
837
838    /// `knora-api:creationDate` for the single-node case.
839    #[serde(rename = "knora-api:creationDate", default)]
840    creation_date: Option<serde_json::Value>,
841
842    /// `knora-api:lastModificationDate` for the single-node case.
843    #[serde(rename = "knora-api:lastModificationDate", default)]
844    last_modification_date: Option<serde_json::Value>,
845
846    /// `knora-api:mayHaveMoreResults` top-level boolean (default false per D5).
847    #[serde(rename = "knora-api:mayHaveMoreResults", default)]
848    may_have_more_results: bool,
849}
850
851/// One node from a resource-list `@graph` array.
852///
853/// Only the envelope fields the list projection needs are modelled; `serde`
854/// ignores the rich value content (ADR-0001 — no DSP-API vocab above the
855/// client boundary). All fields except `id` default, so a node without a
856/// type or label degrades gracefully.
857#[derive(serde::Deserialize)]
858struct ResourceNodeDto {
859    #[serde(rename = "@id")]
860    id: String,
861
862    /// `@type` is an array on the wire; we take the first element.
863    #[serde(rename = "@type", default)]
864    type_field: Option<serde_json::Value>,
865
866    /// `rdfs:label` can be a string or a language-tagged object.
867    #[serde(rename = "rdfs:label", default)]
868    label: Option<serde_json::Value>,
869
870    /// `knora-api:arkUrl` — can be a string or an object with `@value`.
871    #[serde(rename = "knora-api:arkUrl", default)]
872    ark_url: Option<serde_json::Value>,
873
874    /// `knora-api:creationDate` — can be a string or an object with `@value`.
875    #[serde(rename = "knora-api:creationDate", default)]
876    creation_date: Option<serde_json::Value>,
877
878    /// `knora-api:lastModificationDate` — same shape as `knora-api:creationDate`.
879    #[serde(rename = "knora-api:lastModificationDate", default)]
880    last_modification_date: Option<serde_json::Value>,
881}
882
883/// Extract a plain string from a JSON-LD value that may be a bare string,
884/// a language-tagged `{"@value":"…"}` object, or an `{"@id":"…"}` object.
885///
886/// Returns `None` on anything that cannot be mapped to a string.
887fn extract_string_value(v: &serde_json::Value) -> Option<String> {
888    match v {
889        serde_json::Value::String(s) => Some(s.clone()),
890        serde_json::Value::Object(map) => map
891            .get("@value")
892            .or_else(|| map.get("@id"))
893            .and_then(|inner| inner.as_str())
894            .map(str::to_owned),
895        _ => None,
896    }
897}
898
899/// Extract the resource-type name from a `@type` field value.
900///
901/// `@type` may be:
902/// - a bare string IRI/CURIE → take the local name
903/// - a JSON array → take the first element's local name
904/// - absent → sentinel `"unknown"` (fallback, not an error)
905fn extract_resource_type(type_val: Option<&serde_json::Value>) -> String {
906    match type_val {
907        None => "unknown".to_string(),
908        Some(serde_json::Value::String(s)) => local_name(s).to_string(),
909        Some(serde_json::Value::Array(arr)) => arr
910            .first()
911            .and_then(|v| v.as_str())
912            .map(|s| local_name(s).to_string())
913            .unwrap_or_else(|| "unknown".to_string()),
914        _ => "unknown".to_string(),
915    }
916}
917
918/// Translate a `ResourceNodeDto` into a `ResourceSummary`.
919fn node_dto_to_summary(
920    id: String,
921    type_val: Option<&serde_json::Value>,
922    label_val: Option<&serde_json::Value>,
923    ark_val: Option<&serde_json::Value>,
924    creation_val: Option<&serde_json::Value>,
925    last_modification_val: Option<&serde_json::Value>,
926) -> ResourceSummary {
927    let label = label_val.and_then(extract_string_value).unwrap_or_default();
928    let resource_type = extract_resource_type(type_val);
929    let ark_url = ark_val.and_then(extract_string_value);
930    // NOTE (D4, verified live on `dev` 2026-06-17): the command now uses
931    // `schema=complex`, which carries both `knora-api:creationDate` and
932    // `knora-api:lastModificationDate` (live-verified against incunabula on
933    // `dev` 2026-06-17 — `creation_date` now populates). Both are still
934    // `Option` because `lastModificationDate` is server-side optional (a
935    // resource that has never been modified has none). The simple-vs-complex
936    // user-facing terminology is ADR-0013 (Phase 8c) scope.
937    let creation_date = creation_val.and_then(extract_string_value);
938    let last_modified = last_modification_val.and_then(extract_string_value);
939    ResourceSummary {
940        label,
941        iri: id,
942        ark_url,
943        creation_date,
944        last_modified,
945        resource_type,
946    }
947}
948
949/// Wire DTO for a single-resource `GET /v2/resources/{iri}?schema=complex` response.
950///
951/// Named envelope fields are parsed directly; the `@context` is captured for CURIE
952/// expansion; and the `extra` catch-all captures all remaining keys (field values)
953/// for the `with_values` path (ADR-0001 boundary translation).
954///
955/// Wire-key to domain-field mapping (boundary translation — ADR-0001):
956/// - `@id` → `iri`
957/// - `@type` → `resource_type` (via `extract_resource_type`)
958/// - `rdfs:label` → `label` (via `extract_string_value`)
959/// - `knora-api:arkUrl` → `ark_url`
960/// - `knora-api:creationDate` → `creation_date`
961/// - `knora-api:lastModificationDate` → `last_modified`
962/// - `knora-api:attachedToProject` → `attached_project`
963/// - `knora-api:attachedToUser` → `owner`
964/// - `knora-api:hasPermissions` → raw ACL string → `derive_visibility`
965/// - `knora-api:userHasPermission` → raw permission code → `derive_access`
966/// - `@context` → prefix map for CURIE expansion (with_values path)
967/// - all other keys (field values) → `extra` (with_values path)
968#[derive(serde::Deserialize)]
969struct ResourceDetailDto {
970    #[serde(rename = "@id")]
971    id: String,
972
973    /// `@type` is an array on the wire (same as `ResourceNodeDto.type_field`).
974    #[serde(rename = "@type", default)]
975    type_field: Option<serde_json::Value>,
976
977    /// `rdfs:label` can be a string or a language-tagged object.
978    #[serde(rename = "rdfs:label", default)]
979    label: Option<serde_json::Value>,
980
981    /// `knora-api:arkUrl` — string or `{"@value": "…"}` object.
982    #[serde(rename = "knora-api:arkUrl", default)]
983    ark_url: Option<serde_json::Value>,
984
985    /// `knora-api:creationDate` — string or `{"@value": "…"}` object.
986    #[serde(rename = "knora-api:creationDate", default)]
987    creation_date: Option<serde_json::Value>,
988
989    /// `knora-api:lastModificationDate` — same shape as `creationDate`.
990    #[serde(rename = "knora-api:lastModificationDate", default)]
991    last_modification_date: Option<serde_json::Value>,
992
993    /// `knora-api:attachedToProject` — IRI of the project; `{"@id": "…"}` on the wire.
994    #[serde(rename = "knora-api:attachedToProject", default)]
995    attached_to_project: Option<serde_json::Value>,
996
997    /// `knora-api:attachedToUser` — IRI of the user; `{"@id": "…"}` on the wire.
998    #[serde(rename = "knora-api:attachedToUser", default)]
999    attached_to_user: Option<serde_json::Value>,
1000
1001    /// `knora-api:hasPermissions` — bare JSON string on the wire (the full ACL).
1002    /// Not a value object; typed directly as `Option<String>`.
1003    #[serde(rename = "knora-api:hasPermissions", default)]
1004    has_permissions: Option<String>,
1005
1006    /// `knora-api:userHasPermission` — bare JSON string on the wire (the caller's
1007    /// effective permission code). Not a value object; typed directly as `Option<String>`.
1008    #[serde(rename = "knora-api:userHasPermission", default)]
1009    user_has_permission: Option<String>,
1010
1011    /// JSON-LD `@context` — captured as a `Value` so we can extract the string-valued
1012    /// prefix→namespace entries for CURIE expansion (with_values path).
1013    ///
1014    /// **Must be a named field** — NOT in `extra`. If it fell into the flat catch-all
1015    /// we'd lose the context prefix map and CURIE expansion would silently fail, leaving
1016    /// all field labels unresolved.
1017    #[serde(rename = "@context", default)]
1018    context: Option<serde_json::Value>,
1019
1020    /// Catch-all for every key not captured above — primarily field-value entries
1021    /// (e.g. `incunabula:hasPagenum`, `knora-api:hasStillImageFileValue`) plus any
1022    /// other server keys we don't model explicitly. Preserved in insertion order by
1023    /// the `preserve_order` serde_json feature (deterministic field order = server order).
1024    ///
1025    /// Named `extra` so the intent is clear at every use-site; the `#[serde(flatten)]`
1026    /// means it absorbs ALL remaining keys after the above fields are matched.
1027    #[serde(flatten)]
1028    extra: serde_json::Map<String, serde_json::Value>,
1029}
1030
1031/// Map a DSP permission code to a numeric rank.
1032///
1033/// Order is per `Permission.scala` in dsp-api:
1034/// `RV`(1) < `V`(2) < `M`(6) < `D`(7) < `CR`(8).
1035/// Unknown codes rank 0 — treated as below `RV`, i.e. no grant.
1036fn permission_rank(code: &str) -> u8 {
1037    match code {
1038        "RV" => 1,
1039        "V" => 2,
1040        "M" => 6,
1041        "D" => 7,
1042        "CR" => 8,
1043        _ => 0,
1044    }
1045}
1046
1047/// Derive the caller's access level from the `userHasPermission` code.
1048///
1049/// The translation table (D1, Facet B):
1050/// - `RV` → `RestrictedView`
1051/// - `V` → `View`
1052/// - `M` → `Edit`
1053/// - `D` → `Delete`
1054/// - `CR` → `Manage`
1055/// - absent / unknown → `None`
1056fn derive_access(user_has_permission: &str) -> Option<ResourceAccess> {
1057    match user_has_permission {
1058        "RV" => Some(ResourceAccess::RestrictedView),
1059        "V" => Some(ResourceAccess::View),
1060        "M" => Some(ResourceAccess::Edit),
1061        "D" => Some(ResourceAccess::Delete),
1062        "CR" => Some(ResourceAccess::Manage),
1063        _ => None,
1064    }
1065}
1066
1067/// Derive the resource's visibility from the `hasPermissions` ACL string.
1068///
1069/// Implements the D1 ACL parse algorithm (see implementation plan):
1070/// 1. Split on `'|'` into entries; for each, `split_once(' ')` → `(code, group_list)`.
1071/// 2. Split `group_list` on `','`; take each group's local name and match **exactly**
1072///    against `"UnknownUser"` / `"KnownUser"` — never `.contains()`.
1073/// 3. Track the max `permission_rank(code)` seen for each world group across all entries.
1074/// 4. Apply the D1 visibility table.
1075/// 5. Empty or whitespace-only ACL → `None`.
1076fn derive_visibility(has_permissions: &str) -> Option<ResourceVisibility> {
1077    if has_permissions.trim().is_empty() {
1078        return None;
1079    }
1080
1081    let mut unknown_rank: u8 = 0;
1082    let mut known_rank: u8 = 0;
1083    let mut parsed_any = false;
1084
1085    for entry in has_permissions.split('|') {
1086        let entry = entry.trim();
1087        if entry.is_empty() {
1088            continue;
1089        }
1090        // Each entry is "<CODE> <group>[,<group>…]"
1091        let Some((code, group_list)) = entry.split_once(' ') else {
1092            // Malformed entry — skip.
1093            continue;
1094        };
1095        parsed_any = true;
1096        let rank = permission_rank(code);
1097        for group in group_list.split(',') {
1098            let group_local = local_name(group.trim());
1099            if group_local == "UnknownUser" {
1100                unknown_rank = unknown_rank.max(rank);
1101            } else if group_local == "KnownUser" {
1102                known_rank = known_rank.max(rank);
1103            }
1104        }
1105    }
1106
1107    if !parsed_any {
1108        return None;
1109    }
1110
1111    // D1 table: UnknownUser decides first.
1112    // Hoist rank constants once to avoid repeated calls and make the ladder
1113    // stable against future changes to the permission_rank table.
1114    let v_rank = permission_rank("V");
1115    let rv_rank = permission_rank("RV");
1116
1117    if unknown_rank >= v_rank {
1118        Some(ResourceVisibility::Public)
1119    } else if unknown_rank >= rv_rank {
1120        // At this point unknown_rank < v_rank, so >= rv_rank means exactly RV.
1121        Some(ResourceVisibility::PublicRestricted)
1122    } else if known_rank >= rv_rank {
1123        Some(ResourceVisibility::LoggedInUsers)
1124    } else {
1125        Some(ResourceVisibility::ProjectMembers)
1126    }
1127}
1128
1129// ---------------------------------------------------------------------------
1130// HttpDspClient
1131// ---------------------------------------------------------------------------
1132
1133/// Real HTTP implementation of `DspClient`, backed by `reqwest::blocking`.
1134pub struct HttpDspClient {
1135    /// Default HTTP client: 10 s connect timeout + 30 s overall timeout.
1136    /// Used by login, resolve_project, create/get/delete dump methods.
1137    client: reqwest::blocking::Client,
1138    /// Download-specific HTTP client: 30 s connect timeout, **no overall timeout**.
1139    /// A bagit-zip archive can be very large; an overall read timeout would kill the
1140    /// download mid-stream. The connect timeout is retained so a hung server is
1141    /// still detected at connection time.
1142    download_client: reqwest::blocking::Client,
1143}
1144
1145impl HttpDspClient {
1146    /// Construct a new client pair with appropriate timeouts.
1147    ///
1148    /// - `client`: 10 s connect + 30 s overall (used for all short-lived requests).
1149    /// - `download_client`: 30 s connect, **no overall timeout** (used only for
1150    ///   streaming dump archives — they can be large).
1151    ///
1152    /// Returns `Err(Diagnostic::Internal(...))` if either reqwest client cannot be
1153    /// built (rare — only triggered by TLS backend misconfiguration).
1154    pub fn new() -> Result<Self, Diagnostic> {
1155        let client = reqwest::blocking::Client::builder()
1156            .connect_timeout(Duration::from_secs(10))
1157            .timeout(Duration::from_secs(30))
1158            .build()
1159            .map_err(|e| Diagnostic::Internal(format!("failed to build HTTP client: {e}")))?;
1160        let download_client = reqwest::blocking::Client::builder()
1161            .connect_timeout(Some(Duration::from_secs(30)))
1162            .timeout(None)
1163            .build()
1164            .map_err(|e| {
1165                Diagnostic::Internal(format!("failed to build download HTTP client: {e}"))
1166            })?;
1167        Ok(Self {
1168            client,
1169            download_client,
1170        })
1171    }
1172
1173    /// Fetch `GET /v2/ontologies/allentities/{enc(ontology_iri)}` and deserialize.
1174    ///
1175    /// Shared by `describe_data_model` and `describe_resource_type` (which needs
1176    /// both the primary fetch and sibling fetches). Auth is optional; `token` is
1177    /// forwarded as a bearer when `Some`. NEVER log the token.
1178    ///
1179    /// SSRF note (R10): callers must only pass an `ontology_iri` derived from the
1180    /// user-supplied `--data-model` argument or from the queried ontology's own
1181    /// `@context`. The host is always the user-supplied `server`.
1182    fn fetch_allentities(
1183        &self,
1184        server: &str,
1185        ontology_iri: &str,
1186        token: Option<&str>,
1187    ) -> Result<OntologyAllEntitiesResponse, Diagnostic> {
1188        let url = format!(
1189            "{}/v2/ontologies/allentities/{}",
1190            server.trim_end_matches('/'),
1191            enc(ontology_iri)
1192        );
1193
1194        let req = self.client.get(&url);
1195        let req = if let Some(t) = token {
1196            req.bearer_auth(t)
1197        } else {
1198            req
1199        };
1200
1201        let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
1202        let status = response.status();
1203
1204        if status.is_success() {
1205            let resp: OntologyAllEntitiesResponse = response.json().map_err(|e| {
1206                Diagnostic::ServerError(format!("data-model response could not be parsed: {e}"))
1207            })?;
1208            Ok(resp)
1209        } else {
1210            Err(map_unexpected_status(status, &url))
1211        }
1212    }
1213}
1214
1215impl HttpDspClient {
1216    /// Parse field values from a complex-schema resource response.
1217    ///
1218    /// Called from `describe_resource` when `with_values == true`. Iterates the
1219    /// `extra` map, identifies field entries (those with a `knora-api:*Value`
1220    /// `@type`), parses each value object into a `ValueContent`, resolves field
1221    /// labels via project-ontology allentities fetches (deduped), and resolves
1222    /// list-node labels via `/v2/node` fetches (deduped).
1223    ///
1224    /// All label fetch failures degrade gracefully (local name / node IRI fallback)
1225    /// — they NEVER return `Err`. This is intentional: a label failure must not
1226    /// abort the describe.
1227    fn parse_resource_values(
1228        &self,
1229        server: &str,
1230        token: Option<&str>,
1231        context_val: &Option<serde_json::Value>,
1232        extra: &serde_json::Map<String, serde_json::Value>,
1233    ) -> Vec<FieldValues> {
1234        // ── 1. Build prefix → namespace map from @context ────────────────────────
1235        let prefixes: HashMap<String, String> = build_prefix_map(context_val);
1236
1237        // ── 2. Iterate extra, identify field entries ─────────────────────────────
1238        // Denylist: these keys carry value-class-typed objects but are NOT user fields.
1239        const DENYLIST: &[&str] = &[
1240            "knora-api:hasIncomingLinkValue",
1241            "knora-api:hasStandoffLinkToValue",
1242            "knora-api:hasStandoffLinkValue", // non-`To` standoff variant (some ontologies)
1243        ];
1244
1245        // Collect (key, Vec<value_obj>) for each field.  An entry may be a single
1246        // value object or an array of value objects.
1247        let mut field_entries: Vec<(&str, Vec<&serde_json::Value>)> = Vec::new();
1248
1249        for (key, val) in extra.iter() {
1250            if DENYLIST.contains(&key.as_str()) {
1251                continue;
1252            }
1253
1254            // Gather the value object(s) for this key.
1255            let objs: Vec<&serde_json::Value> = match val {
1256                serde_json::Value::Array(arr) => arr.iter().collect(),
1257                obj @ serde_json::Value::Object(_) => vec![obj],
1258                _ => continue, // scalar — not a value field
1259            };
1260
1261            if objs.is_empty() {
1262                continue;
1263            }
1264
1265            // A key is a field iff every non-null value object has a knora-api *Value @type.
1266            // We check only the first one for efficiency (homogeneous arrays).
1267            let first = match objs.first() {
1268                Some(v) => v,
1269                None => continue,
1270            };
1271            if !has_value_class_type(first) {
1272                continue;
1273            }
1274
1275            field_entries.push((key.as_str(), objs));
1276        }
1277
1278        // ── 3. Parse each value object into ValueContent ─────────────────────────
1279        // We also record which field keys are link-typed for name derivation (D3).
1280        struct ParsedField<'a> {
1281            key: &'a str,
1282            is_link: bool,
1283            values: Vec<Value>,
1284        }
1285
1286        let mut parsed_fields: Vec<ParsedField> = Vec::new();
1287
1288        for (key, objs) in &field_entries {
1289            let mut contents: Vec<Value> = Vec::new();
1290            let mut any_link = false;
1291
1292            for obj in objs {
1293                // Skip DeletedValue objects.
1294                if get_type_local(obj) == "DeletedValue" {
1295                    continue;
1296                }
1297                let (content, is_link) = parse_value(obj);
1298                if is_link {
1299                    any_link = true;
1300                }
1301                contents.push(content);
1302            }
1303
1304            if contents.is_empty() {
1305                continue;
1306            }
1307
1308            parsed_fields.push(ParsedField {
1309                key,
1310                is_link: any_link,
1311                values: contents,
1312            });
1313        }
1314
1315        // ── 4. Resolve field labels (project ontologies only, deduped) ───────────
1316        // Collect distinct project ontology IRIs for fields that need labels.
1317        // knora-api built-ins skip the fetch → label = None.
1318        let mut ontology_labels: HashMap<String, HashMap<String, String>> = HashMap::new(); // ont_iri → (prop_iri → label)
1319        let mut fetched_ontologies: HashSet<String> = HashSet::new();
1320
1321        for pf in &parsed_fields {
1322            let prefix = curie_prefix(pf.key).unwrap_or("");
1323            if is_system_prefix(prefix) || prefix.is_empty() {
1324                continue; // built-in or unknown prefix → skip fetch
1325            }
1326            // Expand the CURIE to an ontology IRI (namespace without fragment).
1327            let namespace = match prefixes.get(prefix) {
1328                Some(ns) => ns,
1329                None => continue,
1330            };
1331            let ont_iri = namespace.trim_end_matches(['#', '/']).to_string();
1332            if fetched_ontologies.insert(ont_iri.clone()) {
1333                // SSRF-safe: the host is always the user-supplied `server`; the ontology
1334                // IRI is an enc()-encoded path segment (NON_ALPHANUMERIC) and cannot
1335                // escape the segment or alter the host.
1336                match self.fetch_allentities(server, &ont_iri, token) {
1337                    Ok(resp) => {
1338                        let mut prop_map: HashMap<String, String> = HashMap::new();
1339                        let ctx_prefixes: HashMap<String, String> = resp
1340                            .context
1341                            .iter()
1342                            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1343                            .collect();
1344                        for entity in resp.graph {
1345                            if let Some(lbl) = entity.label {
1346                                let (_, iri) = expand_class_id(&entity.id, &ctx_prefixes);
1347                                prop_map.insert(iri, lbl);
1348                            }
1349                        }
1350                        ontology_labels.insert(ont_iri, prop_map);
1351                    }
1352                    Err(e) => {
1353                        // Non-fatal: warn and continue; affected fields degrade to local name.
1354                        tracing::warn!(
1355                            prefix = %prefix,
1356                            error = %e,
1357                            "field-label ontology fetch failed; using local name as fallback"
1358                        );
1359                    }
1360                }
1361            }
1362        }
1363
1364        // ── 5. Resolve list-node labels (deduped) ────────────────────────────────
1365        let mut node_labels: HashMap<String, Option<String>> = HashMap::new();
1366
1367        // Collect distinct node IRIs.
1368        for pf in &parsed_fields {
1369            for v in &pf.values {
1370                if let ValueContent::ListItem { node_iri, .. } = &v.content {
1371                    node_labels.entry(node_iri.clone()).or_insert(None);
1372                }
1373            }
1374        }
1375
1376        // Fetch each node once.
1377        for (node_iri, label_slot) in node_labels.iter_mut() {
1378            // SSRF-safe: the host is always the user-supplied `server`; the node IRI
1379            // is an enc()-encoded path segment (NON_ALPHANUMERIC) and cannot escape
1380            // the segment or alter the host.
1381            let url = format!("{}/v2/node/{}", server.trim_end_matches('/'), enc(node_iri));
1382            let req = self.client.get(&url);
1383            let req = if let Some(t) = token {
1384                req.bearer_auth(t)
1385            } else {
1386                req
1387            };
1388            match req.send() {
1389                Ok(resp) if resp.status().is_success() => {
1390                    // Degrade to None on parse failure (consistent with sibling tracing arms).
1391                    match resp.json::<serde_json::Value>() {
1392                        Ok(body) => {
1393                            // `rdfs:label` may be a bare string or a language-tagged object.
1394                            let lbl = body.get("rdfs:label").and_then(extract_string_value);
1395                            *label_slot = lbl;
1396                        }
1397                        Err(_) => {
1398                            tracing::debug!(
1399                                node_iri = %node_iri,
1400                                "list-node label response could not be parsed as JSON; using node IRI as fallback"
1401                            );
1402                        }
1403                    }
1404                }
1405                Ok(resp) => {
1406                    // Non-2xx: degrade to node IRI.
1407                    tracing::debug!(
1408                        node_iri = %node_iri,
1409                        status = %resp.status(),
1410                        "list-node label fetch returned non-success; using node IRI as fallback"
1411                    );
1412                }
1413                Err(e) => {
1414                    tracing::debug!(
1415                        node_iri = %node_iri,
1416                        error = %e,
1417                        "list-node label fetch failed; using node IRI as fallback"
1418                    );
1419                }
1420            }
1421        }
1422
1423        // ── 6. Build Vec<FieldValues>, fold labels, preserve server order ─────────
1424        let mut result: Vec<FieldValues> = Vec::new();
1425
1426        for pf in parsed_fields {
1427            // Derive field name (D3): strip `Value` suffix on link-typed fields only.
1428            let raw_name = local_name(pf.key).to_string();
1429            let name = if pf.is_link {
1430                raw_name
1431                    .strip_suffix("Value")
1432                    .unwrap_or(&raw_name)
1433                    .to_string()
1434            } else {
1435                raw_name
1436            };
1437
1438            // Resolve field label from ontology fetch.
1439            let label: Option<String> = {
1440                let prefix = curie_prefix(pf.key).unwrap_or("");
1441                if is_system_prefix(prefix) || prefix.is_empty() {
1442                    None
1443                } else if let Some(ns) = prefixes.get(prefix) {
1444                    let ont_iri = ns.trim_end_matches(['#', '/']).to_string();
1445                    let local = local_name(pf.key);
1446                    let prop_iri = format!("{}{}", ns, local);
1447                    ontology_labels
1448                        .get(&ont_iri)
1449                        .and_then(|m| m.get(&prop_iri).cloned())
1450                } else {
1451                    None
1452                }
1453            };
1454
1455            // Fold list-node labels into the ListItem values.
1456            let values: Vec<Value> = pf
1457                .values
1458                .into_iter()
1459                .map(|v| match v.content {
1460                    ValueContent::ListItem { node_iri, label: _ } => {
1461                        let resolved = node_labels.get(&node_iri).cloned().flatten();
1462                        Value {
1463                            content: ValueContent::ListItem {
1464                                node_iri,
1465                                label: resolved,
1466                            },
1467                            comment: v.comment,
1468                        }
1469                    }
1470                    other => Value {
1471                        content: other,
1472                        comment: v.comment,
1473                    },
1474                })
1475                .collect();
1476
1477            result.push(FieldValues {
1478                name,
1479                label,
1480                values,
1481            });
1482        }
1483
1484        result
1485    }
1486}
1487
1488// ---------------------------------------------------------------------------
1489// Value parsing helpers (pure — no HTTP; tested directly in unit tests)
1490// ---------------------------------------------------------------------------
1491
1492/// Return true iff `val` is a JSON object whose `@type` is a `knora-api:*Value`
1493/// (i.e. its local name ends with `Value` and the prefix is `knora-api`).
1494/// This is the key discriminant for "is this a value field?" (ADR-0013).
1495fn has_value_class_type(val: &serde_json::Value) -> bool {
1496    let type_local = get_type_local(val);
1497    // Must end with "Value" and not be a bare non-CURIE literal.
1498    // Additionally, the @type must come from the knora-api namespace.
1499    type_local.ends_with("Value") && !type_local.is_empty() && {
1500        // Verify the @type is actually `knora-api:*Value`, not e.g. `xsd:anyURI`.
1501        let raw_type = val
1502            .as_object()
1503            .and_then(|m| m.get("@type"))
1504            .and_then(|t| t.as_str())
1505            .unwrap_or("");
1506        raw_type.starts_with("knora-api:")
1507    }
1508}
1509
1510/// Extract the local name of a value object's `@type`.
1511///
1512/// Returns `""` if absent or not a string.
1513fn get_type_local(val: &serde_json::Value) -> &str {
1514    val.as_object()
1515        .and_then(|m| m.get("@type"))
1516        .and_then(|t| t.as_str())
1517        .map(local_name)
1518        .unwrap_or("")
1519}
1520
1521/// Build a `HashMap<String, String>` prefix→namespace map from a JSON-LD `@context` Value.
1522///
1523/// Only string-valued entries are included (object-valued term definitions are
1524/// skipped, mirroring the pattern in `describe_data_model`). A missing or
1525/// non-object context yields an empty map (graceful degradation).
1526fn build_prefix_map(context_val: &Option<serde_json::Value>) -> HashMap<String, String> {
1527    match context_val {
1528        Some(serde_json::Value::Object(map)) => map
1529            .iter()
1530            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1531            .collect(),
1532        _ => HashMap::new(),
1533    }
1534}
1535
1536/// Parse a single value object into `(ValueContent, is_link_type)`.
1537///
1538/// Pure function — no HTTP, no `self`. This is the content-only parser (no
1539/// per-value comment); [`parse_value`] wraps it to additionally produce a
1540/// [`Value`]. All parse failures degrade to `Raw`. The `is_link_type` flag is
1541/// used by the caller for field-name derivation (D3).
1542fn parse_value_content(obj: &serde_json::Value) -> (ValueContent, bool) {
1543    let type_local = get_type_local(obj);
1544
1545    match type_local {
1546        // ── TextValue ────────────────────────────────────────────────────────────
1547        "TextValue" => {
1548            // Presence-based detection (Risk 7 / ADR-0013): if textValueAsXml present
1549            // → formatted (standoff); else valueAsString.
1550            let content =
1551                if let Some(xml) = obj.get("knora-api:textValueAsXml").and_then(|v| v.as_str()) {
1552                    crate::util::text::html_to_text(xml)
1553                } else {
1554                    obj.get("knora-api:valueAsString")
1555                        .and_then(|v| v.as_str())
1556                        .unwrap_or("")
1557                        .to_string()
1558                };
1559            (ValueContent::Text(content), false)
1560        }
1561
1562        // ── IntValue ─────────────────────────────────────────────────────────────
1563        "IntValue" => {
1564            let n = obj
1565                .get("knora-api:intValueAsInt")
1566                .and_then(|v| v.as_i64())
1567                .unwrap_or(0);
1568            (ValueContent::Integer(n), false)
1569        }
1570
1571        // ── DecimalValue ─────────────────────────────────────────────────────────
1572        "DecimalValue" => {
1573            // `decimalValueAsDecimal` is a typed literal: `{"@value": "3.14", "@type": "xsd:decimal"}`.
1574            let s = obj
1575                .get("knora-api:decimalValueAsDecimal")
1576                .and_then(|v| {
1577                    // May be a bare string or a {"@value":…} object.
1578                    if let Some(s) = v.as_str() {
1579                        Some(s.to_string())
1580                    } else {
1581                        v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1582                    }
1583                })
1584                .unwrap_or_default();
1585            (ValueContent::Decimal(s), false)
1586        }
1587
1588        // ── BooleanValue ─────────────────────────────────────────────────────────
1589        "BooleanValue" => {
1590            let b = obj
1591                .get("knora-api:booleanValueAsBoolean")
1592                .and_then(|v| v.as_bool())
1593                .unwrap_or(false);
1594            (ValueContent::Boolean(b), false)
1595        }
1596
1597        // ── DateValue ────────────────────────────────────────────────────────────
1598        "DateValue" => {
1599            let calendar = obj
1600                .get("knora-api:dateValueHasCalendar")
1601                .and_then(|v| v.as_str())
1602                .unwrap_or("GREGORIAN")
1603                .to_string();
1604
1605            let parse_point = |prefix: &str| -> DatePoint {
1606                let year_key = format!("knora-api:{prefix}Year");
1607                let month_key = format!("knora-api:{prefix}Month");
1608                let day_key = format!("knora-api:{prefix}Day");
1609                let era_key = format!("knora-api:{prefix}Era");
1610
1611                DatePoint {
1612                    year: obj
1613                        .get(year_key.as_str())
1614                        .and_then(|v| v.as_i64())
1615                        .map(|v| v as i32),
1616                    month: obj
1617                        .get(month_key.as_str())
1618                        .and_then(|v| v.as_u64())
1619                        .map(|v| v as u32),
1620                    day: obj
1621                        .get(day_key.as_str())
1622                        .and_then(|v| v.as_u64())
1623                        .map(|v| v as u32),
1624                    era: obj
1625                        .get(era_key.as_str())
1626                        .and_then(|v| v.as_str())
1627                        .map(str::to_owned),
1628                }
1629            };
1630
1631            // Check for all required fields: if year is missing on both points, fall
1632            // back to Raw rather than produce a meaningless date.
1633            let start = parse_point("dateValueHasStart");
1634            let end = parse_point("dateValueHasEnd");
1635
1636            if start.year.is_none() && end.year.is_none() {
1637                // Degenerate date with no year info — use raw fallback.
1638                let raw_text = obj
1639                    .get("knora-api:valueAsString")
1640                    .and_then(|v| v.as_str())
1641                    .unwrap_or("")
1642                    .to_string();
1643                return (
1644                    ValueContent::Raw {
1645                        value_type: "date".to_string(),
1646                        text: raw_text,
1647                    },
1648                    false,
1649                );
1650            }
1651
1652            (
1653                ValueContent::Date(DateValue {
1654                    calendar,
1655                    start,
1656                    end,
1657                }),
1658                false,
1659            )
1660        }
1661
1662        // ── TimeValue ────────────────────────────────────────────────────────────
1663        "TimeValue" => {
1664            let s = obj
1665                .get("knora-api:timeValueAsTimeStamp")
1666                .and_then(|v| {
1667                    if let Some(s) = v.as_str() {
1668                        Some(s.to_string())
1669                    } else {
1670                        v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1671                    }
1672                })
1673                .unwrap_or_default();
1674            (ValueContent::Time(s), false)
1675        }
1676
1677        // ── UriValue ─────────────────────────────────────────────────────────────
1678        "UriValue" => {
1679            let s = obj
1680                .get("knora-api:uriValueAsUri")
1681                .and_then(|v| {
1682                    if let Some(s) = v.as_str() {
1683                        Some(s.to_string())
1684                    } else {
1685                        v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1686                    }
1687                })
1688                .unwrap_or_default();
1689            (ValueContent::Uri(s), false)
1690        }
1691
1692        // ── ColorValue ───────────────────────────────────────────────────────────
1693        "ColorValue" => {
1694            let s = obj
1695                .get("knora-api:colorValueAsColor")
1696                .and_then(|v| v.as_str())
1697                .unwrap_or("")
1698                .to_string();
1699            (ValueContent::Color(s), false)
1700        }
1701
1702        // ── GeonameValue ─────────────────────────────────────────────────────────
1703        "GeonameValue" => {
1704            let s = obj
1705                .get("knora-api:geonameValueAsGeonameCode")
1706                .and_then(|v| v.as_str())
1707                .unwrap_or("")
1708                .to_string();
1709            (ValueContent::Geoname(s), false)
1710        }
1711
1712        // ── ListValue ────────────────────────────────────────────────────────────
1713        "ListValue" => {
1714            // `listValueAsListNode` → `{"@id": "…"}`.
1715            let node_iri = obj
1716                .get("knora-api:listValueAsListNode")
1717                .and_then(|v| v.get("@id"))
1718                .and_then(|v| v.as_str())
1719                .unwrap_or("")
1720                .to_string();
1721            (
1722                ValueContent::ListItem {
1723                    node_iri,
1724                    label: None, // resolved later by the caller
1725                },
1726                false,
1727            )
1728        }
1729
1730        // ── LinkValue ────────────────────────────────────────────────────────────
1731        "LinkValue" => {
1732            // Prefer embedded `linkValueHasTarget` (complex schema). Fall back to
1733            // `linkValueHasTargetIri.@id` when only the IRI is available.
1734            let (target_iri, target_label) =
1735                if let Some(target_obj) = obj.get("knora-api:linkValueHasTarget") {
1736                    let iri = target_obj
1737                        .get("@id")
1738                        .and_then(|v| v.as_str())
1739                        .unwrap_or("")
1740                        .to_string();
1741                    let lbl = target_obj.get("rdfs:label").and_then(extract_string_value);
1742                    (iri, lbl)
1743                } else {
1744                    let iri = obj
1745                        .get("knora-api:linkValueHasTargetIri")
1746                        .and_then(|v| v.get("@id"))
1747                        .and_then(|v| v.as_str())
1748                        .unwrap_or("")
1749                        .to_string();
1750                    (iri, None)
1751                };
1752            (
1753                ValueContent::Link {
1754                    target_iri,
1755                    target_label,
1756                },
1757                true, // this IS a link
1758            )
1759        }
1760
1761        // ── File values ──────────────────────────────────────────────────────────
1762        // Match the whole *FileValue family by leading kind (ADR-0013).
1763        t if t.ends_with("FileValue") => {
1764            let filename = obj
1765                .get("knora-api:fileValueHasFilename")
1766                .and_then(|v| v.as_str())
1767                .unwrap_or("")
1768                .to_string();
1769            let url_str = obj
1770                .get("knora-api:fileValueAsUrl")
1771                .and_then(|v| {
1772                    if let Some(s) = v.as_str() {
1773                        Some(s.to_string())
1774                    } else {
1775                        v.get("@value").and_then(|i| i.as_str()).map(str::to_owned)
1776                    }
1777                })
1778                .unwrap_or_default();
1779
1780            // Map leading kind to ValueType.
1781            let value_type_opt = if t.starts_with("StillImage") {
1782                Some(ValueType::StillImage)
1783            } else if t.starts_with("MovingImage") {
1784                Some(ValueType::MovingImage)
1785            } else if t.starts_with("Audio") {
1786                Some(ValueType::Audio)
1787            } else if t.starts_with("Document") || t.starts_with("Text") {
1788                // TextFileValue → document (ADR-0013)
1789                Some(ValueType::Document)
1790            } else if t.starts_with("Archive") {
1791                Some(ValueType::Archive)
1792            } else {
1793                None // unrecognised *FileValue → raw
1794            };
1795
1796            match value_type_opt {
1797                Some(vt) => {
1798                    // Still-image: additionally read dimensions.
1799                    let (width, height) = if vt == ValueType::StillImage {
1800                        let w = obj
1801                            .get("knora-api:stillImageFileValueHasDimX")
1802                            .and_then(|v| v.as_u64())
1803                            .map(|v| v as u32);
1804                        let h = obj
1805                            .get("knora-api:stillImageFileValueHasDimY")
1806                            .and_then(|v| v.as_u64())
1807                            .map(|v| v as u32);
1808                        (w, h)
1809                    } else {
1810                        (None, None)
1811                    };
1812                    (
1813                        ValueContent::File(FileValue {
1814                            value_type: vt,
1815                            filename,
1816                            url: url_str,
1817                            width,
1818                            height,
1819                        }),
1820                        false,
1821                    )
1822                }
1823                None => {
1824                    // Unrecognised *FileValue → raw fallback.
1825                    let raw_text = obj
1826                        .get("knora-api:valueAsString")
1827                        .and_then(|v| v.as_str())
1828                        .unwrap_or(&filename)
1829                        .to_string();
1830                    (
1831                        ValueContent::Raw {
1832                            value_type: object_type_to_kebab(t),
1833                            text: raw_text,
1834                        },
1835                        false,
1836                    )
1837                }
1838            }
1839        }
1840
1841        // ── Long-tail: any other *Value (IntervalValue, GeomValue, …) ────────────
1842        other => {
1843            let value_type = object_type_to_kebab(other);
1844            // Best-effort text: valueAsString if present; else compact JSON of the
1845            // value object minus standard metadata keys.
1846            let raw_text = obj
1847                .get("knora-api:valueAsString")
1848                .and_then(|v| v.as_str())
1849                .map(str::to_owned)
1850                .unwrap_or_else(|| compact_value_text(obj));
1851            (
1852                ValueContent::Raw {
1853                    value_type,
1854                    text: raw_text,
1855                },
1856                false,
1857            )
1858        }
1859    }
1860}
1861
1862/// Parse a single value object into `(Value, is_link_type)`.
1863///
1864/// Wraps [`parse_value_content`] and additionally reads the optional
1865/// per-value comment from the sibling `knora-api:valueHasComment` key.
1866/// Pure function — no HTTP, no `self`.
1867fn parse_value(obj: &serde_json::Value) -> (Value, bool) {
1868    let (content, is_link) = parse_value_content(obj);
1869    let comment = obj
1870        .get("knora-api:valueHasComment")
1871        .and_then(|v| v.as_str())
1872        .filter(|s| !s.trim().is_empty())
1873        .map(str::to_owned);
1874    (Value { content, comment }, is_link)
1875}
1876
1877/// Standard value-object metadata keys to omit when building the raw fallback text.
1878const VALUE_META_KEYS: &[&str] = &[
1879    "@id",
1880    "@type",
1881    "knora-api:attachedToUser",
1882    "knora-api:hasPermissions",
1883    "knora-api:userHasPermission",
1884    "knora-api:valueCreationDate",
1885    "knora-api:valueHasComment",
1886    "knora-api:isDeleted",
1887    "knora-api:arkUrl",
1888    "knora-api:versionArkUrl",
1889    "knora-api:valueHasUUID",
1890];
1891
1892/// Build a compact JSON representation of a value object for the `Raw` fallback.
1893///
1894/// Strips standard metadata keys and returns the compact JSON of what remains.
1895/// If nothing remains (all fields were metadata), returns an empty string.
1896fn compact_value_text(obj: &serde_json::Value) -> String {
1897    if let Some(map) = obj.as_object() {
1898        let filtered: serde_json::Map<String, serde_json::Value> = map
1899            .iter()
1900            .filter(|(k, _)| !VALUE_META_KEYS.contains(&k.as_str()))
1901            .map(|(k, v)| (k.clone(), v.clone()))
1902            .collect();
1903        if filtered.is_empty() {
1904            String::new()
1905        } else {
1906            serde_json::to_string(&serde_json::Value::Object(filtered)).unwrap_or_default()
1907        }
1908    } else {
1909        String::new()
1910    }
1911}
1912
1913impl DspClient for HttpDspClient {
1914    fn login(&self, server: &str, user: &str, password: &str) -> Result<LoginResponse, Diagnostic> {
1915        let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
1916
1917        let mut body = serde_json::Map::with_capacity(2);
1918        body.insert(
1919            identifier_key(user).to_owned(),
1920            serde_json::Value::from(user),
1921        );
1922        body.insert("password".to_owned(), serde_json::Value::from(password));
1923
1924        let response = self
1925            .client
1926            .post(&url)
1927            .json(&body)
1928            .send()
1929            .map_err(|e| Diagnostic::Network(e.to_string()))?;
1930
1931        let status = response.status();
1932
1933        if status.is_success() {
1934            let api: LoginApiResponse = response.json().map_err(|e| {
1935                Diagnostic::ServerError(format!("login response could not be parsed: {e}"))
1936            })?;
1937            let expires_at = extract_exp(&api.token);
1938            Ok(LoginResponse {
1939                token: api.token,
1940                user: user.to_string(),
1941                expires_at,
1942            })
1943        } else if status == reqwest::StatusCode::UNAUTHORIZED
1944            || status == reqwest::StatusCode::FORBIDDEN
1945        {
1946            let body = response.text().unwrap_or_default();
1947            let preview: String = body.chars().take(200).collect();
1948            tracing::trace!("auth failure response body (capped): {}", preview);
1949            // Username MUST NOT appear in the error message (ADR-0007 / PRD AC 7).
1950            Err(Diagnostic::AuthRequired(format!(
1951                "Authentication failed on {server}"
1952            )))
1953        } else if status == reqwest::StatusCode::NOT_FOUND {
1954            Err(Diagnostic::NotFound(format!(
1955                "endpoint not found at {url}; check that --server resolves to a DSP-API instance, not just any HTTPS host"
1956            )))
1957        } else if status.is_server_error() {
1958            let body = response.text().unwrap_or_default();
1959            let preview: String = body.chars().take(200).collect();
1960            tracing::trace!("server error response body (capped): {}", preview);
1961            Err(Diagnostic::ServerError(format!("server returned {status}")))
1962        } else {
1963            Err(Diagnostic::ServerError(format!(
1964                "unexpected status: {status}"
1965            )))
1966        }
1967    }
1968
1969    fn resolve_project(&self, server: &str, project: &str) -> Result<ProjectRef, Diagnostic> {
1970        let base = server.trim_end_matches('/');
1971
1972        let url = project_lookup_url(base, project);
1973
1974        // Project lookup endpoints are public — no Authorization header.
1975        let response = self
1976            .client
1977            .get(&url)
1978            .send()
1979            .map_err(|e| Diagnostic::Network(e.to_string()))?;
1980
1981        let status = response.status();
1982
1983        if status.is_success() {
1984            let api: ProjectGetApiResponse = response.json().map_err(|e| {
1985                Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
1986            })?;
1987            if !is_safe_shortcode(&api.project.shortcode) {
1988                return Err(Diagnostic::ServerError(
1989                    "server returned a project with an unexpected shortcode".into(),
1990                ));
1991            }
1992            Ok(ProjectRef {
1993                iri: api.project.id,
1994                shortcode: api.project.shortcode,
1995                shortname: api.project.shortname,
1996            })
1997        } else if status == reqwest::StatusCode::NOT_FOUND {
1998            // Cap a long IRI input at ~80 chars for readability.
1999            let display_input: String = project.chars().take(80).collect();
2000            let suffix = if project.chars().count() > 80 {
2001                "…"
2002            } else {
2003                ""
2004            };
2005            Err(Diagnostic::NotFound(format!(
2006                "project '{display_input}{suffix}' not found on {server}"
2007            )))
2008        } else {
2009            Err(map_unexpected_status(status, &url))
2010        }
2011    }
2012
2013    fn create_project_dump(
2014        &self,
2015        server: &str,
2016        project_iri: &str,
2017        skip_assets: bool,
2018        token: &str,
2019    ) -> Result<CreateDumpOutcome, Diagnostic> {
2020        let base = server.trim_end_matches('/');
2021        // DSP-API calls this resource an "export" — the CLI calls it a "dump".
2022        // The word "export" is confined to this URL and http.rs internals only;
2023        // the trait and all layers above use "dump" exclusively (ADR-0001).
2024        // skipAssets is a query parameter: ?skipAssets=true|false
2025        let url = format!(
2026            "{base}/v3/projects/{}/exports?skipAssets={skip_assets}",
2027            enc(project_iri)
2028        );
2029
2030        let response = self
2031            .client
2032            .post(&url)
2033            .bearer_auth(token)
2034            .send()
2035            .map_err(|e: reqwest::Error| Diagnostic::Network(e.to_string()))?;
2036
2037        let status = response.status();
2038
2039        match status.as_u16() {
2040            202 => {
2041                let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2042                    Diagnostic::ServerError(format!(
2043                        "dump trigger response could not be parsed: {e}"
2044                    ))
2045                })?;
2046                api.into_dump_task().map(CreateDumpOutcome::Created)
2047            }
2048            409 => {
2049                // Parse the conflict body to determine same- vs. cross-project conflict.
2050                // A 409 with code=="export_exists" carries the occupying dump's id and
2051                // projectIri. Compare that IRI to the requested project_iri to decide
2052                // whether to return Exists (same project) or ExistsForOtherProject (different).
2053                //
2054                // Guard against serde-parsing an absurdly large conflict body
2055                // (the body is already buffered by `text()`; reqwest's request
2056                // timeout bounds the wire read). The `<= 65536` guard only
2057                // avoids serde-parsing an oversized string.
2058                let body_text = response.text().unwrap_or_default();
2059                let error_body: Option<V3ErrorBody> = if body_text.len() <= 65536 {
2060                    serde_json::from_str(&body_text).ok()
2061                } else {
2062                    None
2063                };
2064                match error_body.as_ref().and_then(|b| b.export_exists()) {
2065                    Some(ex) => {
2066                        // Distinct message from the outer `None` — here the export-exists error
2067                        // item WAS present but lacked an id (vs. no parseable item at all).
2068                        let id = ex.id.ok_or_else(|| {
2069                            Diagnostic::ServerError(
2070                                "the server's dump-conflict response was missing the dump id"
2071                                    .into(),
2072                            )
2073                        })?;
2074                        validate_dump_id(id)?;
2075                        // Both IRIs originate from the same DSP-API instance (the request IRI is
2076                        // ProjectRef::iri, parsed from a prior server response; the body IRI is
2077                        // the server's own), so a direct string compare is sound — they are
2078                        // canonical and identically formed. No normalization needed. If the CLI
2079                        // ever accepts raw user IRIs here, canonicalize at the input boundary.
2080                        match ex.project_iri {
2081                            Some(owner) if owner == project_iri => {
2082                                Ok(CreateDumpOutcome::Exists { id: id.to_string() })
2083                            }
2084                            Some(owner) => Ok(CreateDumpOutcome::ExistsForOtherProject {
2085                                id: id.to_string(),
2086                                project_iri: owner.to_string(),
2087                            }),
2088                            // FAIL CLOSED — see Decision 1. The field is contractually always
2089                            // present; its absence is an unexpected response we will not guess on.
2090                            None => Err(Diagnostic::ServerError(
2091                                "the server's dump-conflict response did not identify which \
2092project owns the existing dump; cannot safely proceed"
2093                                    .into(),
2094                            )),
2095                        }
2096                    }
2097                    // No `export_exists` error item at all (unparseable / different conflict).
2098                    None => Err(Diagnostic::ServerError(
2099                        // ADR-0001: user-facing text — no DSP-API "export" vocabulary
2100                        "server reported a 409 conflict whose detail could not be parsed".into(),
2101                    )),
2102                }
2103            }
2104            401 | 403 => Err(Diagnostic::AuthRequired(
2105                "triggering a project dump requires a system-administrator token".into(),
2106            )),
2107            404 => Err(Diagnostic::NotFound(format!("project not found at {url}"))),
2108            _ => Err(map_unexpected_status(status, &url)),
2109        }
2110    }
2111
2112    fn get_project_dump_status(
2113        &self,
2114        server: &str,
2115        project_iri: &str,
2116        dump_id: &str,
2117        token: &str,
2118    ) -> Result<DumpTask, Diagnostic> {
2119        validate_dump_id(dump_id)?;
2120        let base = server.trim_end_matches('/');
2121        // dump_id is URL-safe base64 — inserted verbatim (no encoding).
2122        let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2123
2124        let response = self
2125            .client
2126            .get(&url)
2127            .bearer_auth(token)
2128            .send()
2129            .map_err(|e| Diagnostic::Network(e.to_string()))?;
2130
2131        let status = response.status();
2132
2133        match status.as_u16() {
2134            200 => {
2135                let api: DataTaskStatusApiResponse = response.json().map_err(|e| {
2136                    Diagnostic::ServerError(format!(
2137                        "dump status response could not be parsed: {e}"
2138                    ))
2139                })?;
2140                api.into_dump_task()
2141            }
2142            404 => Err(Diagnostic::NotFound(format!(
2143                "dump '{dump_id}' not found for project at {url}"
2144            ))),
2145            401 | 403 => Err(Diagnostic::AuthRequired(
2146                "fetching dump status requires a system-administrator token".into(),
2147            )),
2148            _ => Err(map_unexpected_status(status, &url)),
2149        }
2150    }
2151
2152    fn download_project_dump(
2153        &self,
2154        server: &str,
2155        project_iri: &str,
2156        dump_id: &str,
2157        token: &str,
2158        dest: &mut dyn Write,
2159    ) -> Result<u64, Diagnostic> {
2160        validate_dump_id(dump_id)?;
2161        let base = server.trim_end_matches('/');
2162        // dump_id is URL-safe base64 — inserted verbatim (no encoding).
2163        let url = format!(
2164            "{base}/v3/projects/{}/exports/{dump_id}/download",
2165            enc(project_iri)
2166        );
2167
2168        // Use download_client (no overall/read timeout) for potentially large archives.
2169        let mut response = self
2170            .download_client
2171            .get(&url)
2172            .bearer_auth(token)
2173            .send()
2174            .map_err(|e| Diagnostic::Network(e.to_string()))?;
2175
2176        let status = response.status();
2177
2178        // Check status BEFORE reading the body — avoid streaming a large error body.
2179        // Content-Disposition is intentionally NOT honoured: the action owns the filename.
2180        match status.as_u16() {
2181            200 => {
2182                // Manual buffered loop so read-side errors (network) and
2183                // write-side errors (disk full / dest failure) are classified
2184                // separately — io::copy would attribute both to the same error.
2185                let mut buf = [0u8; 64 * 1024];
2186                let mut total: u64 = 0;
2187                loop {
2188                    let n = response
2189                        .read(&mut buf)
2190                        .map_err(|e| Diagnostic::Network(format!("download interrupted: {e}")))?;
2191                    if n == 0 {
2192                        break;
2193                    }
2194                    dest.write_all(&buf[..n]).map_err(|e| {
2195                        Diagnostic::Io(format!("failed to write dump to disk: {e}"))
2196                    })?;
2197                    total += n as u64;
2198                }
2199                Ok(total)
2200            }
2201            409 => Err(Diagnostic::Conflict(
2202                "dump not ready — still in progress or failed".into(),
2203            )),
2204            404 => Err(Diagnostic::NotFound(format!(
2205                "dump '{dump_id}' not found at {url}"
2206            ))),
2207            401 | 403 => Err(Diagnostic::AuthRequired(
2208                "downloading a project dump requires a system-administrator token".into(),
2209            )),
2210            _ => Err(map_unexpected_status(status, &url)),
2211        }
2212    }
2213
2214    fn delete_project_dump(
2215        &self,
2216        server: &str,
2217        project_iri: &str,
2218        dump_id: &str,
2219        token: &str,
2220    ) -> Result<(), Diagnostic> {
2221        validate_dump_id(dump_id)?;
2222        let base = server.trim_end_matches('/');
2223        // dump_id is URL-safe base64 — inserted verbatim (no encoding).
2224        let url = format!("{base}/v3/projects/{}/exports/{dump_id}", enc(project_iri));
2225
2226        let response = self
2227            .client
2228            .delete(&url)
2229            .bearer_auth(token)
2230            .send()
2231            .map_err(|e| Diagnostic::Network(e.to_string()))?;
2232
2233        let status = response.status();
2234
2235        match status.as_u16() {
2236            204 => Ok(()),
2237            409 => Err(Diagnostic::Conflict(
2238                "dump is still in progress and cannot be deleted yet".into(),
2239            )),
2240            404 => Err(Diagnostic::NotFound(format!(
2241                "dump '{dump_id}' not found at {url}"
2242            ))),
2243            401 | 403 => Err(Diagnostic::AuthRequired(
2244                "deleting a project dump requires a system-administrator token".into(),
2245            )),
2246            _ => Err(map_unexpected_status(status, &url)),
2247        }
2248    }
2249
2250    fn list_projects(&self, server: &str, token: Option<&str>) -> Result<Vec<Project>, Diagnostic> {
2251        let base = server.trim_end_matches('/');
2252        let url = format!("{base}/admin/projects");
2253
2254        // Build the request: conditionally add Bearer auth ONLY when a token is
2255        // provided. When `token` is `None` the request is sent without any
2256        // Authorization header (public endpoint). Do NOT pass an empty/dummy
2257        // bearer — that would change request semantics vs. a truly unauthenticated
2258        // call.
2259        let req = self.client.get(&url);
2260        let req = if let Some(t) = token {
2261            req.bearer_auth(t)
2262        } else {
2263            req
2264        };
2265
2266        let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2267
2268        let status = response.status();
2269
2270        if status.is_success() {
2271            let api: ProjectsListApiResponse = response.json().map_err(|e| {
2272                Diagnostic::ServerError(format!("projects list response could not be parsed: {e}"))
2273            })?;
2274            let projects = api
2275                .projects
2276                .into_iter()
2277                .map(|dto| Project {
2278                    iri: dto.id,
2279                    shortcode: dto.shortcode,
2280                    shortname: dto.shortname,
2281                    longname: dto.longname,
2282                    // `status` bool → `ProjectStatus` enum: `true` = active, `false` = inactive.
2283                    // Confirmed from live data: active research projects have `status: true`;
2284                    // deprecated/test projects have `status: false`. See ADR-0001.
2285                    status: if dto.status {
2286                        ProjectStatus::Active
2287                    } else {
2288                        ProjectStatus::Inactive
2289                    },
2290                    // `ontologies` is the DSP-API wire name; `data_models` is the dsp-cli
2291                    // vocabulary (ADR-0001 boundary). The count is all we need here.
2292                    data_models: dto.ontologies.len(),
2293                })
2294                .collect();
2295            Ok(projects)
2296        } else {
2297            Err(map_unexpected_status(status, &url))
2298        }
2299    }
2300
2301    fn describe_project(
2302        &self,
2303        server: &str,
2304        project: &str,
2305        token: Option<&str>,
2306    ) -> Result<ProjectDetail, Diagnostic> {
2307        let base = server.trim_end_matches('/');
2308        let url = project_lookup_url(base, project);
2309
2310        // Build the request: conditionally add Bearer auth ONLY when a token is
2311        // provided. When `token` is `None` the request is sent without any
2312        // Authorization header (public endpoint). Mirrors `list_projects`.
2313        let req = self.client.get(&url);
2314        let req = if let Some(t) = token {
2315            req.bearer_auth(t)
2316        } else {
2317            req
2318        };
2319
2320        let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2321
2322        let status = response.status();
2323
2324        if status.is_success() {
2325            let api: ProjectDetailApiResponse = response.json().map_err(|e| {
2326                Diagnostic::ServerError(format!("project lookup response could not be parsed: {e}"))
2327            })?;
2328            let dto = api.project;
2329
2330            // Translate `status` bool → enum (true = Active, false = Inactive).
2331            let project_status = if dto.status {
2332                ProjectStatus::Active
2333            } else {
2334                ProjectStatus::Inactive
2335            };
2336
2337            // Translate description Vec, order preserved.
2338            let description = dto
2339                .description
2340                .into_iter()
2341                .map(|d| ProjectDescription {
2342                    value: d.value,
2343                    language: d.language,
2344                })
2345                .collect();
2346
2347            // Translate ontology IRIs → DataModelSummary, sorted by name ascending.
2348            let mut data_models: Vec<DataModelSummary> = dto
2349                .ontologies
2350                .into_iter()
2351                .map(|iri| {
2352                    let name = data_model_name_from_iri(&iri);
2353                    DataModelSummary { name, iri }
2354                })
2355                .collect();
2356            data_models.sort_by(|a, b| a.name.cmp(&b.name));
2357
2358            Ok(ProjectDetail {
2359                iri: dto.id,
2360                shortcode: dto.shortcode,
2361                shortname: dto.shortname,
2362                longname: dto.longname,
2363                status: project_status,
2364                description,
2365                keywords: dto.keywords,
2366                data_models,
2367            })
2368        } else if status == reqwest::StatusCode::NOT_FOUND {
2369            // Cap a long input at ~80 chars for readability, mirroring resolve_project.
2370            let display_input: String = project.chars().take(80).collect();
2371            let suffix = if project.chars().count() > 80 {
2372                "…"
2373            } else {
2374                ""
2375            };
2376            Err(Diagnostic::NotFound(format!(
2377                "project '{display_input}{suffix}' not found on {server}. Run `dsp vre project list --server {server}` to see available projects."
2378            )))
2379        } else {
2380            Err(map_unexpected_status(status, &url))
2381        }
2382    }
2383
2384    fn describe_data_model(
2385        &self,
2386        server: &str,
2387        data_model_iri: &str,
2388        token: Option<&str>,
2389    ) -> Result<DataModelDetail, Diagnostic> {
2390        let resp = self.fetch_allentities(server, data_model_iri, token)?;
2391
2392        // Build an owned prefix → namespace map BEFORE consuming the graph,
2393        // so nothing borrows `resp` across the `into_iter()` that moves it.
2394        // Object-valued context terms are silently skipped — intended (see Risk 9).
2395        let prefixes: HashMap<String, String> = resp
2396            .context
2397            .iter()
2398            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2399            .collect();
2400
2401        let mut resource_types: Vec<ResourceTypeSummary> = resp
2402            .graph
2403            .into_iter()
2404            .filter(|dto| dto.is_resource_class)
2405            .map(|dto| {
2406                let (name, iri) = expand_class_id(&dto.id, &prefixes);
2407                ResourceTypeSummary {
2408                    name,
2409                    iri,
2410                    label: dto.label,
2411                }
2412            })
2413            .collect();
2414
2415        resource_types.sort_by(|a, b| a.name.cmp(&b.name));
2416
2417        Ok(DataModelDetail {
2418            name: data_model_name_from_iri(&resp.id),
2419            iri: resp.id,
2420            label: resp.label,
2421            last_modified: resp.last_modification_date.map(|d| d.value),
2422            resource_types,
2423        })
2424    }
2425
2426    fn data_model_structure(
2427        &self,
2428        server: &str,
2429        data_model_iri: &str,
2430        token: Option<&str>,
2431    ) -> Result<DataModelStructure, Diagnostic> {
2432        // ── 1. Fetch allentities (single fetch — no sibling fetch in v1) ────────
2433        let resp = self.fetch_allentities(server, data_model_iri, token)?;
2434
2435        let graph_entities: Vec<OntologyEntityDto> = resp.graph;
2436
2437        // ── 2. Build property-node lookup ────────────────────────────────────────
2438        // Partition graph into resource classes and property nodes. Property nodes
2439        // carry objectType / isLinkProperty / isResourceProperty. Resource classes
2440        // carry is_resource_class. The two sets are used separately, so we split
2441        // once rather than cloning. (OntologyEntityDto does not derive Clone.)
2442        let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2443        let mut class_nodes: Vec<OntologyEntityDto> = Vec::new();
2444        for entity in graph_entities {
2445            if entity.is_resource_class {
2446                class_nodes.push(entity);
2447            } else if entity.object_type.is_some()
2448                || entity.is_link_property
2449                || entity.is_resource_property
2450            {
2451                prop_lookup.insert(entity.id.clone(), entity);
2452            }
2453        }
2454
2455        // ── 3. Collect relations ─────────────────────────────────────────────────
2456        let mut relations: Vec<Relation> = Vec::new();
2457
2458        for class in &class_nodes {
2459            let source = local_name(&class.id).to_string();
2460
2461            for element in &class.sub_class_of {
2462                if let Some(type_val) = element.get("@type")
2463                    && type_val.as_str() == Some("owl:Restriction")
2464                {
2465                    // ── Link edge ────────────────────────────────────────────────
2466                    let on_prop_id = match element
2467                        .get("owl:onProperty")
2468                        .and_then(|v| v.get("@id"))
2469                        .and_then(serde_json::Value::as_str)
2470                    {
2471                        Some(s) => s,
2472                        None => continue,
2473                    };
2474
2475                    // Look up the property node (may be absent for cross-DM props).
2476                    let node = match prop_lookup.get(on_prop_id) {
2477                        Some(n) => n,
2478                        None => continue, // v1 limitation: cross-DM prop absent → skip
2479                    };
2480
2481                    // Drop link-value reification twins (…Value properties).
2482                    if node.is_link_value_property {
2483                        continue;
2484                    }
2485
2486                    // Only link properties produce relation edges.
2487                    if !node.is_link_property {
2488                        continue;
2489                    }
2490
2491                    // Target: objectType @id local name.
2492                    let target_id = match node.object_type.as_ref() {
2493                        Some(ot) => &ot.id,
2494                        None => continue, // no target — skip
2495                    };
2496                    let target = local_name(target_id).to_string();
2497
2498                    let t_prefix = curie_prefix(target_id).unwrap_or("");
2499                    let target_data_model = if is_system_prefix(t_prefix) || t_prefix.is_empty() {
2500                        None
2501                    } else {
2502                        Some(t_prefix.to_string())
2503                    };
2504
2505                    // is_builtin for link: keyed off the FIELD's CURIE prefix.
2506                    let field_prefix = curie_prefix(on_prop_id).unwrap_or("");
2507                    let is_builtin = is_system_prefix(field_prefix);
2508
2509                    let field = local_name(on_prop_id).to_string();
2510
2511                    relations.push(Relation {
2512                        source: source.clone(),
2513                        target,
2514                        kind: RelationKind::Link,
2515                        field: Some(field),
2516                        target_data_model,
2517                        is_builtin,
2518                    });
2519                } else if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str)
2520                {
2521                    // ── Inherits edge ────────────────────────────────────────────
2522                    // Bare {"@id": "..."} entries are superclass refs (skip blank
2523                    // nodes / owl:Restriction entries which have @type, not @id at
2524                    // the top level here).
2525                    let target = local_name(id_val).to_string();
2526
2527                    let sup_prefix = curie_prefix(id_val).unwrap_or("");
2528                    let is_builtin = is_system_prefix(sup_prefix);
2529                    let target_data_model = if is_system_prefix(sup_prefix) || sup_prefix.is_empty()
2530                    {
2531                        None
2532                    } else {
2533                        Some(sup_prefix.to_string())
2534                    };
2535
2536                    relations.push(Relation {
2537                        source: source.clone(),
2538                        target,
2539                        kind: RelationKind::Inherits,
2540                        field: None,
2541                        target_data_model,
2542                        is_builtin,
2543                    });
2544                }
2545            }
2546        }
2547
2548        // ── 4. Sort by (source, kind, field, target) — D6 ───────────────────────
2549        // RelationKind derives Ord with Link < Inherits.
2550        // Option<String> sorts None < Some (standard Ord).
2551        relations.sort_by(|a, b| {
2552            a.source
2553                .cmp(&b.source)
2554                .then_with(|| a.kind.cmp(&b.kind))
2555                .then_with(|| a.field.cmp(&b.field))
2556                .then_with(|| a.target.cmp(&b.target))
2557        });
2558
2559        // ── 5. Build and return DataModelStructure ───────────────────────────────
2560        Ok(DataModelStructure {
2561            data_model: data_model_name_from_iri(data_model_iri),
2562            relations,
2563        })
2564    }
2565
2566    fn list_resources(
2567        &self,
2568        server: &str,
2569        project_iri: &str,
2570        resource_type_iri: &str,
2571        order_by: Option<&str>,
2572        page: u32,
2573        token: Option<&str>,
2574    ) -> Result<ResourcePage, Diagnostic> {
2575        let base = server.trim_end_matches('/');
2576        let url = format!("{base}/v2/resources");
2577
2578        // Build the request with query params via reqwest .query() — NEVER manual
2579        // string interpolation, which would not URL-encode the resource-type IRI safely.
2580        // The DSP-API wire parameter name is "resourceClass" (unchanged — stays here at
2581        // the client boundary, per ADR-0001 vocabulary divergence).
2582        let mut req = self.client.get(&url).query(&[
2583            ("resourceClass", resource_type_iri),
2584            ("page", &page.to_string()),
2585            ("schema", "complex"),
2586        ]);
2587        // `order_by` is the already-resolved complex-schema property IRI; pass verbatim.
2588        // reqwest .query() is additive and URL-encodes automatically.
2589        if let Some(prop_iri) = order_by {
2590            req = req.query(&[("orderByProperty", prop_iri)]);
2591        }
2592
2593        // Set x-knora-accept-project header via the fallible HeaderValue path.
2594        // An IRI containing CRLF or other invalid header bytes is a Usage error
2595        // (the caller supplied a bad IRI), not an Internal error. No unwrap.
2596        let header_value = reqwest::header::HeaderValue::from_str(project_iri).map_err(|e| {
2597            Diagnostic::Usage(format!("project IRI is not a valid HTTP header value: {e}"))
2598        })?;
2599        let req = req.header("x-knora-accept-project", header_value);
2600
2601        // Conditional bearer auth — mirrors list_projects.
2602        let req = if let Some(t) = token {
2603            req.bearer_auth(t)
2604        } else {
2605            req
2606        };
2607
2608        let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2609        let status = response.status();
2610
2611        if !status.is_success() {
2612            return Err(map_unexpected_status(status, &url));
2613        }
2614
2615        let dto: ResourceListDto = response.json().map_err(|e| {
2616            Diagnostic::ServerError(format!("resource list response could not be parsed: {e}"))
2617        })?;
2618
2619        let may_have_more_results = dto.may_have_more_results;
2620
2621        // Distinguish the three JSON-LD forms:
2622        // 1. @graph present → many results
2623        // 2. @id present (but no @graph) → single result
2624        // 3. neither → empty
2625        let resources: Vec<ResourceSummary> = if let Some(graph) = dto.graph {
2626            graph
2627                .into_iter()
2628                .map(|node| {
2629                    node_dto_to_summary(
2630                        node.id,
2631                        node.type_field.as_ref(),
2632                        node.label.as_ref(),
2633                        node.ark_url.as_ref(),
2634                        node.creation_date.as_ref(),
2635                        node.last_modification_date.as_ref(),
2636                    )
2637                })
2638                .collect()
2639        } else if let Some(id) = dto.id {
2640            // Single result: the top-level fields carry the single node's data.
2641            vec![node_dto_to_summary(
2642                id,
2643                dto.type_field.as_ref(),
2644                dto.label.as_ref(),
2645                dto.ark_url.as_ref(),
2646                dto.creation_date.as_ref(),
2647                dto.last_modification_date.as_ref(),
2648            )]
2649        } else {
2650            // Empty result.
2651            vec![]
2652        };
2653
2654        Ok(ResourcePage {
2655            resources,
2656            may_have_more_results,
2657        })
2658    }
2659
2660    fn describe_resource(
2661        &self,
2662        server: &str,
2663        resource_iri: &str,
2664        token: Option<&str>,
2665        with_values: bool,
2666    ) -> Result<ResourceDetail, Diagnostic> {
2667        let base = server.trim_end_matches('/');
2668        // D5: percent-encode the IRI for safe insertion as a single URL path segment.
2669        let url = format!("{base}/v2/resources/{}", enc(resource_iri));
2670
2671        // Build request with conditional bearer auth. NEVER log the token.
2672        let req = self.client.get(&url).query(&[("schema", "complex")]);
2673        let req = if let Some(t) = token {
2674            req.bearer_auth(t)
2675        } else {
2676            req
2677        };
2678
2679        let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2680        let status = response.status();
2681
2682        if status.is_success() {
2683            let dto: ResourceDetailDto = response.json().map_err(|e| {
2684                Diagnostic::ServerError(format!(
2685                    "resource describe response could not be parsed: {e}"
2686                ))
2687            })?;
2688
2689            // Boundary translation (ADR-0001): wire DTO → domain model.
2690            let label = dto
2691                .label
2692                .as_ref()
2693                .and_then(extract_string_value)
2694                .unwrap_or_default();
2695            let resource_type = extract_resource_type(dto.type_field.as_ref());
2696            let ark_url = dto.ark_url.as_ref().and_then(extract_string_value);
2697            let creation_date = dto.creation_date.as_ref().and_then(extract_string_value);
2698            let last_modified = dto
2699                .last_modification_date
2700                .as_ref()
2701                .and_then(extract_string_value);
2702            let attached_project = dto
2703                .attached_to_project
2704                .as_ref()
2705                .and_then(extract_string_value);
2706            let owner = dto.attached_to_user.as_ref().and_then(extract_string_value);
2707            let visibility = dto.has_permissions.as_deref().and_then(derive_visibility);
2708            let your_access = dto.user_has_permission.as_deref().and_then(derive_access);
2709
2710            // When with_values == false: exactly 8b behaviour — values = None, no extra fetches.
2711            let values = if with_values {
2712                Some(self.parse_resource_values(server, token, &dto.context, &dto.extra))
2713            } else {
2714                None
2715            };
2716
2717            Ok(ResourceDetail {
2718                label,
2719                iri: dto.id,
2720                resource_type,
2721                ark_url,
2722                creation_date,
2723                last_modified,
2724                attached_project,
2725                owner,
2726                visibility,
2727                your_access,
2728                values,
2729            })
2730        } else if status == reqwest::StatusCode::NOT_FOUND {
2731            // Cap the resource IRI at 80 chars for readability, mirroring resolve_project.
2732            let display_iri: String = resource_iri.chars().take(80).collect();
2733            let iri_suffix = if resource_iri.chars().count() > 80 {
2734                "…"
2735            } else {
2736                ""
2737            };
2738            Err(Diagnostic::NotFound(format!(
2739                "resource '{display_iri}{iri_suffix}' not found"
2740            )))
2741        } else if status == reqwest::StatusCode::UNAUTHORIZED
2742            || status == reqwest::StatusCode::FORBIDDEN
2743        {
2744            // Deliberate: an anonymous caller describing a private resource gets 403.
2745            // AuthRequired (exit 3 + login hint) is the right UX for an auth-optional read.
2746            // NEVER log the token — not in any Diagnostic or tracing call.
2747            let display_iri: String = resource_iri.chars().take(80).collect();
2748            let iri_suffix = if resource_iri.chars().count() > 80 {
2749                "…"
2750            } else {
2751                ""
2752            };
2753            Err(Diagnostic::AuthRequired(format!(
2754                "access denied for resource '{display_iri}{iri_suffix}' — log in to view this resource"
2755            )))
2756        } else {
2757            Err(map_unexpected_status(status, &url))
2758        }
2759    }
2760
2761    fn verify_token(&self, server: &str, token: &str) -> Result<(), Diagnostic> {
2762        let url = format!("{}/v2/authentication", server.trim_end_matches('/'));
2763
2764        let response = self
2765            .client
2766            .get(&url)
2767            .bearer_auth(token)
2768            .send()
2769            .map_err(|e| Diagnostic::Network(e.to_string()))?;
2770
2771        let status = response.status();
2772
2773        if status.is_success() {
2774            // Drain the response body so the connection can be returned to the pool.
2775            // NEVER log the token — log the drained body at trace level only.
2776            let body = response.text().unwrap_or_default();
2777            let preview: String = body.chars().take(200).collect();
2778            tracing::trace!("verify_token success response body (capped): {}", preview);
2779            Ok(())
2780        } else if status == reqwest::StatusCode::UNAUTHORIZED
2781            || status == reqwest::StatusCode::FORBIDDEN
2782        {
2783            // Drain the response body so pooled connections behave.
2784            let body = response.text().unwrap_or_default();
2785            let preview: String = body.chars().take(200).collect();
2786            tracing::trace!("verify_token rejection response body (capped): {}", preview);
2787            // Token MUST NOT appear in the error message.
2788            Err(Diagnostic::AuthRequired(format!(
2789                "token rejected by {server} — it may be expired, revoked, or for a different environment"
2790            )))
2791        } else {
2792            Err(map_unexpected_status(status, &url))
2793        }
2794    }
2795
2796    fn list_data_models(
2797        &self,
2798        server: &str,
2799        project_iri: &str,
2800        token: Option<&str>,
2801    ) -> Result<Vec<DataModel>, Diagnostic> {
2802        let url = format!(
2803            "{}/v2/ontologies/metadata/{}",
2804            server.trim_end_matches('/'),
2805            enc(project_iri)
2806        );
2807
2808        // Build the request: conditionally add Bearer auth ONLY when a token is
2809        // provided. When `token` is `None` the request is sent without any
2810        // Authorization header (public endpoint). Mirrors `list_projects`.
2811        // NEVER log the token — it must not appear in any Diagnostic or tracing call.
2812        let req = self.client.get(&url);
2813        let req = if let Some(t) = token {
2814            req.bearer_auth(t)
2815        } else {
2816            req
2817        };
2818
2819        let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
2820
2821        let status = response.status();
2822
2823        if status.is_success() {
2824            let resp: OntologyMetadataResponse = response.json().map_err(|e| {
2825                Diagnostic::ServerError(format!("data-models response could not be parsed: {e}"))
2826            })?;
2827
2828            // `@graph` present → use it (covers multi AND a server that wraps a single
2829            // ontology in a length-1 array). Else a flattened top-level `@id` → one
2830            // ontology. Else `{}` → none. Order matters: never reorder these arms.
2831            let dtos: Vec<OntologyMetadataDto> = match resp.graph {
2832                Some(g) => g,
2833                None => match resp.id {
2834                    Some(id) => vec![OntologyMetadataDto {
2835                        id,
2836                        label: resp.label,
2837                        last_modification_date: resp.last_modification_date,
2838                    }],
2839                    None => vec![],
2840                },
2841            };
2842
2843            let data_models = dtos
2844                .into_iter()
2845                .map(|dto| DataModel {
2846                    name: data_model_name_from_iri(&dto.id),
2847                    iri: dto.id,
2848                    label: dto.label,
2849                    last_modified: dto.last_modification_date.map(|d| d.value),
2850                    is_builtin: false,
2851                })
2852                .collect();
2853
2854            Ok(data_models)
2855        } else {
2856            Err(map_unexpected_status(status, &url))
2857        }
2858    }
2859
2860    fn describe_resource_type(
2861        &self,
2862        server: &str,
2863        data_model_iri: &str,
2864        resource_type: &str,
2865        token: Option<&str>,
2866    ) -> Result<ResourceTypeDetail, Diagnostic> {
2867        // ── 1. Fetch allentities for the queried data-model ───────────────────
2868        let resp = self.fetch_allentities(server, data_model_iri, token)?;
2869
2870        // Build prefix → namespace map before consuming resp.graph.
2871        let prefixes: HashMap<String, String> = resp
2872            .context
2873            .iter()
2874            .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
2875            .collect();
2876
2877        // ── 2. Find the target class in this ontology's @graph only ───────────
2878        // Expand the queried resource_type: check if it looks like a CURIE or full IRI
2879        // to allow exact IRI matching.
2880        let queried_id = resp.id;
2881        let mut graph_entities: Vec<OntologyEntityDto> = resp.graph;
2882
2883        let target_idx = graph_entities.iter().position(|e| {
2884            if !e.is_resource_class {
2885                return false;
2886            }
2887            let (type_local, expanded_iri) = expand_class_id(&e.id, &prefixes);
2888            // Case-insensitive local name match OR exact IRI match.
2889            type_local.eq_ignore_ascii_case(resource_type) || expanded_iri == resource_type
2890        });
2891
2892        let target_idx = match target_idx {
2893            Some(i) => i,
2894            None => {
2895                let display: String = resource_type.chars().take(80).collect();
2896                let suffix = if resource_type.chars().count() > 80 {
2897                    "…"
2898                } else {
2899                    ""
2900                };
2901                return Err(Diagnostic::NotFound(format!(
2902                    "resource-type '{display}{suffix}' not found in data-model '{}' on {server}",
2903                    data_model_name_from_iri(data_model_iri)
2904                )));
2905            }
2906        };
2907
2908        // Extract the target class from the vec (swap_remove is fine — we only need
2909        // target's fields, and we iterate graph_entities for property nodes separately).
2910        let target = graph_entities.swap_remove(target_idx);
2911
2912        // ── 3. Parse rdfs:subClassOf into superclass refs + restrictions ───────
2913        struct Restriction {
2914            on_property_id: String,
2915            cardinality: Cardinality,
2916            gui_order: u32,
2917        }
2918
2919        let mut restrictions: Vec<Restriction> = Vec::new();
2920        let mut super_type_ids: Vec<String> = Vec::new();
2921        let mut restriction_prop_locals: Vec<String> = Vec::new();
2922
2923        for element in &target.sub_class_of {
2924            if let Some(type_val) = element.get("@type")
2925                && type_val.as_str() == Some("owl:Restriction")
2926            {
2927                // It's a restriction
2928                let on_prop_id = element
2929                    .get("owl:onProperty")
2930                    .and_then(|v| v.get("@id"))
2931                    .and_then(serde_json::Value::as_str)
2932                    .unwrap_or("")
2933                    .to_string();
2934
2935                if on_prop_id.is_empty() {
2936                    tracing::warn!("owl:Restriction missing owl:onProperty @id; skipping");
2937                    continue;
2938                }
2939
2940                let cardinality = decode_cardinality(element);
2941                let gui_order = element
2942                    .get("salsah-gui:guiOrder")
2943                    .and_then(serde_json::Value::as_u64)
2944                    .map(|v| v as u32)
2945                    .unwrap_or(u32::MAX);
2946
2947                restriction_prop_locals.push(local_name(&on_prop_id).to_string());
2948
2949                restrictions.push(Restriction {
2950                    on_property_id: on_prop_id,
2951                    cardinality,
2952                    gui_order,
2953                });
2954                continue;
2955            }
2956            // Not a restriction — it's a superclass ref: {"@id": "..."}
2957            if let Some(id_val) = element.get("@id").and_then(serde_json::Value::as_str) {
2958                super_type_ids.push(id_val.to_string());
2959            }
2960        }
2961
2962        // ── 4. Representation (Decision 5 / R8): from file-value restrictions ─
2963        let representation = detect_representation(
2964            &restriction_prop_locals
2965                .iter()
2966                .map(String::as_str)
2967                .collect::<Vec<_>>(),
2968        );
2969
2970        // ── 5. Build property node lookup from the queried ontology ───────────
2971        let mut prop_lookup: HashMap<String, OntologyEntityDto> = HashMap::new();
2972        for entity in graph_entities {
2973            // Property nodes have an objectType or isResourceProperty/isLinkProperty.
2974            // Use object_type as the discriminant (property nodes carry it; class nodes don't).
2975            if entity.object_type.is_some()
2976                || entity.is_link_property
2977                || entity.is_resource_property
2978            {
2979                prop_lookup.insert(entity.id.clone(), entity);
2980            }
2981        }
2982
2983        // ── 6. Sibling-fetch: resolve missing non-system property nodes ────────
2984        // Collect restriction onProperty ids whose node is absent AND whose CURIE
2985        // prefix is not system-namespace.
2986        //
2987        // SSRF note (R10): sibling IRIs from the server's @context are used ONLY
2988        // as the percent-encoded path argument of
2989        //   `{server}/v2/ontologies/allentities/{enc(sibling_iri)}`
2990        // The host is always the user-supplied `server` argument. A hostile @context
2991        // cannot redirect requests or the bearer token to a foreign host.
2992        let mut missing_prefixes: Vec<String> = Vec::new();
2993        let mut seen_prefixes: HashSet<String> = HashSet::new();
2994        for restriction in &restrictions {
2995            if prop_lookup.contains_key(&restriction.on_property_id) {
2996                continue;
2997            }
2998            let prefix = match curie_prefix(&restriction.on_property_id) {
2999                Some(p) => p,
3000                None => continue,
3001            };
3002            if is_system_prefix(prefix) {
3003                continue;
3004            }
3005            if seen_prefixes.insert(prefix.to_string()) {
3006                missing_prefixes.push(prefix.to_string());
3007            }
3008        }
3009
3010        // Resolve sibling IRIs from @context, dedup, cap at MAX_SIBLING_FETCHES.
3011        let mut fetched_sibling_iris: HashSet<String> = HashSet::new();
3012        let queried_iri_trimmed = data_model_iri.trim_end_matches(['#', '/']);
3013
3014        let mut siblings_to_fetch: Vec<String> = Vec::new();
3015        for prefix in &missing_prefixes {
3016            let namespace = match prefixes.get(prefix.as_str()) {
3017                Some(ns) => ns,
3018                None => {
3019                    tracing::warn!(
3020                        prefix = %prefix,
3021                        "missing @context entry for prefix of cross-DM field; leaving best-effort"
3022                    );
3023                    continue;
3024                }
3025            };
3026            let sibling_iri = namespace.trim_end_matches(['#', '/']).to_string();
3027            if sibling_iri == queried_iri_trimmed {
3028                // Self-loop: this prefix resolves to the queried DM itself; skip.
3029                continue;
3030            }
3031            if fetched_sibling_iris.insert(sibling_iri.clone()) {
3032                siblings_to_fetch.push(sibling_iri);
3033            }
3034        }
3035
3036        if siblings_to_fetch.len() > MAX_SIBLING_FETCHES {
3037            tracing::warn!(
3038                count = siblings_to_fetch.len(),
3039                max = MAX_SIBLING_FETCHES,
3040                "too many sibling ontologies to fetch; capping at MAX_SIBLING_FETCHES"
3041            );
3042            siblings_to_fetch.truncate(MAX_SIBLING_FETCHES);
3043        }
3044
3045        for sibling_iri in &siblings_to_fetch {
3046            // SSRF guard: always use same `server`, never the raw IRI as a URL.
3047            match self.fetch_allentities(server, sibling_iri, token) {
3048                Ok(sibling_resp) => {
3049                    for entity in sibling_resp.graph {
3050                        if entity.object_type.is_some()
3051                            || entity.is_link_property
3052                            || entity.is_resource_property
3053                        {
3054                            prop_lookup.entry(entity.id.clone()).or_insert(entity);
3055                        }
3056                    }
3057                }
3058                Err(e) => {
3059                    // Non-fatal (R5): warn but continue — affected fields degrade.
3060                    // NEVER log the token or a credential-bearing URL.
3061                    tracing::warn!(
3062                        iri = %sibling_iri,
3063                        error = %e,
3064                        "sibling ontology fetch failed; affected fields left best-effort"
3065                    );
3066                }
3067            }
3068        }
3069
3070        // ── 7. Build Vec<Field> from restrictions + merged lookup ─────────────
3071        let mut fields: Vec<(u32, Field)> = Vec::new();
3072
3073        for restriction in &restrictions {
3074            let prop_id = &restriction.on_property_id;
3075
3076            // Look up the property node (may be absent for system or failed-fetch fields).
3077            let node = prop_lookup.get(prop_id.as_str());
3078
3079            // ── Twin drop (after merges — R-twin) ────────────────────────────
3080            if let Some(n) = node {
3081                if n.is_link_value_property {
3082                    // Authoritative node says it's a reification twin — drop it.
3083                    continue;
3084                }
3085            } else {
3086                // Node unavailable: apply name heuristic only when the node is missing.
3087                // If prop_id ends in "Value" and the base name is also a restriction
3088                // on this class, treat it as a twin and drop.
3089                let prop_local = local_name(prop_id);
3090                if let Some(base) = prop_local.strip_suffix("Value") {
3091                    // Look for a restriction whose local name equals `base` (CURIE match).
3092                    let base_present = restrictions
3093                        .iter()
3094                        .any(|r| local_name(&r.on_property_id) == base);
3095                    // Also check: `base` must be present as a restriction prop id
3096                    // (with any prefix, not just same prefix).
3097                    if base_present {
3098                        continue;
3099                    }
3100                }
3101            }
3102
3103            // ── Field attributes ─────────────────────────────────────────────
3104            let prop_prefix = curie_prefix(prop_id).unwrap_or("");
3105            let is_builtin = is_system_prefix(prop_prefix);
3106            let (prop_local, prop_iri) = expand_class_id(prop_id, &prefixes);
3107
3108            // data_model: system → None, otherwise the CURIE prefix (source DM).
3109            let field_data_model = if is_builtin {
3110                None
3111            } else {
3112                // Use the CURIE prefix as the source DM name.
3113                // Even for a failed-fetch field, we know its prefix.
3114                if prop_prefix.is_empty() {
3115                    None
3116                } else {
3117                    Some(prop_prefix.to_string())
3118                }
3119            };
3120
3121            // value_type + link_target.
3122            let (value_type, link_target) = if let Some(n) = node {
3123                if n.is_link_property {
3124                    // Link property: objectType is the target resource class.
3125                    let target_name = n
3126                        .object_type
3127                        .as_ref()
3128                        .map(|ot| local_name(&ot.id).to_string())
3129                        .unwrap_or_else(|| "unknown".to_string());
3130                    (ValueType::Link, Some(target_name))
3131                } else {
3132                    let obj_local = n
3133                        .object_type
3134                        .as_ref()
3135                        .map(|ot| local_name(&ot.id))
3136                        .unwrap_or("");
3137                    (map_object_type_to_value_type(obj_local), None)
3138                }
3139            } else {
3140                // Node unavailable: try builtin file-value map; else Other/None.
3141                if is_builtin {
3142                    if let Some(vt) = builtin_field_value_type(&prop_local) {
3143                        (vt, None)
3144                    } else {
3145                        (ValueType::Other("—".to_string()), None)
3146                    }
3147                } else {
3148                    (ValueType::Other("—".to_string()), None)
3149                }
3150            };
3151
3152            let label = node.and_then(|n| n.label.clone());
3153
3154            // Check that link_target invariant is maintained.
3155            debug_assert!(
3156                (value_type == ValueType::Link) == link_target.is_some(),
3157                "link_target must be Some iff value_type is Link"
3158            );
3159
3160            fields.push((
3161                restriction.gui_order,
3162                Field {
3163                    name: prop_local,
3164                    iri: prop_iri,
3165                    label,
3166                    value_type,
3167                    link_target,
3168                    cardinality: restriction.cardinality,
3169                    is_builtin,
3170                    data_model: field_data_model,
3171                },
3172            ));
3173        }
3174
3175        // ── 8. Sort by guiOrder then name ─────────────────────────────────────
3176        fields.sort_by(|(order_a, field_a), (order_b, field_b)| {
3177            order_a
3178                .cmp(order_b)
3179                .then_with(|| field_a.name.cmp(&field_b.name))
3180        });
3181        let sorted_fields: Vec<Field> = fields.into_iter().map(|(_, f)| f).collect();
3182
3183        // ── 9. super_types: non-system superclass refs ─────────────────────────
3184        let super_types: Vec<String> = super_type_ids
3185            .iter()
3186            .filter(|id| {
3187                let prefix = curie_prefix(id).unwrap_or("");
3188                !is_system_prefix(prefix)
3189            })
3190            .map(|id| local_name(id).to_string())
3191            .collect();
3192
3193        // ── 10. Build ResourceTypeDetail ─────────────────────────────────────
3194        let (class_name, class_iri) = expand_class_id(&target.id, &prefixes);
3195        let class_label = target.label;
3196        let dm_name = data_model_name_from_iri(&queried_id);
3197
3198        Ok(ResourceTypeDetail {
3199            name: class_name,
3200            iri: class_iri,
3201            label: class_label,
3202            data_model: dm_name,
3203            representation,
3204            super_types,
3205            fields: sorted_fields,
3206            count: None,
3207        })
3208    }
3209
3210    fn resource_counts(
3211        &self,
3212        server: &str,
3213        project_iri: &str,
3214        token: Option<&str>,
3215    ) -> Result<HashMap<String, u64>, Diagnostic> {
3216        let url = format!(
3217            "{}/v3/projects/{}/resourcesPerOntology",
3218            server.trim_end_matches('/'),
3219            enc(project_iri)
3220        );
3221
3222        // Conditionally add Bearer auth ONLY when a token is provided — mirrors
3223        // `list_data_models`. NEVER log the token.
3224        let req = self.client.get(&url);
3225        let req = if let Some(t) = token {
3226            req.bearer_auth(t)
3227        } else {
3228            req
3229        };
3230
3231        let response = req.send().map_err(|e| Diagnostic::Network(e.to_string()))?;
3232        let status = response.status();
3233
3234        if status.is_success() {
3235            let entries: Vec<OntologyAndResourceClassesDto> = response.json().map_err(|e| {
3236                Diagnostic::ServerError(format!(
3237                    "resource-counts response could not be parsed: {e}"
3238                ))
3239            })?;
3240
3241            let mut counts = HashMap::new();
3242            for entry in entries {
3243                for cc in entry.classes_and_count {
3244                    counts.insert(cc.resource_class.iri, cc.item_count);
3245                }
3246            }
3247            Ok(counts)
3248        } else if status == reqwest::StatusCode::NOT_FOUND {
3249            Err(Diagnostic::NotFound(format!("project not found at {url}")))
3250        } else {
3251            Err(map_unexpected_status(status, &url))
3252        }
3253    }
3254}
3255
3256// ---------------------------------------------------------------------------
3257// Unit tests for pure helpers (classifier)
3258// ---------------------------------------------------------------------------
3259
3260#[cfg(test)]
3261mod tests {
3262    use super::*;
3263
3264    // ---------------------------------------------------------------------------
3265    // `map_unexpected_status` unit tests
3266    // ---------------------------------------------------------------------------
3267
3268    #[test]
3269    fn map_unexpected_status_401_403_are_auth_required() {
3270        // 0.1.1: a read refused with 401 (missing/expired cached token) or 403
3271        // (permission) must surface as AuthRequired (exit 3) with a
3272        // re-authenticate hint — not a bare "unexpected status" runtime error.
3273        for status in [
3274            reqwest::StatusCode::UNAUTHORIZED,
3275            reqwest::StatusCode::FORBIDDEN,
3276        ] {
3277            let diag = map_unexpected_status(status, "https://example.org/x");
3278            match diag {
3279                Diagnostic::AuthRequired(msg) => assert!(
3280                    msg.contains("dsp auth login"),
3281                    "auth message should hint at re-authentication: {msg}"
3282                ),
3283                other => panic!("expected AuthRequired for {status}, got {other:?}"),
3284            }
3285        }
3286    }
3287
3288    #[test]
3289    fn map_unexpected_status_404_and_5xx_stay_server_error() {
3290        // 404 and 5xx are not auth failures — they remain ServerError (exit 1),
3291        // preserving the existing contract (cf. the set_token 404 integration test).
3292        assert!(matches!(
3293            map_unexpected_status(reqwest::StatusCode::NOT_FOUND, "u"),
3294            Diagnostic::ServerError(_)
3295        ));
3296        assert!(matches!(
3297            map_unexpected_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, "u"),
3298            Diagnostic::ServerError(_)
3299        ));
3300    }
3301
3302    // ---------------------------------------------------------------------------
3303    // `identifier_key` unit tests
3304    // ---------------------------------------------------------------------------
3305
3306    #[test]
3307    fn identifier_key_email_contains_at() {
3308        assert_eq!(identifier_key("a@b.ch"), "email");
3309    }
3310
3311    #[test]
3312    fn identifier_key_bare_username() {
3313        assert_eq!(identifier_key("jdoe"), "username");
3314    }
3315
3316    #[test]
3317    fn identifier_key_http_iri() {
3318        assert_eq!(identifier_key("http://rdfh.ch/users/x"), "iri");
3319    }
3320
3321    #[test]
3322    fn identifier_key_https_iri() {
3323        assert_eq!(identifier_key("https://rdfh.ch/users/x"), "iri");
3324    }
3325
3326    #[test]
3327    fn identifier_key_iri_with_at_uses_iri_not_email() {
3328        // IRI prefix is checked before '@'; an '@' inside an IRI must not mis-classify.
3329        assert_eq!(identifier_key("http://example.org/users/a@b"), "iri");
3330    }
3331
3332    #[test]
3333    fn classify_http_iri() {
3334        let ident = classify("http://rdfh.ch/projects/0001");
3335        assert!(
3336            matches!(ident, ProjectIdent::Iri(_)),
3337            "http:// prefix should classify as Iri"
3338        );
3339    }
3340
3341    #[test]
3342    fn classify_https_iri() {
3343        let ident = classify("https://rdfh.ch/projects/0001");
3344        assert!(
3345            matches!(ident, ProjectIdent::Iri(_)),
3346            "https:// prefix should classify as Iri"
3347        );
3348    }
3349
3350    #[test]
3351    fn classify_four_digit_hex_shortcode() {
3352        let ident = classify("0001");
3353        assert!(
3354            matches!(ident, ProjectIdent::Shortcode(_)),
3355            "four hex digits should classify as Shortcode"
3356        );
3357    }
3358
3359    #[test]
3360    fn classify_four_hex_letter_shortcode() {
3361        // Documents the shortcode-wins overlap: `beef` is valid hex and exactly
3362        // 4 chars, so it classifies as Shortcode even if it looks like a shortname.
3363        // This is intentional (plan risks §6).
3364        let ident = classify("beef");
3365        assert!(
3366            matches!(ident, ProjectIdent::Shortcode(_)),
3367            "4-hex-letter input 'beef' should classify as Shortcode (documented overlap)"
3368        );
3369    }
3370
3371    #[test]
3372    fn classify_mixed_case_hex_shortcode() {
3373        let ident = classify("ABCD");
3374        assert!(
3375            matches!(ident, ProjectIdent::Shortcode(_)),
3376            "upper-case hex digits should classify as Shortcode"
3377        );
3378    }
3379
3380    #[test]
3381    fn classify_shortname() {
3382        let ident = classify("incunabula");
3383        assert!(
3384            matches!(ident, ProjectIdent::Shortname(_)),
3385            "alphabetic string longer than 4 chars should classify as Shortname"
3386        );
3387    }
3388
3389    #[test]
3390    fn classify_five_digit_hex_is_shortname() {
3391        // 5 hex digits — not exactly 4, so falls through to Shortname.
3392        let ident = classify("00001");
3393        assert!(
3394            matches!(ident, ProjectIdent::Shortname(_)),
3395            "5-hex-digit string should classify as Shortname, not Shortcode"
3396        );
3397    }
3398
3399    #[test]
3400    fn classify_three_digit_hex_is_shortname() {
3401        let ident = classify("001");
3402        assert!(
3403            matches!(ident, ProjectIdent::Shortname(_)),
3404            "3-hex-digit string should classify as Shortname, not Shortcode"
3405        );
3406    }
3407
3408    #[test]
3409    fn classify_non_hex_four_chars_is_shortname() {
3410        // 4 chars but contains non-hex ('g') → Shortname.
3411        let ident = classify("zzzz");
3412        assert!(
3413            matches!(ident, ProjectIdent::Shortname(_)),
3414            "4-char non-hex string should classify as Shortname"
3415        );
3416    }
3417
3418    // ---------------------------------------------------------------------------
3419    // `validate_dump_id` unit tests
3420    // ---------------------------------------------------------------------------
3421
3422    #[test]
3423    fn validate_dump_id_valid_accepts() {
3424        assert!(super::validate_dump_id("abc123").is_ok());
3425        assert!(super::validate_dump_id("abc-123_XYZ").is_ok());
3426        // 256-char id is the upper bound — must still be accepted.
3427        let max_id = "a".repeat(256);
3428        assert!(
3429            super::validate_dump_id(&max_id).is_ok(),
3430            "256-char id must be accepted"
3431        );
3432    }
3433
3434    #[test]
3435    fn validate_dump_id_empty_is_rejected() {
3436        let result = super::validate_dump_id("");
3437        assert!(
3438            matches!(result, Err(Diagnostic::ServerError(_))),
3439            "empty id must be rejected"
3440        );
3441    }
3442
3443    #[test]
3444    fn validate_dump_id_too_long_is_rejected() {
3445        let long_id = "a".repeat(257);
3446        let result = super::validate_dump_id(&long_id);
3447        assert!(
3448            matches!(result, Err(Diagnostic::ServerError(_))),
3449            "257-char id must be rejected"
3450        );
3451    }
3452
3453    #[test]
3454    fn validate_dump_id_invalid_chars_rejected() {
3455        let result = super::validate_dump_id("abc/def");
3456        assert!(
3457            matches!(result, Err(Diagnostic::ServerError(_))),
3458            "id with '/' must be rejected"
3459        );
3460    }
3461
3462    // ---------------------------------------------------------------------------
3463    // `into_dump_task` unit tests
3464    // ---------------------------------------------------------------------------
3465
3466    #[test]
3467    fn into_dump_task_in_progress() {
3468        let api = DataTaskStatusApiResponse {
3469            id: "abc123".into(),
3470            status: "in_progress".into(),
3471            error_message: None,
3472            created_at: None,
3473        };
3474        let task = api.into_dump_task().expect("should parse in_progress");
3475        assert_eq!(task.id, "abc123");
3476        assert_eq!(task.status, DumpStatus::InProgress);
3477        assert!(task.error_message.is_none());
3478        assert!(task.created_at.is_none());
3479    }
3480
3481    #[test]
3482    fn into_dump_task_completed() {
3483        let api = DataTaskStatusApiResponse {
3484            id: "done42".into(),
3485            status: "completed".into(),
3486            error_message: None,
3487            created_at: None,
3488        };
3489        let task = api.into_dump_task().expect("should parse completed");
3490        assert_eq!(task.status, DumpStatus::Completed);
3491    }
3492
3493    #[test]
3494    fn into_dump_task_failed_with_message() {
3495        let api = DataTaskStatusApiResponse {
3496            id: "fail7".into(),
3497            status: "failed".into(),
3498            error_message: Some("disk full".into()),
3499            created_at: None,
3500        };
3501        let task = api.into_dump_task().expect("should parse failed");
3502        assert_eq!(task.status, DumpStatus::Failed);
3503        assert_eq!(task.error_message.as_deref(), Some("disk full"));
3504    }
3505
3506    #[test]
3507    fn into_dump_task_unknown_status_is_server_error() {
3508        let api = DataTaskStatusApiResponse {
3509            id: "x".into(),
3510            status: "pending".into(), // not a known status
3511            error_message: None,
3512            created_at: None,
3513        };
3514        let result = api.into_dump_task();
3515        assert!(result.is_err(), "unknown status should yield an error");
3516        assert!(
3517            matches!(result.unwrap_err(), Diagnostic::ServerError(_)),
3518            "unknown status should yield ServerError"
3519        );
3520    }
3521
3522    #[test]
3523    fn into_dump_task_long_error_message_is_truncated() {
3524        // Build a message that is 501 chars long (just over the 500-char cap).
3525        let long_msg = "x".repeat(501);
3526        let api = DataTaskStatusApiResponse {
3527            id: "trunc".into(),
3528            status: "failed".into(),
3529            error_message: Some(long_msg),
3530            created_at: None,
3531        };
3532        let task = api
3533            .into_dump_task()
3534            .expect("should parse even with long message");
3535        let stored = task.error_message.unwrap();
3536        assert_eq!(
3537            stored.len(),
3538            500,
3539            "error_message must be truncated to ≤500 chars at the client boundary"
3540        );
3541    }
3542
3543    #[test]
3544    fn into_dump_task_exact_500_chars_not_truncated() {
3545        // Exactly 500 chars — must pass through unchanged.
3546        let exact_msg = "y".repeat(500);
3547        let api = DataTaskStatusApiResponse {
3548            id: "exact".into(),
3549            status: "failed".into(),
3550            error_message: Some(exact_msg.clone()),
3551            created_at: None,
3552        };
3553        let task = api.into_dump_task().expect("should parse");
3554        assert_eq!(task.error_message.unwrap(), exact_msg);
3555    }
3556
3557    // ---------------------------------------------------------------------------
3558    // `created_at` parsing unit tests
3559    // ---------------------------------------------------------------------------
3560
3561    #[test]
3562    fn into_dump_task_valid_created_at_is_parsed() {
3563        let api = DataTaskStatusApiResponse {
3564            id: "ts-test".into(),
3565            status: "completed".into(),
3566            error_message: None,
3567            created_at: Some("2026-05-20T14:03:00Z".into()),
3568        };
3569        let task = api.into_dump_task().expect("should parse with created_at");
3570        use chrono::Datelike;
3571        let ts = task.created_at.expect("created_at should be Some");
3572        assert_eq!(ts.year(), 2026);
3573        assert_eq!(ts.month(), 5);
3574        assert_eq!(ts.day(), 20);
3575    }
3576
3577    #[test]
3578    fn into_dump_task_garbage_created_at_yields_none() {
3579        let api = DataTaskStatusApiResponse {
3580            id: "ts-bad".into(),
3581            status: "in_progress".into(),
3582            error_message: None,
3583            created_at: Some("not-a-date!!".into()),
3584        };
3585        // Must succeed (garbage timestamp ≠ parse failure for the whole task).
3586        let task = api
3587            .into_dump_task()
3588            .expect("garbage created_at must not fail parse");
3589        assert!(
3590            task.created_at.is_none(),
3591            "garbage created_at must map to None"
3592        );
3593    }
3594
3595    // ---------------------------------------------------------------------------
3596    // `V3ErrorBody::export_exists` unit tests
3597    // ---------------------------------------------------------------------------
3598
3599    #[test]
3600    fn export_exists_present_with_both_fields() {
3601        let body = V3ErrorBody {
3602            errors: vec![V3ErrorItem {
3603                code: "export_exists".into(),
3604                details: [
3605                    ("id".to_string(), "dGVzdC1pZA".to_string()),
3606                    (
3607                        "projectIri".to_string(),
3608                        "http://rdfh.ch/projects/0001".to_string(),
3609                    ),
3610                ]
3611                .into(),
3612            }],
3613        };
3614        let ex = body.export_exists().expect("export_exists must be Some");
3615        assert_eq!(ex.id, Some("dGVzdC1pZA"));
3616        assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3617    }
3618
3619    #[test]
3620    fn export_exists_wrong_code_returns_none() {
3621        let body = V3ErrorBody {
3622            errors: vec![V3ErrorItem {
3623                code: "some_other_error".into(),
3624                details: [("id".to_string(), "abc".to_string())].into(),
3625            }],
3626        };
3627        assert!(body.export_exists().is_none(), "wrong code must not match");
3628    }
3629
3630    #[test]
3631    fn export_exists_missing_details_id_returns_some_with_none_id() {
3632        let body = V3ErrorBody {
3633            errors: vec![V3ErrorItem {
3634                code: "export_exists".into(),
3635                details: [(
3636                    "projectIri".to_string(),
3637                    "http://rdfh.ch/projects/0001".to_string(),
3638                )]
3639                .into(),
3640            }],
3641        };
3642        // export_exists returns Some (the code matched) but id is None.
3643        let ex = body
3644            .export_exists()
3645            .expect("export_exists must be Some when code matches");
3646        assert!(ex.id.is_none(), "id must be None when 'id' key is absent");
3647        assert_eq!(ex.project_iri, Some("http://rdfh.ch/projects/0001"));
3648    }
3649
3650    #[test]
3651    fn export_exists_empty_errors_returns_none() {
3652        let body = V3ErrorBody { errors: vec![] };
3653        assert!(body.export_exists().is_none());
3654    }
3655
3656    #[test]
3657    fn export_exists_missing_project_iri_returns_some_with_none_iri() {
3658        let body = V3ErrorBody {
3659            errors: vec![V3ErrorItem {
3660                code: "export_exists".into(),
3661                details: [("id".to_string(), "abc123".to_string())].into(),
3662            }],
3663        };
3664        let ex = body
3665            .export_exists()
3666            .expect("export_exists must be Some when code matches");
3667        assert_eq!(ex.id, Some("abc123"));
3668        assert!(
3669            ex.project_iri.is_none(),
3670            "project_iri must be None when 'projectIri' key is absent"
3671        );
3672    }
3673
3674    // ---------------------------------------------------------------------------
3675    // `is_safe_shortcode` unit tests
3676    // ---------------------------------------------------------------------------
3677
3678    #[test]
3679    fn is_safe_shortcode_valid_hex_shortcode() {
3680        assert!(
3681            super::is_safe_shortcode("0001"),
3682            "4-hex-digit shortcode must be accepted"
3683        );
3684        assert!(
3685            super::is_safe_shortcode("ABCD"),
3686            "upper-case hex shortcode must be accepted"
3687        );
3688        assert!(
3689            super::is_safe_shortcode("beef"),
3690            "lower-case hex shortcode must be accepted"
3691        );
3692    }
3693
3694    #[test]
3695    fn is_safe_shortcode_alphanumeric_within_32_chars_accepted() {
3696        let long_code = "a".repeat(32);
3697        assert!(
3698            super::is_safe_shortcode(&long_code),
3699            "32-char alphanumeric must be accepted"
3700        );
3701    }
3702
3703    #[test]
3704    fn is_safe_shortcode_empty_is_rejected() {
3705        assert!(
3706            !super::is_safe_shortcode(""),
3707            "empty shortcode must be rejected"
3708        );
3709    }
3710
3711    #[test]
3712    fn is_safe_shortcode_too_long_is_rejected() {
3713        let long_code = "a".repeat(33);
3714        assert!(
3715            !super::is_safe_shortcode(&long_code),
3716            "33-char shortcode must be rejected"
3717        );
3718    }
3719
3720    #[test]
3721    fn is_safe_shortcode_slash_is_rejected() {
3722        assert!(
3723            !super::is_safe_shortcode("ab/cd"),
3724            "shortcode with '/' must be rejected"
3725        );
3726        assert!(
3727            !super::is_safe_shortcode("/evil"),
3728            "absolute path shortcode must be rejected"
3729        );
3730    }
3731
3732    #[test]
3733    fn is_safe_shortcode_dot_dot_is_rejected() {
3734        assert!(
3735            !super::is_safe_shortcode("../evil"),
3736            "path traversal shortcode must be rejected"
3737        );
3738        assert!(
3739            !super::is_safe_shortcode(".."),
3740            "'..' shortcode must be rejected"
3741        );
3742    }
3743
3744    #[test]
3745    fn is_safe_shortcode_backslash_is_rejected() {
3746        assert!(
3747            !super::is_safe_shortcode("ab\\cd"),
3748            "shortcode with '\\' must be rejected"
3749        );
3750    }
3751
3752    #[test]
3753    fn is_safe_shortcode_dot_is_rejected() {
3754        // A single '.' or mixed dots are not ASCII-alphanumeric.
3755        assert!(
3756            !super::is_safe_shortcode("ab.cd"),
3757            "shortcode with '.' must be rejected"
3758        );
3759    }
3760
3761    #[test]
3762    fn resolve_project_rejects_unsafe_shortcode() {
3763        // Verify that the `is_safe_shortcode` guard in `resolve_project` rejects
3764        // a shortcode containing path-traversal characters. We test `is_safe_shortcode`
3765        // directly here since the HTTP boundary is the validation point.
3766        let unsafe_examples = ["../evil", "/abs", "ab/cd", "a\\b", ""];
3767        for s in &unsafe_examples {
3768            assert!(
3769                !super::is_safe_shortcode(s),
3770                "is_safe_shortcode must reject '{s}' — resolve_project would have returned ServerError for this input"
3771            );
3772        }
3773    }
3774
3775    // ---------------------------------------------------------------------------
3776    // `data_model_name_from_iri` unit tests
3777    // ---------------------------------------------------------------------------
3778
3779    #[test]
3780    fn data_model_name_from_iri_standard_form() {
3781        // Standard form: http://…/ontology/<code>/<name>/v2 → <name>
3782        assert_eq!(
3783            super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2"),
3784            "beol"
3785        );
3786    }
3787
3788    #[test]
3789    fn data_model_name_from_iri_no_v2_suffix() {
3790        // No /v2 suffix: fall back to last path segment
3791        assert_eq!(
3792            super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol"),
3793            "beol"
3794        );
3795    }
3796
3797    #[test]
3798    fn data_model_name_from_iri_trailing_slash() {
3799        // Trailing slash is stripped before /v2 is checked
3800        assert_eq!(
3801            super::data_model_name_from_iri("http://api.dasch.swiss/ontology/0801/beol/v2/"),
3802            "beol"
3803        );
3804    }
3805
3806    #[test]
3807    fn data_model_name_from_iri_bare_name() {
3808        // No slash at all: the whole string is the name
3809        assert_eq!(super::data_model_name_from_iri("beol"), "beol");
3810    }
3811
3812    #[test]
3813    fn data_model_name_from_iri_empty_string() {
3814        // Empty input degrades silently to an empty name (benign; server contract trusted)
3815        assert_eq!(super::data_model_name_from_iri(""), "");
3816    }
3817
3818    // ---------------------------------------------------------------------------
3819    // `expand_class_id` unit tests
3820    // ---------------------------------------------------------------------------
3821
3822    fn beol_prefixes() -> HashMap<String, String> {
3823        let mut m = HashMap::new();
3824        m.insert(
3825            "beol".to_string(),
3826            "http://api.dasch.swiss/ontology/0801/beol/v2#".to_string(),
3827        );
3828        m
3829    }
3830
3831    #[test]
3832    fn expand_class_id_curie_expands_with_known_prefix() {
3833        // `beol:Archive` + a `beol` prefix → expanded IRI + name `Archive`
3834        let (name, iri) = super::expand_class_id("beol:Archive", &beol_prefixes());
3835        assert_eq!(name, "Archive");
3836        assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Archive");
3837    }
3838
3839    #[test]
3840    fn expand_class_id_unknown_prefix_falls_back_to_raw_id() {
3841        // `urn:uuid:x` with no `urn` prefix in context → iri = raw `@id`, name = `x`
3842        let (name, iri) = super::expand_class_id("urn:uuid:x", &HashMap::new());
3843        assert_eq!(name, "x");
3844        assert_eq!(iri, "urn:uuid:x");
3845    }
3846
3847    #[test]
3848    fn expand_class_id_full_iri_passes_through() {
3849        // `http://…/v2#Letter` has scheme `://`, so the local starts with `//` and
3850        // falls through to the passthrough arm. Name = `Letter`, IRI unchanged.
3851        let (name, iri) = super::expand_class_id(
3852            "http://api.dasch.swiss/ontology/0801/beol/v2#Letter",
3853            &beol_prefixes(),
3854        );
3855        assert_eq!(name, "Letter");
3856        assert_eq!(iri, "http://api.dasch.swiss/ontology/0801/beol/v2#Letter");
3857    }
3858
3859    #[test]
3860    fn expand_class_id_no_colon_degenerate() {
3861        // `bare` has no colon at all → name = `bare`, iri = `bare`
3862        let (name, iri) = super::expand_class_id("bare", &HashMap::new());
3863        assert_eq!(name, "bare");
3864        assert_eq!(iri, "bare");
3865    }
3866
3867    // ---------------------------------------------------------------------------
3868    // `local_name` unit tests
3869    // ---------------------------------------------------------------------------
3870
3871    #[test]
3872    fn local_name_hash_iri() {
3873        assert_eq!(super::local_name("http://example.org/onto#Thing"), "Thing");
3874    }
3875
3876    #[test]
3877    fn local_name_slash_iri() {
3878        assert_eq!(super::local_name("http://example.org/onto/Thing"), "Thing");
3879    }
3880
3881    #[test]
3882    fn local_name_curie_colon() {
3883        assert_eq!(super::local_name("incunabula:Page"), "Page");
3884    }
3885
3886    #[test]
3887    fn local_name_bare_name_fallback() {
3888        assert_eq!(super::local_name("Page"), "Page");
3889    }
3890
3891    #[test]
3892    fn local_name_empty_string() {
3893        assert_eq!(super::local_name(""), "");
3894    }
3895
3896    #[test]
3897    fn local_name_trailing_separator() {
3898        // Pins existing inline behaviour: rsplit yields Some("") for "foo#",
3899        // so the result is "" (the unwrap_or fallback is structurally dead here).
3900        assert_eq!(super::local_name("foo#"), "");
3901    }
3902
3903    // ---------------------------------------------------------------------------
3904    // `object_type_to_kebab` / `map_object_type_to_value_type` unit tests (new)
3905    // ---------------------------------------------------------------------------
3906
3907    #[test]
3908    fn object_type_to_kebab_text_value() {
3909        assert_eq!(super::object_type_to_kebab("TextValue"), "text");
3910    }
3911
3912    #[test]
3913    fn object_type_to_kebab_geom_value() {
3914        // "Geom" has no consecutive uppercase → "geom"
3915        assert_eq!(super::object_type_to_kebab("GeomValue"), "geom");
3916    }
3917
3918    #[test]
3919    fn object_type_to_kebab_geo_name_value() {
3920        // "GeoName" — "N" follows lowercase "o", so insert "-" before "N"
3921        assert_eq!(super::object_type_to_kebab("GeoNameValue"), "geo-name");
3922    }
3923
3924    #[test]
3925    fn object_type_to_kebab_uri_value() {
3926        // "URI" — three consecutive uppercase letters; "R" follows "U" (uppercase)
3927        // so no dash; "I" follows "R" (uppercase) so no dash → "uri"
3928        assert_eq!(super::object_type_to_kebab("URIValue"), "uri");
3929    }
3930
3931    #[test]
3932    fn object_type_to_kebab_interval_value() {
3933        // "Interval" — "n" is lowercase before "I"... no, it's the start. "I" is
3934        // uppercase at position 0, so no dash. Result: "interval"
3935        assert_eq!(super::object_type_to_kebab("IntervalValue"), "interval");
3936    }
3937
3938    #[test]
3939    fn object_type_to_kebab_no_value_suffix() {
3940        // No "Value" suffix — returned as-is after kebab conversion
3941        assert_eq!(super::object_type_to_kebab("Geom"), "geom");
3942    }
3943
3944    #[test]
3945    fn map_object_type_known_text_value() {
3946        use crate::model::ValueType;
3947        assert_eq!(
3948            super::map_object_type_to_value_type("TextValue"),
3949            ValueType::Text
3950        );
3951    }
3952
3953    #[test]
3954    fn map_object_type_known_list_value() {
3955        use crate::model::ValueType;
3956        assert_eq!(
3957            super::map_object_type_to_value_type("ListValue"),
3958            ValueType::ListItem
3959        );
3960    }
3961
3962    #[test]
3963    fn map_object_type_other_geom() {
3964        use crate::model::ValueType;
3965        // "GeomValue" is not a named variant → Other("geom")
3966        assert_eq!(
3967            super::map_object_type_to_value_type("GeomValue"),
3968            ValueType::Other("geom".to_string())
3969        );
3970    }
3971
3972    #[test]
3973    fn map_object_type_other_uri_value() {
3974        use crate::model::ValueType;
3975        // "URIValue" is not named (the named variant is "UriValue"); kebab → "uri"
3976        assert_eq!(
3977            super::map_object_type_to_value_type("URIValue"),
3978            ValueType::Other("uri".to_string())
3979        );
3980    }
3981
3982    #[test]
3983    fn map_object_type_other_geo_name_value() {
3984        use crate::model::ValueType;
3985        assert_eq!(
3986            super::map_object_type_to_value_type("GeoNameValue"),
3987            ValueType::Other("geo-name".to_string())
3988        );
3989    }
3990
3991    // ---------------------------------------------------------------------------
3992    // `decode_cardinality` unit tests (new)
3993    // ---------------------------------------------------------------------------
3994
3995    #[test]
3996    fn decode_cardinality_owl_cardinality_1() {
3997        use crate::model::Cardinality;
3998        let v = serde_json::json!({"owl:cardinality": 1});
3999        assert_eq!(super::decode_cardinality(&v), Cardinality::One);
4000    }
4001
4002    #[test]
4003    fn decode_cardinality_owl_max_cardinality_1() {
4004        use crate::model::Cardinality;
4005        let v = serde_json::json!({"owl:maxCardinality": 1});
4006        assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrOne);
4007    }
4008
4009    #[test]
4010    fn decode_cardinality_owl_min_cardinality_0() {
4011        use crate::model::Cardinality;
4012        let v = serde_json::json!({"owl:minCardinality": 0});
4013        assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4014    }
4015
4016    #[test]
4017    fn decode_cardinality_owl_min_cardinality_1() {
4018        use crate::model::Cardinality;
4019        let v = serde_json::json!({"owl:minCardinality": 1});
4020        assert_eq!(super::decode_cardinality(&v), Cardinality::OneOrMore);
4021    }
4022
4023    #[test]
4024    fn decode_cardinality_fallback_no_key() {
4025        use crate::model::Cardinality;
4026        // No recognized cardinality key → ZeroOrMore (defensive fallback)
4027        let v = serde_json::json!({});
4028        assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4029    }
4030
4031    #[test]
4032    fn decode_cardinality_fallback_owl_cardinality_unexpected_value() {
4033        use crate::model::Cardinality;
4034        // owl:cardinality=5 is unexpected → ZeroOrMore
4035        let v = serde_json::json!({"owl:cardinality": 5});
4036        assert_eq!(super::decode_cardinality(&v), Cardinality::ZeroOrMore);
4037    }
4038
4039    #[test]
4040    fn decode_cardinality_fallback_owl_max_cardinality_gt1() {
4041        use crate::model::Cardinality;
4042        // owl:maxCardinality=2 is not a shape DSP emits (only 1 is expected) →
4043        // defensive fallback: ZeroOrMore.
4044        let v = serde_json::json!({"owl:maxCardinality": 2});
4045        assert_eq!(
4046            super::decode_cardinality(&v),
4047            Cardinality::ZeroOrMore,
4048            "owl:maxCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4049        );
4050    }
4051
4052    #[test]
4053    fn decode_cardinality_fallback_owl_min_cardinality_gt1() {
4054        use crate::model::Cardinality;
4055        // owl:minCardinality=2 is not a shape DSP emits (only 0 or 1 are expected) →
4056        // defensive fallback: ZeroOrMore.
4057        let v = serde_json::json!({"owl:minCardinality": 2});
4058        assert_eq!(
4059            super::decode_cardinality(&v),
4060            Cardinality::ZeroOrMore,
4061            "owl:minCardinality=2 must fall back to ZeroOrMore (defensive fallback)"
4062        );
4063    }
4064
4065    // ---------------------------------------------------------------------------
4066    // `detect_representation` unit tests (new)
4067    // ---------------------------------------------------------------------------
4068
4069    #[test]
4070    fn detect_representation_still_image() {
4071        use crate::model::Representation;
4072        let locals = vec!["hasStillImageFileValue"];
4073        assert_eq!(
4074            super::detect_representation(&locals),
4075            Some(Representation::StillImage)
4076        );
4077    }
4078
4079    #[test]
4080    fn detect_representation_moving_image() {
4081        use crate::model::Representation;
4082        let locals = vec!["hasMovingImageFileValue"];
4083        assert_eq!(
4084            super::detect_representation(&locals),
4085            Some(Representation::MovingImage)
4086        );
4087    }
4088
4089    #[test]
4090    fn detect_representation_audio() {
4091        use crate::model::Representation;
4092        let locals = vec!["hasAudioFileValue"];
4093        assert_eq!(
4094            super::detect_representation(&locals),
4095            Some(Representation::Audio)
4096        );
4097    }
4098
4099    #[test]
4100    fn detect_representation_none_when_absent() {
4101        // No file-value property in the list → None
4102        let locals = vec!["hasTitle", "hasAuthor"];
4103        assert_eq!(super::detect_representation(&locals), None);
4104    }
4105
4106    #[test]
4107    fn detect_representation_takes_first() {
4108        use crate::model::Representation;
4109        // Both still-image and document present → first hit wins
4110        let locals = vec!["hasDocumentFileValue", "hasStillImageFileValue"];
4111        assert_eq!(
4112            super::detect_representation(&locals),
4113            Some(Representation::Document)
4114        );
4115    }
4116
4117    // ---------------------------------------------------------------------------
4118    // `is_system_prefix` unit tests (new)
4119    // ---------------------------------------------------------------------------
4120
4121    #[test]
4122    fn is_system_prefix_knora_api() {
4123        assert!(super::is_system_prefix("knora-api"));
4124    }
4125
4126    #[test]
4127    fn is_system_prefix_rdf() {
4128        assert!(super::is_system_prefix("rdf"));
4129    }
4130
4131    #[test]
4132    fn is_system_prefix_project_prefix_is_not_system() {
4133        assert!(!super::is_system_prefix("incunabula"));
4134        assert!(!super::is_system_prefix("beol"));
4135        assert!(!super::is_system_prefix("biblio"));
4136    }
4137
4138    // ---------------------------------------------------------------------------
4139    // `curie_prefix` unit tests (new)
4140    // ---------------------------------------------------------------------------
4141
4142    #[test]
4143    fn curie_prefix_returns_prefix_for_curie() {
4144        assert_eq!(super::curie_prefix("knora-api:arkUrl"), Some("knora-api"));
4145        assert_eq!(super::curie_prefix("beol:hasTitle"), Some("beol"));
4146    }
4147
4148    #[test]
4149    fn curie_prefix_returns_none_for_full_iri() {
4150        // http:// starts with "//" after the colon → not a CURIE prefix
4151        assert_eq!(
4152            super::curie_prefix("http://api.dasch.swiss/ontology/0801/beol/v2#hasTitle"),
4153            None
4154        );
4155    }
4156
4157    #[test]
4158    fn curie_prefix_returns_none_for_no_colon() {
4159        assert_eq!(super::curie_prefix("hasTitle"), None);
4160    }
4161
4162    // ---------------------------------------------------------------------------
4163    // Sibling-IRI resolution / self-loop / delimiter unit tests (new)
4164    // ---------------------------------------------------------------------------
4165
4166    #[test]
4167    fn sibling_iri_trim_hash_delimiter() {
4168        // Namespace ending in '#' → sibling IRI without the '#'
4169        let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4170        let trimmed = namespace.trim_end_matches(['#', '/']);
4171        assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4172    }
4173
4174    #[test]
4175    fn sibling_iri_trim_slash_delimiter() {
4176        // Namespace ending in '/' → sibling IRI without the '/'
4177        let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2/";
4178        let trimmed = namespace.trim_end_matches(['#', '/']);
4179        assert_eq!(trimmed, "http://api.dasch.swiss/ontology/0801/biblio/v2");
4180    }
4181
4182    #[test]
4183    fn sibling_iri_self_loop_detected() {
4184        // When the sibling IRI (trimmed) equals the queried DM IRI (trimmed) → self-loop
4185        let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4186        let namespace = "http://api.dasch.swiss/ontology/0801/beol/v2#";
4187        let sibling_iri = namespace.trim_end_matches(['#', '/']);
4188        let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4189        assert_eq!(sibling_iri, queried_trimmed); // self-loop
4190    }
4191
4192    #[test]
4193    fn sibling_iri_different_ontology_is_not_self_loop() {
4194        let data_model_iri = "http://api.dasch.swiss/ontology/0801/beol/v2";
4195        let namespace = "http://api.dasch.swiss/ontology/0801/biblio/v2#";
4196        let sibling_iri = namespace.trim_end_matches(['#', '/']);
4197        let queried_trimmed = data_model_iri.trim_end_matches(['#', '/']);
4198        assert_ne!(sibling_iri, queried_trimmed); // not a self-loop
4199    }
4200
4201    #[test]
4202    fn missing_prefix_in_context_is_skipped() {
4203        // If a CURIE prefix is not in the @context map, no sibling IRI can be derived.
4204        let prefixes: HashMap<String, String> = HashMap::new();
4205        let result = prefixes.get("biblio");
4206        assert!(result.is_none()); // caller skips and warns
4207    }
4208
4209    // ---------------------------------------------------------------------------
4210    // `derive_access` unit tests (D1, Facet B)
4211    // ---------------------------------------------------------------------------
4212
4213    #[test]
4214    fn derive_access_rv() {
4215        assert_eq!(
4216            super::derive_access("RV"),
4217            Some(super::ResourceAccess::RestrictedView)
4218        );
4219    }
4220
4221    #[test]
4222    fn derive_access_v() {
4223        assert_eq!(super::derive_access("V"), Some(super::ResourceAccess::View));
4224    }
4225
4226    #[test]
4227    fn derive_access_m() {
4228        assert_eq!(super::derive_access("M"), Some(super::ResourceAccess::Edit));
4229    }
4230
4231    #[test]
4232    fn derive_access_d() {
4233        assert_eq!(
4234            super::derive_access("D"),
4235            Some(super::ResourceAccess::Delete)
4236        );
4237    }
4238
4239    #[test]
4240    fn derive_access_cr() {
4241        assert_eq!(
4242            super::derive_access("CR"),
4243            Some(super::ResourceAccess::Manage)
4244        );
4245    }
4246
4247    #[test]
4248    fn derive_access_unknown_is_none() {
4249        assert_eq!(super::derive_access("XYZ"), None);
4250    }
4251
4252    #[test]
4253    fn derive_access_empty_is_none() {
4254        assert_eq!(super::derive_access(""), None);
4255    }
4256
4257    // ---------------------------------------------------------------------------
4258    // `derive_visibility` unit tests (D1 ACL parse algorithm)
4259    // ---------------------------------------------------------------------------
4260
4261    #[test]
4262    fn derive_visibility_public_when_unknown_user_has_view() {
4263        // Real ACL from incunabula: UnknownUser gets V → public.
4264        let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|V knora-admin:KnownUser,knora-admin:UnknownUser";
4265        assert_eq!(
4266            super::derive_visibility(acl),
4267            Some(super::ResourceVisibility::Public)
4268        );
4269    }
4270
4271    #[test]
4272    fn derive_visibility_public_when_unknown_user_has_cr() {
4273        // UnknownUser granted CR (>= V) → public.
4274        let acl = "CR knora-admin:UnknownUser";
4275        assert_eq!(
4276            super::derive_visibility(acl),
4277            Some(super::ResourceVisibility::Public)
4278        );
4279    }
4280
4281    #[test]
4282    fn derive_visibility_public_restricted_when_unknown_user_has_rv() {
4283        // UnknownUser granted exactly RV → public (restricted view).
4284        let acl = "RV knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4285        assert_eq!(
4286            super::derive_visibility(acl),
4287            Some(super::ResourceVisibility::PublicRestricted)
4288        );
4289    }
4290
4291    #[test]
4292    fn derive_visibility_logged_in_when_known_user_has_rv_unknown_absent() {
4293        // UnknownUser absent; KnownUser gets RV → logged-in users.
4294        let acl = "RV knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4295        assert_eq!(
4296            super::derive_visibility(acl),
4297            Some(super::ResourceVisibility::LoggedInUsers)
4298        );
4299    }
4300
4301    #[test]
4302    fn derive_visibility_logged_in_when_known_user_has_v() {
4303        // KnownUser ≥ RV (has V) and UnknownUser absent → logged-in users.
4304        let acl = "V knora-admin:KnownUser|CR knora-admin:ProjectAdmin";
4305        assert_eq!(
4306            super::derive_visibility(acl),
4307            Some(super::ResourceVisibility::LoggedInUsers)
4308        );
4309    }
4310
4311    #[test]
4312    fn derive_visibility_project_members_when_neither_world_group_granted() {
4313        // Only project-specific groups in ACL → project members only.
4314        let acl = "CR knora-admin:Creator,knora-admin:ProjectAdmin|M knora-admin:ProjectMember";
4315        assert_eq!(
4316            super::derive_visibility(acl),
4317            Some(super::ResourceVisibility::ProjectMembers)
4318        );
4319    }
4320
4321    #[test]
4322    fn derive_visibility_empty_string_is_none() {
4323        assert_eq!(super::derive_visibility(""), None);
4324    }
4325
4326    #[test]
4327    fn derive_visibility_whitespace_only_is_none() {
4328        assert_eq!(super::derive_visibility("   "), None);
4329    }
4330
4331    #[test]
4332    fn derive_visibility_malformed_entry_without_space_is_skipped() {
4333        // "CRMALFORMED" has no space — skip it; the rest of the ACL may still parse.
4334        let acl = "CRMALFORMED|CR knora-admin:ProjectAdmin";
4335        // Only valid entry is CR ProjectAdmin; neither world group granted → ProjectMembers.
4336        assert_eq!(
4337            super::derive_visibility(acl),
4338            Some(super::ResourceVisibility::ProjectMembers)
4339        );
4340    }
4341
4342    #[test]
4343    fn derive_visibility_unknown_code_ranks_zero_no_implicit_grant() {
4344        // Unknown code "BOGUS" ranks 0 — even for UnknownUser, no implicit grant.
4345        let acl = "BOGUS knora-admin:UnknownUser|CR knora-admin:ProjectAdmin";
4346        // UnknownUser rank = 0 (< RV); KnownUser rank = 0 → ProjectMembers.
4347        assert_eq!(
4348            super::derive_visibility(acl),
4349            Some(super::ResourceVisibility::ProjectMembers)
4350        );
4351    }
4352
4353    #[test]
4354    fn derive_visibility_same_group_two_entries_max_wins() {
4355        // UnknownUser appears in two entries: RV and V. Max is V → public.
4356        let acl = "RV knora-admin:UnknownUser|V knora-admin:UnknownUser";
4357        assert_eq!(
4358            super::derive_visibility(acl),
4359            Some(super::ResourceVisibility::Public)
4360        );
4361    }
4362
4363    #[test]
4364    fn derive_visibility_both_world_groups_unknown_user_decides() {
4365        // Both UnknownUser (V) and KnownUser (CR) present — UnknownUser's grant decides.
4366        // UnknownUser ≥ V → public (not logged-in users, even though KnownUser is higher).
4367        let acl = "V knora-admin:UnknownUser|CR knora-admin:KnownUser";
4368        assert_eq!(
4369            super::derive_visibility(acl),
4370            Some(super::ResourceVisibility::Public)
4371        );
4372    }
4373
4374    #[test]
4375    fn derive_visibility_super_unknown_user_does_not_match() {
4376        // A hypothetical "SuperUnknownUser" must NOT be treated as UnknownUser
4377        // (exact local-name match only, never substring contains).
4378        let acl = "CR knora-admin:SuperUnknownUser|CR knora-admin:ProjectAdmin";
4379        // SuperUnknownUser doesn't match → neither world group → ProjectMembers.
4380        assert_eq!(
4381            super::derive_visibility(acl),
4382            Some(super::ResourceVisibility::ProjectMembers)
4383        );
4384    }
4385
4386    #[test]
4387    fn derive_visibility_all_malformed_entries_no_space_returns_none() {
4388        // Every entry lacks a space separator (no "<CODE> <group>" shape).
4389        // `parsed_any` stays false → the function must return None, not
4390        // fall through to a default visibility.
4391        let acl = "NOSPACE|ALSONOSPACE|STILLNOSPACE";
4392        assert_eq!(
4393            super::derive_visibility(acl),
4394            None,
4395            "all-malformed ACL (no space in any entry) must return None"
4396        );
4397    }
4398
4399    // ---------------------------------------------------------------------------
4400    // Value-type parse matrix unit tests (pure — no HTTP)
4401    // ---------------------------------------------------------------------------
4402
4403    use crate::model::ValueType;
4404    use crate::model::resource::{DatePoint, DateValue, FileValue, ValueContent};
4405
4406    // ── TextValue ────────────────────────────────────────────────────────────────
4407
4408    #[test]
4409    fn parse_value_text_plain() {
4410        let obj = serde_json::json!({
4411            "@type": "knora-api:TextValue",
4412            "knora-api:valueAsString": "Hello world"
4413        });
4414        let (content, is_link) = super::parse_value_content(&obj);
4415        assert_eq!(content, ValueContent::Text("Hello world".into()));
4416        assert!(!is_link);
4417    }
4418
4419    #[test]
4420    fn parse_value_text_standoff_xml_stripped() {
4421        // textValueAsXml present → html_to_text is applied (standoff path).
4422        let obj = serde_json::json!({
4423            "@type": "knora-api:TextValue",
4424            "knora-api:textValueAsXml": "<p>Hello <b>world</b></p>",
4425            "knora-api:valueAsString": "This is ignored when xml present"
4426        });
4427        let (content, is_link) = super::parse_value_content(&obj);
4428        // html_to_text strips tags; exact output depends on the util helper.
4429        assert!(matches!(content, ValueContent::Text(_)));
4430        assert!(!is_link);
4431        if let ValueContent::Text(s) = content {
4432            // Must not contain raw HTML tags.
4433            assert!(!s.contains('<'), "no raw tags: {s:?}");
4434            assert!(s.contains("Hello"), "text retained: {s:?}");
4435        }
4436    }
4437
4438    // ── IntValue ─────────────────────────────────────────────────────────────────
4439
4440    #[test]
4441    fn parse_value_integer() {
4442        let obj = serde_json::json!({
4443            "@type": "knora-api:IntValue",
4444            "knora-api:intValueAsInt": 42
4445        });
4446        let (content, is_link) = super::parse_value_content(&obj);
4447        assert_eq!(content, ValueContent::Integer(42));
4448        assert!(!is_link);
4449    }
4450
4451    #[test]
4452    fn parse_value_integer_negative() {
4453        let obj = serde_json::json!({
4454            "@type": "knora-api:IntValue",
4455            "knora-api:intValueAsInt": -7
4456        });
4457        let (content, _) = super::parse_value_content(&obj);
4458        assert_eq!(content, ValueContent::Integer(-7));
4459    }
4460
4461    // ── DecimalValue ─────────────────────────────────────────────────────────────
4462
4463    #[test]
4464    fn parse_value_decimal_object_form() {
4465        // `{"@value": "3.14159", "@type": "xsd:decimal"}` form.
4466        let obj = serde_json::json!({
4467            "@type": "knora-api:DecimalValue",
4468            "knora-api:decimalValueAsDecimal": {"@value": "3.14159", "@type": "xsd:decimal"}
4469        });
4470        let (content, is_link) = super::parse_value_content(&obj);
4471        assert_eq!(content, ValueContent::Decimal("3.14159".into()));
4472        assert!(!is_link);
4473    }
4474
4475    #[test]
4476    fn parse_value_decimal_bare_string_form() {
4477        let obj = serde_json::json!({
4478            "@type": "knora-api:DecimalValue",
4479            "knora-api:decimalValueAsDecimal": "2.71828"
4480        });
4481        let (content, _) = super::parse_value_content(&obj);
4482        assert_eq!(content, ValueContent::Decimal("2.71828".into()));
4483    }
4484
4485    // ── BooleanValue ─────────────────────────────────────────────────────────────
4486
4487    #[test]
4488    fn parse_value_boolean_true() {
4489        let obj = serde_json::json!({
4490            "@type": "knora-api:BooleanValue",
4491            "knora-api:booleanValueAsBoolean": true
4492        });
4493        let (content, is_link) = super::parse_value_content(&obj);
4494        assert_eq!(content, ValueContent::Boolean(true));
4495        assert!(!is_link);
4496    }
4497
4498    #[test]
4499    fn parse_value_boolean_false() {
4500        let obj = serde_json::json!({
4501            "@type": "knora-api:BooleanValue",
4502            "knora-api:booleanValueAsBoolean": false
4503        });
4504        let (content, _) = super::parse_value_content(&obj);
4505        assert_eq!(content, ValueContent::Boolean(false));
4506    }
4507
4508    // ── DateValue ────────────────────────────────────────────────────────────────
4509
4510    #[test]
4511    fn parse_value_date_single_point() {
4512        // start == end → single-point date (year-only, CE).
4513        let obj = serde_json::json!({
4514            "@type": "knora-api:DateValue",
4515            "knora-api:dateValueHasCalendar": "GREGORIAN",
4516            "knora-api:dateValueHasStartYear": 1489,
4517            "knora-api:dateValueHasStartEra": "CE",
4518            "knora-api:dateValueHasEndYear": 1489,
4519            "knora-api:dateValueHasEndEra": "CE"
4520        });
4521        let (content, is_link) = super::parse_value_content(&obj);
4522        assert!(!is_link);
4523        let expected = ValueContent::Date(DateValue {
4524            calendar: "GREGORIAN".into(),
4525            start: DatePoint {
4526                year: Some(1489),
4527                month: None,
4528                day: None,
4529                era: Some("CE".into()),
4530            },
4531            end: DatePoint {
4532                year: Some(1489),
4533                month: None,
4534                day: None,
4535                era: Some("CE".into()),
4536            },
4537        });
4538        assert_eq!(content, expected);
4539    }
4540
4541    #[test]
4542    fn parse_value_date_range() {
4543        // start != end → range.
4544        let obj = serde_json::json!({
4545            "@type": "knora-api:DateValue",
4546            "knora-api:dateValueHasCalendar": "GREGORIAN",
4547            "knora-api:dateValueHasStartYear": 1489,
4548            "knora-api:dateValueHasStartEra": "CE",
4549            "knora-api:dateValueHasEndYear": 1490,
4550            "knora-api:dateValueHasEndEra": "CE"
4551        });
4552        let (content, _) = super::parse_value_content(&obj);
4553        if let ValueContent::Date(dv) = content {
4554            assert_eq!(dv.start.year, Some(1489));
4555            assert_eq!(dv.end.year, Some(1490));
4556            assert_ne!(dv.start, dv.end, "range: start != end");
4557        } else {
4558            panic!("expected DateValue, got {content:?}");
4559        }
4560    }
4561
4562    #[test]
4563    fn parse_value_date_full_day_precision() {
4564        // Year + month + day + era (full Julian day).
4565        let obj = serde_json::json!({
4566            "@type": "knora-api:DateValue",
4567            "knora-api:dateValueHasCalendar": "JULIAN",
4568            "knora-api:dateValueHasStartYear": 1456,
4569            "knora-api:dateValueHasStartMonth": 3,
4570            "knora-api:dateValueHasStartDay": 14,
4571            "knora-api:dateValueHasStartEra": "CE",
4572            "knora-api:dateValueHasEndYear": 1456,
4573            "knora-api:dateValueHasEndMonth": 3,
4574            "knora-api:dateValueHasEndDay": 14,
4575            "knora-api:dateValueHasEndEra": "CE"
4576        });
4577        let (content, _) = super::parse_value_content(&obj);
4578        if let ValueContent::Date(dv) = content {
4579            assert_eq!(dv.calendar, "JULIAN");
4580            assert_eq!(dv.start.month, Some(3));
4581            assert_eq!(dv.start.day, Some(14));
4582        } else {
4583            panic!("expected DateValue, got {content:?}");
4584        }
4585    }
4586
4587    #[test]
4588    fn parse_value_date_no_year_falls_back_to_raw() {
4589        // A date object with no year on either point → raw fallback.
4590        let obj = serde_json::json!({
4591            "@type": "knora-api:DateValue",
4592            "knora-api:dateValueHasCalendar": "GREGORIAN",
4593            "knora-api:valueAsString": "some date"
4594        });
4595        let (content, _) = super::parse_value_content(&obj);
4596        assert!(
4597            matches!(content, ValueContent::Raw { value_type, .. } if value_type == "date"),
4598            "missing years must degrade to Raw date"
4599        );
4600    }
4601
4602    // ── TimeValue ────────────────────────────────────────────────────────────────
4603
4604    #[test]
4605    fn parse_value_time() {
4606        let obj = serde_json::json!({
4607            "@type": "knora-api:TimeValue",
4608            "knora-api:timeValueAsTimeStamp": {"@value": "2021-01-01T12:00:00Z", "@type": "xsd:dateTimeStamp"}
4609        });
4610        let (content, is_link) = super::parse_value_content(&obj);
4611        assert_eq!(content, ValueContent::Time("2021-01-01T12:00:00Z".into()));
4612        assert!(!is_link);
4613    }
4614
4615    #[test]
4616    fn parse_value_time_bare_string() {
4617        let obj = serde_json::json!({
4618            "@type": "knora-api:TimeValue",
4619            "knora-api:timeValueAsTimeStamp": "2022-06-01T00:00:00Z"
4620        });
4621        let (content, _) = super::parse_value_content(&obj);
4622        assert_eq!(content, ValueContent::Time("2022-06-01T00:00:00Z".into()));
4623    }
4624
4625    // ── UriValue ─────────────────────────────────────────────────────────────────
4626
4627    #[test]
4628    fn parse_value_uri() {
4629        let obj = serde_json::json!({
4630            "@type": "knora-api:UriValue",
4631            "knora-api:uriValueAsUri": {"@value": "https://example.com", "@type": "xsd:anyURI"}
4632        });
4633        let (content, is_link) = super::parse_value_content(&obj);
4634        assert_eq!(content, ValueContent::Uri("https://example.com".into()));
4635        assert!(!is_link);
4636    }
4637
4638    // ── ColorValue ───────────────────────────────────────────────────────────────
4639
4640    #[test]
4641    fn parse_value_color() {
4642        let obj = serde_json::json!({
4643            "@type": "knora-api:ColorValue",
4644            "knora-api:colorValueAsColor": "#ff0000"
4645        });
4646        let (content, is_link) = super::parse_value_content(&obj);
4647        assert_eq!(content, ValueContent::Color("#ff0000".into()));
4648        assert!(!is_link);
4649    }
4650
4651    // ── GeonameValue ─────────────────────────────────────────────────────────────
4652
4653    #[test]
4654    fn parse_value_geoname() {
4655        let obj = serde_json::json!({
4656            "@type": "knora-api:GeonameValue",
4657            "knora-api:geonameValueAsGeonameCode": "2661552"
4658        });
4659        let (content, is_link) = super::parse_value_content(&obj);
4660        assert_eq!(content, ValueContent::Geoname("2661552".into()));
4661        assert!(!is_link);
4662    }
4663
4664    // ── ListValue ────────────────────────────────────────────────────────────────
4665
4666    #[test]
4667    fn parse_value_list_item() {
4668        let obj = serde_json::json!({
4669            "@type": "knora-api:ListValue",
4670            "knora-api:listValueAsListNode": {"@id": "http://rdfh.ch/lists/0001/node1"}
4671        });
4672        let (content, is_link) = super::parse_value_content(&obj);
4673        assert_eq!(
4674            content,
4675            ValueContent::ListItem {
4676                node_iri: "http://rdfh.ch/lists/0001/node1".into(),
4677                label: None, // resolved later
4678            }
4679        );
4680        assert!(!is_link);
4681    }
4682
4683    // ── LinkValue ────────────────────────────────────────────────────────────────
4684
4685    #[test]
4686    fn parse_value_link_with_embedded_target() {
4687        let obj = serde_json::json!({
4688            "@type": "knora-api:LinkValue",
4689            "knora-api:linkValueHasTarget": {
4690                "@id": "http://rdfh.ch/0803/res1",
4691                "@type": "incunabula:Book",
4692                "rdfs:label": "Incunabula Book 1"
4693            }
4694        });
4695        let (content, is_link) = super::parse_value_content(&obj);
4696        assert!(is_link, "LinkValue must set is_link=true");
4697        assert_eq!(
4698            content,
4699            ValueContent::Link {
4700                target_iri: "http://rdfh.ch/0803/res1".into(),
4701                target_label: Some("Incunabula Book 1".into()),
4702            }
4703        );
4704    }
4705
4706    #[test]
4707    fn parse_value_link_with_target_iri_only() {
4708        // `linkValueHasTargetIri` only, no embedded target object.
4709        let obj = serde_json::json!({
4710            "@type": "knora-api:LinkValue",
4711            "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res2"}
4712        });
4713        let (content, is_link) = super::parse_value_content(&obj);
4714        assert!(is_link);
4715        assert_eq!(
4716            content,
4717            ValueContent::Link {
4718                target_iri: "http://rdfh.ch/0803/res2".into(),
4719                target_label: None,
4720            }
4721        );
4722    }
4723
4724    // ── StillImageFileValue ───────────────────────────────────────────────────────
4725
4726    #[test]
4727    fn parse_value_still_image_file() {
4728        let obj = serde_json::json!({
4729            "@type": "knora-api:StillImageFileValue",
4730            "knora-api:fileValueHasFilename": "image.jp2",
4731            "knora-api:fileValueAsUrl": {"@value": "https://iiif.example.com/image.jp2/full/max/0/default.jpg"},
4732            "knora-api:stillImageFileValueHasDimX": 1200,
4733            "knora-api:stillImageFileValueHasDimY": 800
4734        });
4735        let (content, is_link) = super::parse_value_content(&obj);
4736        assert!(!is_link);
4737        assert_eq!(
4738            content,
4739            ValueContent::File(FileValue {
4740                value_type: ValueType::StillImage,
4741                filename: "image.jp2".into(),
4742                url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
4743                width: Some(1200),
4744                height: Some(800),
4745            })
4746        );
4747    }
4748
4749    #[test]
4750    fn parse_value_still_image_external_file_value() {
4751        // StillImageExternalFileValue variant (ADR-0013: StillImage* → still-image).
4752        let obj = serde_json::json!({
4753            "@type": "knora-api:StillImageExternalFileValue",
4754            "knora-api:fileValueHasFilename": "external.jpg",
4755            "knora-api:fileValueAsUrl": {"@value": "https://iiif.external.com/image.jpg"}
4756        });
4757        let (content, _) = super::parse_value_content(&obj);
4758        if let ValueContent::File(fv) = content {
4759            assert_eq!(
4760                fv.value_type,
4761                ValueType::StillImage,
4762                "StillImageExternal* → StillImage"
4763            );
4764        } else {
4765            panic!("expected File, got {content:?}");
4766        }
4767    }
4768
4769    // ── MovingImageFileValue ──────────────────────────────────────────────────────
4770
4771    #[test]
4772    fn parse_value_moving_image_file() {
4773        let obj = serde_json::json!({
4774            "@type": "knora-api:MovingImageFileValue",
4775            "knora-api:fileValueHasFilename": "video.mp4",
4776            "knora-api:fileValueAsUrl": {"@value": "https://example.com/video.mp4"}
4777        });
4778        let (content, is_link) = super::parse_value_content(&obj);
4779        assert!(!is_link);
4780        assert_eq!(
4781            content,
4782            ValueContent::File(FileValue {
4783                value_type: ValueType::MovingImage,
4784                filename: "video.mp4".into(),
4785                url: "https://example.com/video.mp4".into(),
4786                width: None,
4787                height: None,
4788            })
4789        );
4790    }
4791
4792    // ── AudioFileValue ────────────────────────────────────────────────────────────
4793
4794    #[test]
4795    fn parse_value_audio_file() {
4796        let obj = serde_json::json!({
4797            "@type": "knora-api:AudioFileValue",
4798            "knora-api:fileValueHasFilename": "sound.wav",
4799            "knora-api:fileValueAsUrl": {"@value": "https://example.com/sound.wav"}
4800        });
4801        let (content, _) = super::parse_value_content(&obj);
4802        assert_eq!(
4803            content,
4804            ValueContent::File(FileValue {
4805                value_type: ValueType::Audio,
4806                filename: "sound.wav".into(),
4807                url: "https://example.com/sound.wav".into(),
4808                width: None,
4809                height: None,
4810            })
4811        );
4812    }
4813
4814    // ── DocumentFileValue ─────────────────────────────────────────────────────────
4815
4816    #[test]
4817    fn parse_value_document_file() {
4818        let obj = serde_json::json!({
4819            "@type": "knora-api:DocumentFileValue",
4820            "knora-api:fileValueHasFilename": "doc.pdf",
4821            "knora-api:fileValueAsUrl": {"@value": "https://example.com/doc.pdf"}
4822        });
4823        let (content, _) = super::parse_value_content(&obj);
4824        assert_eq!(
4825            content,
4826            ValueContent::File(FileValue {
4827                value_type: ValueType::Document,
4828                filename: "doc.pdf".into(),
4829                url: "https://example.com/doc.pdf".into(),
4830                width: None,
4831                height: None,
4832            })
4833        );
4834    }
4835
4836    // ── ArchiveFileValue ──────────────────────────────────────────────────────────
4837
4838    #[test]
4839    fn parse_value_archive_file() {
4840        let obj = serde_json::json!({
4841            "@type": "knora-api:ArchiveFileValue",
4842            "knora-api:fileValueHasFilename": "data.zip",
4843            "knora-api:fileValueAsUrl": {"@value": "https://example.com/data.zip"}
4844        });
4845        let (content, _) = super::parse_value_content(&obj);
4846        assert_eq!(
4847            content,
4848            ValueContent::File(FileValue {
4849                value_type: ValueType::Archive,
4850                filename: "data.zip".into(),
4851                url: "https://example.com/data.zip".into(),
4852                width: None,
4853                height: None,
4854            })
4855        );
4856    }
4857
4858    // ── TextFileValue (maps to Document per ADR-0013) ─────────────────────────────
4859
4860    #[test]
4861    fn parse_value_text_file_value_maps_to_document() {
4862        let obj = serde_json::json!({
4863            "@type": "knora-api:TextFileValue",
4864            "knora-api:fileValueHasFilename": "text.txt",
4865            "knora-api:fileValueAsUrl": {"@value": "https://example.com/text.txt"}
4866        });
4867        let (content, _) = super::parse_value_content(&obj);
4868        if let ValueContent::File(fv) = content {
4869            assert_eq!(
4870                fv.value_type,
4871                ValueType::Document,
4872                "TextFileValue → Document"
4873            );
4874        } else {
4875            panic!("expected File, got {content:?}");
4876        }
4877    }
4878
4879    // ── IntervalValue (raw fallback) ──────────────────────────────────────────────
4880
4881    #[test]
4882    fn parse_value_interval_raw_fallback() {
4883        let obj = serde_json::json!({
4884            "@type": "knora-api:IntervalValue",
4885            "knora-api:intervalValueHasStart": {"@value": "0.0", "@type": "xsd:decimal"},
4886            "knora-api:intervalValueHasEnd": {"@value": "10.5", "@type": "xsd:decimal"},
4887            "knora-api:valueAsString": "0.0 - 10.5"
4888        });
4889        let (content, is_link) = super::parse_value_content(&obj);
4890        assert!(!is_link);
4891        assert!(
4892            matches!(content, ValueContent::Raw { ref value_type, .. } if value_type == "interval"),
4893            "IntervalValue must degrade to Raw with token 'interval'"
4894        );
4895        if let ValueContent::Raw { text, .. } = content {
4896            assert_eq!(text, "0.0 - 10.5");
4897        }
4898    }
4899
4900    #[test]
4901    fn parse_value_geom_raw_fallback() {
4902        let obj = serde_json::json!({
4903            "@type": "knora-api:GeomValue",
4904            "knora-api:geometryValueAsGeometry": "POINT(1 2)"
4905        });
4906        let (content, _) = super::parse_value_content(&obj);
4907        assert!(
4908            matches!(content, ValueContent::Raw { value_type, .. } if value_type == "geom"),
4909            "GeomValue must degrade to Raw with token 'geom'"
4910        );
4911    }
4912
4913    // ── Value wrapper: per-value comment (`knora-api:valueHasComment`) ───────────
4914
4915    #[test]
4916    fn parse_value_with_comment() {
4917        let obj = serde_json::json!({
4918            "@type": "knora-api:TextValue",
4919            "knora-api:valueAsString": "Hello world",
4920            "knora-api:valueHasComment": "reading uncertain"
4921        });
4922        let (value, is_link) = super::parse_value(&obj);
4923        assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4924        assert_eq!(value.comment.as_deref(), Some("reading uncertain"));
4925        assert!(!is_link);
4926    }
4927
4928    #[test]
4929    fn parse_value_without_comment() {
4930        let obj = serde_json::json!({
4931            "@type": "knora-api:TextValue",
4932            "knora-api:valueAsString": "Hello world"
4933        });
4934        let (value, is_link) = super::parse_value(&obj);
4935        assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4936        assert_eq!(value.comment, None);
4937        assert!(!is_link);
4938    }
4939
4940    #[test]
4941    fn parse_value_with_empty_comment() {
4942        let obj = serde_json::json!({
4943            "@type": "knora-api:TextValue",
4944            "knora-api:valueAsString": "Hello world",
4945            "knora-api:valueHasComment": ""
4946        });
4947        let (value, is_link) = super::parse_value(&obj);
4948        assert_eq!(value.content, ValueContent::Text("Hello world".into()));
4949        assert_eq!(value.comment, None);
4950        assert!(!is_link);
4951    }
4952
4953    // ── Field-name derivation (D3) ────────────────────────────────────────────────
4954
4955    #[test]
4956    fn parse_value_link_is_link_true() {
4957        // LinkValue → is_link = true (used by caller to strip "Value" suffix).
4958        let obj = serde_json::json!({
4959            "@type": "knora-api:LinkValue",
4960            "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4961        });
4962        let (_, is_link) = super::parse_value_content(&obj);
4963        assert!(
4964            is_link,
4965            "LinkValue must report is_link=true for name derivation"
4966        );
4967    }
4968
4969    #[test]
4970    fn field_name_link_strips_value_suffix() {
4971        // A LinkValue object whose key ends in `Value` → is_link=true → suffix stripped.
4972        // Uses the real `parse_value` path to determine is_link, then applies the
4973        // same name-derivation logic the production code uses (D3).
4974        let key = "incunabula:isPartOfBookValue";
4975        let link_obj = serde_json::json!({
4976            "@type": "knora-api:LinkValue",
4977            "knora-api:linkValueHasTargetIri": {"@id": "http://rdfh.ch/0803/res1"}
4978        });
4979        let (_, is_link) = super::parse_value_content(&link_obj);
4980        assert!(
4981            is_link,
4982            "LinkValue must report is_link=true for name derivation"
4983        );
4984
4985        let raw_name = super::local_name(key).to_string();
4986        // is_link = true → strip "Value" suffix (same logic as production code).
4987        let name = if is_link {
4988            raw_name
4989                .strip_suffix("Value")
4990                .unwrap_or(&raw_name)
4991                .to_string()
4992        } else {
4993            raw_name
4994        };
4995        assert_eq!(name, "isPartOfBook");
4996    }
4997
4998    #[test]
4999    fn field_name_non_link_does_not_strip_value_suffix() {
5000        // A TextValue object whose key ends in `Value` → is_link=false → suffix KEPT.
5001        // Uses the real `parse_value` path (not an inline re-implementation) to
5002        // determine is_link, then confirms the production name-derivation preserves
5003        // the trailing "Value" (D3: only link-typed fields are stripped).
5004        let key = "incunabula:hasAValue";
5005        let text_obj = serde_json::json!({
5006            "@type": "knora-api:TextValue",
5007            "knora-api:valueAsString": "some text"
5008        });
5009        let (_, is_link) = super::parse_value_content(&text_obj);
5010        assert!(!is_link, "TextValue must report is_link=false");
5011
5012        let raw_name = super::local_name(key).to_string();
5013        // is_link = false → no stripping (same logic as production code).
5014        let name = if is_link {
5015            raw_name
5016                .strip_suffix("Value")
5017                .unwrap_or(&raw_name)
5018                .to_string()
5019        } else {
5020            raw_name
5021        };
5022        assert_eq!(
5023            name, "hasAValue",
5024            "non-link ending in Value must NOT be stripped; is_link={is_link}"
5025        );
5026    }
5027
5028    // ── Field / non-field discrimination (ADR-0013) ───────────────────────────────
5029
5030    #[test]
5031    fn has_value_class_type_rejects_xsd_any_uri() {
5032        // `versionArkUrl` has @type `xsd:anyURI` — NOT a knora-api *Value → excluded.
5033        let obj = serde_json::json!({
5034            "@value": "http://ark.dasch.swiss/ark:/…",
5035            "@type": "xsd:anyURI"
5036        });
5037        assert!(
5038            !super::has_value_class_type(&obj),
5039            "xsd:anyURI must not pass the value-class test"
5040        );
5041    }
5042
5043    #[test]
5044    fn has_value_class_type_rejects_scalar() {
5045        // Bare string value → not an object → not a value field.
5046        let obj = serde_json::json!("just a string");
5047        assert!(!super::has_value_class_type(&obj));
5048    }
5049
5050    #[test]
5051    fn has_value_class_type_accepts_text_value() {
5052        let obj = serde_json::json!({
5053            "@type": "knora-api:TextValue",
5054            "knora-api:valueAsString": "hello"
5055        });
5056        assert!(super::has_value_class_type(&obj));
5057    }
5058
5059    #[test]
5060    fn has_value_class_type_accepts_still_image_file_value() {
5061        let obj = serde_json::json!({
5062            "@type": "knora-api:StillImageFileValue",
5063            "knora-api:fileValueHasFilename": "img.jp2"
5064        });
5065        assert!(super::has_value_class_type(&obj));
5066    }
5067
5068    // ── build_prefix_map ──────────────────────────────────────────────────────────
5069
5070    #[test]
5071    fn build_prefix_map_string_entries_only() {
5072        let ctx = Some(serde_json::json!({
5073            "incunabula": "http://api.dasch.swiss/ontology/0803/incunabula/v2#",
5074            "knora-api": "http://api.knora.org/ontology/knora-api/v2#",
5075            // Object-valued entry — must be skipped.
5076            "someterm": {"@id": "http://example.com/term", "@type": "@id"}
5077        }));
5078        let map = super::build_prefix_map(&ctx);
5079        assert_eq!(
5080            map.get("incunabula").map(String::as_str),
5081            Some("http://api.dasch.swiss/ontology/0803/incunabula/v2#")
5082        );
5083        assert_eq!(
5084            map.get("knora-api").map(String::as_str),
5085            Some("http://api.knora.org/ontology/knora-api/v2#")
5086        );
5087        assert!(
5088            !map.contains_key("someterm"),
5089            "object-valued entry must be skipped"
5090        );
5091    }
5092
5093    #[test]
5094    fn build_prefix_map_empty_when_no_context() {
5095        let map = super::build_prefix_map(&None);
5096        assert!(map.is_empty());
5097    }
5098
5099    // ── compact_value_text (raw fallback) ─────────────────────────────────────────
5100
5101    #[test]
5102    fn compact_value_text_excludes_meta_keys() {
5103        let obj = serde_json::json!({
5104            "@id": "http://rdfh.ch/0803/val1",
5105            "@type": "knora-api:GeomValue",
5106            "knora-api:geometryValueAsGeometry": "POINT(1 2)"
5107        });
5108        let text = super::compact_value_text(&obj);
5109        // Must include the geometry key, not the metadata keys.
5110        assert!(
5111            text.contains("geometryValueAsGeometry"),
5112            "geometry key present: {text}"
5113        );
5114        assert!(!text.contains("@id"), "@id must be excluded: {text}");
5115        assert!(!text.contains("@type"), "@type must be excluded: {text}");
5116    }
5117
5118    #[test]
5119    fn compact_value_text_all_meta_yields_empty() {
5120        let obj = serde_json::json!({
5121            "@id": "http://rdfh.ch/0803/val1",
5122            "@type": "knora-api:IntervalValue"
5123        });
5124        let text = super::compact_value_text(&obj);
5125        assert!(
5126            text.is_empty(),
5127            "all-meta object must yield empty string: {text:?}"
5128        );
5129    }
5130}