Skip to main content

dsp_cli/client/
http.rs

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