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        fn sparql_query(
910            &self,
911            _server: &str,
912            _token: &str,
913            _query: &str,
914            _accept: &str,
915            _timeout_secs: u64,
916        ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
917            Err(Diagnostic::Internal("not used in this test".into()))
918        }
919    }
920
921    // ── RecordingRenderer ─────────────────────────────────────────────────────
922
923    struct RecordingRenderer {
924        resources_view: Option<ResourceListView>,
925        resources_meta: Option<MetaContext>,
926        resource_describe_detail: Option<ResourceDetail>,
927        resource_describe_meta: Option<MetaContext>,
928    }
929
930    impl RecordingRenderer {
931        fn new() -> Self {
932            Self {
933                resources_view: None,
934                resources_meta: None,
935                resource_describe_detail: None,
936                resource_describe_meta: None,
937            }
938        }
939    }
940
941    impl Renderer for RecordingRenderer {
942        fn diagnostic(
943            &mut self,
944            _diag: &Diagnostic,
945            _meta: &MetaContext,
946        ) -> Result<(), Diagnostic> {
947            Ok(())
948        }
949
950        fn auth_login(
951            &mut self,
952            _outcome: &AuthLoginOutcome,
953            _meta: &MetaContext,
954        ) -> Result<(), Diagnostic> {
955            Ok(())
956        }
957
958        fn auth_status(
959            &mut self,
960            _outcome: &AuthStatusOutcome,
961            _meta: &MetaContext,
962        ) -> Result<(), Diagnostic> {
963            Ok(())
964        }
965
966        fn auth_logout(
967            &mut self,
968            _outcome: &AuthLogoutOutcome,
969            _meta: &MetaContext,
970        ) -> Result<(), Diagnostic> {
971            Ok(())
972        }
973
974        fn auth_set_token(
975            &mut self,
976            _outcome: &AuthSetTokenOutcome,
977            _meta: &MetaContext,
978        ) -> Result<(), Diagnostic> {
979            Ok(())
980        }
981
982        fn project_dump(
983            &mut self,
984            _outcome: &DumpOutcome,
985            _meta: &MetaContext,
986        ) -> Result<(), Diagnostic> {
987            Ok(())
988        }
989
990        fn project_dump_deleted(
991            &mut self,
992            _outcome: &DumpDeleteOutcome,
993            _meta: &MetaContext,
994        ) -> Result<(), Diagnostic> {
995            Ok(())
996        }
997
998        fn projects(
999            &mut self,
1000            _view: &ProjectListView,
1001            _meta: &MetaContext,
1002        ) -> Result<(), Diagnostic> {
1003            Ok(())
1004        }
1005
1006        fn project_describe(
1007            &mut self,
1008            _project: &crate::model::ProjectDetail,
1009            _meta: &MetaContext,
1010        ) -> Result<(), Diagnostic> {
1011            Ok(())
1012        }
1013
1014        fn data_models(
1015            &mut self,
1016            _view: &DataModelListView,
1017            _meta: &MetaContext,
1018        ) -> Result<(), Diagnostic> {
1019            Ok(())
1020        }
1021
1022        fn data_model_describe(
1023            &mut self,
1024            _detail: &crate::model::DataModelDetail,
1025            _meta: &MetaContext,
1026        ) -> Result<(), Diagnostic> {
1027            Ok(())
1028        }
1029
1030        fn resource_types(
1031            &mut self,
1032            _view: &ResourceTypeListView,
1033            _meta: &MetaContext,
1034        ) -> Result<(), Diagnostic> {
1035            Ok(())
1036        }
1037
1038        fn resource_type_describe(
1039            &mut self,
1040            _detail: &crate::model::ResourceTypeDetail,
1041            _meta: &MetaContext,
1042        ) -> Result<(), Diagnostic> {
1043            Ok(())
1044        }
1045
1046        fn data_model_structure(
1047            &mut self,
1048            _structure: &crate::model::DataModelStructure,
1049            _meta: &MetaContext,
1050        ) -> Result<(), Diagnostic> {
1051            Ok(())
1052        }
1053
1054        fn resources(
1055            &mut self,
1056            view: &ResourceListView,
1057            meta: &MetaContext,
1058        ) -> Result<(), Diagnostic> {
1059            self.resources_view = Some(view.clone());
1060            self.resources_meta = Some(meta.clone());
1061            Ok(())
1062        }
1063
1064        fn resource_describe(
1065            &mut self,
1066            detail: &crate::model::ResourceDetail,
1067            meta: &MetaContext,
1068        ) -> Result<(), Diagnostic> {
1069            self.resource_describe_detail = Some(detail.clone());
1070            self.resource_describe_meta = Some(meta.clone());
1071            Ok(())
1072        }
1073
1074        fn vocabularies(
1075            &mut self,
1076            _view: &crate::render::VocabularyListView,
1077            _meta: &MetaContext,
1078        ) -> Result<(), Diagnostic> {
1079            unimplemented!("not exercised by this file's tests")
1080        }
1081
1082        fn vocabulary_describe(
1083            &mut self,
1084            _detail: &crate::model::VocabularyDetail,
1085            _meta: &MetaContext,
1086        ) -> Result<(), Diagnostic> {
1087            unimplemented!("not exercised by this file's tests")
1088        }
1089    }
1090
1091    // ── helpers ───────────────────────────────────────────────────────────────
1092
1093    const SERVER: &str = "https://api.test.dasch.swiss";
1094    const PROJECT_IRI: &str = "http://rdfh.ch/projects/0801";
1095    const BEOL_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1096    const LETTER_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#Letter";
1097
1098    fn make_cfg() -> Config {
1099        Config {
1100            server: SERVER.to_string(),
1101        }
1102    }
1103
1104    fn make_project_ref() -> ProjectRef {
1105        ProjectRef {
1106            iri: PROJECT_IRI.to_string(),
1107            shortcode: "0801".to_string(),
1108            shortname: "beol".to_string(),
1109        }
1110    }
1111
1112    fn make_args(
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    ) -> ResourceListArgs {
1120        make_args_with_order_by(project, resource_type, data_model, page, all, filter, None)
1121    }
1122
1123    fn make_args_with_order_by(
1124        project: Option<&str>,
1125        resource_type: Option<&str>,
1126        data_model: Option<&str>,
1127        page: Option<u32>,
1128        all: bool,
1129        filter: Option<&str>,
1130        order_by: Option<&str>,
1131    ) -> ResourceListArgs {
1132        ResourceListArgs {
1133            server: Some(SERVER.to_string()),
1134            project: project.map(str::to_owned),
1135            resource_type: resource_type.map(str::to_owned),
1136            data_model: data_model.map(str::to_owned),
1137            page,
1138            all,
1139            filter: filter.map(str::to_owned),
1140            order_by: order_by.map(str::to_owned),
1141            format: FormatArgs {
1142                format: Format::Prose,
1143                json: false,
1144                lines: false,
1145                columns: None,
1146                no_header: false,
1147                header_only: false,
1148            },
1149        }
1150    }
1151
1152    fn make_data_model(name: &str) -> DataModel {
1153        DataModel {
1154            name: name.to_string(),
1155            iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
1156            label: None,
1157            last_modified: None,
1158            is_builtin: false,
1159        }
1160    }
1161
1162    fn make_detail_with_rts(dm_name: &str, rt_names: &[&str]) -> DataModelDetail {
1163        let resource_types = rt_names
1164            .iter()
1165            .map(|name| ResourceTypeSummary {
1166                name: name.to_string(),
1167                iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2#{name}"),
1168                label: None,
1169            })
1170            .collect();
1171        DataModelDetail {
1172            name: dm_name.to_string(),
1173            iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2"),
1174            label: None,
1175            last_modified: None,
1176            resource_types,
1177        }
1178    }
1179
1180    fn make_resource(label: &str) -> ResourceSummary {
1181        ResourceSummary {
1182            label: label.to_string(),
1183            iri: format!("http://rdfh.ch/0801/{label}"),
1184            ark_url: None,
1185            creation_date: None,
1186            last_modified: None,
1187            resource_type: "Letter".to_string(),
1188        }
1189    }
1190
1191    fn make_resource_page(labels: &[&str], may_have_more: bool) -> ResourcePage {
1192        ResourcePage {
1193            resources: labels.iter().map(|l| make_resource(l)).collect(),
1194            may_have_more_results: may_have_more,
1195        }
1196    }
1197
1198    fn cache_with_entry(server: &str, token: &str, user: &str) -> AuthCache {
1199        let mut cache = AuthCache::default();
1200        cache.set_entry(
1201            server,
1202            ServerEntry {
1203                token: token.to_string(),
1204                user: Some(user.to_string()),
1205                acquired_at: None,
1206                expires_at: None,
1207            },
1208        );
1209        cache
1210    }
1211
1212    fn write_cache(dir: &TempDir, cache: &AuthCache) -> std::path::PathBuf {
1213        let path = dir.path().join("auth.toml");
1214        cache.save_to(&path).expect("failed to write test cache");
1215        path
1216    }
1217
1218    const RESOURCE_IRI: &str = "http://rdfh.ch/0803/--6Esp4SVnGG1DBzFvYErw";
1219    const OTHER_PROJECT_IRI: &str = "http://rdfh.ch/projects/9999";
1220
1221    fn make_describe_args(resource: Option<&str>, project: Option<&str>) -> ResourceDescribeArgs {
1222        make_describe_args_with_values(resource, project, false)
1223    }
1224
1225    fn make_describe_args_with_values(
1226        resource: Option<&str>,
1227        project: Option<&str>,
1228        values: bool,
1229    ) -> ResourceDescribeArgs {
1230        ResourceDescribeArgs {
1231            server: Some(SERVER.to_string()),
1232            resource: resource.map(str::to_owned),
1233            project: project.map(str::to_owned),
1234            values,
1235            format: FormatArgs {
1236                format: Format::Prose,
1237                json: false,
1238                lines: false,
1239                columns: None,
1240                no_header: false,
1241                header_only: false,
1242            },
1243        }
1244    }
1245
1246    fn make_resource_detail() -> ResourceDetail {
1247        ResourceDetail {
1248            label: "Test Page".to_string(),
1249            iri: RESOURCE_IRI.to_string(),
1250            resource_type: "Page".to_string(),
1251            ark_url: Some("ark:/72163/1/0803/--6Esp4SVnGG1DBzFvYErw".to_string()),
1252            creation_date: Some("2021-01-01T00:00:00Z".to_string()),
1253            last_modified: Some("2021-06-15T12:00:00Z".to_string()),
1254            attached_project: Some(PROJECT_IRI.to_string()),
1255            owner: Some("http://rdfh.ch/users/root".to_string()),
1256            visibility: Some(ResourceVisibility::Public),
1257            your_access: Some(ResourceAccess::View),
1258            values: None,
1259        }
1260    }
1261
1262    // ── tests ─────────────────────────────────────────────────────────────────
1263
1264    // ── describe tests ────────────────────────────────────────────────────────
1265
1266    /// Happy path: renderer receives the expected ResourceDetail.
1267    #[test]
1268    fn test_describe_happy_path() {
1269        let detail = make_resource_detail();
1270        let client = MockDspClient::new().with_describe_resource(Ok(detail.clone()));
1271
1272        let mut renderer = RecordingRenderer::new();
1273        let args = make_describe_args(Some(RESOURCE_IRI), None);
1274        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1275
1276        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1277        assert_eq!(client.describe_resource_calls(), 1);
1278
1279        let recorded = renderer
1280            .resource_describe_detail
1281            .expect("resource_describe must have been called");
1282        assert_eq!(recorded.label, "Test Page");
1283        assert_eq!(recorded.iri, RESOURCE_IRI);
1284        assert_eq!(recorded.resource_type, "Page");
1285        assert_eq!(recorded.visibility, Some(ResourceVisibility::Public));
1286        assert_eq!(recorded.your_access, Some(ResourceAccess::View));
1287    }
1288
1289    /// --resource missing → Usage error; no client calls.
1290    #[test]
1291    fn test_describe_missing_resource_is_usage_error() {
1292        let client = MockDspClient::new();
1293        let mut renderer = RecordingRenderer::new();
1294        let args = make_describe_args(None, None);
1295        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1296
1297        assert!(
1298            matches!(result, Err(Diagnostic::Usage(_))),
1299            "missing --resource must yield Usage, got {:?}",
1300            result
1301        );
1302        assert_eq!(client.describe_resource_calls(), 0);
1303    }
1304
1305    /// --resource missing → Usage error message mentions --resource.
1306    #[test]
1307    fn test_describe_missing_resource_error_mentions_flag() {
1308        let client = MockDspClient::new();
1309        let mut renderer = RecordingRenderer::new();
1310        let args = make_describe_args(None, None);
1311        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1312
1313        if let Err(Diagnostic::Usage(msg)) = result {
1314            assert!(
1315                msg.contains("--resource"),
1316                "error message must mention --resource, got: {msg}"
1317            );
1318        } else {
1319            panic!("expected Usage error");
1320        }
1321    }
1322
1323    /// Cross-project guard MATCH: resource.attached_project == resolved_project_iri → no error.
1324    #[test]
1325    fn test_describe_cross_project_guard_match_no_error() {
1326        let detail = make_resource_detail(); // attached_project = PROJECT_IRI
1327        let client = MockDspClient::new()
1328            .with_resolve_project(Ok(make_project_ref())) // iri = PROJECT_IRI
1329            .with_describe_resource(Ok(detail));
1330
1331        let mut renderer = RecordingRenderer::new();
1332        let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1333        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1334
1335        assert!(
1336            result.is_ok(),
1337            "matching project must not error, got {:?}",
1338            result
1339        );
1340        assert!(
1341            renderer.resource_describe_detail.is_some(),
1342            "renderer must have been called"
1343        );
1344    }
1345
1346    /// Cross-project guard MISMATCH: resource.attached_project != resolved_project_iri → Usage error.
1347    ///
1348    /// The error message must name BOTH the resource IRI and the local-name tails of
1349    /// the actual project (OTHER_PROJECT_IRI → "9999") and the expected project
1350    /// (PROJECT_IRI → "0801"), so a generic "mismatch" message would fail this test.
1351    #[test]
1352    fn test_describe_cross_project_guard_mismatch_usage_error() {
1353        let mut detail = make_resource_detail();
1354        detail.attached_project = Some(OTHER_PROJECT_IRI.to_string()); // belongs to a different project
1355
1356        let client = MockDspClient::new()
1357            .with_resolve_project(Ok(make_project_ref())) // iri = PROJECT_IRI
1358            .with_describe_resource(Ok(detail));
1359
1360        let mut renderer = RecordingRenderer::new();
1361        let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1362        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1363
1364        assert!(
1365            matches!(result, Err(Diagnostic::Usage(_))),
1366            "project mismatch must yield Usage, got {:?}",
1367            result
1368        );
1369        assert!(
1370            renderer.resource_describe_detail.is_none(),
1371            "renderer must NOT be called on mismatch"
1372        );
1373
1374        // Tightened: the message must mention the resource IRI and local-name
1375        // tails of BOTH the actual project ("9999") and the expected project ("0801").
1376        if let Err(Diagnostic::Usage(msg)) = result {
1377            assert!(
1378                msg.contains(RESOURCE_IRI),
1379                "mismatch error must mention the resource IRI; got: {msg}"
1380            );
1381            // local_name("http://rdfh.ch/projects/9999") → "9999"
1382            assert!(
1383                msg.contains("9999"),
1384                "mismatch error must mention the actual project's local name '9999'; got: {msg}"
1385            );
1386            // local_name("http://rdfh.ch/projects/0801") → "0801"
1387            assert!(
1388                msg.contains("0801"),
1389                "mismatch error must mention the expected project's local name '0801'; got: {msg}"
1390            );
1391        } else {
1392            panic!("expected Err(Diagnostic::Usage(_))");
1393        }
1394    }
1395
1396    /// Cross-project guard with attached_project == None → passes through (no error).
1397    ///
1398    /// Per D2: absence of attached_project does NOT trigger the guard.
1399    #[test]
1400    fn test_describe_cross_project_guard_none_passes_through() {
1401        let mut detail = make_resource_detail();
1402        detail.attached_project = None; // absent from response
1403
1404        let client = MockDspClient::new()
1405            .with_resolve_project(Ok(make_project_ref()))
1406            .with_describe_resource(Ok(detail));
1407
1408        let mut renderer = RecordingRenderer::new();
1409        let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1410        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1411
1412        assert!(
1413            result.is_ok(),
1414            "None attached_project must not error, got {:?}",
1415            result
1416        );
1417        assert!(
1418            renderer.resource_describe_detail.is_some(),
1419            "renderer must have been called"
1420        );
1421    }
1422
1423    /// No --project given: guard is entirely skipped, resolve_project not called.
1424    #[test]
1425    fn test_describe_no_project_guard_skipped() {
1426        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1427
1428        let mut renderer = RecordingRenderer::new();
1429        let args = make_describe_args(Some(RESOURCE_IRI), None);
1430        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1431
1432        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1433        assert_eq!(
1434            client.resolve_calls(),
1435            0,
1436            "resolve_project must not be called when --project is omitted"
1437        );
1438    }
1439
1440    /// D3: anonymous → filter_warning is "results may be filtered; login to see private resources".
1441    #[test]
1442    fn test_describe_filter_warning_anonymous() {
1443        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1444
1445        let mut renderer = RecordingRenderer::new();
1446        let args = make_describe_args(Some(RESOURCE_IRI), None);
1447        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1448            .expect("expected Ok");
1449
1450        let meta = renderer
1451            .resource_describe_meta
1452            .expect("meta must be present");
1453        assert_eq!(meta.auth_state, "anonymous");
1454        let fw = meta
1455            .filter_warning
1456            .expect("filter_warning must be Some for anonymous");
1457        assert_eq!(
1458            fw,
1459            "results may be filtered; login to see private resources"
1460        );
1461    }
1462
1463    /// D3: authenticated (cache token) → filter_warning is "results limited to your permissions".
1464    #[test]
1465    fn test_describe_filter_warning_authenticated() {
1466        let dir = TempDir::new().expect("tempdir");
1467        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1468        let path = write_cache(&dir, &cache);
1469
1470        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1471
1472        let mut renderer = RecordingRenderer::new();
1473        let args = make_describe_args(Some(RESOURCE_IRI), None);
1474        run_describe_impl(
1475            &args,
1476            &make_cfg(),
1477            &client,
1478            &mut renderer,
1479            None,
1480            Some(&path),
1481        )
1482        .expect("expected Ok");
1483
1484        let meta = renderer
1485            .resource_describe_meta
1486            .expect("meta must be present");
1487        let fw = meta
1488            .filter_warning
1489            .expect("filter_warning must be Some for authenticated");
1490        assert_eq!(fw, "results limited to your permissions");
1491    }
1492
1493    /// Not-found propagation: mock returns Diagnostic::NotFound → surfaced.
1494    #[test]
1495    fn test_describe_not_found_propagates() {
1496        let client = MockDspClient::new().with_describe_resource(Err(Diagnostic::NotFound(
1497            "resource not found on server".to_string(),
1498        )));
1499
1500        let mut renderer = RecordingRenderer::new();
1501        let args = make_describe_args(Some(RESOURCE_IRI), None);
1502        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1503
1504        assert!(
1505            matches!(result, Err(Diagnostic::NotFound(_))),
1506            "not-found from client must propagate, got {:?}",
1507            result
1508        );
1509        assert!(
1510            renderer.resource_describe_detail.is_none(),
1511            "renderer must not be called on error"
1512        );
1513    }
1514
1515    /// Token forwarded to describe_resource when authenticated.
1516    #[test]
1517    fn test_describe_token_forwarded() {
1518        let dir = TempDir::new().expect("tempdir");
1519        let cache = cache_with_entry(SERVER, "my-describe-token", "user@example.com");
1520        let path = write_cache(&dir, &cache);
1521
1522        // We need to capture the token passed to describe_resource.
1523        // Use env_token injection (the public describe() fn reads DSP_TOKEN; we
1524        // inject via env_token parameter to run_describe_impl directly).
1525        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1526
1527        let mut renderer = RecordingRenderer::new();
1528        let args = make_describe_args(Some(RESOURCE_IRI), None);
1529        run_describe_impl(
1530            &args,
1531            &make_cfg(),
1532            &client,
1533            &mut renderer,
1534            None,
1535            Some(&path),
1536        )
1537        .expect("expected Ok");
1538
1539        // The auth_state reported in meta reveals that auth was used.
1540        let meta = renderer
1541            .resource_describe_meta
1542            .expect("meta must be present");
1543        // Authenticated (cache token) → filter_warning uses the authenticated wording.
1544        let fw = meta.filter_warning.expect("filter_warning must be present");
1545        assert_eq!(fw, "results limited to your permissions");
1546    }
1547
1548    /// --values absent (default false) → describe_resource called with with_values=false.
1549    #[test]
1550    fn test_describe_values_flag_absent_passes_false() {
1551        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1552        let mut renderer = RecordingRenderer::new();
1553        let args = make_describe_args(Some(RESOURCE_IRI), None); // values=false by default
1554        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1555            .expect("expected Ok");
1556
1557        let with_values_args = client.describe_resource_with_values_args();
1558        assert_eq!(with_values_args.len(), 1);
1559        assert!(
1560            !with_values_args[0],
1561            "with_values must be false when --values is absent"
1562        );
1563    }
1564
1565    /// --values true → describe_resource called with with_values=true.
1566    #[test]
1567    fn test_describe_values_flag_present_passes_true() {
1568        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1569        let mut renderer = RecordingRenderer::new();
1570        let args = make_describe_args_with_values(Some(RESOURCE_IRI), None, true);
1571        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1572            .expect("expected Ok");
1573
1574        let with_values_args = client.describe_resource_with_values_args();
1575        assert_eq!(with_values_args.len(), 1);
1576        assert!(
1577            with_values_args[0],
1578            "with_values must be true when --values is given"
1579        );
1580    }
1581
1582    /// Corrupt/missing cache → falls back to anonymous, never returns Err.
1583    #[test]
1584    fn test_describe_corrupt_cache_falls_back_to_anonymous() {
1585        let dir = TempDir::new().expect("tempdir");
1586        let bad_path = dir.path().join("nonexistent_auth.toml");
1587
1588        let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1589
1590        let mut renderer = RecordingRenderer::new();
1591        let args = make_describe_args(Some(RESOURCE_IRI), None);
1592        let result = run_describe_impl(
1593            &args,
1594            &make_cfg(),
1595            &client,
1596            &mut renderer,
1597            None,
1598            Some(&bad_path),
1599        );
1600        assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
1601
1602        let meta = renderer
1603            .resource_describe_meta
1604            .expect("meta must be present");
1605        assert_eq!(meta.auth_state, "anonymous");
1606    }
1607
1608    // ── list tests ────────────────────────────────────────────────────────────
1609
1610    /// Full-IRI bypass: no data-model scan, list_data_models not called.
1611    #[test]
1612    fn test_list_full_iri_bypass_skips_scan() {
1613        let client = MockDspClient::new()
1614            .with_resolve_project(Ok(make_project_ref()))
1615            .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1616
1617        let mut renderer = RecordingRenderer::new();
1618        // Full IRI → bypass scan
1619        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1620        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1621
1622        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1623        assert_eq!(
1624            client.list_data_models_calls(),
1625            0,
1626            "full IRI must not trigger a data-model scan"
1627        );
1628        assert_eq!(client.list_resources_calls(), 1);
1629
1630        let view = renderer
1631            .resources_view
1632            .expect("resources must have been called");
1633        assert_eq!(view.items.len(), 1);
1634        assert_eq!(view.resource_type, "Letter"); // local name extracted
1635    }
1636
1637    /// Full-IRI bypass: correct resource_type_iri and project_iri passed to list_resources.
1638    #[test]
1639    fn test_list_full_iri_passes_correct_params() {
1640        let client = MockDspClient::new()
1641            .with_resolve_project(Ok(make_project_ref()))
1642            .with_list_resources(Ok(make_resource_page(&[], false)));
1643
1644        let mut renderer = RecordingRenderer::new();
1645        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1646        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1647
1648        let calls = client.list_resources_args();
1649        assert_eq!(calls.len(), 1);
1650        assert_eq!(
1651            calls[0].0, PROJECT_IRI,
1652            "project_iri must be the resolved IRI"
1653        );
1654        assert_eq!(
1655            calls[0].1, LETTER_IRI,
1656            "resource_type_iri must be the full IRI"
1657        );
1658        assert_eq!(calls[0].2, None, "order_by is None (placeholder)");
1659        assert_eq!(calls[0].3, 0, "default page is 0");
1660        assert_eq!(calls[0].4, None, "no token for anonymous");
1661    }
1662
1663    /// Bare-name scan (one match): resolves correctly.
1664    #[test]
1665    fn test_list_bare_name_scan_single_match() {
1666        let client = MockDspClient::new()
1667            .with_resolve_project(Ok(make_project_ref()))
1668            .with_list_data_models(Ok(vec![make_data_model("beol")]))
1669            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1670            .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1671
1672        let mut renderer = RecordingRenderer::new();
1673        let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1674        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1675
1676        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1677        let view = renderer.resources_view.expect("must have been called");
1678        assert_eq!(view.resource_type, "Letter");
1679        assert_eq!(view.items.len(), 1);
1680    }
1681
1682    /// Bare-name scan with zero matches → NotFound.
1683    #[test]
1684    fn test_list_bare_name_scan_not_found() {
1685        let client = MockDspClient::new()
1686            .with_resolve_project(Ok(make_project_ref()))
1687            .with_list_data_models(Ok(vec![make_data_model("beol")]))
1688            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])));
1689
1690        let mut renderer = RecordingRenderer::new();
1691        let args = make_args(Some("0801"), Some("Nonexistent"), None, None, false, None);
1692        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1693
1694        assert!(
1695            matches!(result, Err(Diagnostic::NotFound(_))),
1696            "unknown resource-type must yield NotFound, got {:?}",
1697            result
1698        );
1699        assert_eq!(client.list_resources_calls(), 0);
1700    }
1701
1702    /// Bare-name ambiguous across two data-models → Usage error listing both DMs.
1703    #[test]
1704    fn test_list_bare_name_ambiguous_gives_usage_error() {
1705        let client = MockDspClient::new()
1706            .with_resolve_project(Ok(make_project_ref()))
1707            .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
1708            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1709            .with_describe_data_model(Ok(make_detail_with_rts("webern", &["Letter", "Score"])));
1710
1711        let mut renderer = RecordingRenderer::new();
1712        // "Letter" exists in both data-models → ambiguous
1713        let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1714        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1715
1716        assert!(
1717            matches!(result, Err(Diagnostic::Usage(_))),
1718            "ambiguous resource-type must yield Usage error, got {:?}",
1719            result
1720        );
1721        if let Err(Diagnostic::Usage(msg)) = &result {
1722            assert!(
1723                msg.contains("beol") && msg.contains("webern"),
1724                "Usage error must list both data-models, got: {msg}"
1725            );
1726            assert!(
1727                msg.contains("--data-model"),
1728                "Usage error must suggest --data-model, got: {msg}"
1729            );
1730        }
1731        assert_eq!(client.list_resources_calls(), 0);
1732    }
1733
1734    /// --data-model name scope: resolves only within the named data-model.
1735    #[test]
1736    fn test_list_data_model_name_scope() {
1737        let client = MockDspClient::new()
1738            .with_resolve_project(Ok(make_project_ref()))
1739            .with_list_data_models(Ok(vec![make_data_model("beol")]))
1740            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1741            .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1742
1743        let mut renderer = RecordingRenderer::new();
1744        let args = make_args(
1745            Some("0801"),
1746            Some("Letter"),
1747            Some("beol"),
1748            None,
1749            false,
1750            None,
1751        );
1752        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1753
1754        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1755        let view = renderer.resources_view.expect("must have been called");
1756        assert_eq!(view.items.len(), 1);
1757    }
1758
1759    /// --data-model full IRI scope: skips list_data_models.
1760    #[test]
1761    fn test_list_data_model_full_iri_scope_skips_list() {
1762        let client = MockDspClient::new()
1763            .with_resolve_project(Ok(make_project_ref()))
1764            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1765            .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1766
1767        let mut renderer = RecordingRenderer::new();
1768        let args = make_args(
1769            Some("0801"),
1770            Some("Letter"),
1771            Some(BEOL_IRI),
1772            None,
1773            false,
1774            None,
1775        );
1776        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1777
1778        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1779        assert_eq!(
1780            client.list_data_models_calls(),
1781            0,
1782            "--data-model as full IRI must skip list_data_models"
1783        );
1784    }
1785
1786    /// Single page with may_have_more=false: pagination is SinglePage { may_have_more: false }.
1787    #[test]
1788    fn test_list_single_page_no_more() {
1789        let client = MockDspClient::new()
1790            .with_resolve_project(Ok(make_project_ref()))
1791            .with_list_resources(Ok(make_resource_page(&["A", "B"], false)));
1792
1793        let mut renderer = RecordingRenderer::new();
1794        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1795        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1796
1797        let view = renderer.resources_view.expect("must have been called");
1798        assert_eq!(view.items.len(), 2);
1799        assert_eq!(view.total, 2);
1800        match &view.pagination {
1801            crate::render::ResourceListPagination::SinglePage {
1802                page,
1803                may_have_more,
1804            } => {
1805                assert_eq!(*page, 0);
1806                assert!(!may_have_more);
1807            }
1808            other => panic!("expected SinglePage, got {:?}", other),
1809        }
1810    }
1811
1812    /// Single page with may_have_more=true.
1813    #[test]
1814    fn test_list_single_page_has_more() {
1815        let client = MockDspClient::new()
1816            .with_resolve_project(Ok(make_project_ref()))
1817            .with_list_resources(Ok(make_resource_page(&["A", "B"], true)));
1818
1819        let mut renderer = RecordingRenderer::new();
1820        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1821        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1822
1823        let view = renderer.resources_view.expect("must have been called");
1824        match &view.pagination {
1825            crate::render::ResourceListPagination::SinglePage { may_have_more, .. } => {
1826                assert!(*may_have_more);
1827            }
1828            other => panic!("expected SinglePage, got {:?}", other),
1829        }
1830    }
1831
1832    /// --page N: the correct page number is passed to list_resources.
1833    #[test]
1834    fn test_list_explicit_page_passed_to_client() {
1835        let client = MockDspClient::new()
1836            .with_resolve_project(Ok(make_project_ref()))
1837            .with_list_resources(Ok(make_resource_page(&["A"], false)));
1838
1839        let mut renderer = RecordingRenderer::new();
1840        let args = make_args(Some("0801"), Some(LETTER_IRI), None, Some(3), false, None);
1841        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1842
1843        let calls = client.list_resources_args();
1844        assert_eq!(calls[0].3, 3, "--page 3 must be passed to list_resources");
1845
1846        let view = renderer.resources_view.expect("must have been called");
1847        match &view.pagination {
1848            crate::render::ResourceListPagination::SinglePage { page, .. } => {
1849                assert_eq!(*page, 3);
1850            }
1851            other => panic!("expected SinglePage, got {:?}", other),
1852        }
1853    }
1854
1855    /// --all fetches multiple pages; stops when may_have_more=false.
1856    #[test]
1857    fn test_list_all_multi_page() {
1858        let client = MockDspClient::new()
1859            .with_resolve_project(Ok(make_project_ref()))
1860            .with_list_resources(Ok(make_resource_page(&["A", "B"], true)))
1861            .with_list_resources(Ok(make_resource_page(&["C"], false)));
1862
1863        let mut renderer = RecordingRenderer::new();
1864        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1865        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1866
1867        assert_eq!(client.list_resources_calls(), 2);
1868        // Pages requested: 0 then 1
1869        let calls = client.list_resources_args();
1870        assert_eq!(calls[0].3, 0);
1871        assert_eq!(calls[1].3, 1);
1872
1873        let view = renderer.resources_view.expect("must have been called");
1874        assert_eq!(view.items.len(), 3, "all pages merged");
1875        assert_eq!(view.total, 3);
1876        match &view.pagination {
1877            crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1878                assert_eq!(*pages_fetched, 2);
1879            }
1880            other => panic!("expected AllPages, got {:?}", other),
1881        }
1882    }
1883
1884    /// --all with empty final page: accumulate nothing, terminate normally.
1885    #[test]
1886    fn test_list_all_empty_final_page() {
1887        // Page 0 has items + more; page 1 is empty + no more.
1888        let client = MockDspClient::new()
1889            .with_resolve_project(Ok(make_project_ref()))
1890            .with_list_resources(Ok(make_resource_page(&["A"], true)))
1891            .with_list_resources(Ok(make_resource_page(&[], false)));
1892
1893        let mut renderer = RecordingRenderer::new();
1894        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1895        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1896
1897        assert_eq!(client.list_resources_calls(), 2);
1898        let view = renderer.resources_view.expect("must have been called");
1899        assert_eq!(
1900            view.items.len(),
1901            1,
1902            "empty final page contributes zero resources"
1903        );
1904        match &view.pagination {
1905            crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1906                assert_eq!(*pages_fetched, 2);
1907            }
1908            other => panic!("expected AllPages, got {:?}", other),
1909        }
1910    }
1911
1912    /// --all propagates error mid-loop; nothing is rendered.
1913    #[test]
1914    fn test_list_all_propagates_error_mid_loop() {
1915        let client = MockDspClient::new()
1916            .with_resolve_project(Ok(make_project_ref()))
1917            .with_list_resources(Ok(make_resource_page(&["A"], true)))
1918            .with_list_resources(Err(Diagnostic::ServerError("server exploded".into())));
1919
1920        let mut renderer = RecordingRenderer::new();
1921        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1922        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1923
1924        assert!(
1925            matches!(result, Err(Diagnostic::ServerError(_))),
1926            "mid-loop error must propagate, got {:?}",
1927            result
1928        );
1929        assert!(
1930            renderer.resources_view.is_none(),
1931            "no partial result must be rendered on error"
1932        );
1933    }
1934
1935    /// Empty result (zero resources): renders with total=0.
1936    #[test]
1937    fn test_list_empty_result() {
1938        let client = MockDspClient::new()
1939            .with_resolve_project(Ok(make_project_ref()))
1940            .with_list_resources(Ok(make_resource_page(&[], false)));
1941
1942        let mut renderer = RecordingRenderer::new();
1943        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1944        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1945
1946        let view = renderer.resources_view.expect("must have been called");
1947        assert!(view.items.is_empty());
1948        assert_eq!(view.total, 0);
1949    }
1950
1951    /// --filter narrows items; total reflects pre-filter count.
1952    #[test]
1953    fn test_list_filter_narrows_total_is_prefilter() {
1954        let client = MockDspClient::new()
1955            .with_resolve_project(Ok(make_project_ref()))
1956            .with_list_resources(Ok(make_resource_page(&["AliceA", "BobB", "AliceC"], false)));
1957
1958        let mut renderer = RecordingRenderer::new();
1959        let args = make_args(
1960            Some("0801"),
1961            Some(LETTER_IRI),
1962            None,
1963            None,
1964            false,
1965            Some("alice"),
1966        );
1967        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1968
1969        let view = renderer.resources_view.expect("must have been called");
1970        assert_eq!(view.total, 3, "total is pre-filter");
1971        assert_eq!(view.items.len(), 2, "filter keeps only Alice items");
1972        assert!(
1973            view.items
1974                .iter()
1975                .all(|r| r.label.to_lowercase().contains("alice"))
1976        );
1977        assert_eq!(view.filter.as_deref(), Some("alice"));
1978    }
1979
1980    /// --project missing → Usage error; no client calls.
1981    #[test]
1982    fn test_list_missing_project_is_usage_error() {
1983        let client = MockDspClient::new();
1984        let mut renderer = RecordingRenderer::new();
1985        let args = make_args(None, Some(LETTER_IRI), None, None, false, None);
1986        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1987
1988        assert!(
1989            matches!(result, Err(Diagnostic::Usage(_))),
1990            "missing --project must yield Usage, got {:?}",
1991            result
1992        );
1993        assert_eq!(client.resolve_calls(), 0);
1994    }
1995
1996    /// --resource-type missing → Usage error; no client calls.
1997    #[test]
1998    fn test_list_missing_resource_type_is_usage_error() {
1999        let client = MockDspClient::new();
2000        let mut renderer = RecordingRenderer::new();
2001        let args = make_args(Some("0801"), None, None, None, false, None);
2002        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2003
2004        assert!(
2005            matches!(result, Err(Diagnostic::Usage(_))),
2006            "missing --resource-type must yield Usage, got {:?}",
2007            result
2008        );
2009        assert_eq!(client.resolve_calls(), 0);
2010    }
2011
2012    /// D3: anonymous → filter_warning is "results may be filtered; login to see private resources".
2013    #[test]
2014    fn test_list_filter_warning_anonymous() {
2015        let client = MockDspClient::new()
2016            .with_resolve_project(Ok(make_project_ref()))
2017            .with_list_resources(Ok(make_resource_page(&[], false)));
2018
2019        let mut renderer = RecordingRenderer::new();
2020        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2021        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2022
2023        let meta = renderer.resources_meta.expect("meta must be present");
2024        assert_eq!(meta.auth_state, "anonymous");
2025        let fw = meta
2026            .filter_warning
2027            .expect("filter_warning must be Some for anonymous");
2028        assert_eq!(
2029            fw,
2030            "results may be filtered; login to see private resources"
2031        );
2032    }
2033
2034    /// D3: authenticated (cache token) → filter_warning is "results limited to your permissions".
2035    #[test]
2036    fn test_list_filter_warning_authenticated() {
2037        let dir = TempDir::new().expect("tempdir");
2038        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
2039        let path = write_cache(&dir, &cache);
2040
2041        let client = MockDspClient::new()
2042            .with_resolve_project(Ok(make_project_ref()))
2043            .with_list_resources(Ok(make_resource_page(&[], false)));
2044
2045        let mut renderer = RecordingRenderer::new();
2046        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2047        run_list_impl(
2048            &args,
2049            &make_cfg(),
2050            &client,
2051            &mut renderer,
2052            None,
2053            Some(&path),
2054        )
2055        .expect("expected Ok");
2056
2057        let meta = renderer.resources_meta.expect("meta must be present");
2058        let fw = meta
2059            .filter_warning
2060            .expect("filter_warning must be Some for authenticated");
2061        assert_eq!(fw, "results limited to your permissions");
2062    }
2063
2064    /// Corrupt/missing cache → falls back to anonymous, never returns Err.
2065    #[test]
2066    fn test_list_corrupt_cache_falls_back_to_anonymous() {
2067        let dir = TempDir::new().expect("tempdir");
2068        let bad_path = dir.path().join("nonexistent_auth.toml");
2069
2070        let client = MockDspClient::new()
2071            .with_resolve_project(Ok(make_project_ref()))
2072            .with_list_resources(Ok(make_resource_page(&[], false)));
2073
2074        let mut renderer = RecordingRenderer::new();
2075        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2076        let result = run_list_impl(
2077            &args,
2078            &make_cfg(),
2079            &client,
2080            &mut renderer,
2081            None,
2082            Some(&bad_path),
2083        );
2084        assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
2085
2086        let meta = renderer.resources_meta.expect("meta must be present");
2087        assert_eq!(meta.auth_state, "anonymous");
2088    }
2089
2090    /// Mid-scan `describe_data_model` error is propagated; `list_resources` is never
2091    /// called.
2092    ///
2093    /// The project has two data-models. The first `describe_data_model` call succeeds
2094    /// (returning a detail with no matching type), but the second returns
2095    /// `Err(Diagnostic::ServerError(...))`. The action must propagate that error and
2096    /// never proceed to `list_resources`.
2097    #[test]
2098    fn test_list_mid_scan_describe_error_propagates() {
2099        let client = MockDspClient::new()
2100            .with_resolve_project(Ok(make_project_ref()))
2101            .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
2102            // First call succeeds — but the target type is not in it.
2103            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
2104            // Second call returns a server error mid-scan.
2105            .with_describe_data_model(Err(Diagnostic::ServerError(
2106                "data-model service unavailable".into(),
2107            )));
2108
2109        let mut renderer = RecordingRenderer::new();
2110        // "Score" exists only in webern, but the second call errors before we get there.
2111        let args = make_args(Some("0801"), Some("Score"), None, None, false, None);
2112        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2113
2114        assert!(
2115            matches!(result, Err(Diagnostic::ServerError(_))),
2116            "mid-scan describe_data_model error must propagate as ServerError, got {:?}",
2117            result
2118        );
2119        assert_eq!(
2120            client.list_resources_calls(),
2121            0,
2122            "list_resources must never be called when describe_data_model errors mid-scan"
2123        );
2124    }
2125
2126    /// Token passed to list_resources when authenticated.
2127    #[test]
2128    fn test_list_token_forwarded_to_list_resources() {
2129        let dir = TempDir::new().expect("tempdir");
2130        let cache = cache_with_entry(SERVER, "my-token", "user@example.com");
2131        let path = write_cache(&dir, &cache);
2132
2133        let client = MockDspClient::new()
2134            .with_resolve_project(Ok(make_project_ref()))
2135            .with_list_resources(Ok(make_resource_page(&[], false)));
2136
2137        let mut renderer = RecordingRenderer::new();
2138        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2139        run_list_impl(
2140            &args,
2141            &make_cfg(),
2142            &client,
2143            &mut renderer,
2144            None,
2145            Some(&path),
2146        )
2147        .expect("expected Ok");
2148
2149        let calls = client.list_resources_args();
2150        assert_eq!(
2151            calls[0].4,
2152            Some("my-token".to_string()),
2153            "token must be forwarded to list_resources"
2154        );
2155    }
2156
2157    // ── order-by tests ────────────────────────────────────────────────────────
2158
2159    /// Build a ResourceTypeDetail with a set of fields for testing.
2160    fn make_rt_detail_with_fields(
2161        rt_name: &str,
2162        field_names: &[(&str, &str)],
2163    ) -> ResourceTypeDetail {
2164        let fields = field_names
2165            .iter()
2166            .map(|(name, iri)| Field {
2167                name: name.to_string(),
2168                iri: iri.to_string(),
2169                label: None,
2170                value_type: crate::model::resource_type::ValueType::Text,
2171                link_target: None,
2172                cardinality: Cardinality::ZeroOrOne,
2173                is_builtin: false,
2174                data_model: Some("beol".to_string()),
2175            })
2176            .collect();
2177        ResourceTypeDetail {
2178            name: rt_name.to_string(),
2179            iri: LETTER_IRI.to_string(),
2180            label: None,
2181            data_model: "beol".to_string(),
2182            representation: None,
2183            super_types: vec![],
2184            fields,
2185            count: None,
2186        }
2187    }
2188
2189    const TITLE_PROP_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#hasTitle";
2190
2191    /// Bare field name: resolves to the correct property IRI passed to list_resources.
2192    #[test]
2193    fn test_order_by_bare_name_resolves_to_property_iri() {
2194        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2195        let client = MockDspClient::new()
2196            .with_resolve_project(Ok(make_project_ref()))
2197            .with_list_data_models(Ok(vec![make_data_model("beol")]))
2198            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2199            .with_describe_resource_type(rt_detail)
2200            .with_list_resources(Ok(make_resource_page(&["A"], false)));
2201
2202        let mut renderer = RecordingRenderer::new();
2203        let args = make_args_with_order_by(
2204            Some("0801"),
2205            Some("Letter"),
2206            None,
2207            None,
2208            false,
2209            None,
2210            Some("hasTitle"),
2211        );
2212        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2213
2214        assert_eq!(
2215            client.describe_resource_type_calls(),
2216            1,
2217            "bare field name must call describe_resource_type"
2218        );
2219        let calls = client.list_resources_args();
2220        assert_eq!(calls.len(), 1);
2221        assert_eq!(
2222            calls[0].2,
2223            Some(TITLE_PROP_IRI.to_string()),
2224            "order_by must be the resolved property IRI"
2225        );
2226    }
2227
2228    /// `://` bypass: value used verbatim, describe_resource_type NOT called.
2229    #[test]
2230    fn test_order_by_full_iri_bypass_skips_describe_resource_type() {
2231        let client = MockDspClient::new()
2232            .with_resolve_project(Ok(make_project_ref()))
2233            .with_list_resources(Ok(make_resource_page(&["A"], false)));
2234
2235        let mut renderer = RecordingRenderer::new();
2236        let args = make_args_with_order_by(
2237            Some("0801"),
2238            Some(LETTER_IRI),
2239            None,
2240            None,
2241            false,
2242            None,
2243            Some(TITLE_PROP_IRI),
2244        );
2245        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2246
2247        assert_eq!(
2248            client.describe_resource_type_calls(),
2249            0,
2250            "full-IRI bypass must NOT call describe_resource_type"
2251        );
2252        let calls = client.list_resources_args();
2253        assert_eq!(calls.len(), 1);
2254        assert_eq!(
2255            calls[0].2,
2256            Some(TITLE_PROP_IRI.to_string()),
2257            "order_by must be the verbatim IRI"
2258        );
2259    }
2260
2261    /// Path A (full resource-type IRI with `#`) + bare order-by → resolves correctly.
2262    #[test]
2263    fn test_order_by_path_a_with_fragment_resolves() {
2264        // LETTER_IRI contains '#', so data_model_iri will be derived as the part before '#'.
2265        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2266        let client = MockDspClient::new()
2267            .with_resolve_project(Ok(make_project_ref()))
2268            .with_describe_resource_type(rt_detail)
2269            .with_list_resources(Ok(make_resource_page(&["A"], false)));
2270
2271        let mut renderer = RecordingRenderer::new();
2272        let args = make_args_with_order_by(
2273            Some("0801"),
2274            Some(LETTER_IRI), // full IRI with '#'
2275            None,
2276            None,
2277            false,
2278            None,
2279            Some("hasTitle"),
2280        );
2281        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2282
2283        assert_eq!(
2284            client.describe_resource_type_calls(),
2285            1,
2286            "bare field name with path-A resource-type must call describe_resource_type"
2287        );
2288        let calls = client.list_resources_args();
2289        assert_eq!(
2290            calls[0].2,
2291            Some(TITLE_PROP_IRI.to_string()),
2292            "order_by must be the resolved property IRI"
2293        );
2294    }
2295
2296    /// Path A resource-type IRI lacking `#` + bare order-by → Usage error.
2297    #[test]
2298    fn test_order_by_path_a_no_fragment_bare_name_is_usage_error() {
2299        // A class IRI without '#' — data_model_iri cannot be derived. Resolution
2300        // must fail before any fetch, so no `with_list_resources` is seeded.
2301        let no_fragment_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2/Letter";
2302        let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
2303
2304        let mut renderer = RecordingRenderer::new();
2305        let args = make_args_with_order_by(
2306            Some("0801"),
2307            Some(no_fragment_iri),
2308            None,
2309            None,
2310            false,
2311            None,
2312            Some("hasTitle"),
2313        );
2314        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2315
2316        assert!(
2317            matches!(result, Err(Diagnostic::Usage(_))),
2318            "path-A IRI lacking '#' + bare order-by must yield Usage, got {:?}",
2319            result
2320        );
2321        assert_eq!(
2322            client.describe_resource_type_calls(),
2323            0,
2324            "describe_resource_type must not be called when error is expected"
2325        );
2326    }
2327
2328    /// Unknown field name → Usage error with a helpful hint.
2329    #[test]
2330    fn test_order_by_unknown_field_is_usage_error() {
2331        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2332        // No `with_list_resources` seed: resolution must fail before any fetch.
2333        let client = MockDspClient::new()
2334            .with_resolve_project(Ok(make_project_ref()))
2335            .with_list_data_models(Ok(vec![make_data_model("beol")]))
2336            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2337            .with_describe_resource_type(rt_detail);
2338
2339        let mut renderer = RecordingRenderer::new();
2340        let args = make_args_with_order_by(
2341            Some("0801"),
2342            Some("Letter"),
2343            None,
2344            None,
2345            false,
2346            None,
2347            Some("nonExistentField"),
2348        );
2349        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2350
2351        assert!(
2352            matches!(result, Err(Diagnostic::Usage(_))),
2353            "unknown field name must yield Usage, got {:?}",
2354            result
2355        );
2356        if let Err(Diagnostic::Usage(msg)) = &result {
2357            assert!(
2358                msg.contains("nonExistentField"),
2359                "Usage error must echo the field name, got: {msg}"
2360            );
2361            assert!(
2362                msg.contains("resource-type describe"),
2363                "Usage error must hint at resource-type describe, got: {msg}"
2364            );
2365        }
2366        // Resolution looked the field up, then bailed before fetching.
2367        assert_eq!(client.describe_resource_type_calls(), 1);
2368        assert_eq!(
2369            client.list_resources_calls(),
2370            0,
2371            "list_resources must not be called when order-by resolution fails"
2372        );
2373    }
2374
2375    /// No `--order-by` → None recorded by mock.
2376    #[test]
2377    fn test_order_by_absent_passes_none_to_list_resources() {
2378        let client = MockDspClient::new()
2379            .with_resolve_project(Ok(make_project_ref()))
2380            .with_list_resources(Ok(make_resource_page(&[], false)));
2381
2382        let mut renderer = RecordingRenderer::new();
2383        let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2384        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2385
2386        let calls = client.list_resources_args();
2387        assert_eq!(
2388            calls[0].2, None,
2389            "--order-by absent must pass None to list_resources"
2390        );
2391        assert_eq!(
2392            client.describe_resource_type_calls(),
2393            0,
2394            "describe_resource_type must not be called when --order-by is absent"
2395        );
2396    }
2397
2398    /// `--order-by` + `--all` → resolved IRI threaded to EVERY page request.
2399    #[test]
2400    fn test_order_by_with_all_threads_iri_to_every_page() {
2401        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2402        let client = MockDspClient::new()
2403            .with_resolve_project(Ok(make_project_ref()))
2404            .with_list_data_models(Ok(vec![make_data_model("beol")]))
2405            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2406            .with_describe_resource_type(rt_detail)
2407            .with_list_resources(Ok(make_resource_page(&["A"], true)))
2408            .with_list_resources(Ok(make_resource_page(&["B"], false)));
2409
2410        let mut renderer = RecordingRenderer::new();
2411        let args = make_args_with_order_by(
2412            Some("0801"),
2413            Some("Letter"),
2414            None,
2415            None,
2416            true, // --all
2417            None,
2418            Some("hasTitle"),
2419        );
2420        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2421
2422        assert_eq!(client.list_resources_calls(), 2);
2423        let calls = client.list_resources_args();
2424        assert_eq!(
2425            calls[0].2,
2426            Some(TITLE_PROP_IRI.to_string()),
2427            "order_by must be threaded to page 0"
2428        );
2429        assert_eq!(
2430            calls[1].2,
2431            Some(TITLE_PROP_IRI.to_string()),
2432            "order_by must be threaded to page 1"
2433        );
2434    }
2435
2436    /// Case-insensitive field name match: `HASTITLE` resolves to the same IRI as `hasTitle`.
2437    #[test]
2438    fn test_order_by_field_name_is_case_insensitive() {
2439        let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2440        let client = MockDspClient::new()
2441            .with_resolve_project(Ok(make_project_ref()))
2442            .with_list_data_models(Ok(vec![make_data_model("beol")]))
2443            .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2444            .with_describe_resource_type(rt_detail)
2445            .with_list_resources(Ok(make_resource_page(&[], false)));
2446
2447        let mut renderer = RecordingRenderer::new();
2448        let args = make_args_with_order_by(
2449            Some("0801"),
2450            Some("Letter"),
2451            None,
2452            None,
2453            false,
2454            None,
2455            Some("HASTITLE"),
2456        );
2457        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2458
2459        let calls = client.list_resources_args();
2460        assert_eq!(
2461            calls[0].2,
2462            Some(TITLE_PROP_IRI.to_string()),
2463            "case-insensitive field name match must resolve to the property IRI"
2464        );
2465    }
2466}