Skip to main content

dsp_cli/actions/vre/
resource.rs

1//! Actions for `dsp vre resource { list | describe }`.
2//!
3//! Phase 8a implements `list` — the first instance-side read command. Introduces
4//! pagination (`--page` / `--all`) and the first real use of
5//! `MetaContext.filter_warning` (dsp-cli/ADR-0007 silent-filter disclosure). Class-IRI
6//! resolution follows D1 (plan 022): full-IRI bypass, optional `--data-model`
7//! scope, bare-name scan across all project data-models.
8//!
9//! Phase 8b implements `describe` — fetches a single resource's envelope metadata
10//! by its internal IRI. Includes the D2 cross-project guard and D3 filter_warning.
11
12use std::path::Path;
13
14use crate::actions::auth_state::read_auth_state;
15use crate::cli::{ResourceDescribeArgs, ResourceListArgs};
16use crate::client::DspClient;
17use crate::config::{AuthCache, Config, resolve_token};
18use crate::diagnostic::Diagnostic;
19use crate::model::ResourceSummary;
20use crate::render::{MetaContext, Renderer, ResourceListPagination, ResourceListView};
21use crate::util::text::strip_control_chars;
22
23/// Resolved resource-type reference: IRI, local name, and optionally the
24/// data-model IRI (absent on path A when the class IRI has no `#`).
25struct ResourceTypeRef {
26    iri: String,
27    name: String,
28    data_model_iri: Option<String>,
29}
30
31/// List resource instances of a given type within a project.
32///
33/// Authentication is optional (instance-side read; anonymous callers see only
34/// publicly-visible resources per dsp-cli/ADR-0007). Reads `DSP_TOKEN` from the
35/// environment and delegates all work to `run_list_impl` with injectable seams
36/// for deterministic testing.
37pub fn list(
38    args: &ResourceListArgs,
39    cfg: &Config,
40    client: &dyn DspClient,
41    renderer: &mut dyn Renderer,
42) -> Result<(), Diagnostic> {
43    let env_token = std::env::var("DSP_TOKEN").ok();
44    run_list_impl(args, cfg, client, renderer, env_token, None)
45}
46
47/// Internal entry point for `list` with injectable seams for testing.
48///
49/// - `env_token`: the `DSP_TOKEN` env value (read by the public `list` entry point before calling
50///   this, so tests never touch process env).
51/// - `cache_path`: `Some(path)` in tests to use a temp auth cache; `None` in production to use the
52///   default `~/.config/dsp-cli/auth.toml`.
53///
54/// **Auth-optional:** a cache-load failure ALWAYS falls back to an empty cache
55/// with a `tracing::warn!` — NEVER returns `Err`. For an instance-side read,
56/// a corrupt or missing `auth.toml` must still list anonymously.
57pub(crate) fn run_list_impl(
58    args: &ResourceListArgs,
59    cfg: &Config,
60    client: &dyn DspClient,
61    renderer: &mut dyn Renderer,
62    env_token: Option<String>,
63    cache_path: Option<&Path>,
64) -> Result<(), Diagnostic> {
65    // ── 1. --project required (fail-fast, BEFORE any cache/IO) ───────────────
66    let project = args
67        .project
68        .as_deref()
69        .ok_or_else(|| Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string()))?;
70
71    // ── 2. --resource-type required (fail-fast, BEFORE any cache/IO) ─────────
72    let resource_type_arg = args
73        .resource_type
74        .as_deref()
75        .ok_or_else(|| Diagnostic::Usage("--resource-type <name-or-IRI> is required".to_string()))?;
76
77    // ── 3. Load cache (auth-optional: failures fall back to empty cache) ──────
78    let cache_result = match cache_path {
79        Some(p) => AuthCache::load_from(p),
80        None => AuthCache::load(),
81    };
82    let cache = match cache_result {
83        Ok(c) => c,
84        Err(e) => {
85            crate::util::warn_auth_cache_load_failed(&e, "falling back to anonymous for resource list");
86            AuthCache::default()
87        }
88    };
89
90    // ── 4. Resolve token (optional) ───────────────────────────────────────────
91    let resolved = resolve_token(env_token, &cache, &cfg.server);
92    let token = resolved.as_ref().map(|r| r.token.as_str());
93
94    // ── 5. Build auth-state disclosure string ─────────────────────────────────
95    let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
96
97    // ── 6. D3: filter_warning — always Some for instance-side commands ────────
98    //
99    // Anonymous callers see only public resources; authenticated callers are
100    // still bounded by their permissions. Both warrant disclosure.
101    let filter_warning = if resolved.is_none() {
102        Some("results may be filtered; login to see private resources".to_string())
103    } else {
104        Some("results limited to your permissions".to_string())
105    };
106
107    // ── 7. Resolve project ────────────────────────────────────────────────────
108    let pref = client.resolve_project(&cfg.server, project)?;
109
110    // ── 8. D1: Resolve resource-type IRI ─────────────────────────────────────
111    //
112    // Three paths (plan 022 D1):
113    //   a. Full IRI (heuristic: contains `://`) → use directly; no scan.
114    //   b. `--data-model` given (name or IRI) → scope resolution to one DM.
115    //   c. Bare name, no `--data-model` → scan all project data-models.
116    let rt_ref = resolve_resource_type_iri(
117        resource_type_arg,
118        args.data_model.as_deref(),
119        &cfg.server,
120        &pref.iri,
121        client,
122        token,
123    )?;
124
125    // ── 8b. Resolve --order-by field name to property IRI ────────────────────
126    let order_by: Option<String> = if let Some(ob) = args.order_by.as_deref() {
127        Some(resolve_order_by_property(ob, &rt_ref, &cfg.server, client, token)?)
128    } else {
129        None
130    };
131
132    // ── 9. D5: Fetch pages ────────────────────────────────────────────────────
133    let (all_resources, pagination) = if args.all {
134        // Fetch all pages: 0, 1, 2, … until may_have_more is false.
135        let mut all: Vec<ResourceSummary> = Vec::new();
136        let mut page = 0u32;
137        loop {
138            let page_result =
139                client.list_resources(&cfg.server, &pref.iri, &rt_ref.iri, order_by.as_deref(), page, token)?;
140            all.extend(page_result.resources);
141            if !page_result.may_have_more_results {
142                break;
143            }
144            page += 1;
145        }
146        let pages_fetched = page + 1;
147        (all, ResourceListPagination::AllPages { pages_fetched })
148    } else {
149        // Single-page mode: fetch exactly the resolved page (default: 0).
150        let page = args.page.unwrap_or(0);
151        let page_result =
152            client.list_resources(&cfg.server, &pref.iri, &rt_ref.iri, order_by.as_deref(), page, token)?;
153        let may_have_more = page_result.may_have_more_results;
154        (
155            page_result.resources,
156            ResourceListPagination::SinglePage { page, may_have_more },
157        )
158    };
159
160    // ── 10. Capture total BEFORE client-side filter ───────────────────────────
161    let total = all_resources.len();
162
163    // ── 11. Apply --filter (case-insensitive substring over label) ────────────
164    let mut items = all_resources;
165    if let Some(ref f) = args.filter {
166        let lower = f.to_lowercase();
167        items.retain(|r| r.label.to_lowercase().contains(&lower));
168    }
169
170    // ── 12. Build view + meta and render ─────────────────────────────────────
171    let view = ResourceListView {
172        items,
173        total,
174        filter: args.filter.clone(),
175        resource_type: rt_ref.name,
176        pagination,
177    };
178    let meta = MetaContext {
179        server_label: cfg.server.clone(),
180        auth_state,
181        filter_warning,
182        count_caveat: None,
183        count_cost: None,
184    };
185    renderer.resources(&view, &meta)
186}
187
188/// Resolve the `resource_type` argument to a [`ResourceTypeRef`].
189///
190/// Three paths per D1 (plan 022):
191/// - Full IRI (contains `://`) → use as-is; extract local name with `local_name`; `data_model_iri`
192///   derived by stripping the `#fragment` (or `None` if no `#`).
193/// - `--data-model` given → scope to that one data-model.
194/// - Bare name, no `--data-model` → scan all project data-models.
195fn resolve_resource_type_iri(
196    resource_type_arg: &str,
197    data_model_arg: Option<&str>,
198    server: &str,
199    project_iri: &str,
200    client: &dyn DspClient,
201    token: Option<&str>,
202) -> Result<ResourceTypeRef, Diagnostic> {
203    // Path A: full IRI (heuristic: contains `://`)
204    if resource_type_arg.contains("://") {
205        // heuristic: looks like a full IRI → skip the scan
206        let name = local_name(resource_type_arg);
207        // Derive data_model_iri by stripping the #fragment, if present.
208        let data_model_iri = resource_type_arg.find('#').map(|idx| resource_type_arg[..idx].to_string());
209        return Ok(ResourceTypeRef { iri: resource_type_arg.to_string(), name, data_model_iri });
210    }
211
212    // Path B / C: bare name — possibly scoped by --data-model.
213    if let Some(dm_arg) = data_model_arg {
214        // Path B: --data-model given → scope to one data-model.
215        let dm_iri = if dm_arg.contains("://") {
216            // --data-model is a full IRI → use directly.
217            dm_arg.to_string()
218        } else {
219            // --data-model is a name → resolve via list_data_models.
220            let data_models = client.list_data_models(server, project_iri, token)?;
221            data_models
222                .iter()
223                .find(|dm| dm.iri == dm_arg || dm.name.eq_ignore_ascii_case(dm_arg))
224                .map(|dm| dm.iri.clone())
225                .ok_or_else(|| {
226                    let dm_disp: String = dm_arg.chars().take(80).collect();
227                    let dm_suffix = if dm_arg.chars().count() > 80 { "…" } else { "" };
228                    Diagnostic::NotFound(format!("data-model '{dm_disp}{dm_suffix}' not found in project on {server}"))
229                })?
230        };
231
232        // Fetch the data-model detail and find the resource-type by name.
233        let detail = client.describe_data_model(server, &dm_iri, token)?;
234        let matched = detail
235            .resource_types
236            .iter()
237            .find(|rt| rt.name.eq_ignore_ascii_case(resource_type_arg) || rt.iri == resource_type_arg)
238            .ok_or_else(|| {
239                let rt_disp: String = resource_type_arg.chars().take(80).collect();
240                let rt_suffix = if resource_type_arg.chars().count() > 80 {
241                    "…"
242                } else {
243                    ""
244                };
245                Diagnostic::NotFound(format!(
246                    "resource-type '{rt_disp}{rt_suffix}' not found in data-model '{name}' on {server}; \
247                     run `dsp vre resource-type list --project <project> --data-model {name} --server {server}` \
248                     to see available resource-types.",
249                    name = detail.name,
250                ))
251            })?;
252        return Ok(ResourceTypeRef {
253            iri: matched.iri.clone(),
254            name: matched.name.clone(),
255            data_model_iri: Some(dm_iri),
256        });
257    }
258
259    // Path C: bare name, no --data-model → scan all project data-models.
260    let data_models = client.list_data_models(server, project_iri, token)?;
261    if data_models.len() > 10 {
262        tracing::warn!(
263            count = data_models.len(),
264            "bare-name resource-type resolution will issue {} describe_data_model calls; \
265             use --data-model or a full IRI to avoid scanning",
266            data_models.len()
267        );
268    }
269
270    // Single accumulator: (resource_type_iri, rt_name, dm_iri, dm_name).
271    // Using one Vec eliminates the redundant parallel state and removes any need
272    // for `.expect()` in the match arm below.
273    let mut matches: Vec<(String, String, String, String)> = Vec::new(); // (resource_type_iri, rt_name, dm_iri, dm_name)
274
275    for dm in &data_models {
276        let detail = client.describe_data_model(server, &dm.iri, token)?;
277        for rt in &detail.resource_types {
278            if rt.name.eq_ignore_ascii_case(resource_type_arg) || rt.iri == resource_type_arg {
279                matches.push((rt.iri.clone(), rt.name.clone(), dm.iri.clone(), dm.name.clone()));
280            }
281        }
282    }
283
284    match matches.len() {
285        0 => {
286            let rt_disp: String = resource_type_arg.chars().take(80).collect();
287            let rt_suffix = if resource_type_arg.chars().count() > 80 {
288                "…"
289            } else {
290                ""
291            };
292            Err(Diagnostic::NotFound(format!(
293                "resource-type '{rt_disp}{rt_suffix}' not found in any data-model of the project on {server}; \
294                 run `dsp vre resource-type list --project <project> --server {server}` \
295                 to see available resource-types."
296            )))
297        }
298        1 => {
299            let (resource_type_iri, rt_name, dm_iri, _dm_name) = matches.remove(0);
300            Ok(ResourceTypeRef {
301                iri: resource_type_iri,
302                name: rt_name,
303                data_model_iri: Some(dm_iri),
304            })
305        }
306        _ => {
307            // Dedup data-model names: the same DM must not appear twice if a
308            // resource-type name appears multiple times within it.
309            let mut seen_dm_names: Vec<String> = Vec::new();
310            for (_, _, _, dm_name) in &matches {
311                if !seen_dm_names.contains(dm_name) {
312                    seen_dm_names.push(dm_name.clone());
313                }
314            }
315            let dm_list = seen_dm_names.join(", ");
316            let rt_disp: String = resource_type_arg.chars().take(80).collect();
317            let rt_suffix = if resource_type_arg.chars().count() > 80 {
318                "…"
319            } else {
320                ""
321            };
322            Err(Diagnostic::Usage(format!(
323                "resource-type '{rt_disp}{rt_suffix}' is ambiguous: found in data-models [{dm_list}]; \
324                 use --data-model to scope the search to one data-model."
325            )))
326        }
327    }
328}
329
330/// Resolve a `--order-by` argument to a complex-schema property IRI.
331///
332/// Two paths:
333/// - `order_by_arg` contains `://` → full-IRI bypass; returned verbatim, `describe_resource_type`
334///   is NOT called.
335/// - Bare field name → look up via `describe_resource_type(server, dm_iri, rt_iri, token)`, match
336///   `field.name` case-insensitively, return `field.iri`. If no match → `Diagnostic::Usage` with a
337///   hint pointing at `dsp vre resource-type describe` to list field names.
338///
339/// Requires `rt_ref.data_model_iri` on the bare-name path; if `None`
340/// (path A class IRI without `#`), returns `Diagnostic::Usage` telling the
341/// user to pass a full property IRI instead.
342fn resolve_order_by_property(
343    order_by_arg: &str,
344    rt_ref: &ResourceTypeRef,
345    server: &str,
346    client: &dyn DspClient,
347    token: Option<&str>,
348) -> Result<String, Diagnostic> {
349    // Full-IRI bypass: contains `://` → return verbatim, no describe call.
350    if order_by_arg.contains("://") {
351        return Ok(order_by_arg.to_string());
352    }
353
354    // Bare field name — need the data-model IRI.
355    let dm_iri = rt_ref.data_model_iri.as_deref().ok_or_else(|| {
356        Diagnostic::Usage(
357            "cannot resolve --order-by as a field name when --resource-type is a full IRI \
358             that contains no '#' fragment (no data-model can be derived); \
359             pass --order-by as a full field IRI instead"
360                .to_string(),
361        )
362    })?;
363
364    // Fetch the resource-type detail (allentities for this data-model).
365    // Arg order: (server, data_model_iri, resource_type, token).
366    let detail = client.describe_resource_type(server, dm_iri, &rt_ref.iri, token)?;
367
368    // Match field by name (case-insensitive); cover ALL fields (built-ins included).
369    if let Some(field) = detail.fields.iter().find(|f| f.name.eq_ignore_ascii_case(order_by_arg)) {
370        return Ok(field.iri.clone());
371    }
372
373    // No match — Usage error with a hint and the echoed field name
374    // (truncated to 80 chars + ellipsis, control-char-sanitised).
375    let sanitised = strip_control_chars(order_by_arg);
376    let field_disp: String = sanitised.chars().take(80).collect();
377    let field_suffix = if sanitised.chars().count() > 80 { "…" } else { "" };
378    Err(Diagnostic::Usage(format!(
379        "field '{field_disp}{field_suffix}' not found in resource-type '{}'; \
380         run `dsp vre resource-type describe --server <s> --project <project> \
381         --resource-type {}` to see available field names.",
382        rt_ref.name, rt_ref.name,
383    )))
384}
385
386/// Strip an IRI to its local name (the segment after the last `#`, `/`, or `:`).
387///
388/// Intentional kept-in-sync duplicate of `local_name` in `src/client/http.rs`.
389/// The client's `local_name` is private to the client module; the action layer
390/// must not reach into client internals (dsp-cli/ADR-0008 layering). Do NOT introduce a
391/// shared util module — the two copies are adjacent enough to audit on sight.
392///
393/// `rsplit` always yields at least one element so `unwrap_or` is a no-panic
394/// guard rather than a live fallback, mirroring the http.rs implementation.
395fn local_name(iri: &str) -> String {
396    iri.rsplit(['#', '/', ':']).next().unwrap_or(iri).to_string()
397}
398
399// ─────────────────────────────────────────────────────────────────────────────
400// resource describe
401// ─────────────────────────────────────────────────────────────────────────────
402
403/// Describe a single resource by its internal IRI.
404///
405/// Authentication is optional (instance-side read; anonymous callers see only
406/// publicly-visible resources per dsp-cli/ADR-0007). Reads `DSP_TOKEN` from the
407/// environment and delegates all work to `run_describe_impl` with injectable
408/// seams for deterministic testing.
409pub fn describe(
410    args: &ResourceDescribeArgs,
411    cfg: &Config,
412    client: &dyn DspClient,
413    renderer: &mut dyn Renderer,
414) -> Result<(), Diagnostic> {
415    let env_token = std::env::var("DSP_TOKEN").ok();
416    run_describe_impl(args, cfg, client, renderer, env_token, None)
417}
418
419/// Internal entry point for `describe` with injectable seams for testing.
420///
421/// - `env_token`: the `DSP_TOKEN` env value (read by the public `describe` entry point before
422///   calling this, so tests never touch process env).
423/// - `cache_path`: `Some(path)` in tests to use a temp auth cache; `None` in production to use the
424///   default `~/.config/dsp-cli/auth.toml`.
425///
426/// **Auth-optional:** a cache-load failure ALWAYS falls back to an empty cache
427/// with a `tracing::warn!` — NEVER returns `Err`. For an instance-side read,
428/// a corrupt or missing `auth.toml` must still describe anonymously.
429pub(crate) fn run_describe_impl(
430    args: &ResourceDescribeArgs,
431    cfg: &Config,
432    client: &dyn DspClient,
433    renderer: &mut dyn Renderer,
434    env_token: Option<String>,
435    cache_path: Option<&Path>,
436) -> Result<(), Diagnostic> {
437    // ── 1. --resource required (fail-fast, BEFORE any cache/IO) ──────────────
438    let resource_iri = args
439        .resource
440        .as_deref()
441        .ok_or_else(|| Diagnostic::Usage("--resource <iri> is required".to_string()))?;
442
443    // ── 2. Load cache (auth-optional: failures fall back to empty cache) ──────
444    let cache_result = match cache_path {
445        Some(p) => AuthCache::load_from(p),
446        None => AuthCache::load(),
447    };
448    let cache = match cache_result {
449        Ok(c) => c,
450        Err(e) => {
451            crate::util::warn_auth_cache_load_failed(&e, "falling back to anonymous for resource describe");
452            AuthCache::default()
453        }
454    };
455
456    // ── 3. Resolve token (optional) ───────────────────────────────────────────
457    let resolved = resolve_token(env_token, &cache, &cfg.server);
458    let token = resolved.as_ref().map(|r| r.token.as_str());
459
460    // ── 4. Build auth-state disclosure string ─────────────────────────────────
461    let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
462
463    // ── 5. D3: filter_warning — always Some for instance-side commands ────────
464    //
465    // Anonymous callers see only public resources; authenticated callers are
466    // still bounded by their permissions. Both warrant disclosure.
467    let filter_warning = if resolved.is_none() {
468        Some("results may be filtered; login to see private resources".to_string())
469    } else {
470        Some("results limited to your permissions".to_string())
471    };
472
473    // ── 6. D2: Resolve project guard (optional) ───────────────────────────────
474    //
475    // If --project was given, resolve it to a ProjectRef so we have the IRI for
476    // the guard comparison after fetching the resource.
477    let resolved_project = if let Some(p) = args.project.as_deref() {
478        Some(client.resolve_project(&cfg.server, p)?)
479    } else {
480        None
481    };
482
483    // ── 7. Fetch resource detail ──────────────────────────────────────────────
484    let detail = client.describe_resource(&cfg.server, resource_iri, token, args.values)?;
485
486    // ── 8. D2: Cross-project guard ────────────────────────────────────────────
487    //
488    // Fire ONLY when a project was given AND the resource's attached_project is
489    // Some(actual) AND actual != resolved_project_iri. A None attached_project
490    // (field absent from the response) passes through — absence ≠ mismatch.
491    if let Some(ref proj) = resolved_project
492        && let Some(ref actual) = detail.attached_project
493        && actual != &proj.iri
494    {
495        let actual_name = local_name(actual);
496        let expected_name = local_name(&proj.iri);
497        // Cap the resource IRI at 80 chars for readability, mirroring the
498        // idiom used throughout http.rs for user-supplied IRIs.
499        let display_iri: String = resource_iri.chars().take(80).collect();
500        let iri_suffix = if resource_iri.chars().count() > 80 { "…" } else { "" };
501        return Err(Diagnostic::Usage(format!(
502            "resource '{display_iri}{iri_suffix}' belongs to project {actual_name}, not {expected_name}"
503        )));
504    }
505
506    // ── 9. Build meta and render ──────────────────────────────────────────────
507    let meta = MetaContext {
508        server_label: cfg.server.clone(),
509        auth_state,
510        filter_warning,
511        count_caveat: None,
512        count_cost: None,
513    };
514    renderer.resource_describe(&detail, &meta)
515}
516
517// ─────────────────────────────────────────────────────────────────────────────
518// Tests
519// ─────────────────────────────────────────────────────────────────────────────
520
521#[cfg(test)]
522mod tests {
523    use std::cell::RefCell;
524
525    use tempfile::TempDir;
526
527    use super::{run_describe_impl, run_list_impl};
528    use crate::cli::{FormatArgs, ResourceDescribeArgs, ResourceListArgs};
529    use crate::client::DspClient;
530    use crate::config::auth_cache::ServerEntry;
531    use crate::config::{AuthCache, Config};
532    use crate::diagnostic::Diagnostic;
533    use crate::model::{
534        Cardinality, DataModel, DataModelDetail, Field, ProjectRef, ResourceAccess, ResourceDetail, ResourcePage,
535        ResourceSummary, ResourceTypeDetail, ResourceTypeSummary, ResourceVisibility,
536    };
537    use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
538    use crate::render::{
539        DataModelListView, DumpDeleteOutcome, DumpOutcome, Format, MetaContext, ProjectListView, Renderer,
540        ResourceListView, ResourceTypeListView,
541    };
542
543    // ── MockDspClient ─────────────────────────────────────────────────────────
544    //
545    // Uses a `Vec<Result<DataModelDetail, Diagnostic>>` queue for
546    // `describe_data_model` (popped in call order) so that the ambiguous-name
547    // test can return different data-models for each call.
548
549    /// Captured `list_resources` arguments: (project_iri, resource_type_iri, order_by, page,
550    /// token).
551    type ListResourcesArg = (String, String, Option<String>, u32, Option<String>);
552
553    struct MockDspClient {
554        resolve_result: Option<Result<ProjectRef, Diagnostic>>,
555        resolve_calls: RefCell<u32>,
556        list_data_models_result: Option<Result<Vec<DataModel>, Diagnostic>>,
557        list_data_models_calls: RefCell<u32>,
558        // Queue: each call pops the front; None = unimplemented
559        describe_data_model_queue: RefCell<Vec<Result<DataModelDetail, Diagnostic>>>,
560        describe_data_model_calls: RefCell<u32>,
561        // Configurable describe_resource_type: returns canned detail or unimplemented
562        describe_resource_type_result: Option<ResourceTypeDetail>,
563        describe_resource_type_calls: RefCell<u32>,
564        // Queue for list_resources: each call pops the front
565        list_resources_queue: RefCell<Vec<Result<ResourcePage, Diagnostic>>>,
566        list_resources_calls: RefCell<u32>,
567        list_resources_args: RefCell<Vec<ListResourcesArg>>,
568        // Queue for describe_resource: each call pops the front
569        describe_resource_queue: RefCell<Vec<Result<ResourceDetail, Diagnostic>>>,
570        describe_resource_calls: RefCell<u32>,
571        describe_resource_with_values_args: RefCell<Vec<bool>>,
572    }
573
574    impl MockDspClient {
575        fn new() -> Self {
576            Self {
577                resolve_result: None,
578                resolve_calls: RefCell::new(0),
579                list_data_models_result: None,
580                list_data_models_calls: RefCell::new(0),
581                describe_data_model_queue: RefCell::new(Vec::new()),
582                describe_data_model_calls: RefCell::new(0),
583                describe_resource_type_result: None,
584                describe_resource_type_calls: RefCell::new(0),
585                list_resources_queue: RefCell::new(Vec::new()),
586                list_resources_calls: RefCell::new(0),
587                list_resources_args: RefCell::new(Vec::new()),
588                describe_resource_queue: RefCell::new(Vec::new()),
589                describe_resource_calls: RefCell::new(0),
590                describe_resource_with_values_args: RefCell::new(Vec::new()),
591            }
592        }
593
594        fn with_resolve_project(mut self, result: Result<ProjectRef, Diagnostic>) -> Self {
595            self.resolve_result = Some(result);
596            self
597        }
598
599        fn with_list_data_models(mut self, result: Result<Vec<DataModel>, Diagnostic>) -> Self {
600            self.list_data_models_result = Some(result);
601            self
602        }
603
604        fn with_describe_data_model(self, result: Result<DataModelDetail, Diagnostic>) -> Self {
605            self.describe_data_model_queue.borrow_mut().push(result);
606            self
607        }
608
609        fn with_describe_resource_type(mut self, detail: ResourceTypeDetail) -> Self {
610            self.describe_resource_type_result = Some(detail);
611            self
612        }
613
614        fn with_list_resources(self, result: Result<ResourcePage, Diagnostic>) -> Self {
615            self.list_resources_queue.borrow_mut().push(result);
616            self
617        }
618
619        fn with_describe_resource(self, result: Result<ResourceDetail, Diagnostic>) -> Self {
620            self.describe_resource_queue.borrow_mut().push(result);
621            self
622        }
623
624        fn resolve_calls(&self) -> u32 {
625            *self.resolve_calls.borrow()
626        }
627
628        fn list_data_models_calls(&self) -> u32 {
629            *self.list_data_models_calls.borrow()
630        }
631
632        fn describe_resource_type_calls(&self) -> u32 {
633            *self.describe_resource_type_calls.borrow()
634        }
635
636        fn list_resources_calls(&self) -> u32 {
637            *self.list_resources_calls.borrow()
638        }
639
640        fn list_resources_args(&self) -> Vec<ListResourcesArg> {
641            self.list_resources_args.borrow().clone()
642        }
643
644        fn describe_resource_calls(&self) -> u32 {
645            *self.describe_resource_calls.borrow()
646        }
647
648        fn describe_resource_with_values_args(&self) -> Vec<bool> {
649            self.describe_resource_with_values_args.borrow().clone()
650        }
651    }
652
653    impl DspClient for MockDspClient {
654        fn login(
655            &self,
656            _server: &str,
657            _user: &str,
658            _password: &str,
659        ) -> Result<crate::model::LoginResponse, Diagnostic> {
660            unimplemented!("login not used in resource action tests")
661        }
662
663        fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
664            *self.resolve_calls.borrow_mut() += 1;
665            self.resolve_result
666                .clone()
667                .expect("resolve_result must be set when resolve_project is called")
668        }
669
670        fn create_project_dump(
671            &self,
672            _server: &str,
673            _project_iri: &str,
674            _skip_assets: bool,
675            _token: &str,
676        ) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
677            unimplemented!("create_project_dump not used in resource action tests")
678        }
679
680        fn get_project_dump_status(
681            &self,
682            _server: &str,
683            _project_iri: &str,
684            _dump_id: &str,
685            _token: &str,
686        ) -> Result<crate::model::DumpTask, Diagnostic> {
687            unimplemented!("get_project_dump_status not used in resource action tests")
688        }
689
690        fn download_project_dump(
691            &self,
692            _server: &str,
693            _project_iri: &str,
694            _dump_id: &str,
695            _token: &str,
696            _dest: &mut dyn std::io::Write,
697        ) -> Result<u64, Diagnostic> {
698            unimplemented!("download_project_dump not used in resource action tests")
699        }
700
701        fn delete_project_dump(
702            &self,
703            _server: &str,
704            _project_iri: &str,
705            _dump_id: &str,
706            _token: &str,
707        ) -> Result<(), Diagnostic> {
708            unimplemented!("delete_project_dump not used in resource action tests")
709        }
710
711        fn list_projects(&self, _server: &str, _token: Option<&str>) -> Result<Vec<crate::model::Project>, Diagnostic> {
712            unimplemented!("list_projects not used in resource action tests")
713        }
714
715        fn describe_project(
716            &self,
717            _server: &str,
718            _project: &str,
719            _token: Option<&str>,
720        ) -> Result<crate::model::ProjectDetail, Diagnostic> {
721            unimplemented!("describe_project not used in resource action tests")
722        }
723
724        fn list_data_models(
725            &self,
726            _server: &str,
727            _project_iri: &str,
728            _token: Option<&str>,
729        ) -> Result<Vec<DataModel>, Diagnostic> {
730            *self.list_data_models_calls.borrow_mut() += 1;
731            self.list_data_models_result
732                .clone()
733                .expect("list_data_models_result must be set when list_data_models is called")
734        }
735
736        fn describe_data_model(
737            &self,
738            _server: &str,
739            _data_model_iri: &str,
740            _token: Option<&str>,
741        ) -> Result<crate::model::DataModelDetail, Diagnostic> {
742            *self.describe_data_model_calls.borrow_mut() += 1;
743            let mut queue = self.describe_data_model_queue.borrow_mut();
744            if queue.is_empty() {
745                panic!("describe_data_model called more times than expected (queue is empty)");
746            }
747            queue.remove(0)
748        }
749
750        fn describe_resource_type(
751            &self,
752            _server: &str,
753            _data_model_iri: &str,
754            _resource_type: &str,
755            _token: Option<&str>,
756        ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
757            *self.describe_resource_type_calls.borrow_mut() += 1;
758            match &self.describe_resource_type_result {
759                Some(detail) => Ok(detail.clone()),
760                None => Err(Diagnostic::NotFound("describe_resource_type: no result configured".to_string())),
761            }
762        }
763
764        fn data_model_structure(
765            &self,
766            _server: &str,
767            _data_model_iri: &str,
768            _token: Option<&str>,
769        ) -> Result<crate::model::DataModelStructure, Diagnostic> {
770            unimplemented!("data_model_structure not used in resource action tests")
771        }
772
773        fn list_resources(
774            &self,
775            _server: &str,
776            project_iri: &str,
777            resource_type_iri: &str,
778            order_by: Option<&str>,
779            page: u32,
780            token: Option<&str>,
781        ) -> Result<ResourcePage, Diagnostic> {
782            *self.list_resources_calls.borrow_mut() += 1;
783            self.list_resources_args.borrow_mut().push((
784                project_iri.to_string(),
785                resource_type_iri.to_string(),
786                order_by.map(str::to_owned),
787                page,
788                token.map(str::to_owned),
789            ));
790            let mut queue = self.list_resources_queue.borrow_mut();
791            if queue.is_empty() {
792                panic!("list_resources called more times than expected (queue is empty)");
793            }
794            queue.remove(0)
795        }
796
797        fn describe_resource(
798            &self,
799            _server: &str,
800            _resource_iri: &str,
801            _token: Option<&str>,
802            with_values: bool,
803        ) -> Result<crate::model::ResourceDetail, Diagnostic> {
804            *self.describe_resource_calls.borrow_mut() += 1;
805            self.describe_resource_with_values_args.borrow_mut().push(with_values);
806            let mut queue = self.describe_resource_queue.borrow_mut();
807            if queue.is_empty() {
808                panic!("describe_resource called more times than expected (queue is empty)");
809            }
810            queue.remove(0)
811        }
812
813        fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
814            unimplemented!("verify_token not used in resource action tests")
815        }
816
817        fn resource_counts(
818            &self,
819            _server: &str,
820            _project_iri: &str,
821            _token: Option<&str>,
822        ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
823            Ok(std::collections::HashMap::new())
824        }
825
826        fn list_vocabularies(
827            &self,
828            _server: &str,
829            _project_iri: &str,
830            _token: Option<&str>,
831        ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
832            unimplemented!("not exercised by this file's tests")
833        }
834
835        fn describe_vocabulary(
836            &self,
837            _server: &str,
838            _iri: &str,
839            _token: Option<&str>,
840        ) -> Result<crate::model::VocabularyTree, Diagnostic> {
841            unimplemented!("not exercised by this file's tests")
842        }
843
844        fn sparql_query(
845            &self,
846            _server: &str,
847            _token: &str,
848            _query: &str,
849            _accept: &str,
850            _timeout_secs: u64,
851        ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
852            Err(Diagnostic::Internal("not used in this test".into()))
853        }
854    }
855
856    // ── RecordingRenderer ─────────────────────────────────────────────────────
857
858    struct RecordingRenderer {
859        resources_view: Option<ResourceListView>,
860        resources_meta: Option<MetaContext>,
861        resource_describe_detail: Option<ResourceDetail>,
862        resource_describe_meta: Option<MetaContext>,
863    }
864
865    impl RecordingRenderer {
866        fn new() -> Self {
867            Self {
868                resources_view: None,
869                resources_meta: None,
870                resource_describe_detail: None,
871                resource_describe_meta: None,
872            }
873        }
874    }
875
876    impl Renderer for RecordingRenderer {
877        fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
878            Ok(())
879        }
880
881        fn auth_login(&mut self, _outcome: &AuthLoginOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
882            Ok(())
883        }
884
885        fn auth_status(&mut self, _outcome: &AuthStatusOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
886            Ok(())
887        }
888
889        fn auth_logout(&mut self, _outcome: &AuthLogoutOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
890            Ok(())
891        }
892
893        fn auth_set_token(&mut self, _outcome: &AuthSetTokenOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
894            Ok(())
895        }
896
897        fn project_dump(&mut self, _outcome: &DumpOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
898            Ok(())
899        }
900
901        fn project_dump_deleted(
902            &mut self,
903            _outcome: &DumpDeleteOutcome,
904            _meta: &MetaContext,
905        ) -> Result<(), Diagnostic> {
906            Ok(())
907        }
908
909        fn projects(&mut self, _view: &ProjectListView, _meta: &MetaContext) -> Result<(), Diagnostic> {
910            Ok(())
911        }
912
913        fn project_describe(
914            &mut self,
915            _project: &crate::model::ProjectDetail,
916            _meta: &MetaContext,
917        ) -> Result<(), Diagnostic> {
918            Ok(())
919        }
920
921        fn data_models(&mut self, _view: &DataModelListView, _meta: &MetaContext) -> Result<(), Diagnostic> {
922            Ok(())
923        }
924
925        fn data_model_describe(
926            &mut self,
927            _detail: &crate::model::DataModelDetail,
928            _meta: &MetaContext,
929        ) -> Result<(), Diagnostic> {
930            Ok(())
931        }
932
933        fn resource_types(&mut self, _view: &ResourceTypeListView, _meta: &MetaContext) -> Result<(), Diagnostic> {
934            Ok(())
935        }
936
937        fn resource_type_describe(
938            &mut self,
939            _detail: &crate::model::ResourceTypeDetail,
940            _meta: &MetaContext,
941        ) -> Result<(), Diagnostic> {
942            Ok(())
943        }
944
945        fn data_model_structure(
946            &mut self,
947            _structure: &crate::model::DataModelStructure,
948            _meta: &MetaContext,
949        ) -> Result<(), Diagnostic> {
950            Ok(())
951        }
952
953        fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
954            self.resources_view = Some(view.clone());
955            self.resources_meta = Some(meta.clone());
956            Ok(())
957        }
958
959        fn resource_describe(
960            &mut self,
961            detail: &crate::model::ResourceDetail,
962            meta: &MetaContext,
963        ) -> Result<(), Diagnostic> {
964            self.resource_describe_detail = Some(detail.clone());
965            self.resource_describe_meta = Some(meta.clone());
966            Ok(())
967        }
968
969        fn vocabularies(
970            &mut self,
971            _view: &crate::render::VocabularyListView,
972            _meta: &MetaContext,
973        ) -> Result<(), Diagnostic> {
974            unimplemented!("not exercised by this file's tests")
975        }
976
977        fn vocabulary_describe(
978            &mut self,
979            _detail: &crate::model::VocabularyDetail,
980            _meta: &MetaContext,
981        ) -> Result<(), Diagnostic> {
982            unimplemented!("not exercised by this file's tests")
983        }
984    }
985
986    // ── helpers ───────────────────────────────────────────────────────────────
987
988    const SERVER: &str = "https://api.test.dasch.swiss";
989    const PROJECT_IRI: &str = "http://rdfh.ch/projects/0801";
990    const BEOL_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
991    const LETTER_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#Letter";
992
993    fn make_cfg() -> Config {
994        Config { server: SERVER.to_string() }
995    }
996
997    fn make_project_ref() -> ProjectRef {
998        ProjectRef {
999            iri: PROJECT_IRI.to_string(),
1000            shortcode: "0801".to_string(),
1001            shortname: "beol".to_string(),
1002        }
1003    }
1004
1005    fn make_args(
1006        project: Option<&str>,
1007        resource_type: Option<&str>,
1008        data_model: Option<&str>,
1009        page: Option<u32>,
1010        all: bool,
1011        filter: Option<&str>,
1012    ) -> ResourceListArgs {
1013        make_args_with_order_by(project, resource_type, data_model, page, all, filter, None)
1014    }
1015
1016    fn make_args_with_order_by(
1017        project: Option<&str>,
1018        resource_type: Option<&str>,
1019        data_model: Option<&str>,
1020        page: Option<u32>,
1021        all: bool,
1022        filter: Option<&str>,
1023        order_by: Option<&str>,
1024    ) -> ResourceListArgs {
1025        ResourceListArgs {
1026            server: Some(SERVER.to_string()),
1027            project: project.map(str::to_owned),
1028            resource_type: resource_type.map(str::to_owned),
1029            data_model: data_model.map(str::to_owned),
1030            page,
1031            all,
1032            filter: filter.map(str::to_owned),
1033            order_by: order_by.map(str::to_owned),
1034            format: FormatArgs {
1035                format: Format::Prose,
1036                json: false,
1037                lines: false,
1038                columns: None,
1039                no_header: false,
1040                header_only: false,
1041            },
1042        }
1043    }
1044
1045    fn make_data_model(name: &str) -> DataModel {
1046        DataModel {
1047            name: name.to_string(),
1048            iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
1049            label: None,
1050            last_modified: None,
1051            is_builtin: false,
1052        }
1053    }
1054
1055    fn make_detail_with_rts(dm_name: &str, rt_names: &[&str]) -> DataModelDetail {
1056        let resource_types = rt_names
1057            .iter()
1058            .map(|name| ResourceTypeSummary {
1059                name: name.to_string(),
1060                iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2#{name}"),
1061                label: None,
1062            })
1063            .collect();
1064        DataModelDetail {
1065            name: dm_name.to_string(),
1066            iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2"),
1067            label: None,
1068            last_modified: None,
1069            resource_types,
1070        }
1071    }
1072
1073    fn make_resource(label: &str) -> ResourceSummary {
1074        ResourceSummary {
1075            label: label.to_string(),
1076            iri: format!("http://rdfh.ch/0801/{label}"),
1077            ark_url: None,
1078            creation_date: None,
1079            last_modified: None,
1080            resource_type: "Letter".to_string(),
1081        }
1082    }
1083
1084    fn make_resource_page(labels: &[&str], may_have_more: bool) -> ResourcePage {
1085        ResourcePage {
1086            resources: labels.iter().map(|l| make_resource(l)).collect(),
1087            may_have_more_results: may_have_more,
1088        }
1089    }
1090
1091    fn cache_with_entry(server: &str, token: &str, user: &str) -> AuthCache {
1092        let mut cache = AuthCache::default();
1093        cache.set_entry(
1094            server,
1095            ServerEntry {
1096                token: token.to_string(),
1097                user: Some(user.to_string()),
1098                acquired_at: None,
1099                expires_at: None,
1100            },
1101        );
1102        cache
1103    }
1104
1105    fn write_cache(dir: &TempDir, cache: &AuthCache) -> std::path::PathBuf {
1106        let path = dir.path().join("auth.toml");
1107        cache.save_to(&path).expect("failed to write test cache");
1108        path
1109    }
1110
1111    const RESOURCE_IRI: &str = "http://rdfh.ch/0803/--6Esp4SVnGG1DBzFvYErw";
1112    const OTHER_PROJECT_IRI: &str = "http://rdfh.ch/projects/9999";
1113
1114    fn make_describe_args(resource: Option<&str>, project: Option<&str>) -> ResourceDescribeArgs {
1115        make_describe_args_with_values(resource, project, false)
1116    }
1117
1118    fn make_describe_args_with_values(
1119        resource: Option<&str>,
1120        project: Option<&str>,
1121        values: bool,
1122    ) -> ResourceDescribeArgs {
1123        ResourceDescribeArgs {
1124            server: Some(SERVER.to_string()),
1125            resource: resource.map(str::to_owned),
1126            project: project.map(str::to_owned),
1127            values,
1128            format: FormatArgs {
1129                format: Format::Prose,
1130                json: false,
1131                lines: false,
1132                columns: None,
1133                no_header: false,
1134                header_only: false,
1135            },
1136        }
1137    }
1138
1139    fn make_resource_detail() -> ResourceDetail {
1140        ResourceDetail {
1141            label: "Test Page".to_string(),
1142            iri: RESOURCE_IRI.to_string(),
1143            resource_type: "Page".to_string(),
1144            ark_url: Some("ark:/72163/1/0803/--6Esp4SVnGG1DBzFvYErw".to_string()),
1145            creation_date: Some("2021-01-01T00:00:00Z".to_string()),
1146            last_modified: Some("2021-06-15T12:00:00Z".to_string()),
1147            attached_project: Some(PROJECT_IRI.to_string()),
1148            owner: Some("http://rdfh.ch/users/root".to_string()),
1149            visibility: Some(ResourceVisibility::Public),
1150            your_access: Some(ResourceAccess::View),
1151            values: None,
1152        }
1153    }
1154
1155    // ── tests ─────────────────────────────────────────────────────────────────
1156
1157    // ── describe tests ────────────────────────────────────────────────────────
1158
1159    /// Happy path: renderer receives the expected ResourceDetail.
1160    #[test]
1161    fn test_describe_happy_path() {
1162        let detail = make_resource_detail();
1163        let client = MockDspClient::new().with_describe_resource(Ok(detail.clone()));
1164
1165        let mut renderer = RecordingRenderer::new();
1166        let args = make_describe_args(Some(RESOURCE_IRI), None);
1167        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1168
1169        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1170        assert_eq!(client.describe_resource_calls(), 1);
1171
1172        let recorded = renderer
1173            .resource_describe_detail
1174            .expect("resource_describe must have been called");
1175        assert_eq!(recorded.label, "Test Page");
1176        assert_eq!(recorded.iri, RESOURCE_IRI);
1177        assert_eq!(recorded.resource_type, "Page");
1178        assert_eq!(recorded.visibility, Some(ResourceVisibility::Public));
1179        assert_eq!(recorded.your_access, Some(ResourceAccess::View));
1180    }
1181
1182    /// --resource missing → Usage error; no client calls.
1183    #[test]
1184    fn test_describe_missing_resource_is_usage_error() {
1185        let client = MockDspClient::new();
1186        let mut renderer = RecordingRenderer::new();
1187        let args = make_describe_args(None, None);
1188        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1189
1190        assert!(
1191            matches!(result, Err(Diagnostic::Usage(_))),
1192            "missing --resource must yield Usage, got {:?}",
1193            result
1194        );
1195        assert_eq!(client.describe_resource_calls(), 0);
1196    }
1197
1198    /// --resource missing → Usage error message mentions --resource.
1199    #[test]
1200    fn test_describe_missing_resource_error_mentions_flag() {
1201        let client = MockDspClient::new();
1202        let mut renderer = RecordingRenderer::new();
1203        let args = make_describe_args(None, None);
1204        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1205
1206        if let Err(Diagnostic::Usage(msg)) = result {
1207            assert!(msg.contains("--resource"), "error message must mention --resource, got: {msg}");
1208        } else {
1209            panic!("expected Usage error");
1210        }
1211    }
1212
1213    /// Cross-project guard MATCH: resource.attached_project == resolved_project_iri → no error.
1214    #[test]
1215    fn test_describe_cross_project_guard_match_no_error() {
1216        let detail = make_resource_detail(); // attached_project = PROJECT_IRI
1217        let client = MockDspClient::new()
1218            .with_resolve_project(Ok(make_project_ref())) // iri = PROJECT_IRI
1219            .with_describe_resource(Ok(detail));
1220
1221        let mut renderer = RecordingRenderer::new();
1222        let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1223        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1224
1225        assert!(result.is_ok(), "matching project must not error, got {:?}", result);
1226        assert!(renderer.resource_describe_detail.is_some(), "renderer must have been called");
1227    }
1228
1229    /// Cross-project guard MISMATCH: resource.attached_project != resolved_project_iri → Usage
1230    /// error.
1231    ///
1232    /// The error message must name BOTH the resource IRI and the local-name tails of
1233    /// the actual project (OTHER_PROJECT_IRI → "9999") and the expected project
1234    /// (PROJECT_IRI → "0801"), so a generic "mismatch" message would fail this test.
1235    #[test]
1236    fn test_describe_cross_project_guard_mismatch_usage_error() {
1237        let mut detail = make_resource_detail();
1238        detail.attached_project = Some(OTHER_PROJECT_IRI.to_string()); // belongs to a different project
1239
1240        let client = MockDspClient::new()
1241            .with_resolve_project(Ok(make_project_ref())) // iri = PROJECT_IRI
1242            .with_describe_resource(Ok(detail));
1243
1244        let mut renderer = RecordingRenderer::new();
1245        let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1246        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1247
1248        assert!(
1249            matches!(result, Err(Diagnostic::Usage(_))),
1250            "project mismatch must yield Usage, got {:?}",
1251            result
1252        );
1253        assert!(
1254            renderer.resource_describe_detail.is_none(),
1255            "renderer must NOT be called on mismatch"
1256        );
1257
1258        // Tightened: the message must mention the resource IRI and local-name
1259        // tails of BOTH the actual project ("9999") and the expected project ("0801").
1260        if let Err(Diagnostic::Usage(msg)) = result {
1261            assert!(
1262                msg.contains(RESOURCE_IRI),
1263                "mismatch error must mention the resource IRI; got: {msg}"
1264            );
1265            // local_name("http://rdfh.ch/projects/9999") → "9999"
1266            assert!(
1267                msg.contains("9999"),
1268                "mismatch error must mention the actual project's local name '9999'; got: {msg}"
1269            );
1270            // local_name("http://rdfh.ch/projects/0801") → "0801"
1271            assert!(
1272                msg.contains("0801"),
1273                "mismatch error must mention the expected project's local name '0801'; got: {msg}"
1274            );
1275        } else {
1276            panic!("expected Err(Diagnostic::Usage(_))");
1277        }
1278    }
1279
1280    /// Cross-project guard with attached_project == None → passes through (no error).
1281    ///
1282    /// Per D2: absence of attached_project does NOT trigger the guard.
1283    #[test]
1284    fn test_describe_cross_project_guard_none_passes_through() {
1285        let mut detail = make_resource_detail();
1286        detail.attached_project = None; // absent from response
1287
1288        let client = MockDspClient::new()
1289            .with_resolve_project(Ok(make_project_ref()))
1290            .with_describe_resource(Ok(detail));
1291
1292        let mut renderer = RecordingRenderer::new();
1293        let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1294        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1295
1296        assert!(result.is_ok(), "None attached_project must not error, got {:?}", result);
1297        assert!(renderer.resource_describe_detail.is_some(), "renderer must have been called");
1298    }
1299
1300    /// No --project given: guard is entirely skipped, resolve_project not called.
1301    #[test]
1302    fn test_describe_no_project_guard_skipped() {
1303        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1304
1305        let mut renderer = RecordingRenderer::new();
1306        let args = make_describe_args(Some(RESOURCE_IRI), None);
1307        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1308
1309        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1310        assert_eq!(
1311            client.resolve_calls(),
1312            0,
1313            "resolve_project must not be called when --project is omitted"
1314        );
1315    }
1316
1317    /// D3: anonymous → filter_warning is "results may be filtered; login to see private resources".
1318    #[test]
1319    fn test_describe_filter_warning_anonymous() {
1320        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1321
1322        let mut renderer = RecordingRenderer::new();
1323        let args = make_describe_args(Some(RESOURCE_IRI), None);
1324        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1325
1326        let meta = renderer.resource_describe_meta.expect("meta must be present");
1327        assert_eq!(meta.auth_state, "anonymous");
1328        let fw = meta.filter_warning.expect("filter_warning must be Some for anonymous");
1329        assert_eq!(fw, "results may be filtered; login to see private resources");
1330    }
1331
1332    /// D3: authenticated (cache token) → filter_warning is "results limited to your permissions".
1333    #[test]
1334    fn test_describe_filter_warning_authenticated() {
1335        let dir = TempDir::new().expect("tempdir");
1336        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1337        let path = write_cache(&dir, &cache);
1338
1339        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1340
1341        let mut renderer = RecordingRenderer::new();
1342        let args = make_describe_args(Some(RESOURCE_IRI), None);
1343        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&path)).expect("expected Ok");
1344
1345        let meta = renderer.resource_describe_meta.expect("meta must be present");
1346        let fw = meta.filter_warning.expect("filter_warning must be Some for authenticated");
1347        assert_eq!(fw, "results limited to your permissions");
1348    }
1349
1350    /// Not-found propagation: mock returns Diagnostic::NotFound → surfaced.
1351    #[test]
1352    fn test_describe_not_found_propagates() {
1353        let client = MockDspClient::new()
1354            .with_describe_resource(Err(Diagnostic::NotFound("resource not found on server".to_string())));
1355
1356        let mut renderer = RecordingRenderer::new();
1357        let args = make_describe_args(Some(RESOURCE_IRI), None);
1358        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1359
1360        assert!(
1361            matches!(result, Err(Diagnostic::NotFound(_))),
1362            "not-found from client must propagate, got {:?}",
1363            result
1364        );
1365        assert!(
1366            renderer.resource_describe_detail.is_none(),
1367            "renderer must not be called on error"
1368        );
1369    }
1370
1371    /// Token forwarded to describe_resource when authenticated.
1372    #[test]
1373    fn test_describe_token_forwarded() {
1374        let dir = TempDir::new().expect("tempdir");
1375        let cache = cache_with_entry(SERVER, "my-describe-token", "user@example.com");
1376        let path = write_cache(&dir, &cache);
1377
1378        // We need to capture the token passed to describe_resource.
1379        // Use env_token injection (the public describe() fn reads DSP_TOKEN; we
1380        // inject via env_token parameter to run_describe_impl directly).
1381        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1382
1383        let mut renderer = RecordingRenderer::new();
1384        let args = make_describe_args(Some(RESOURCE_IRI), None);
1385        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&path)).expect("expected Ok");
1386
1387        // The auth_state reported in meta reveals that auth was used.
1388        let meta = renderer.resource_describe_meta.expect("meta must be present");
1389        // Authenticated (cache token) → filter_warning uses the authenticated wording.
1390        let fw = meta.filter_warning.expect("filter_warning must be present");
1391        assert_eq!(fw, "results limited to your permissions");
1392    }
1393
1394    /// --values absent (default false) → describe_resource called with with_values=false.
1395    #[test]
1396    fn test_describe_values_flag_absent_passes_false() {
1397        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1398        let mut renderer = RecordingRenderer::new();
1399        let args = make_describe_args(Some(RESOURCE_IRI), None); // values=false by default
1400        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1401
1402        let with_values_args = client.describe_resource_with_values_args();
1403        assert_eq!(with_values_args.len(), 1);
1404        assert!(!with_values_args[0], "with_values must be false when --values is absent");
1405    }
1406
1407    /// --values true → describe_resource called with with_values=true.
1408    #[test]
1409    fn test_describe_values_flag_present_passes_true() {
1410        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1411        let mut renderer = RecordingRenderer::new();
1412        let args = make_describe_args_with_values(Some(RESOURCE_IRI), None, true);
1413        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1414
1415        let with_values_args = client.describe_resource_with_values_args();
1416        assert_eq!(with_values_args.len(), 1);
1417        assert!(with_values_args[0], "with_values must be true when --values is given");
1418    }
1419
1420    /// Corrupt/missing cache → falls back to anonymous, never returns Err.
1421    #[test]
1422    fn test_describe_corrupt_cache_falls_back_to_anonymous() {
1423        let dir = TempDir::new().expect("tempdir");
1424        let bad_path = dir.path().join("nonexistent_auth.toml");
1425
1426        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1427
1428        let mut renderer = RecordingRenderer::new();
1429        let args = make_describe_args(Some(RESOURCE_IRI), None);
1430        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&bad_path));
1431        assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
1432
1433        let meta = renderer.resource_describe_meta.expect("meta must be present");
1434        assert_eq!(meta.auth_state, "anonymous");
1435    }
1436
1437    // ── list tests ────────────────────────────────────────────────────────────
1438
1439    /// Full-IRI bypass: no data-model scan, list_data_models not called.
1440    #[test]
1441    fn test_list_full_iri_bypass_skips_scan() {
1442        let client = MockDspClient::new()
1443            .with_resolve_project(Ok(make_project_ref()))
1444            .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1445
1446        let mut renderer = RecordingRenderer::new();
1447        // Full IRI → bypass scan
1448        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1449        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1450
1451        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1452        assert_eq!(
1453            client.list_data_models_calls(),
1454            0,
1455            "full IRI must not trigger a data-model scan"
1456        );
1457        assert_eq!(client.list_resources_calls(), 1);
1458
1459        let view = renderer.resources_view.expect("resources must have been called");
1460        assert_eq!(view.items.len(), 1);
1461        assert_eq!(view.resource_type, "Letter"); // local name extracted
1462    }
1463
1464    /// Full-IRI bypass: correct resource_type_iri and project_iri passed to list_resources.
1465    #[test]
1466    fn test_list_full_iri_passes_correct_params() {
1467        let client = MockDspClient::new()
1468            .with_resolve_project(Ok(make_project_ref()))
1469            .with_list_resources(Ok(make_resource_page(&[], false)));
1470
1471        let mut renderer = RecordingRenderer::new();
1472        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1473        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1474
1475        let calls = client.list_resources_args();
1476        assert_eq!(calls.len(), 1);
1477        assert_eq!(calls[0].0, PROJECT_IRI, "project_iri must be the resolved IRI");
1478        assert_eq!(calls[0].1, LETTER_IRI, "resource_type_iri must be the full IRI");
1479        assert_eq!(calls[0].2, None, "order_by is None (placeholder)");
1480        assert_eq!(calls[0].3, 0, "default page is 0");
1481        assert_eq!(calls[0].4, None, "no token for anonymous");
1482    }
1483
1484    /// Bare-name scan (one match): resolves correctly.
1485    #[test]
1486    fn test_list_bare_name_scan_single_match() {
1487        let client = MockDspClient::new()
1488            .with_resolve_project(Ok(make_project_ref()))
1489            .with_list_data_models(Ok(vec![make_data_model("beol")]))
1490            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1491            .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1492
1493        let mut renderer = RecordingRenderer::new();
1494        let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1495        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1496
1497        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1498        let view = renderer.resources_view.expect("must have been called");
1499        assert_eq!(view.resource_type, "Letter");
1500        assert_eq!(view.items.len(), 1);
1501    }
1502
1503    /// Bare-name scan with zero matches → NotFound.
1504    #[test]
1505    fn test_list_bare_name_scan_not_found() {
1506        let client = MockDspClient::new()
1507            .with_resolve_project(Ok(make_project_ref()))
1508            .with_list_data_models(Ok(vec![make_data_model("beol")]))
1509            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])));
1510
1511        let mut renderer = RecordingRenderer::new();
1512        let args = make_args(Some("0801"), Some("Nonexistent"), None, None, false, None);
1513        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1514
1515        assert!(
1516            matches!(result, Err(Diagnostic::NotFound(_))),
1517            "unknown resource-type must yield NotFound, got {:?}",
1518            result
1519        );
1520        assert_eq!(client.list_resources_calls(), 0);
1521    }
1522
1523    /// Bare-name ambiguous across two data-models → Usage error listing both DMs.
1524    #[test]
1525    fn test_list_bare_name_ambiguous_gives_usage_error() {
1526        let client = MockDspClient::new()
1527            .with_resolve_project(Ok(make_project_ref()))
1528            .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
1529            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1530            .with_describe_data_model(Ok(make_detail_with_rts("webern", &["Letter", "Score"])));
1531
1532        let mut renderer = RecordingRenderer::new();
1533        // "Letter" exists in both data-models → ambiguous
1534        let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1535        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1536
1537        assert!(
1538            matches!(result, Err(Diagnostic::Usage(_))),
1539            "ambiguous resource-type must yield Usage error, got {:?}",
1540            result
1541        );
1542        if let Err(Diagnostic::Usage(msg)) = &result {
1543            assert!(
1544                msg.contains("beol") && msg.contains("webern"),
1545                "Usage error must list both data-models, got: {msg}"
1546            );
1547            assert!(
1548                msg.contains("--data-model"),
1549                "Usage error must suggest --data-model, got: {msg}"
1550            );
1551        }
1552        assert_eq!(client.list_resources_calls(), 0);
1553    }
1554
1555    /// --data-model name scope: resolves only within the named data-model.
1556    #[test]
1557    fn test_list_data_model_name_scope() {
1558        let client = MockDspClient::new()
1559            .with_resolve_project(Ok(make_project_ref()))
1560            .with_list_data_models(Ok(vec![make_data_model("beol")]))
1561            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1562            .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1563
1564        let mut renderer = RecordingRenderer::new();
1565        let args = make_args(Some("0801"), Some("Letter"), Some("beol"), None, false, None);
1566        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1567
1568        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1569        let view = renderer.resources_view.expect("must have been called");
1570        assert_eq!(view.items.len(), 1);
1571    }
1572
1573    /// --data-model full IRI scope: skips list_data_models.
1574    #[test]
1575    fn test_list_data_model_full_iri_scope_skips_list() {
1576        let client = MockDspClient::new()
1577            .with_resolve_project(Ok(make_project_ref()))
1578            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1579            .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1580
1581        let mut renderer = RecordingRenderer::new();
1582        let args = make_args(Some("0801"), Some("Letter"), Some(BEOL_IRI), None, false, None);
1583        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1584
1585        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1586        assert_eq!(
1587            client.list_data_models_calls(),
1588            0,
1589            "--data-model as full IRI must skip list_data_models"
1590        );
1591    }
1592
1593    /// Single page with may_have_more=false: pagination is SinglePage { may_have_more: false }.
1594    #[test]
1595    fn test_list_single_page_no_more() {
1596        let client = MockDspClient::new()
1597            .with_resolve_project(Ok(make_project_ref()))
1598            .with_list_resources(Ok(make_resource_page(&["A", "B"], false)));
1599
1600        let mut renderer = RecordingRenderer::new();
1601        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1602        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1603
1604        let view = renderer.resources_view.expect("must have been called");
1605        assert_eq!(view.items.len(), 2);
1606        assert_eq!(view.total, 2);
1607        match &view.pagination {
1608            crate::render::ResourceListPagination::SinglePage { page, may_have_more } => {
1609                assert_eq!(*page, 0);
1610                assert!(!may_have_more);
1611            }
1612            other => panic!("expected SinglePage, got {:?}", other),
1613        }
1614    }
1615
1616    /// Single page with may_have_more=true.
1617    #[test]
1618    fn test_list_single_page_has_more() {
1619        let client = MockDspClient::new()
1620            .with_resolve_project(Ok(make_project_ref()))
1621            .with_list_resources(Ok(make_resource_page(&["A", "B"], true)));
1622
1623        let mut renderer = RecordingRenderer::new();
1624        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1625        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1626
1627        let view = renderer.resources_view.expect("must have been called");
1628        match &view.pagination {
1629            crate::render::ResourceListPagination::SinglePage { may_have_more, .. } => {
1630                assert!(*may_have_more);
1631            }
1632            other => panic!("expected SinglePage, got {:?}", other),
1633        }
1634    }
1635
1636    /// --page N: the correct page number is passed to list_resources.
1637    #[test]
1638    fn test_list_explicit_page_passed_to_client() {
1639        let client = MockDspClient::new()
1640            .with_resolve_project(Ok(make_project_ref()))
1641            .with_list_resources(Ok(make_resource_page(&["A"], false)));
1642
1643        let mut renderer = RecordingRenderer::new();
1644        let args = make_args(Some("0801"), Some(LETTER_IRI), None, Some(3), false, None);
1645        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1646
1647        let calls = client.list_resources_args();
1648        assert_eq!(calls[0].3, 3, "--page 3 must be passed to list_resources");
1649
1650        let view = renderer.resources_view.expect("must have been called");
1651        match &view.pagination {
1652            crate::render::ResourceListPagination::SinglePage { page, .. } => {
1653                assert_eq!(*page, 3);
1654            }
1655            other => panic!("expected SinglePage, got {:?}", other),
1656        }
1657    }
1658
1659    /// --all fetches multiple pages; stops when may_have_more=false.
1660    #[test]
1661    fn test_list_all_multi_page() {
1662        let client = MockDspClient::new()
1663            .with_resolve_project(Ok(make_project_ref()))
1664            .with_list_resources(Ok(make_resource_page(&["A", "B"], true)))
1665            .with_list_resources(Ok(make_resource_page(&["C"], false)));
1666
1667        let mut renderer = RecordingRenderer::new();
1668        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1669        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1670
1671        assert_eq!(client.list_resources_calls(), 2);
1672        // Pages requested: 0 then 1
1673        let calls = client.list_resources_args();
1674        assert_eq!(calls[0].3, 0);
1675        assert_eq!(calls[1].3, 1);
1676
1677        let view = renderer.resources_view.expect("must have been called");
1678        assert_eq!(view.items.len(), 3, "all pages merged");
1679        assert_eq!(view.total, 3);
1680        match &view.pagination {
1681            crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1682                assert_eq!(*pages_fetched, 2);
1683            }
1684            other => panic!("expected AllPages, got {:?}", other),
1685        }
1686    }
1687
1688    /// --all with empty final page: accumulate nothing, terminate normally.
1689    #[test]
1690    fn test_list_all_empty_final_page() {
1691        // Page 0 has items + more; page 1 is empty + no more.
1692        let client = MockDspClient::new()
1693            .with_resolve_project(Ok(make_project_ref()))
1694            .with_list_resources(Ok(make_resource_page(&["A"], true)))
1695            .with_list_resources(Ok(make_resource_page(&[], false)));
1696
1697        let mut renderer = RecordingRenderer::new();
1698        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1699        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1700
1701        assert_eq!(client.list_resources_calls(), 2);
1702        let view = renderer.resources_view.expect("must have been called");
1703        assert_eq!(view.items.len(), 1, "empty final page contributes zero resources");
1704        match &view.pagination {
1705            crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1706                assert_eq!(*pages_fetched, 2);
1707            }
1708            other => panic!("expected AllPages, got {:?}", other),
1709        }
1710    }
1711
1712    /// --all propagates error mid-loop; nothing is rendered.
1713    #[test]
1714    fn test_list_all_propagates_error_mid_loop() {
1715        let client = MockDspClient::new()
1716            .with_resolve_project(Ok(make_project_ref()))
1717            .with_list_resources(Ok(make_resource_page(&["A"], true)))
1718            .with_list_resources(Err(Diagnostic::ServerError("server exploded".into())));
1719
1720        let mut renderer = RecordingRenderer::new();
1721        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1722        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1723
1724        assert!(
1725            matches!(result, Err(Diagnostic::ServerError(_))),
1726            "mid-loop error must propagate, got {:?}",
1727            result
1728        );
1729        assert!(renderer.resources_view.is_none(), "no partial result must be rendered on error");
1730    }
1731
1732    /// Empty result (zero resources): renders with total=0.
1733    #[test]
1734    fn test_list_empty_result() {
1735        let client = MockDspClient::new()
1736            .with_resolve_project(Ok(make_project_ref()))
1737            .with_list_resources(Ok(make_resource_page(&[], false)));
1738
1739        let mut renderer = RecordingRenderer::new();
1740        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1741        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1742
1743        let view = renderer.resources_view.expect("must have been called");
1744        assert!(view.items.is_empty());
1745        assert_eq!(view.total, 0);
1746    }
1747
1748    /// --filter narrows items; total reflects pre-filter count.
1749    #[test]
1750    fn test_list_filter_narrows_total_is_prefilter() {
1751        let client = MockDspClient::new()
1752            .with_resolve_project(Ok(make_project_ref()))
1753            .with_list_resources(Ok(make_resource_page(&["AliceA", "BobB", "AliceC"], false)));
1754
1755        let mut renderer = RecordingRenderer::new();
1756        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, Some("alice"));
1757        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1758
1759        let view = renderer.resources_view.expect("must have been called");
1760        assert_eq!(view.total, 3, "total is pre-filter");
1761        assert_eq!(view.items.len(), 2, "filter keeps only Alice items");
1762        assert!(view.items.iter().all(|r| r.label.to_lowercase().contains("alice")));
1763        assert_eq!(view.filter.as_deref(), Some("alice"));
1764    }
1765
1766    /// --project missing → Usage error; no client calls.
1767    #[test]
1768    fn test_list_missing_project_is_usage_error() {
1769        let client = MockDspClient::new();
1770        let mut renderer = RecordingRenderer::new();
1771        let args = make_args(None, Some(LETTER_IRI), None, None, false, None);
1772        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1773
1774        assert!(
1775            matches!(result, Err(Diagnostic::Usage(_))),
1776            "missing --project must yield Usage, got {:?}",
1777            result
1778        );
1779        assert_eq!(client.resolve_calls(), 0);
1780    }
1781
1782    /// --resource-type missing → Usage error; no client calls.
1783    #[test]
1784    fn test_list_missing_resource_type_is_usage_error() {
1785        let client = MockDspClient::new();
1786        let mut renderer = RecordingRenderer::new();
1787        let args = make_args(Some("0801"), None, None, None, false, None);
1788        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1789
1790        assert!(
1791            matches!(result, Err(Diagnostic::Usage(_))),
1792            "missing --resource-type must yield Usage, got {:?}",
1793            result
1794        );
1795        assert_eq!(client.resolve_calls(), 0);
1796    }
1797
1798    /// D3: anonymous → filter_warning is "results may be filtered; login to see private resources".
1799    #[test]
1800    fn test_list_filter_warning_anonymous() {
1801        let client = MockDspClient::new()
1802            .with_resolve_project(Ok(make_project_ref()))
1803            .with_list_resources(Ok(make_resource_page(&[], false)));
1804
1805        let mut renderer = RecordingRenderer::new();
1806        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1807        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1808
1809        let meta = renderer.resources_meta.expect("meta must be present");
1810        assert_eq!(meta.auth_state, "anonymous");
1811        let fw = meta.filter_warning.expect("filter_warning must be Some for anonymous");
1812        assert_eq!(fw, "results may be filtered; login to see private resources");
1813    }
1814
1815    /// D3: authenticated (cache token) → filter_warning is "results limited to your permissions".
1816    #[test]
1817    fn test_list_filter_warning_authenticated() {
1818        let dir = TempDir::new().expect("tempdir");
1819        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1820        let path = write_cache(&dir, &cache);
1821
1822        let client = MockDspClient::new()
1823            .with_resolve_project(Ok(make_project_ref()))
1824            .with_list_resources(Ok(make_resource_page(&[], false)));
1825
1826        let mut renderer = RecordingRenderer::new();
1827        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1828        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&path)).expect("expected Ok");
1829
1830        let meta = renderer.resources_meta.expect("meta must be present");
1831        let fw = meta.filter_warning.expect("filter_warning must be Some for authenticated");
1832        assert_eq!(fw, "results limited to your permissions");
1833    }
1834
1835    /// Corrupt/missing cache → falls back to anonymous, never returns Err.
1836    #[test]
1837    fn test_list_corrupt_cache_falls_back_to_anonymous() {
1838        let dir = TempDir::new().expect("tempdir");
1839        let bad_path = dir.path().join("nonexistent_auth.toml");
1840
1841        let client = MockDspClient::new()
1842            .with_resolve_project(Ok(make_project_ref()))
1843            .with_list_resources(Ok(make_resource_page(&[], false)));
1844
1845        let mut renderer = RecordingRenderer::new();
1846        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1847        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&bad_path));
1848        assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
1849
1850        let meta = renderer.resources_meta.expect("meta must be present");
1851        assert_eq!(meta.auth_state, "anonymous");
1852    }
1853
1854    /// Mid-scan `describe_data_model` error is propagated; `list_resources` is never
1855    /// called.
1856    ///
1857    /// The project has two data-models. The first `describe_data_model` call succeeds
1858    /// (returning a detail with no matching type), but the second returns
1859    /// `Err(Diagnostic::ServerError(...))`. The action must propagate that error and
1860    /// never proceed to `list_resources`.
1861    #[test]
1862    fn test_list_mid_scan_describe_error_propagates() {
1863        let client = MockDspClient::new()
1864            .with_resolve_project(Ok(make_project_ref()))
1865            .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
1866            // First call succeeds — but the target type is not in it.
1867            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1868            // Second call returns a server error mid-scan.
1869            .with_describe_data_model(Err(Diagnostic::ServerError("data-model service unavailable".into())));
1870
1871        let mut renderer = RecordingRenderer::new();
1872        // "Score" exists only in webern, but the second call errors before we get there.
1873        let args = make_args(Some("0801"), Some("Score"), None, None, false, None);
1874        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1875
1876        assert!(
1877            matches!(result, Err(Diagnostic::ServerError(_))),
1878            "mid-scan describe_data_model error must propagate as ServerError, got {:?}",
1879            result
1880        );
1881        assert_eq!(
1882            client.list_resources_calls(),
1883            0,
1884            "list_resources must never be called when describe_data_model errors mid-scan"
1885        );
1886    }
1887
1888    /// Token passed to list_resources when authenticated.
1889    #[test]
1890    fn test_list_token_forwarded_to_list_resources() {
1891        let dir = TempDir::new().expect("tempdir");
1892        let cache = cache_with_entry(SERVER, "my-token", "user@example.com");
1893        let path = write_cache(&dir, &cache);
1894
1895        let client = MockDspClient::new()
1896            .with_resolve_project(Ok(make_project_ref()))
1897            .with_list_resources(Ok(make_resource_page(&[], false)));
1898
1899        let mut renderer = RecordingRenderer::new();
1900        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1901        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&path)).expect("expected Ok");
1902
1903        let calls = client.list_resources_args();
1904        assert_eq!(
1905            calls[0].4,
1906            Some("my-token".to_string()),
1907            "token must be forwarded to list_resources"
1908        );
1909    }
1910
1911    // ── order-by tests ────────────────────────────────────────────────────────
1912
1913    /// Build a ResourceTypeDetail with a set of fields for testing.
1914    fn make_rt_detail_with_fields(rt_name: &str, field_names: &[(&str, &str)]) -> ResourceTypeDetail {
1915        let fields = field_names
1916            .iter()
1917            .map(|(name, iri)| Field {
1918                name: name.to_string(),
1919                iri: iri.to_string(),
1920                label: None,
1921                value_type: crate::model::resource_type::ValueType::Text,
1922                link_target: None,
1923                cardinality: Cardinality::ZeroOrOne,
1924                is_builtin: false,
1925                data_model: Some("beol".to_string()),
1926            })
1927            .collect();
1928        ResourceTypeDetail {
1929            name: rt_name.to_string(),
1930            iri: LETTER_IRI.to_string(),
1931            label: None,
1932            data_model: "beol".to_string(),
1933            representation: None,
1934            super_types: vec![],
1935            fields,
1936            count: None,
1937        }
1938    }
1939
1940    const TITLE_PROP_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#hasTitle";
1941
1942    /// Bare field name: resolves to the correct property IRI passed to list_resources.
1943    #[test]
1944    fn test_order_by_bare_name_resolves_to_property_iri() {
1945        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
1946        let client = MockDspClient::new()
1947            .with_resolve_project(Ok(make_project_ref()))
1948            .with_list_data_models(Ok(vec![make_data_model("beol")]))
1949            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1950            .with_describe_resource_type(rt_detail)
1951            .with_list_resources(Ok(make_resource_page(&["A"], false)));
1952
1953        let mut renderer = RecordingRenderer::new();
1954        let args = make_args_with_order_by(Some("0801"), Some("Letter"), None, None, false, None, Some("hasTitle"));
1955        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1956
1957        assert_eq!(
1958            client.describe_resource_type_calls(),
1959            1,
1960            "bare field name must call describe_resource_type"
1961        );
1962        let calls = client.list_resources_args();
1963        assert_eq!(calls.len(), 1);
1964        assert_eq!(
1965            calls[0].2,
1966            Some(TITLE_PROP_IRI.to_string()),
1967            "order_by must be the resolved property IRI"
1968        );
1969    }
1970
1971    /// `://` bypass: value used verbatim, describe_resource_type NOT called.
1972    #[test]
1973    fn test_order_by_full_iri_bypass_skips_describe_resource_type() {
1974        let client = MockDspClient::new()
1975            .with_resolve_project(Ok(make_project_ref()))
1976            .with_list_resources(Ok(make_resource_page(&["A"], false)));
1977
1978        let mut renderer = RecordingRenderer::new();
1979        let args =
1980            make_args_with_order_by(Some("0801"), Some(LETTER_IRI), None, None, false, None, Some(TITLE_PROP_IRI));
1981        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1982
1983        assert_eq!(
1984            client.describe_resource_type_calls(),
1985            0,
1986            "full-IRI bypass must NOT call describe_resource_type"
1987        );
1988        let calls = client.list_resources_args();
1989        assert_eq!(calls.len(), 1);
1990        assert_eq!(
1991            calls[0].2,
1992            Some(TITLE_PROP_IRI.to_string()),
1993            "order_by must be the verbatim IRI"
1994        );
1995    }
1996
1997    /// Path A (full resource-type IRI with `#`) + bare order-by → resolves correctly.
1998    #[test]
1999    fn test_order_by_path_a_with_fragment_resolves() {
2000        // LETTER_IRI contains '#', so data_model_iri will be derived as the part before '#'.
2001        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2002        let client = MockDspClient::new()
2003            .with_resolve_project(Ok(make_project_ref()))
2004            .with_describe_resource_type(rt_detail)
2005            .with_list_resources(Ok(make_resource_page(&["A"], false)));
2006
2007        let mut renderer = RecordingRenderer::new();
2008        let args = make_args_with_order_by(
2009            Some("0801"),
2010            Some(LETTER_IRI), // full IRI with '#'
2011            None,
2012            None,
2013            false,
2014            None,
2015            Some("hasTitle"),
2016        );
2017        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2018
2019        assert_eq!(
2020            client.describe_resource_type_calls(),
2021            1,
2022            "bare field name with path-A resource-type must call describe_resource_type"
2023        );
2024        let calls = client.list_resources_args();
2025        assert_eq!(
2026            calls[0].2,
2027            Some(TITLE_PROP_IRI.to_string()),
2028            "order_by must be the resolved property IRI"
2029        );
2030    }
2031
2032    /// Path A resource-type IRI lacking `#` + bare order-by → Usage error.
2033    #[test]
2034    fn test_order_by_path_a_no_fragment_bare_name_is_usage_error() {
2035        // A class IRI without '#' — data_model_iri cannot be derived. Resolution
2036        // must fail before any fetch, so no `with_list_resources` is seeded.
2037        let no_fragment_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2/Letter";
2038        let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
2039
2040        let mut renderer = RecordingRenderer::new();
2041        let args =
2042            make_args_with_order_by(Some("0801"), Some(no_fragment_iri), None, None, false, None, Some("hasTitle"));
2043        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2044
2045        assert!(
2046            matches!(result, Err(Diagnostic::Usage(_))),
2047            "path-A IRI lacking '#' + bare order-by must yield Usage, got {:?}",
2048            result
2049        );
2050        assert_eq!(
2051            client.describe_resource_type_calls(),
2052            0,
2053            "describe_resource_type must not be called when error is expected"
2054        );
2055    }
2056
2057    /// Unknown field name → Usage error with a helpful hint.
2058    #[test]
2059    fn test_order_by_unknown_field_is_usage_error() {
2060        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2061        // No `with_list_resources` seed: resolution must fail before any fetch.
2062        let client = MockDspClient::new()
2063            .with_resolve_project(Ok(make_project_ref()))
2064            .with_list_data_models(Ok(vec![make_data_model("beol")]))
2065            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2066            .with_describe_resource_type(rt_detail);
2067
2068        let mut renderer = RecordingRenderer::new();
2069        let args =
2070            make_args_with_order_by(Some("0801"), Some("Letter"), None, None, false, None, Some("nonExistentField"));
2071        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2072
2073        assert!(
2074            matches!(result, Err(Diagnostic::Usage(_))),
2075            "unknown field name must yield Usage, got {:?}",
2076            result
2077        );
2078        if let Err(Diagnostic::Usage(msg)) = &result {
2079            assert!(
2080                msg.contains("nonExistentField"),
2081                "Usage error must echo the field name, got: {msg}"
2082            );
2083            assert!(
2084                msg.contains("resource-type describe"),
2085                "Usage error must hint at resource-type describe, got: {msg}"
2086            );
2087        }
2088        // Resolution looked the field up, then bailed before fetching.
2089        assert_eq!(client.describe_resource_type_calls(), 1);
2090        assert_eq!(
2091            client.list_resources_calls(),
2092            0,
2093            "list_resources must not be called when order-by resolution fails"
2094        );
2095    }
2096
2097    /// No `--order-by` → None recorded by mock.
2098    #[test]
2099    fn test_order_by_absent_passes_none_to_list_resources() {
2100        let client = MockDspClient::new()
2101            .with_resolve_project(Ok(make_project_ref()))
2102            .with_list_resources(Ok(make_resource_page(&[], false)));
2103
2104        let mut renderer = RecordingRenderer::new();
2105        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2106        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2107
2108        let calls = client.list_resources_args();
2109        assert_eq!(calls[0].2, None, "--order-by absent must pass None to list_resources");
2110        assert_eq!(
2111            client.describe_resource_type_calls(),
2112            0,
2113            "describe_resource_type must not be called when --order-by is absent"
2114        );
2115    }
2116
2117    /// `--order-by` + `--all` → resolved IRI threaded to EVERY page request.
2118    #[test]
2119    fn test_order_by_with_all_threads_iri_to_every_page() {
2120        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2121        let client = MockDspClient::new()
2122            .with_resolve_project(Ok(make_project_ref()))
2123            .with_list_data_models(Ok(vec![make_data_model("beol")]))
2124            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2125            .with_describe_resource_type(rt_detail)
2126            .with_list_resources(Ok(make_resource_page(&["A"], true)))
2127            .with_list_resources(Ok(make_resource_page(&["B"], false)));
2128
2129        let mut renderer = RecordingRenderer::new();
2130        let args = make_args_with_order_by(
2131            Some("0801"),
2132            Some("Letter"),
2133            None,
2134            None,
2135            true, // --all
2136            None,
2137            Some("hasTitle"),
2138        );
2139        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2140
2141        assert_eq!(client.list_resources_calls(), 2);
2142        let calls = client.list_resources_args();
2143        assert_eq!(
2144            calls[0].2,
2145            Some(TITLE_PROP_IRI.to_string()),
2146            "order_by must be threaded to page 0"
2147        );
2148        assert_eq!(
2149            calls[1].2,
2150            Some(TITLE_PROP_IRI.to_string()),
2151            "order_by must be threaded to page 1"
2152        );
2153    }
2154
2155    /// Case-insensitive field name match: `HASTITLE` resolves to the same IRI as `hasTitle`.
2156    #[test]
2157    fn test_order_by_field_name_is_case_insensitive() {
2158        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2159        let client = MockDspClient::new()
2160            .with_resolve_project(Ok(make_project_ref()))
2161            .with_list_data_models(Ok(vec![make_data_model("beol")]))
2162            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2163            .with_describe_resource_type(rt_detail)
2164            .with_list_resources(Ok(make_resource_page(&[], false)));
2165
2166        let mut renderer = RecordingRenderer::new();
2167        let args = make_args_with_order_by(Some("0801"), Some("Letter"), None, None, false, None, Some("HASTITLE"));
2168        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2169
2170        let calls = client.list_resources_args();
2171        assert_eq!(
2172            calls[0].2,
2173            Some(TITLE_PROP_IRI.to_string()),
2174            "case-insensitive field name match must resolve to the property IRI"
2175        );
2176    }
2177}