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