Skip to main content

dsp_cli/client/
http.rs

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