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