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