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