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