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