Skip to main content

dsp_cli/actions/vre/
data_model.rs

1//! Actions for `dsp vre data-model { list | describe | structure }`.
2//!
3//! Phase 5 implements `list` (the first data-model read command) establishing
4//! the per-noun `Renderer::data_models` + `DataModelListView` view-struct pattern.
5//! See ADR-0008.
6
7use std::path::Path;
8
9use crate::cli::{DataModelDescribeArgs, DataModelListArgs, DataModelStructureArgs};
10use crate::client::DspClient;
11use crate::config::{AuthCache, Config, resolve_token};
12use crate::diagnostic::Diagnostic;
13use crate::render::{DataModelListView, MetaContext, Renderer};
14
15use crate::actions::auth_state::read_auth_state;
16
17/// List all data-models in a project.
18///
19/// Authentication is optional (public endpoint per ADR-0007). Reads `DSP_TOKEN`
20/// from the environment (env wins over cache per ADR-0007), and delegates all
21/// work to `run_list_impl` with injectable seams for deterministic testing.
22pub fn list(
23    args: &DataModelListArgs,
24    cfg: &Config,
25    client: &dyn DspClient,
26    renderer: &mut dyn Renderer,
27) -> Result<(), Diagnostic> {
28    let env_token = std::env::var("DSP_TOKEN").ok();
29    run_list_impl(args, cfg, client, renderer, env_token, None)
30}
31
32/// Internal entry point for `list` with injectable seams for testing.
33///
34/// - `env_token`: the `DSP_TOKEN` env value (read by the public `list` entry
35///   point before calling this, so tests never touch process env).
36/// - `cache_path`: `Some(path)` in tests to use a temp auth cache; `None` in
37///   production to use the default `~/.config/dsp-cli/auth.toml`.
38///
39/// **Auth-optional:** a cache-load failure ALWAYS falls back to an empty cache
40/// with a `tracing::warn!` — NEVER returns `Err`. This differs deliberately from
41/// `dump`, which requires auth and propagates errors. For a public endpoint, a
42/// corrupt or missing `auth.toml` must still list anonymously (PRD AC 2).
43fn run_list_impl(
44    args: &DataModelListArgs,
45    cfg: &Config,
46    client: &dyn DspClient,
47    renderer: &mut dyn Renderer,
48    env_token: Option<String>,
49    cache_path: Option<&Path>,
50) -> Result<(), Diagnostic> {
51    // ── 1. --project required (fail-fast, BEFORE any cache/IO) ───────────────
52    let project = args.project.as_deref().ok_or_else(|| {
53        Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
54    })?;
55
56    // ── 2. Load cache (auth-optional: failures fall back to empty cache) ──────
57    let cache_result = match cache_path {
58        Some(p) => AuthCache::load_from(p),
59        None => AuthCache::load(),
60    };
61    let cache = match cache_result {
62        Ok(c) => c,
63        Err(e) => {
64            tracing::warn!(
65                error = %e,
66                "auth cache load failed; falling back to anonymous for data-model list"
67            );
68            AuthCache::default()
69        }
70    };
71
72    // ── 3. Resolve token (optional) ───────────────────────────────────────────
73    let resolved = resolve_token(env_token, &cache, &cfg.server);
74    let token = resolved.as_ref().map(|r| r.token.as_str());
75
76    // ── 4. Build auth-state disclosure string ─────────────────────────────────
77    let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
78
79    // ── 5. Resolve project (public endpoint; no token needed) ─────────────────
80    let pref = client.resolve_project(&cfg.server, project)?;
81
82    // ── 6. Fetch project's own data-models ───────────────────────────────────
83    let mut items = client.list_data_models(&cfg.server, &pref.iri, token)?;
84
85    // ── 7. Append builtins if requested ──────────────────────────────────────
86    if args.include_builtins {
87        items.extend(crate::client::builtin_data_models());
88    }
89
90    // ── 8. Capture total AFTER builtins, BEFORE filter ───────────────────────
91    let total = items.len();
92
93    // ── 9. Apply --filter (case-insensitive substring over name + label) ──────
94    if let Some(ref f) = args.filter {
95        let lower = f.to_lowercase();
96        items.retain(|dm| {
97            dm.name.to_lowercase().contains(&lower)
98                || dm
99                    .label
100                    .as_deref()
101                    .unwrap_or("")
102                    .to_lowercase()
103                    .contains(&lower)
104        });
105    }
106
107    // ── 10. Sort surviving items by name ascending ────────────────────────────
108    items.sort_by(|a, b| a.name.cmp(&b.name));
109
110    // ── 11. Build view + meta and render ─────────────────────────────────────
111    let view = DataModelListView {
112        items,
113        total,
114        filter: args.filter.clone(),
115    };
116    let meta = MetaContext {
117        server_label: cfg.server.clone(),
118        auth_state,
119        filter_warning: None,
120        count_caveat: None,
121        count_cost: None,
122    };
123    renderer.data_models(&view, &meta)
124}
125
126/// Describe a single data-model.
127///
128/// Authentication is optional (public endpoint per ADR-0007). Reads `DSP_TOKEN`
129/// from the environment (env wins over cache per ADR-0007), and delegates all
130/// work to `run_describe_impl` with injectable seams for deterministic testing.
131pub fn describe(
132    args: &DataModelDescribeArgs,
133    cfg: &Config,
134    client: &dyn DspClient,
135    renderer: &mut dyn Renderer,
136) -> Result<(), Diagnostic> {
137    let env_token = std::env::var("DSP_TOKEN").ok();
138    run_describe_impl(args, cfg, client, renderer, env_token, None)
139}
140
141/// Internal entry point for `describe` with injectable seams for testing.
142///
143/// - `env_token`: the `DSP_TOKEN` env value (read by the public `describe` entry
144///   point before calling this, so tests never touch process env).
145/// - `cache_path`: `Some(path)` in tests to use a temp auth cache; `None` in
146///   production to use the default `~/.config/dsp-cli/auth.toml`.
147///
148/// **Auth-optional:** a cache-load failure ALWAYS falls back to an empty cache
149/// with a `tracing::warn!` — NEVER returns `Err`. The data-model endpoint is
150/// public (ADR-0007), so a corrupt or missing `auth.toml` must still describe
151/// anonymously.
152fn run_describe_impl(
153    args: &DataModelDescribeArgs,
154    cfg: &Config,
155    client: &dyn DspClient,
156    renderer: &mut dyn Renderer,
157    env_token: Option<String>,
158    cache_path: Option<&Path>,
159) -> Result<(), Diagnostic> {
160    // ── 1. --project required (fail-fast, BEFORE any cache/IO) ───────────────
161    let project = args.project.as_deref().ok_or_else(|| {
162        Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
163    })?;
164
165    // ── 2. --data-model required (fail-fast, BEFORE any cache/IO) ────────────
166    let data_model = args
167        .data_model
168        .as_deref()
169        .ok_or_else(|| Diagnostic::Usage("--data-model <name-or-IRI> is required".to_string()))?;
170
171    // ── 3. Load cache (auth-optional: failures fall back to empty cache) ──────
172    let cache_result = match cache_path {
173        Some(p) => AuthCache::load_from(p),
174        None => AuthCache::load(),
175    };
176    let cache = match cache_result {
177        Ok(c) => c,
178        Err(e) => {
179            tracing::warn!(
180                error = %e,
181                "auth cache load failed; falling back to anonymous for data-model describe"
182            );
183            AuthCache::default()
184        }
185    };
186
187    // ── 4. Resolve token (optional) ───────────────────────────────────────────
188    let resolved = resolve_token(env_token, &cache, &cfg.server);
189    let token = resolved.as_ref().map(|r| r.token.as_str());
190
191    // ── 5. Build auth-state disclosure string ─────────────────────────────────
192    let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
193
194    // ── 6. Resolve project ────────────────────────────────────────────────────
195    let pref = client.resolve_project(&cfg.server, project)?;
196
197    // ── 7. Fetch project's own data-models ───────────────────────────────────
198    let data_models = client.list_data_models(&cfg.server, &pref.iri, token)?;
199
200    // ── 8. Match --data-model against the project's data-models ──────────────
201    // Truncation is for display only — never truncate before the equality check.
202    let dm_iri = data_models
203        .iter()
204        .find(|dm| {
205            dm.iri == data_model || dm.name.eq_ignore_ascii_case(data_model)
206        })
207        .map(|dm| dm.iri.clone())
208        .ok_or_else(|| {
209            let dm_disp: String = data_model.chars().take(80).collect();
210            let dm_suffix = if data_model.chars().count() > 80 { "…" } else { "" };
211            let proj_disp: String = project.chars().take(80).collect();
212            let proj_suffix = if project.chars().count() > 80 { "…" } else { "" };
213            Diagnostic::NotFound(format!(
214                "data-model '{dm_disp}{dm_suffix}' not found in project '{proj_disp}{proj_suffix}' on {server}. \
215                 Run `dsp vre data-model list --project {proj_disp}{proj_suffix} --server {server}` \
216                 to see available data-models.",
217                server = cfg.server,
218            ))
219        })?;
220
221    // ── 9. Fetch the data-model detail ────────────────────────────────────────
222    let detail = client.describe_data_model(&cfg.server, &dm_iri, token)?;
223
224    // ── 10. Build meta and render ─────────────────────────────────────────────
225    let meta = MetaContext {
226        server_label: cfg.server.clone(),
227        auth_state,
228        filter_warning: None,
229        count_caveat: None,
230        count_cost: None,
231    };
232    renderer.data_model_describe(&detail, &meta)
233}
234
235/// Show the structure of a single data-model (links + inheritance relations).
236///
237/// Authentication is optional (public endpoint per ADR-0007). Reads `DSP_TOKEN`
238/// from the environment (env wins over cache per ADR-0007), and delegates all
239/// work to `run_structure_impl` with injectable seams for deterministic testing.
240pub fn structure(
241    args: &DataModelStructureArgs,
242    cfg: &Config,
243    client: &dyn DspClient,
244    renderer: &mut dyn Renderer,
245) -> Result<(), Diagnostic> {
246    let env_token = std::env::var("DSP_TOKEN").ok();
247    run_structure_impl(args, cfg, client, renderer, env_token, None)
248}
249
250/// Internal entry point for `structure` with injectable seams for testing.
251///
252/// - `env_token`: the `DSP_TOKEN` env value (read by the public `structure` entry
253///   point before calling this, so tests never touch process env).
254/// - `cache_path`: `Some(path)` in tests to use a temp auth cache; `None` in
255///   production to use the default `~/.config/dsp-cli/auth.toml`.
256///
257/// **Auth-optional:** a cache-load failure ALWAYS falls back to an empty cache
258/// with a `tracing::warn!` — NEVER returns `Err`. The data-model endpoint is
259/// public (ADR-0007), so a corrupt or missing `auth.toml` must still show structure
260/// anonymously.
261fn run_structure_impl(
262    args: &DataModelStructureArgs,
263    cfg: &Config,
264    client: &dyn DspClient,
265    renderer: &mut dyn Renderer,
266    env_token: Option<String>,
267    cache_path: Option<&Path>,
268) -> Result<(), Diagnostic> {
269    // ── 1. --project required (fail-fast, BEFORE any cache/IO) ───────────────
270    let project = args.project.as_deref().ok_or_else(|| {
271        Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
272    })?;
273
274    // ── 2. --data-model required (fail-fast, BEFORE any cache/IO) ────────────
275    let data_model = args
276        .data_model
277        .as_deref()
278        .ok_or_else(|| Diagnostic::Usage("--data-model <name-or-IRI> is required".to_string()))?;
279
280    // ── 3. Load cache (auth-optional: failures fall back to empty cache) ──────
281    let cache_result = match cache_path {
282        Some(p) => AuthCache::load_from(p),
283        None => AuthCache::load(),
284    };
285    let cache = match cache_result {
286        Ok(c) => c,
287        Err(e) => {
288            tracing::warn!(
289                error = %e,
290                "auth cache load failed; falling back to anonymous for data-model structure"
291            );
292            AuthCache::default()
293        }
294    };
295
296    // ── 4. Resolve token (optional) ───────────────────────────────────────────
297    let resolved = resolve_token(env_token, &cache, &cfg.server);
298    let token = resolved.as_ref().map(|r| r.token.as_str());
299
300    // ── 5. Build auth-state disclosure string ─────────────────────────────────
301    let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
302
303    // ── 6. Resolve project ────────────────────────────────────────────────────
304    let pref = client.resolve_project(&cfg.server, project)?;
305
306    // ── 7. Fetch project's own data-models ───────────────────────────────────
307    let data_models = client.list_data_models(&cfg.server, &pref.iri, token)?;
308
309    // ── 8. Match --data-model against the project's data-models ──────────────
310    let dm_iri = data_models
311        .iter()
312        .find(|dm| {
313            dm.iri == data_model || dm.name.eq_ignore_ascii_case(data_model)
314        })
315        .map(|dm| dm.iri.clone())
316        .ok_or_else(|| {
317            let dm_disp: String = data_model.chars().take(80).collect();
318            let dm_suffix = if data_model.chars().count() > 80 { "…" } else { "" };
319            let proj_disp: String = project.chars().take(80).collect();
320            let proj_suffix = if project.chars().count() > 80 { "…" } else { "" };
321            Diagnostic::NotFound(format!(
322                "data-model '{dm_disp}{dm_suffix}' not found in project '{proj_disp}{proj_suffix}' on {server}. \
323                 Run `dsp vre data-model list --project {proj_disp}{proj_suffix} --server {server}` \
324                 to see available data-models.",
325                server = cfg.server,
326            ))
327        })?;
328
329    // ── 9. Fetch the data-model structure ─────────────────────────────────────
330    let mut structure = client.data_model_structure(&cfg.server, &dm_iri, token)?;
331
332    // ── 10. Filter built-in relations unless --include-builtins ───────────────
333    if !args.include_builtins {
334        structure.relations.retain(|r| !r.is_builtin);
335    }
336
337    // ── 11. Build meta and render ─────────────────────────────────────────────
338    let meta = MetaContext {
339        server_label: cfg.server.clone(),
340        auth_state,
341        filter_warning: None,
342        count_caveat: None,
343        count_cost: None,
344    };
345    renderer.data_model_structure(&structure, &meta)
346}
347
348// ─────────────────────────────────────────────────────────────────────────────
349// Tests
350// ─────────────────────────────────────────────────────────────────────────────
351
352#[cfg(test)]
353mod tests {
354    use std::cell::RefCell;
355
356    use tempfile::TempDir;
357
358    use super::{run_describe_impl, run_list_impl, run_structure_impl};
359    use crate::cli::{
360        DataModelDescribeArgs, DataModelListArgs, DataModelStructureArgs, FormatArgs,
361    };
362    use crate::client::DspClient;
363    use crate::config::auth_cache::ServerEntry;
364    use crate::config::{AuthCache, Config};
365    use crate::diagnostic::Diagnostic;
366    use crate::model::ProjectDetail;
367    use crate::model::{
368        DataModel, DataModelDetail, DataModelStructure, ProjectRef, ResourceTypeSummary,
369    };
370    use crate::render::Format;
371    use crate::render::auth::{
372        AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
373    };
374    use crate::render::{
375        DataModelListView, DumpDeleteOutcome, DumpOutcome, MetaContext, ProjectListView, Renderer,
376    };
377
378    // ── MockDspClient ─────────────────────────────────────────────────────────
379
380    struct MockDspClient {
381        resolve_result: Option<Result<ProjectRef, Diagnostic>>,
382        resolve_calls: RefCell<u32>,
383        /// Records the project_iri passed to list_data_models.
384        list_data_models_result: Option<Result<Vec<DataModel>, Diagnostic>>,
385        list_data_models_calls: RefCell<u32>,
386        list_data_models_iri: RefCell<Option<String>>,
387        list_data_models_token: RefCell<Option<Option<String>>>,
388        /// Canned result returned by describe_data_model.
389        describe_data_model_result: Option<Result<DataModelDetail, Diagnostic>>,
390        /// Records the data_model_iri passed to describe_data_model.
391        describe_data_model_iri: RefCell<Option<String>>,
392        describe_data_model_calls: RefCell<u32>,
393        /// Canned result returned by data_model_structure (Step 6 action tests).
394        data_model_structure_result: Option<Result<DataModelStructure, Diagnostic>>,
395        /// Records the data_model_iri passed to data_model_structure.
396        data_model_structure_iri: RefCell<Option<String>>,
397        /// Records the token passed to data_model_structure.
398        data_model_structure_token: RefCell<Option<Option<String>>>,
399        data_model_structure_calls: RefCell<u32>,
400    }
401
402    impl MockDspClient {
403        fn new() -> Self {
404            Self {
405                resolve_result: None,
406                resolve_calls: RefCell::new(0),
407                list_data_models_result: None,
408                list_data_models_calls: RefCell::new(0),
409                list_data_models_iri: RefCell::new(None),
410                list_data_models_token: RefCell::new(None),
411                describe_data_model_result: None,
412                describe_data_model_iri: RefCell::new(None),
413                describe_data_model_calls: RefCell::new(0),
414                data_model_structure_result: None,
415                data_model_structure_iri: RefCell::new(None),
416                data_model_structure_token: RefCell::new(None),
417                data_model_structure_calls: RefCell::new(0),
418            }
419        }
420
421        fn with_resolve_project(mut self, result: Result<ProjectRef, Diagnostic>) -> Self {
422            self.resolve_result = Some(result);
423            self
424        }
425
426        fn with_list_data_models(mut self, result: Result<Vec<DataModel>, Diagnostic>) -> Self {
427            self.list_data_models_result = Some(result);
428            self
429        }
430
431        fn with_describe_data_model(mut self, result: Result<DataModelDetail, Diagnostic>) -> Self {
432            self.describe_data_model_result = Some(result);
433            self
434        }
435
436        fn with_data_model_structure(
437            mut self,
438            result: Result<DataModelStructure, Diagnostic>,
439        ) -> Self {
440            self.data_model_structure_result = Some(result);
441            self
442        }
443
444        fn resolve_calls(&self) -> u32 {
445            *self.resolve_calls.borrow()
446        }
447
448        fn list_data_models_calls(&self) -> u32 {
449            *self.list_data_models_calls.borrow()
450        }
451
452        fn list_data_models_iri(&self) -> Option<String> {
453            self.list_data_models_iri.borrow().clone()
454        }
455
456        fn list_data_models_token(&self) -> Option<Option<String>> {
457            self.list_data_models_token.borrow().clone()
458        }
459
460        fn describe_data_model_calls(&self) -> u32 {
461            *self.describe_data_model_calls.borrow()
462        }
463
464        fn describe_data_model_iri(&self) -> Option<String> {
465            self.describe_data_model_iri.borrow().clone()
466        }
467
468        fn data_model_structure_calls(&self) -> u32 {
469            *self.data_model_structure_calls.borrow()
470        }
471
472        fn data_model_structure_iri(&self) -> Option<String> {
473            self.data_model_structure_iri.borrow().clone()
474        }
475
476        fn data_model_structure_token(&self) -> Option<Option<String>> {
477            self.data_model_structure_token.borrow().clone()
478        }
479    }
480
481    impl DspClient for MockDspClient {
482        fn login(
483            &self,
484            _server: &str,
485            _user: &str,
486            _password: &str,
487        ) -> Result<crate::model::LoginResponse, Diagnostic> {
488            unimplemented!("login not used in data-model action tests")
489        }
490
491        fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
492            *self.resolve_calls.borrow_mut() += 1;
493            self.resolve_result
494                .clone()
495                .expect("resolve_result must be set when resolve_project is called")
496        }
497
498        fn create_project_dump(
499            &self,
500            _server: &str,
501            _project_iri: &str,
502            _skip_assets: bool,
503            _token: &str,
504        ) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
505            unimplemented!("create_project_dump not used in data-model action tests")
506        }
507
508        fn get_project_dump_status(
509            &self,
510            _server: &str,
511            _project_iri: &str,
512            _dump_id: &str,
513            _token: &str,
514        ) -> Result<crate::model::DumpTask, Diagnostic> {
515            unimplemented!("get_project_dump_status not used in data-model action tests")
516        }
517
518        fn download_project_dump(
519            &self,
520            _server: &str,
521            _project_iri: &str,
522            _dump_id: &str,
523            _token: &str,
524            _dest: &mut dyn std::io::Write,
525        ) -> Result<u64, Diagnostic> {
526            unimplemented!("download_project_dump not used in data-model action tests")
527        }
528
529        fn delete_project_dump(
530            &self,
531            _server: &str,
532            _project_iri: &str,
533            _dump_id: &str,
534            _token: &str,
535        ) -> Result<(), Diagnostic> {
536            unimplemented!("delete_project_dump not used in data-model action tests")
537        }
538
539        fn list_projects(
540            &self,
541            _server: &str,
542            _token: Option<&str>,
543        ) -> Result<Vec<crate::model::Project>, Diagnostic> {
544            unimplemented!("list_projects not used in data-model action tests")
545        }
546
547        fn describe_project(
548            &self,
549            _server: &str,
550            _project: &str,
551            _token: Option<&str>,
552        ) -> Result<crate::model::ProjectDetail, Diagnostic> {
553            unimplemented!("describe_project not used in data-model action tests")
554        }
555
556        fn list_data_models(
557            &self,
558            _server: &str,
559            project_iri: &str,
560            token: Option<&str>,
561        ) -> Result<Vec<DataModel>, Diagnostic> {
562            *self.list_data_models_calls.borrow_mut() += 1;
563            *self.list_data_models_iri.borrow_mut() = Some(project_iri.to_string());
564            *self.list_data_models_token.borrow_mut() = Some(token.map(str::to_owned));
565            self.list_data_models_result
566                .clone()
567                .expect("list_data_models_result must be set when list_data_models is called")
568        }
569
570        fn describe_data_model(
571            &self,
572            _server: &str,
573            data_model_iri: &str,
574            _token: Option<&str>,
575        ) -> Result<DataModelDetail, Diagnostic> {
576            *self.describe_data_model_calls.borrow_mut() += 1;
577            *self.describe_data_model_iri.borrow_mut() = Some(data_model_iri.to_string());
578            self.describe_data_model_result
579                .clone()
580                .expect("describe_data_model_result must be set when describe_data_model is called")
581        }
582
583        fn describe_resource_type(
584            &self,
585            _server: &str,
586            _data_model_iri: &str,
587            _resource_type: &str,
588            _token: Option<&str>,
589        ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
590            unimplemented!("describe_resource_type not used in data-model action tests")
591        }
592
593        fn data_model_structure(
594            &self,
595            _server: &str,
596            data_model_iri: &str,
597            token: Option<&str>,
598        ) -> Result<DataModelStructure, Diagnostic> {
599            *self.data_model_structure_calls.borrow_mut() += 1;
600            *self.data_model_structure_iri.borrow_mut() = Some(data_model_iri.to_string());
601            *self.data_model_structure_token.borrow_mut() = Some(token.map(str::to_owned));
602            self.data_model_structure_result.clone().unwrap_or_else(|| {
603                unimplemented!("data_model_structure not configured in data-model action tests")
604            })
605        }
606
607        fn list_resources(
608            &self,
609            _server: &str,
610            _project_iri: &str,
611            _resource_type_iri: &str,
612            _order_by: Option<&str>,
613            _page: u32,
614            _token: Option<&str>,
615        ) -> Result<crate::model::ResourcePage, Diagnostic> {
616            unimplemented!("list_resources not used in data-model action tests")
617        }
618
619        fn describe_resource(
620            &self,
621            _server: &str,
622            _resource_iri: &str,
623            _token: Option<&str>,
624            _with_values: bool,
625        ) -> Result<crate::model::ResourceDetail, Diagnostic> {
626            unimplemented!("describe_resource not used in data-model action tests")
627        }
628
629        fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
630            unimplemented!("verify_token not used in data-model action tests")
631        }
632
633        fn resource_counts(
634            &self,
635            _server: &str,
636            _project_iri: &str,
637            _token: Option<&str>,
638        ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
639            Ok(std::collections::HashMap::new())
640        }
641
642        fn list_vocabularies(
643            &self,
644            _server: &str,
645            _project_iri: &str,
646            _token: Option<&str>,
647        ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
648            unimplemented!("not exercised by this file's tests")
649        }
650
651        fn describe_vocabulary(
652            &self,
653            _server: &str,
654            _iri: &str,
655            _token: Option<&str>,
656        ) -> Result<crate::model::VocabularyTree, Diagnostic> {
657            unimplemented!("not exercised by this file's tests")
658        }
659    }
660
661    // ── RecordingRenderer ─────────────────────────────────────────────────────
662
663    struct RecordingRenderer {
664        data_models_view: Option<DataModelListView>,
665        data_models_meta: Option<MetaContext>,
666        /// Records the `detail` passed to `data_model_describe` for assertion in Step 4.
667        data_model_describe_detail: Option<DataModelDetail>,
668        /// Records the `meta` passed to `data_model_describe` for assertion in Step 4.
669        data_model_describe_meta: Option<MetaContext>,
670        /// Records the `structure` passed to `data_model_structure` for assertion in Step 6.
671        data_model_structure_val: Option<crate::model::DataModelStructure>,
672        /// Records the `meta` passed to `data_model_structure` for assertion in Step 6.
673        data_model_structure_meta: Option<MetaContext>,
674    }
675
676    impl RecordingRenderer {
677        fn new() -> Self {
678            Self {
679                data_models_view: None,
680                data_models_meta: None,
681                data_model_describe_detail: None,
682                data_model_describe_meta: None,
683                data_model_structure_val: None,
684                data_model_structure_meta: None,
685            }
686        }
687    }
688
689    impl Renderer for RecordingRenderer {
690        fn diagnostic(
691            &mut self,
692            _diag: &Diagnostic,
693            _meta: &MetaContext,
694        ) -> Result<(), Diagnostic> {
695            Ok(())
696        }
697
698        fn auth_login(
699            &mut self,
700            _outcome: &AuthLoginOutcome,
701            _meta: &MetaContext,
702        ) -> Result<(), Diagnostic> {
703            Ok(())
704        }
705
706        fn auth_status(
707            &mut self,
708            _outcome: &AuthStatusOutcome,
709            _meta: &MetaContext,
710        ) -> Result<(), Diagnostic> {
711            Ok(())
712        }
713
714        fn auth_logout(
715            &mut self,
716            _outcome: &AuthLogoutOutcome,
717            _meta: &MetaContext,
718        ) -> Result<(), Diagnostic> {
719            Ok(())
720        }
721
722        fn auth_set_token(
723            &mut self,
724            _outcome: &AuthSetTokenOutcome,
725            _meta: &MetaContext,
726        ) -> Result<(), Diagnostic> {
727            Ok(())
728        }
729
730        fn project_dump(
731            &mut self,
732            _outcome: &DumpOutcome,
733            _meta: &MetaContext,
734        ) -> Result<(), Diagnostic> {
735            Ok(())
736        }
737
738        fn project_dump_deleted(
739            &mut self,
740            _outcome: &DumpDeleteOutcome,
741            _meta: &MetaContext,
742        ) -> Result<(), Diagnostic> {
743            Ok(())
744        }
745
746        fn projects(
747            &mut self,
748            _view: &ProjectListView,
749            _meta: &MetaContext,
750        ) -> Result<(), Diagnostic> {
751            Ok(())
752        }
753
754        fn project_describe(
755            &mut self,
756            _project: &ProjectDetail,
757            _meta: &MetaContext,
758        ) -> Result<(), Diagnostic> {
759            Ok(())
760        }
761
762        fn data_models(
763            &mut self,
764            view: &DataModelListView,
765            meta: &MetaContext,
766        ) -> Result<(), Diagnostic> {
767            self.data_models_view = Some(view.clone());
768            self.data_models_meta = Some(meta.clone());
769            Ok(())
770        }
771
772        fn data_model_describe(
773            &mut self,
774            detail: &crate::model::DataModelDetail,
775            meta: &MetaContext,
776        ) -> Result<(), Diagnostic> {
777            self.data_model_describe_detail = Some(detail.clone());
778            self.data_model_describe_meta = Some(meta.clone());
779            Ok(())
780        }
781
782        fn resource_types(
783            &mut self,
784            _view: &crate::render::ResourceTypeListView,
785            _meta: &MetaContext,
786        ) -> Result<(), Diagnostic> {
787            Ok(())
788        }
789
790        fn resource_type_describe(
791            &mut self,
792            _detail: &crate::model::ResourceTypeDetail,
793            _meta: &MetaContext,
794        ) -> Result<(), Diagnostic> {
795            unimplemented!("resource_type_describe not used in data-model action tests")
796        }
797
798        fn data_model_structure(
799            &mut self,
800            structure: &crate::model::DataModelStructure,
801            meta: &MetaContext,
802        ) -> Result<(), Diagnostic> {
803            self.data_model_structure_val = Some(structure.clone());
804            self.data_model_structure_meta = Some(meta.clone());
805            Ok(())
806        }
807
808        fn resources(
809            &mut self,
810            _view: &crate::render::ResourceListView,
811            _meta: &MetaContext,
812        ) -> Result<(), Diagnostic> {
813            Ok(())
814        }
815
816        fn resource_describe(
817            &mut self,
818            _detail: &crate::model::ResourceDetail,
819            _meta: &MetaContext,
820        ) -> Result<(), Diagnostic> {
821            Ok(())
822        }
823
824        fn vocabularies(
825            &mut self,
826            _view: &crate::render::VocabularyListView,
827            _meta: &MetaContext,
828        ) -> Result<(), Diagnostic> {
829            unimplemented!("not exercised by this file's tests")
830        }
831
832        fn vocabulary_describe(
833            &mut self,
834            _detail: &crate::model::VocabularyDetail,
835            _meta: &MetaContext,
836        ) -> Result<(), Diagnostic> {
837            unimplemented!("not exercised by this file's tests")
838        }
839    }
840
841    // ── helpers ───────────────────────────────────────────────────────────────
842
843    const SERVER: &str = "https://api.test.dasch.swiss";
844
845    fn make_cfg() -> Config {
846        Config {
847            server: SERVER.to_string(),
848        }
849    }
850
851    fn make_project_ref() -> ProjectRef {
852        ProjectRef {
853            iri: "http://rdfh.ch/projects/0801".to_string(),
854            shortcode: "0801".to_string(),
855            shortname: "beol".to_string(),
856        }
857    }
858
859    fn make_args(project: Option<&str>) -> DataModelListArgs {
860        DataModelListArgs {
861            server: Some(SERVER.to_string()),
862            project: project.map(str::to_owned),
863            filter: None,
864            include_builtins: false,
865            format: FormatArgs {
866                format: Format::Prose,
867                json: false,
868                lines: false,
869                columns: None,
870                no_header: false,
871                header_only: false,
872            },
873        }
874    }
875
876    fn make_data_model(name: &str, label: Option<&str>) -> DataModel {
877        DataModel {
878            name: name.to_string(),
879            iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
880            label: label.map(str::to_owned),
881            last_modified: None,
882            is_builtin: false,
883        }
884    }
885
886    fn cache_with_entry(server: &str, token: &str, user: &str) -> AuthCache {
887        let mut cache = AuthCache::default();
888        cache.set_entry(
889            server,
890            ServerEntry {
891                token: token.to_string(),
892                user: Some(user.to_string()),
893                acquired_at: None,
894                expires_at: None,
895            },
896        );
897        cache
898    }
899
900    fn write_cache(dir: &TempDir, cache: &AuthCache) -> std::path::PathBuf {
901        let path = dir.path().join("auth.toml");
902        cache.save_to(&path).expect("failed to write test cache");
903        path
904    }
905
906    // ── tests ─────────────────────────────────────────────────────────────────
907
908    #[test]
909    fn test_list_data_models_success_sorted_view() {
910        let project_ref = make_project_ref();
911        let items = vec![
912            make_data_model("zebra", None),
913            make_data_model("apple", Some("Apple data-model")),
914        ];
915
916        let client = MockDspClient::new()
917            .with_resolve_project(Ok(project_ref.clone()))
918            .with_list_data_models(Ok(items));
919
920        let mut renderer = RecordingRenderer::new();
921        let args = make_args(Some("0801"));
922        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
923
924        assert!(result.is_ok(), "expected Ok, got {:?}", result);
925
926        // Mock was called with the resolved IRI
927        assert_eq!(client.list_data_models_calls(), 1);
928        assert_eq!(
929            client.list_data_models_iri().as_deref(),
930            Some(project_ref.iri.as_str())
931        );
932        // No token (anonymous)
933        assert_eq!(
934            client.list_data_models_token(),
935            Some(None),
936            "token should be None for anonymous call"
937        );
938
939        let view = renderer
940            .data_models_view
941            .expect("data_models must have been called");
942        // Sorted by name: apple < zebra
943        assert_eq!(view.items.len(), 2);
944        assert_eq!(view.items[0].name, "apple");
945        assert_eq!(view.items[1].name, "zebra");
946        assert_eq!(view.total, 2);
947        assert!(view.filter.is_none());
948    }
949
950    /// This is the canonical test: default (no `--include-builtins`) renders
951    /// only project data-models. Every item has `is_builtin == false`; none of
952    /// knora-api / standoff / salsah-gui appear.
953    #[test]
954    fn test_list_data_models_excludes_builtins() {
955        let items = vec![
956            make_data_model("beol", Some("The BEOL data-model")),
957            make_data_model("images", None),
958        ];
959
960        let client = MockDspClient::new()
961            .with_resolve_project(Ok(make_project_ref()))
962            .with_list_data_models(Ok(items));
963
964        let mut renderer = RecordingRenderer::new();
965        let mut args = make_args(Some("0801"));
966        args.include_builtins = false;
967        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
968
969        let view = renderer
970            .data_models_view
971            .expect("data_models must have been called");
972
973        // total must equal the project data-model count (captured AFTER builtins
974        // append, BEFORE filter — with no builtins appended, that means 2).
975        assert_eq!(
976            view.total, 2,
977            "total must equal the number of project data-models (2); \
978             a regression capturing total after filtering would break this"
979        );
980
981        // All items have is_builtin == false
982        for item in &view.items {
983            assert!(
984                !item.is_builtin,
985                "item '{}' has is_builtin == true but should be false",
986                item.name
987            );
988        }
989
990        // None of the built-in names appear
991        let names: Vec<&str> = view.items.iter().map(|i| i.name.as_str()).collect();
992        assert!(
993            !names.contains(&"knora-api"),
994            "knora-api must not appear without --include-builtins"
995        );
996        assert!(
997            !names.contains(&"standoff"),
998            "standoff must not appear without --include-builtins"
999        );
1000        assert!(
1001            !names.contains(&"salsah-gui"),
1002            "salsah-gui must not appear without --include-builtins"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_list_data_models_include_builtins_appended_and_sorted() {
1008        let items = vec![
1009            make_data_model("limc", Some("LIMC")),
1010            make_data_model("rosetta", None),
1011        ];
1012
1013        let client = MockDspClient::new()
1014            .with_resolve_project(Ok(make_project_ref()))
1015            .with_list_data_models(Ok(items));
1016
1017        let mut renderer = RecordingRenderer::new();
1018        let mut args = make_args(Some("0801"));
1019        args.include_builtins = true;
1020        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1021
1022        let view = renderer
1023            .data_models_view
1024            .expect("data_models must have been called");
1025
1026        // total = 2 project + 3 builtins = 5, before filter
1027        assert_eq!(view.total, 5, "total must include builtins before filter");
1028        assert_eq!(view.items.len(), 5);
1029
1030        // Sort order: knora-api, limc, rosetta, salsah-gui, standoff
1031        let names: Vec<&str> = view.items.iter().map(|i| i.name.as_str()).collect();
1032        assert_eq!(
1033            names,
1034            vec!["knora-api", "limc", "rosetta", "salsah-gui", "standoff"]
1035        );
1036
1037        // Builtins have is_builtin == true
1038        for item in &view.items {
1039            if matches!(item.name.as_str(), "knora-api" | "standoff" | "salsah-gui") {
1040                assert!(
1041                    item.is_builtin,
1042                    "'{}' should have is_builtin == true",
1043                    item.name
1044                );
1045            } else {
1046                assert!(
1047                    !item.is_builtin,
1048                    "'{}' should have is_builtin == false",
1049                    item.name
1050                );
1051            }
1052        }
1053    }
1054
1055    #[test]
1056    fn test_list_data_models_missing_project_is_usage_error() {
1057        let client = MockDspClient::new();
1058        let mut renderer = RecordingRenderer::new();
1059        let args = make_args(None); // no --project
1060        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1061
1062        assert!(
1063            matches!(result, Err(Diagnostic::Usage(_))),
1064            "missing --project must yield Diagnostic::Usage, got {:?}",
1065            result
1066        );
1067        // No client calls should be made
1068        assert_eq!(
1069            client.resolve_calls(),
1070            0,
1071            "resolve_project must not be called when --project is missing"
1072        );
1073        assert_eq!(
1074            client.list_data_models_calls(),
1075            0,
1076            "list_data_models must not be called when --project is missing"
1077        );
1078        // Renderer not called either
1079        assert!(renderer.data_models_view.is_none());
1080    }
1081
1082    #[test]
1083    fn test_list_data_models_resolve_not_found_propagates() {
1084        let client = MockDspClient::new()
1085            .with_resolve_project(Err(Diagnostic::NotFound("project '9999' not found".into())));
1086
1087        let mut renderer = RecordingRenderer::new();
1088        let args = make_args(Some("9999"));
1089        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1090
1091        assert!(
1092            matches!(result, Err(Diagnostic::NotFound(_))),
1093            "resolve_project NotFound must propagate, got {:?}",
1094            result
1095        );
1096        assert_eq!(
1097            client.list_data_models_calls(),
1098            0,
1099            "list_data_models must not be called when resolve_project fails"
1100        );
1101    }
1102
1103    #[test]
1104    fn test_list_data_models_list_error_propagates() {
1105        let client = MockDspClient::new()
1106            .with_resolve_project(Ok(make_project_ref()))
1107            .with_list_data_models(Err(Diagnostic::ServerError("server is broken".into())));
1108
1109        let mut renderer = RecordingRenderer::new();
1110        let args = make_args(Some("0801"));
1111        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1112
1113        assert!(
1114            matches!(result, Err(Diagnostic::ServerError(_))),
1115            "list_data_models error must propagate, got {:?}",
1116            result
1117        );
1118    }
1119
1120    #[test]
1121    fn test_list_data_models_filter_case_insensitive() {
1122        let items = vec![
1123            make_data_model("beol", Some("The BEOL data-model")),
1124            make_data_model("images", Some("Image collection")),
1125            make_data_model("admin", None),
1126        ];
1127
1128        let client = MockDspClient::new()
1129            .with_resolve_project(Ok(make_project_ref()))
1130            .with_list_data_models(Ok(items));
1131
1132        let mut renderer = RecordingRenderer::new();
1133        let mut args = make_args(Some("0801"));
1134        args.filter = Some("beol".to_string());
1135        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1136
1137        let view = renderer
1138            .data_models_view
1139            .expect("data_models must have been called");
1140
1141        // total = 3 (before filter), shown = 1
1142        assert_eq!(view.total, 3, "total must be pre-filter count");
1143        assert_eq!(view.items.len(), 1, "only the matching item should remain");
1144        assert_eq!(view.items[0].name, "beol");
1145        assert_eq!(view.filter.as_deref(), Some("beol"));
1146    }
1147
1148    #[test]
1149    fn test_list_data_models_filter_matches_label() {
1150        let items = vec![
1151            make_data_model("ont1", Some("Awesome Label")),
1152            make_data_model("ont2", Some("Other label")),
1153            make_data_model("ont3", None),
1154        ];
1155
1156        let client = MockDspClient::new()
1157            .with_resolve_project(Ok(make_project_ref()))
1158            .with_list_data_models(Ok(items));
1159
1160        let mut renderer = RecordingRenderer::new();
1161        let mut args = make_args(Some("0801"));
1162        // "awesome" only matches ont1 via label
1163        args.filter = Some("AWESOME".to_string());
1164        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1165
1166        let view = renderer
1167            .data_models_view
1168            .expect("data_models must have been called");
1169        assert_eq!(view.total, 3);
1170        assert_eq!(view.items.len(), 1);
1171        assert_eq!(view.items[0].name, "ont1");
1172    }
1173
1174    #[test]
1175    fn test_list_data_models_filter_no_match_yields_empty() {
1176        let items = vec![make_data_model("beol", None)];
1177
1178        let client = MockDspClient::new()
1179            .with_resolve_project(Ok(make_project_ref()))
1180            .with_list_data_models(Ok(items));
1181
1182        let mut renderer = RecordingRenderer::new();
1183        let mut args = make_args(Some("0801"));
1184        args.filter = Some("zzznomatch".to_string());
1185        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1186
1187        let view = renderer
1188            .data_models_view
1189            .expect("data_models must have been called");
1190        assert_eq!(view.total, 1, "total is pre-filter");
1191        assert_eq!(view.items.len(), 0, "no items match the filter");
1192    }
1193
1194    #[test]
1195    fn test_list_data_models_filter_all_pass_still_renders() {
1196        let items = vec![
1197            make_data_model("beol", Some("beol label")),
1198            make_data_model("images", Some("images label")),
1199        ];
1200
1201        let client = MockDspClient::new()
1202            .with_resolve_project(Ok(make_project_ref()))
1203            .with_list_data_models(Ok(items));
1204
1205        let mut renderer = RecordingRenderer::new();
1206        let mut args = make_args(Some("0801"));
1207        // "label" appears in all items' labels
1208        args.filter = Some("label".to_string());
1209        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1210
1211        let view = renderer
1212            .data_models_view
1213            .expect("data_models must have been called");
1214        // Both items pass — shown == total (the "(m of total matching …)" prose branch)
1215        assert_eq!(view.total, 2);
1216        assert_eq!(view.items.len(), 2);
1217        assert!(view.filter.is_some());
1218    }
1219
1220    #[test]
1221    fn test_list_data_models_filter_matches_only_builtins() {
1222        let items = vec![
1223            make_data_model("beol", None),
1224            make_data_model("images", None),
1225        ];
1226
1227        let client = MockDspClient::new()
1228            .with_resolve_project(Ok(make_project_ref()))
1229            .with_list_data_models(Ok(items));
1230
1231        let mut renderer = RecordingRenderer::new();
1232        let mut args = make_args(Some("0801"));
1233        args.include_builtins = true;
1234        // "knora" only matches the knora-api builtin name
1235        args.filter = Some("knora".to_string());
1236        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1237
1238        let view = renderer
1239            .data_models_view
1240            .expect("data_models must have been called");
1241        // total = 2 project + 3 builtins = 5 (before filter)
1242        assert_eq!(view.total, 5, "total must include builtins before filter");
1243        // Only knora-api survives the filter
1244        assert_eq!(view.items.len(), 1);
1245        assert_eq!(view.items[0].name, "knora-api");
1246        assert!(view.items[0].is_builtin);
1247    }
1248
1249    #[test]
1250    fn test_list_data_models_filter_excludes_builtins_total_still_counts_them() {
1251        let items = vec![make_data_model("beol", None)];
1252
1253        let client = MockDspClient::new()
1254            .with_resolve_project(Ok(make_project_ref()))
1255            .with_list_data_models(Ok(items));
1256
1257        let mut renderer = RecordingRenderer::new();
1258        let mut args = make_args(Some("0801"));
1259        args.include_builtins = true;
1260        // "beol" only matches the project item, not builtins
1261        args.filter = Some("beol".to_string());
1262        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1263
1264        let view = renderer
1265            .data_models_view
1266            .expect("data_models must have been called");
1267        // total = 1 project + 3 builtins = 4 (before filter), even though builtins are filtered out
1268        assert_eq!(
1269            view.total, 4,
1270            "total must count builtins even when filtered out"
1271        );
1272        assert_eq!(view.items.len(), 1);
1273        assert_eq!(view.items[0].name, "beol");
1274    }
1275
1276    // ── auth-state seam tests ─────────────────────────────────────────────────
1277
1278    #[test]
1279    fn test_list_data_models_auth_anonymous_when_no_token() {
1280        let client = MockDspClient::new()
1281            .with_resolve_project(Ok(make_project_ref()))
1282            .with_list_data_models(Ok(vec![]));
1283
1284        let mut renderer = RecordingRenderer::new();
1285        let args = make_args(Some("0801"));
1286        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1287
1288        let meta = renderer.data_models_meta.expect("meta must be present");
1289        assert_eq!(meta.auth_state, "anonymous");
1290        // Token passed to list_data_models must be None
1291        assert_eq!(client.list_data_models_token(), Some(None));
1292    }
1293
1294    #[test]
1295    fn test_list_data_models_auth_via_env_token() {
1296        let client = MockDspClient::new()
1297            .with_resolve_project(Ok(make_project_ref()))
1298            .with_list_data_models(Ok(vec![]));
1299
1300        let mut renderer = RecordingRenderer::new();
1301        let args = make_args(Some("0801"));
1302        // Pass env token via seam
1303        run_list_impl(
1304            &args,
1305            &make_cfg(),
1306            &client,
1307            &mut renderer,
1308            Some("env-jwt-token".to_string()),
1309            None,
1310        )
1311        .expect("expected Ok");
1312
1313        let meta = renderer.data_models_meta.expect("meta must be present");
1314        assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
1315        // Token passed to list_data_models must be the env token
1316        assert_eq!(
1317            client.list_data_models_token(),
1318            Some(Some("env-jwt-token".to_string()))
1319        );
1320    }
1321
1322    #[test]
1323    fn test_list_data_models_auth_via_cache_with_user() {
1324        let dir = TempDir::new().expect("tempdir");
1325        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1326        let path = write_cache(&dir, &cache);
1327
1328        let client = MockDspClient::new()
1329            .with_resolve_project(Ok(make_project_ref()))
1330            .with_list_data_models(Ok(vec![]));
1331
1332        let mut renderer = RecordingRenderer::new();
1333        let args = make_args(Some("0801"));
1334        run_list_impl(
1335            &args,
1336            &make_cfg(),
1337            &client,
1338            &mut renderer,
1339            None,
1340            Some(&path),
1341        )
1342        .expect("expected Ok");
1343
1344        let meta = renderer.data_models_meta.expect("meta must be present");
1345        assert_eq!(meta.auth_state, "authenticated as user@example.com");
1346        assert_eq!(
1347            client.list_data_models_token(),
1348            Some(Some("cache-token-xyz".to_string()))
1349        );
1350    }
1351
1352    #[test]
1353    fn test_list_data_models_corrupt_cache_falls_back_to_anonymous() {
1354        // Point cache_path at a non-existent path to simulate corrupt/missing cache
1355        let dir = TempDir::new().expect("tempdir");
1356        let bad_path = dir.path().join("nonexistent_auth.toml");
1357
1358        let client = MockDspClient::new()
1359            .with_resolve_project(Ok(make_project_ref()))
1360            .with_list_data_models(Ok(vec![]));
1361
1362        let mut renderer = RecordingRenderer::new();
1363        let args = make_args(Some("0801"));
1364        // Must succeed (no Err) even with missing cache
1365        let result = run_list_impl(
1366            &args,
1367            &make_cfg(),
1368            &client,
1369            &mut renderer,
1370            None,
1371            Some(&bad_path),
1372        );
1373        assert!(
1374            result.is_ok(),
1375            "corrupt/missing cache must not fail: {:?}",
1376            result
1377        );
1378
1379        let meta = renderer.data_models_meta.expect("meta must be present");
1380        assert_eq!(
1381            meta.auth_state, "anonymous",
1382            "corrupt cache + no env token must fall back to anonymous"
1383        );
1384    }
1385
1386    // ── describe helpers ──────────────────────────────────────────────────────
1387
1388    fn make_describe_args(
1389        project: Option<&str>,
1390        data_model: Option<&str>,
1391    ) -> DataModelDescribeArgs {
1392        DataModelDescribeArgs {
1393            server: Some(SERVER.to_string()),
1394            project: project.map(str::to_owned),
1395            data_model: data_model.map(str::to_owned),
1396            format: FormatArgs {
1397                format: Format::Prose,
1398                json: false,
1399                lines: false,
1400                columns: None,
1401                no_header: false,
1402                header_only: false,
1403            },
1404        }
1405    }
1406
1407    fn make_data_model_detail(name: &str) -> DataModelDetail {
1408        DataModelDetail {
1409            name: name.to_string(),
1410            iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
1411            label: Some(format!("The {name} data-model")),
1412            last_modified: Some("2024-05-27T12:00:00Z".to_string()),
1413            resource_types: vec![ResourceTypeSummary {
1414                name: "Letter".to_string(),
1415                iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2#Letter"),
1416                label: Some("Letter".to_string()),
1417            }],
1418        }
1419    }
1420
1421    // ── describe tests ────────────────────────────────────────────────────────
1422
1423    #[test]
1424    fn test_describe_data_model_success() {
1425        let data_model_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1426        let detail = make_data_model_detail("beol");
1427        let client = MockDspClient::new()
1428            .with_resolve_project(Ok(make_project_ref()))
1429            .with_list_data_models(Ok(vec![make_data_model(
1430                "beol",
1431                Some("The BEOL data-model"),
1432            )]))
1433            .with_describe_data_model(Ok(detail.clone()));
1434
1435        let mut renderer = RecordingRenderer::new();
1436        let args = make_describe_args(Some("0801"), Some("beol"));
1437        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1438
1439        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1440        assert_eq!(client.describe_data_model_calls(), 1);
1441        assert_eq!(
1442            client.describe_data_model_iri().as_deref(),
1443            Some(data_model_iri),
1444            "describe_data_model must be called with the matched IRI"
1445        );
1446        let recorded = renderer
1447            .data_model_describe_detail
1448            .expect("data_model_describe must have been called");
1449        assert_eq!(recorded, detail);
1450    }
1451
1452    #[test]
1453    fn test_describe_data_model_match_by_name_case_insensitive() {
1454        // "BEOL" (uppercase) should match the data-model named "beol"
1455        let data_model_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1456        let client = MockDspClient::new()
1457            .with_resolve_project(Ok(make_project_ref()))
1458            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1459            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1460
1461        let mut renderer = RecordingRenderer::new();
1462        let args = make_describe_args(Some("0801"), Some("BEOL"));
1463        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1464
1465        assert!(
1466            result.is_ok(),
1467            "expected Ok for case-insensitive match, got {:?}",
1468            result
1469        );
1470        assert_eq!(
1471            client.describe_data_model_iri().as_deref(),
1472            Some(data_model_iri)
1473        );
1474    }
1475
1476    #[test]
1477    fn test_describe_data_model_match_by_exact_iri() {
1478        let data_model_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1479        let client = MockDspClient::new()
1480            .with_resolve_project(Ok(make_project_ref()))
1481            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1482            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1483
1484        let mut renderer = RecordingRenderer::new();
1485        // Pass the full IRI as --data-model
1486        let args = make_describe_args(Some("0801"), Some(data_model_iri));
1487        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1488
1489        assert!(
1490            result.is_ok(),
1491            "expected Ok for exact IRI match, got {:?}",
1492            result
1493        );
1494        assert_eq!(
1495            client.describe_data_model_iri().as_deref(),
1496            Some(data_model_iri)
1497        );
1498    }
1499
1500    #[test]
1501    fn test_describe_data_model_not_found() {
1502        let client = MockDspClient::new()
1503            .with_resolve_project(Ok(make_project_ref()))
1504            .with_list_data_models(Ok(vec![make_data_model("beol", None)]));
1505
1506        let mut renderer = RecordingRenderer::new();
1507        let args = make_describe_args(Some("0801"), Some("nonexistent-dm"));
1508        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1509
1510        assert!(
1511            matches!(result, Err(Diagnostic::NotFound(_))),
1512            "unknown data-model must yield NotFound, got {:?}",
1513            result
1514        );
1515        assert_eq!(
1516            client.describe_data_model_calls(),
1517            0,
1518            "describe_data_model must not be called when the data-model is not found"
1519        );
1520    }
1521
1522    #[test]
1523    fn test_describe_data_model_missing_project_is_usage_error() {
1524        let client = MockDspClient::new();
1525        let mut renderer = RecordingRenderer::new();
1526        let args = make_describe_args(None, Some("beol")); // no --project
1527        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1528
1529        assert!(
1530            matches!(result, Err(Diagnostic::Usage(_))),
1531            "missing --project must yield Diagnostic::Usage, got {:?}",
1532            result
1533        );
1534        assert_eq!(
1535            client.resolve_calls(),
1536            0,
1537            "resolve_project must not be called when --project is missing"
1538        );
1539        assert_eq!(
1540            client.list_data_models_calls(),
1541            0,
1542            "list_data_models must not be called when --project is missing"
1543        );
1544        assert_eq!(
1545            client.describe_data_model_calls(),
1546            0,
1547            "describe_data_model must not be called when --project is missing"
1548        );
1549    }
1550
1551    #[test]
1552    fn test_describe_data_model_missing_data_model_is_usage_error() {
1553        let client = MockDspClient::new();
1554        let mut renderer = RecordingRenderer::new();
1555        let args = make_describe_args(Some("0801"), None); // no --data-model
1556        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1557
1558        assert!(
1559            matches!(result, Err(Diagnostic::Usage(_))),
1560            "missing --data-model must yield Diagnostic::Usage, got {:?}",
1561            result
1562        );
1563        assert_eq!(
1564            client.resolve_calls(),
1565            0,
1566            "resolve_project must not be called when --data-model is missing"
1567        );
1568        assert_eq!(
1569            client.list_data_models_calls(),
1570            0,
1571            "list_data_models must not be called when --data-model is missing"
1572        );
1573        assert_eq!(
1574            client.describe_data_model_calls(),
1575            0,
1576            "describe_data_model must not be called when --data-model is missing"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_describe_data_model_resolve_project_not_found_propagates() {
1582        let client = MockDspClient::new()
1583            .with_resolve_project(Err(Diagnostic::NotFound("project '9999' not found".into())));
1584
1585        let mut renderer = RecordingRenderer::new();
1586        let args = make_describe_args(Some("9999"), Some("beol"));
1587        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1588
1589        assert!(
1590            matches!(result, Err(Diagnostic::NotFound(_))),
1591            "resolve_project NotFound must propagate, got {:?}",
1592            result
1593        );
1594        assert_eq!(
1595            client.list_data_models_calls(),
1596            0,
1597            "list_data_models must not be called when resolve_project fails"
1598        );
1599        assert_eq!(
1600            client.describe_data_model_calls(),
1601            0,
1602            "describe_data_model must not be called when resolve_project fails"
1603        );
1604    }
1605
1606    #[test]
1607    fn test_describe_data_model_list_error_propagates() {
1608        let client = MockDspClient::new()
1609            .with_resolve_project(Ok(make_project_ref()))
1610            .with_list_data_models(Err(Diagnostic::ServerError("server is broken".into())));
1611
1612        let mut renderer = RecordingRenderer::new();
1613        let args = make_describe_args(Some("0801"), Some("beol"));
1614        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1615
1616        assert!(
1617            matches!(result, Err(Diagnostic::ServerError(_))),
1618            "list_data_models error must propagate, got {:?}",
1619            result
1620        );
1621        assert_eq!(
1622            client.describe_data_model_calls(),
1623            0,
1624            "describe_data_model must not be called when list_data_models fails"
1625        );
1626    }
1627
1628    #[test]
1629    fn test_describe_data_model_describe_error_propagates() {
1630        let client = MockDspClient::new()
1631            .with_resolve_project(Ok(make_project_ref()))
1632            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1633            .with_describe_data_model(Err(Diagnostic::ServerError("allentities failed".into())));
1634
1635        let mut renderer = RecordingRenderer::new();
1636        let args = make_describe_args(Some("0801"), Some("beol"));
1637        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1638
1639        assert!(
1640            matches!(result, Err(Diagnostic::ServerError(_))),
1641            "describe_data_model error must propagate, got {:?}",
1642            result
1643        );
1644    }
1645
1646    #[test]
1647    fn test_describe_data_model_auth_anonymous() {
1648        let client = MockDspClient::new()
1649            .with_resolve_project(Ok(make_project_ref()))
1650            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1651            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1652
1653        let mut renderer = RecordingRenderer::new();
1654        let args = make_describe_args(Some("0801"), Some("beol"));
1655        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1656            .expect("expected Ok");
1657
1658        let meta = renderer
1659            .data_model_describe_meta
1660            .expect("meta must be present");
1661        assert_eq!(meta.auth_state, "anonymous");
1662    }
1663
1664    #[test]
1665    fn test_describe_data_model_auth_via_env_token() {
1666        let client = MockDspClient::new()
1667            .with_resolve_project(Ok(make_project_ref()))
1668            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1669            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1670
1671        let mut renderer = RecordingRenderer::new();
1672        let args = make_describe_args(Some("0801"), Some("beol"));
1673        run_describe_impl(
1674            &args,
1675            &make_cfg(),
1676            &client,
1677            &mut renderer,
1678            Some("env-jwt-token".to_string()),
1679            None,
1680        )
1681        .expect("expected Ok");
1682
1683        let meta = renderer
1684            .data_model_describe_meta
1685            .expect("meta must be present");
1686        assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
1687    }
1688
1689    #[test]
1690    fn test_describe_data_model_auth_via_cache_with_user() {
1691        let dir = TempDir::new().expect("tempdir");
1692        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1693        let path = write_cache(&dir, &cache);
1694
1695        let client = MockDspClient::new()
1696            .with_resolve_project(Ok(make_project_ref()))
1697            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1698            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1699
1700        let mut renderer = RecordingRenderer::new();
1701        let args = make_describe_args(Some("0801"), Some("beol"));
1702        run_describe_impl(
1703            &args,
1704            &make_cfg(),
1705            &client,
1706            &mut renderer,
1707            None,
1708            Some(&path),
1709        )
1710        .expect("expected Ok");
1711
1712        let meta = renderer
1713            .data_model_describe_meta
1714            .expect("meta must be present");
1715        assert_eq!(meta.auth_state, "authenticated as user@example.com");
1716    }
1717
1718    #[test]
1719    fn test_describe_data_model_corrupt_cache_falls_back_to_anonymous() {
1720        let dir = TempDir::new().expect("tempdir");
1721        let bad_path = dir.path().join("nonexistent_auth.toml");
1722
1723        let client = MockDspClient::new()
1724            .with_resolve_project(Ok(make_project_ref()))
1725            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1726            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1727
1728        let mut renderer = RecordingRenderer::new();
1729        let args = make_describe_args(Some("0801"), Some("beol"));
1730        let result = run_describe_impl(
1731            &args,
1732            &make_cfg(),
1733            &client,
1734            &mut renderer,
1735            None,
1736            Some(&bad_path),
1737        );
1738        assert!(
1739            result.is_ok(),
1740            "corrupt/missing cache must not fail: {:?}",
1741            result
1742        );
1743
1744        let meta = renderer
1745            .data_model_describe_meta
1746            .expect("meta must be present");
1747        assert_eq!(
1748            meta.auth_state, "anonymous",
1749            "corrupt cache + no env token must fall back to anonymous"
1750        );
1751    }
1752
1753    // ── structure helpers ─────────────────────────────────────────────────────
1754
1755    fn make_structure_args(
1756        project: Option<&str>,
1757        data_model: Option<&str>,
1758        include_builtins: bool,
1759    ) -> DataModelStructureArgs {
1760        DataModelStructureArgs {
1761            server: Some(SERVER.to_string()),
1762            project: project.map(str::to_owned),
1763            data_model: data_model.map(str::to_owned),
1764            include_builtins,
1765            format: FormatArgs {
1766                format: Format::Prose,
1767                json: false,
1768                lines: false,
1769                columns: None,
1770                no_header: false,
1771                header_only: false,
1772            },
1773        }
1774    }
1775
1776    fn make_link_relation(
1777        source: &str,
1778        field: &str,
1779        target: &str,
1780        target_data_model: Option<&str>,
1781        is_builtin: bool,
1782    ) -> crate::model::Relation {
1783        crate::model::Relation {
1784            source: source.to_string(),
1785            target: target.to_string(),
1786            kind: crate::model::RelationKind::Link,
1787            field: Some(field.to_string()),
1788            target_data_model: target_data_model.map(str::to_owned),
1789            is_builtin,
1790        }
1791    }
1792
1793    fn make_inherits_relation(
1794        source: &str,
1795        target: &str,
1796        target_data_model: Option<&str>,
1797        is_builtin: bool,
1798    ) -> crate::model::Relation {
1799        crate::model::Relation {
1800            source: source.to_string(),
1801            target: target.to_string(),
1802            kind: crate::model::RelationKind::Inherits,
1803            field: None,
1804            target_data_model: target_data_model.map(str::to_owned),
1805            is_builtin,
1806        }
1807    }
1808
1809    fn make_structure(
1810        data_model: &str,
1811        relations: Vec<crate::model::Relation>,
1812    ) -> DataModelStructure {
1813        DataModelStructure {
1814            data_model: data_model.to_string(),
1815            relations,
1816        }
1817    }
1818
1819    // ── structure tests ───────────────────────────────────────────────────────
1820
1821    #[test]
1822    fn test_structure_link_only() {
1823        let structure = make_structure(
1824            "beol",
1825            vec![
1826                make_link_relation("letter", "hasSender", "person", None, false),
1827                make_link_relation("letter", "hasRecipient", "person", None, false),
1828            ],
1829        );
1830        let client = MockDspClient::new()
1831            .with_resolve_project(Ok(make_project_ref()))
1832            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1833            .with_data_model_structure(Ok(structure.clone()));
1834
1835        let mut renderer = RecordingRenderer::new();
1836        let args = make_structure_args(Some("0801"), Some("beol"), false);
1837        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1838
1839        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1840        assert_eq!(client.data_model_structure_calls(), 1);
1841        let recorded = renderer
1842            .data_model_structure_val
1843            .expect("data_model_structure must have been called");
1844        assert_eq!(recorded.relations.len(), 2);
1845        assert!(
1846            recorded
1847                .relations
1848                .iter()
1849                .all(|r| r.kind == crate::model::RelationKind::Link)
1850        );
1851    }
1852
1853    #[test]
1854    fn test_structure_inherits_only() {
1855        let structure = make_structure(
1856            "beol",
1857            vec![make_inherits_relation(
1858                "letter",
1859                "writtenSource",
1860                None,
1861                false,
1862            )],
1863        );
1864        let client = MockDspClient::new()
1865            .with_resolve_project(Ok(make_project_ref()))
1866            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1867            .with_data_model_structure(Ok(structure.clone()));
1868
1869        let mut renderer = RecordingRenderer::new();
1870        let args = make_structure_args(Some("0801"), Some("beol"), false);
1871        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1872            .expect("expected Ok");
1873
1874        let recorded = renderer
1875            .data_model_structure_val
1876            .expect("data_model_structure must have been called");
1877        assert_eq!(recorded.relations.len(), 1);
1878        assert_eq!(
1879            recorded.relations[0].kind,
1880            crate::model::RelationKind::Inherits
1881        );
1882    }
1883
1884    #[test]
1885    fn test_structure_mixed_link_and_inherits() {
1886        let structure = make_structure(
1887            "beol",
1888            vec![
1889                make_link_relation("letter", "hasSender", "person", None, false),
1890                make_inherits_relation("letter", "writtenSource", None, false),
1891            ],
1892        );
1893        let client = MockDspClient::new()
1894            .with_resolve_project(Ok(make_project_ref()))
1895            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1896            .with_data_model_structure(Ok(structure.clone()));
1897
1898        let mut renderer = RecordingRenderer::new();
1899        let args = make_structure_args(Some("0801"), Some("beol"), false);
1900        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1901            .expect("expected Ok");
1902
1903        let recorded = renderer
1904            .data_model_structure_val
1905            .expect("data_model_structure must have been called");
1906        assert_eq!(recorded.relations.len(), 2);
1907        let kinds: Vec<_> = recorded.relations.iter().map(|r| r.kind).collect();
1908        assert!(kinds.contains(&crate::model::RelationKind::Link));
1909        assert!(kinds.contains(&crate::model::RelationKind::Inherits));
1910    }
1911
1912    #[test]
1913    fn test_structure_cross_model_target_tag_carried_through() {
1914        // A link field pointing to a target in a sibling data-model carries
1915        // target_data_model = Some("biblio"), which the renderer will tag [to biblio].
1916        let structure = make_structure(
1917            "beol",
1918            vec![make_link_relation(
1919                "letter",
1920                "cites",
1921                "Book",
1922                Some("biblio"),
1923                false,
1924            )],
1925        );
1926        let client = MockDspClient::new()
1927            .with_resolve_project(Ok(make_project_ref()))
1928            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1929            .with_data_model_structure(Ok(structure.clone()));
1930
1931        let mut renderer = RecordingRenderer::new();
1932        let args = make_structure_args(Some("0801"), Some("beol"), false);
1933        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1934            .expect("expected Ok");
1935
1936        let recorded = renderer
1937            .data_model_structure_val
1938            .expect("data_model_structure must have been called");
1939        assert_eq!(recorded.relations.len(), 1);
1940        assert_eq!(
1941            recorded.relations[0].target_data_model.as_deref(),
1942            Some("biblio"),
1943            "cross-model target_data_model must be preserved through the action"
1944        );
1945    }
1946
1947    #[test]
1948    fn test_structure_asymmetric_builtin_project_link_to_builtin_target_shown_by_default() {
1949        // A project-defined link field pointing to a built-in target has is_builtin=false
1950        // (keyed off the FIELD's prefix). It must NOT be filtered by default.
1951        let project_link_to_builtin = make_link_relation(
1952            "letter",
1953            "hasRelation",
1954            "Resource",
1955            None,  // system target → target_data_model None
1956            false, // is_builtin=false: the FIELD is project-defined
1957        );
1958        // An inherits relation to a system super is is_builtin=true, filtered by default.
1959        let system_inherit = make_inherits_relation(
1960            "letter", "Resource", None, // system target → target_data_model None
1961            true, // is_builtin=true: system superclass
1962        );
1963        let structure = make_structure("beol", vec![project_link_to_builtin, system_inherit]);
1964        let client = MockDspClient::new()
1965            .with_resolve_project(Ok(make_project_ref()))
1966            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1967            .with_data_model_structure(Ok(structure));
1968
1969        let mut renderer = RecordingRenderer::new();
1970        let args = make_structure_args(Some("0801"), Some("beol"), false); // no --include-builtins
1971        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1972            .expect("expected Ok");
1973
1974        let recorded = renderer
1975            .data_model_structure_val
1976            .expect("data_model_structure must have been called");
1977        // Only the project link field (is_builtin=false) must survive; the system inherit is dropped.
1978        assert_eq!(
1979            recorded.relations.len(),
1980            1,
1981            "only the project link (is_builtin=false) should survive default filtering"
1982        );
1983        assert_eq!(recorded.relations[0].kind, crate::model::RelationKind::Link);
1984        assert_eq!(recorded.relations[0].field.as_deref(), Some("hasRelation"));
1985    }
1986
1987    #[test]
1988    fn test_structure_include_builtins_shows_all() {
1989        // With --include-builtins, both the project link AND the system inherit are shown.
1990        let project_link_to_builtin =
1991            make_link_relation("letter", "hasRelation", "Resource", None, false);
1992        let system_inherit = make_inherits_relation("letter", "Resource", None, true);
1993        let structure = make_structure("beol", vec![project_link_to_builtin, system_inherit]);
1994        let client = MockDspClient::new()
1995            .with_resolve_project(Ok(make_project_ref()))
1996            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1997            .with_data_model_structure(Ok(structure));
1998
1999        let mut renderer = RecordingRenderer::new();
2000        let args = make_structure_args(Some("0801"), Some("beol"), true); // --include-builtins
2001        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
2002            .expect("expected Ok");
2003
2004        let recorded = renderer
2005            .data_model_structure_val
2006            .expect("data_model_structure must have been called");
2007        assert_eq!(
2008            recorded.relations.len(),
2009            2,
2010            "--include-builtins must show all relations including is_builtin=true ones"
2011        );
2012    }
2013
2014    #[test]
2015    fn test_structure_zero_relations() {
2016        let structure = make_structure("beol", vec![]);
2017        let client = MockDspClient::new()
2018            .with_resolve_project(Ok(make_project_ref()))
2019            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2020            .with_data_model_structure(Ok(structure));
2021
2022        let mut renderer = RecordingRenderer::new();
2023        let args = make_structure_args(Some("0801"), Some("beol"), false);
2024        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2025
2026        assert!(
2027            result.is_ok(),
2028            "zero relations must render OK, got {:?}",
2029            result
2030        );
2031        let recorded = renderer
2032            .data_model_structure_val
2033            .expect("data_model_structure must have been called");
2034        assert!(
2035            recorded.relations.is_empty(),
2036            "zero relations must be empty after filtering"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_structure_missing_project_is_usage_error() {
2042        let client = MockDspClient::new();
2043        let mut renderer = RecordingRenderer::new();
2044        let args = make_structure_args(None, Some("beol"), false);
2045        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2046
2047        assert!(
2048            matches!(result, Err(Diagnostic::Usage(_))),
2049            "missing --project must yield Diagnostic::Usage, got {:?}",
2050            result
2051        );
2052        assert_eq!(
2053            client.resolve_calls(),
2054            0,
2055            "resolve_project must not be called when --project is missing"
2056        );
2057        assert_eq!(
2058            client.data_model_structure_calls(),
2059            0,
2060            "data_model_structure must not be called when --project is missing"
2061        );
2062    }
2063
2064    #[test]
2065    fn test_structure_missing_data_model_is_usage_error() {
2066        let client = MockDspClient::new();
2067        let mut renderer = RecordingRenderer::new();
2068        let args = make_structure_args(Some("0801"), None, false);
2069        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2070
2071        assert!(
2072            matches!(result, Err(Diagnostic::Usage(_))),
2073            "missing --data-model must yield Diagnostic::Usage, got {:?}",
2074            result
2075        );
2076        assert_eq!(
2077            client.resolve_calls(),
2078            0,
2079            "resolve_project must not be called when --data-model is missing"
2080        );
2081        assert_eq!(
2082            client.data_model_structure_calls(),
2083            0,
2084            "data_model_structure must not be called when --data-model is missing"
2085        );
2086    }
2087
2088    #[test]
2089    fn test_structure_project_not_found_propagates() {
2090        let client = MockDspClient::new()
2091            .with_resolve_project(Err(Diagnostic::NotFound("project '9999' not found".into())));
2092
2093        let mut renderer = RecordingRenderer::new();
2094        let args = make_structure_args(Some("9999"), Some("beol"), false);
2095        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2096
2097        assert!(
2098            matches!(result, Err(Diagnostic::NotFound(_))),
2099            "resolve_project NotFound must propagate, got {:?}",
2100            result
2101        );
2102        assert_eq!(
2103            client.list_data_models_calls(),
2104            0,
2105            "list_data_models must not be called when resolve_project fails"
2106        );
2107        assert_eq!(
2108            client.data_model_structure_calls(),
2109            0,
2110            "data_model_structure must not be called when resolve_project fails"
2111        );
2112    }
2113
2114    #[test]
2115    fn test_structure_data_model_not_found_reframed_to_not_found_diagnostic() {
2116        // When the data-model name is not in the project's data-model list, the action
2117        // reframes the miss as a NotFound Diagnostic (not a server error or panic).
2118        let client = MockDspClient::new()
2119            .with_resolve_project(Ok(make_project_ref()))
2120            .with_list_data_models(Ok(vec![make_data_model("beol", None)]));
2121
2122        let mut renderer = RecordingRenderer::new();
2123        let args = make_structure_args(Some("0801"), Some("nonexistent-dm"), false);
2124        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2125
2126        assert!(
2127            matches!(result, Err(Diagnostic::NotFound(_))),
2128            "unknown data-model must yield NotFound Diagnostic, got {:?}",
2129            result
2130        );
2131        assert_eq!(
2132            client.data_model_structure_calls(),
2133            0,
2134            "data_model_structure must not be called when the data-model is not found"
2135        );
2136    }
2137
2138    #[test]
2139    fn test_structure_data_model_iri_forwarded() {
2140        // Confirm the resolved IRI (not the user-provided name) is passed to data_model_structure.
2141        let dm_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
2142        let structure = make_structure("beol", vec![]);
2143        let client = MockDspClient::new()
2144            .with_resolve_project(Ok(make_project_ref()))
2145            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2146            .with_data_model_structure(Ok(structure));
2147
2148        let mut renderer = RecordingRenderer::new();
2149        let args = make_structure_args(Some("0801"), Some("beol"), false);
2150        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
2151            .expect("expected Ok");
2152
2153        assert_eq!(
2154            client.data_model_structure_iri().as_deref(),
2155            Some(dm_iri),
2156            "data_model_structure must be called with the resolved IRI"
2157        );
2158    }
2159
2160    #[test]
2161    fn test_structure_token_forwarded_to_client() {
2162        // The resolved token (from env seam) must be forwarded to data_model_structure.
2163        let structure = make_structure("beol", vec![]);
2164        let client = MockDspClient::new()
2165            .with_resolve_project(Ok(make_project_ref()))
2166            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2167            .with_data_model_structure(Ok(structure));
2168
2169        let mut renderer = RecordingRenderer::new();
2170        let args = make_structure_args(Some("0801"), Some("beol"), false);
2171        run_structure_impl(
2172            &args,
2173            &make_cfg(),
2174            &client,
2175            &mut renderer,
2176            Some("env-jwt-token".to_string()),
2177            None,
2178        )
2179        .expect("expected Ok");
2180
2181        assert_eq!(
2182            client.data_model_structure_token(),
2183            Some(Some("env-jwt-token".to_string())),
2184            "env token must be forwarded to data_model_structure"
2185        );
2186        let meta = renderer
2187            .data_model_structure_meta
2188            .expect("meta must be present");
2189        assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
2190    }
2191
2192    #[test]
2193    fn test_structure_auth_anonymous_when_no_token() {
2194        let structure = make_structure("beol", vec![]);
2195        let client = MockDspClient::new()
2196            .with_resolve_project(Ok(make_project_ref()))
2197            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2198            .with_data_model_structure(Ok(structure));
2199
2200        let mut renderer = RecordingRenderer::new();
2201        let args = make_structure_args(Some("0801"), Some("beol"), false);
2202        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
2203            .expect("expected Ok");
2204
2205        let meta = renderer
2206            .data_model_structure_meta
2207            .expect("meta must be present");
2208        assert_eq!(meta.auth_state, "anonymous");
2209        assert_eq!(client.data_model_structure_token(), Some(None));
2210    }
2211
2212    #[test]
2213    fn test_structure_auth_via_cache_with_user() {
2214        // Parallel to test_describe_data_model_auth_via_cache_with_user and
2215        // test_list_data_models_auth_via_cache_with_user: the cached token must be
2216        // resolved and forwarded to data_model_structure, and the auth-state disclosure
2217        // must reflect the cached user identity.
2218        let dir = TempDir::new().expect("tempdir");
2219        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
2220        let path = write_cache(&dir, &cache);
2221
2222        let structure = make_structure("beol", vec![]);
2223        let client = MockDspClient::new()
2224            .with_resolve_project(Ok(make_project_ref()))
2225            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2226            .with_data_model_structure(Ok(structure));
2227
2228        let mut renderer = RecordingRenderer::new();
2229        let args = make_structure_args(Some("0801"), Some("beol"), false);
2230        run_structure_impl(
2231            &args,
2232            &make_cfg(),
2233            &client,
2234            &mut renderer,
2235            None,
2236            Some(&path),
2237        )
2238        .expect("expected Ok");
2239
2240        let meta = renderer
2241            .data_model_structure_meta
2242            .expect("meta must be present");
2243        assert_eq!(
2244            meta.auth_state, "authenticated as user@example.com",
2245            "cached-token path must produce 'authenticated as <user>' auth state"
2246        );
2247        assert_eq!(
2248            client.data_model_structure_token(),
2249            Some(Some("cache-token-xyz".to_string())),
2250            "cached token must be forwarded to data_model_structure"
2251        );
2252    }
2253}