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