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        fn sparql_query(
661            &self,
662            _server: &str,
663            _token: &str,
664            _query: &str,
665            _accept: &str,
666            _timeout_secs: u64,
667        ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
668            Err(Diagnostic::Internal("not used in this test".into()))
669        }
670    }
671
672    // ── RecordingRenderer ─────────────────────────────────────────────────────
673
674    struct RecordingRenderer {
675        data_models_view: Option<DataModelListView>,
676        data_models_meta: Option<MetaContext>,
677        /// Records the `detail` passed to `data_model_describe` for assertion in Step 4.
678        data_model_describe_detail: Option<DataModelDetail>,
679        /// Records the `meta` passed to `data_model_describe` for assertion in Step 4.
680        data_model_describe_meta: Option<MetaContext>,
681        /// Records the `structure` passed to `data_model_structure` for assertion in Step 6.
682        data_model_structure_val: Option<crate::model::DataModelStructure>,
683        /// Records the `meta` passed to `data_model_structure` for assertion in Step 6.
684        data_model_structure_meta: Option<MetaContext>,
685    }
686
687    impl RecordingRenderer {
688        fn new() -> Self {
689            Self {
690                data_models_view: None,
691                data_models_meta: None,
692                data_model_describe_detail: None,
693                data_model_describe_meta: None,
694                data_model_structure_val: None,
695                data_model_structure_meta: None,
696            }
697        }
698    }
699
700    impl Renderer for RecordingRenderer {
701        fn diagnostic(
702            &mut self,
703            _diag: &Diagnostic,
704            _meta: &MetaContext,
705        ) -> Result<(), Diagnostic> {
706            Ok(())
707        }
708
709        fn auth_login(
710            &mut self,
711            _outcome: &AuthLoginOutcome,
712            _meta: &MetaContext,
713        ) -> Result<(), Diagnostic> {
714            Ok(())
715        }
716
717        fn auth_status(
718            &mut self,
719            _outcome: &AuthStatusOutcome,
720            _meta: &MetaContext,
721        ) -> Result<(), Diagnostic> {
722            Ok(())
723        }
724
725        fn auth_logout(
726            &mut self,
727            _outcome: &AuthLogoutOutcome,
728            _meta: &MetaContext,
729        ) -> Result<(), Diagnostic> {
730            Ok(())
731        }
732
733        fn auth_set_token(
734            &mut self,
735            _outcome: &AuthSetTokenOutcome,
736            _meta: &MetaContext,
737        ) -> Result<(), Diagnostic> {
738            Ok(())
739        }
740
741        fn project_dump(
742            &mut self,
743            _outcome: &DumpOutcome,
744            _meta: &MetaContext,
745        ) -> Result<(), Diagnostic> {
746            Ok(())
747        }
748
749        fn project_dump_deleted(
750            &mut self,
751            _outcome: &DumpDeleteOutcome,
752            _meta: &MetaContext,
753        ) -> Result<(), Diagnostic> {
754            Ok(())
755        }
756
757        fn projects(
758            &mut self,
759            _view: &ProjectListView,
760            _meta: &MetaContext,
761        ) -> Result<(), Diagnostic> {
762            Ok(())
763        }
764
765        fn project_describe(
766            &mut self,
767            _project: &ProjectDetail,
768            _meta: &MetaContext,
769        ) -> Result<(), Diagnostic> {
770            Ok(())
771        }
772
773        fn data_models(
774            &mut self,
775            view: &DataModelListView,
776            meta: &MetaContext,
777        ) -> Result<(), Diagnostic> {
778            self.data_models_view = Some(view.clone());
779            self.data_models_meta = Some(meta.clone());
780            Ok(())
781        }
782
783        fn data_model_describe(
784            &mut self,
785            detail: &crate::model::DataModelDetail,
786            meta: &MetaContext,
787        ) -> Result<(), Diagnostic> {
788            self.data_model_describe_detail = Some(detail.clone());
789            self.data_model_describe_meta = Some(meta.clone());
790            Ok(())
791        }
792
793        fn resource_types(
794            &mut self,
795            _view: &crate::render::ResourceTypeListView,
796            _meta: &MetaContext,
797        ) -> Result<(), Diagnostic> {
798            Ok(())
799        }
800
801        fn resource_type_describe(
802            &mut self,
803            _detail: &crate::model::ResourceTypeDetail,
804            _meta: &MetaContext,
805        ) -> Result<(), Diagnostic> {
806            unimplemented!("resource_type_describe not used in data-model action tests")
807        }
808
809        fn data_model_structure(
810            &mut self,
811            structure: &crate::model::DataModelStructure,
812            meta: &MetaContext,
813        ) -> Result<(), Diagnostic> {
814            self.data_model_structure_val = Some(structure.clone());
815            self.data_model_structure_meta = Some(meta.clone());
816            Ok(())
817        }
818
819        fn resources(
820            &mut self,
821            _view: &crate::render::ResourceListView,
822            _meta: &MetaContext,
823        ) -> Result<(), Diagnostic> {
824            Ok(())
825        }
826
827        fn resource_describe(
828            &mut self,
829            _detail: &crate::model::ResourceDetail,
830            _meta: &MetaContext,
831        ) -> Result<(), Diagnostic> {
832            Ok(())
833        }
834
835        fn vocabularies(
836            &mut self,
837            _view: &crate::render::VocabularyListView,
838            _meta: &MetaContext,
839        ) -> Result<(), Diagnostic> {
840            unimplemented!("not exercised by this file's tests")
841        }
842
843        fn vocabulary_describe(
844            &mut self,
845            _detail: &crate::model::VocabularyDetail,
846            _meta: &MetaContext,
847        ) -> Result<(), Diagnostic> {
848            unimplemented!("not exercised by this file's tests")
849        }
850    }
851
852    // ── helpers ───────────────────────────────────────────────────────────────
853
854    const SERVER: &str = "https://api.test.dasch.swiss";
855
856    fn make_cfg() -> Config {
857        Config {
858            server: SERVER.to_string(),
859        }
860    }
861
862    fn make_project_ref() -> ProjectRef {
863        ProjectRef {
864            iri: "http://rdfh.ch/projects/0801".to_string(),
865            shortcode: "0801".to_string(),
866            shortname: "beol".to_string(),
867        }
868    }
869
870    fn make_args(project: Option<&str>) -> DataModelListArgs {
871        DataModelListArgs {
872            server: Some(SERVER.to_string()),
873            project: project.map(str::to_owned),
874            filter: None,
875            include_builtins: false,
876            format: FormatArgs {
877                format: Format::Prose,
878                json: false,
879                lines: false,
880                columns: None,
881                no_header: false,
882                header_only: false,
883            },
884        }
885    }
886
887    fn make_data_model(name: &str, label: Option<&str>) -> DataModel {
888        DataModel {
889            name: name.to_string(),
890            iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
891            label: label.map(str::to_owned),
892            last_modified: None,
893            is_builtin: false,
894        }
895    }
896
897    fn cache_with_entry(server: &str, token: &str, user: &str) -> AuthCache {
898        let mut cache = AuthCache::default();
899        cache.set_entry(
900            server,
901            ServerEntry {
902                token: token.to_string(),
903                user: Some(user.to_string()),
904                acquired_at: None,
905                expires_at: None,
906            },
907        );
908        cache
909    }
910
911    fn write_cache(dir: &TempDir, cache: &AuthCache) -> std::path::PathBuf {
912        let path = dir.path().join("auth.toml");
913        cache.save_to(&path).expect("failed to write test cache");
914        path
915    }
916
917    // ── tests ─────────────────────────────────────────────────────────────────
918
919    #[test]
920    fn test_list_data_models_success_sorted_view() {
921        let project_ref = make_project_ref();
922        let items = vec![
923            make_data_model("zebra", None),
924            make_data_model("apple", Some("Apple data-model")),
925        ];
926
927        let client = MockDspClient::new()
928            .with_resolve_project(Ok(project_ref.clone()))
929            .with_list_data_models(Ok(items));
930
931        let mut renderer = RecordingRenderer::new();
932        let args = make_args(Some("0801"));
933        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
934
935        assert!(result.is_ok(), "expected Ok, got {:?}", result);
936
937        // Mock was called with the resolved IRI
938        assert_eq!(client.list_data_models_calls(), 1);
939        assert_eq!(
940            client.list_data_models_iri().as_deref(),
941            Some(project_ref.iri.as_str())
942        );
943        // No token (anonymous)
944        assert_eq!(
945            client.list_data_models_token(),
946            Some(None),
947            "token should be None for anonymous call"
948        );
949
950        let view = renderer
951            .data_models_view
952            .expect("data_models must have been called");
953        // Sorted by name: apple < zebra
954        assert_eq!(view.items.len(), 2);
955        assert_eq!(view.items[0].name, "apple");
956        assert_eq!(view.items[1].name, "zebra");
957        assert_eq!(view.total, 2);
958        assert!(view.filter.is_none());
959    }
960
961    /// This is the canonical test: default (no `--include-builtins`) renders
962    /// only project data-models. Every item has `is_builtin == false`; none of
963    /// knora-api / standoff / salsah-gui appear.
964    #[test]
965    fn test_list_data_models_excludes_builtins() {
966        let items = vec![
967            make_data_model("beol", Some("The BEOL data-model")),
968            make_data_model("images", None),
969        ];
970
971        let client = MockDspClient::new()
972            .with_resolve_project(Ok(make_project_ref()))
973            .with_list_data_models(Ok(items));
974
975        let mut renderer = RecordingRenderer::new();
976        let mut args = make_args(Some("0801"));
977        args.include_builtins = false;
978        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
979
980        let view = renderer
981            .data_models_view
982            .expect("data_models must have been called");
983
984        // total must equal the project data-model count (captured AFTER builtins
985        // append, BEFORE filter — with no builtins appended, that means 2).
986        assert_eq!(
987            view.total, 2,
988            "total must equal the number of project data-models (2); \
989             a regression capturing total after filtering would break this"
990        );
991
992        // All items have is_builtin == false
993        for item in &view.items {
994            assert!(
995                !item.is_builtin,
996                "item '{}' has is_builtin == true but should be false",
997                item.name
998            );
999        }
1000
1001        // None of the built-in names appear
1002        let names: Vec<&str> = view.items.iter().map(|i| i.name.as_str()).collect();
1003        assert!(
1004            !names.contains(&"knora-api"),
1005            "knora-api must not appear without --include-builtins"
1006        );
1007        assert!(
1008            !names.contains(&"standoff"),
1009            "standoff must not appear without --include-builtins"
1010        );
1011        assert!(
1012            !names.contains(&"salsah-gui"),
1013            "salsah-gui must not appear without --include-builtins"
1014        );
1015    }
1016
1017    #[test]
1018    fn test_list_data_models_include_builtins_appended_and_sorted() {
1019        let items = vec![
1020            make_data_model("limc", Some("LIMC")),
1021            make_data_model("rosetta", None),
1022        ];
1023
1024        let client = MockDspClient::new()
1025            .with_resolve_project(Ok(make_project_ref()))
1026            .with_list_data_models(Ok(items));
1027
1028        let mut renderer = RecordingRenderer::new();
1029        let mut args = make_args(Some("0801"));
1030        args.include_builtins = true;
1031        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1032
1033        let view = renderer
1034            .data_models_view
1035            .expect("data_models must have been called");
1036
1037        // total = 2 project + 3 builtins = 5, before filter
1038        assert_eq!(view.total, 5, "total must include builtins before filter");
1039        assert_eq!(view.items.len(), 5);
1040
1041        // Sort order: knora-api, limc, rosetta, salsah-gui, standoff
1042        let names: Vec<&str> = view.items.iter().map(|i| i.name.as_str()).collect();
1043        assert_eq!(
1044            names,
1045            vec!["knora-api", "limc", "rosetta", "salsah-gui", "standoff"]
1046        );
1047
1048        // Builtins have is_builtin == true
1049        for item in &view.items {
1050            if matches!(item.name.as_str(), "knora-api" | "standoff" | "salsah-gui") {
1051                assert!(
1052                    item.is_builtin,
1053                    "'{}' should have is_builtin == true",
1054                    item.name
1055                );
1056            } else {
1057                assert!(
1058                    !item.is_builtin,
1059                    "'{}' should have is_builtin == false",
1060                    item.name
1061                );
1062            }
1063        }
1064    }
1065
1066    #[test]
1067    fn test_list_data_models_missing_project_is_usage_error() {
1068        let client = MockDspClient::new();
1069        let mut renderer = RecordingRenderer::new();
1070        let args = make_args(None); // no --project
1071        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1072
1073        assert!(
1074            matches!(result, Err(Diagnostic::Usage(_))),
1075            "missing --project must yield Diagnostic::Usage, got {:?}",
1076            result
1077        );
1078        // No client calls should be made
1079        assert_eq!(
1080            client.resolve_calls(),
1081            0,
1082            "resolve_project must not be called when --project is missing"
1083        );
1084        assert_eq!(
1085            client.list_data_models_calls(),
1086            0,
1087            "list_data_models must not be called when --project is missing"
1088        );
1089        // Renderer not called either
1090        assert!(renderer.data_models_view.is_none());
1091    }
1092
1093    #[test]
1094    fn test_list_data_models_resolve_not_found_propagates() {
1095        let client = MockDspClient::new()
1096            .with_resolve_project(Err(Diagnostic::NotFound("project '9999' not found".into())));
1097
1098        let mut renderer = RecordingRenderer::new();
1099        let args = make_args(Some("9999"));
1100        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1101
1102        assert!(
1103            matches!(result, Err(Diagnostic::NotFound(_))),
1104            "resolve_project NotFound must propagate, got {:?}",
1105            result
1106        );
1107        assert_eq!(
1108            client.list_data_models_calls(),
1109            0,
1110            "list_data_models must not be called when resolve_project fails"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_list_data_models_list_error_propagates() {
1116        let client = MockDspClient::new()
1117            .with_resolve_project(Ok(make_project_ref()))
1118            .with_list_data_models(Err(Diagnostic::ServerError("server is broken".into())));
1119
1120        let mut renderer = RecordingRenderer::new();
1121        let args = make_args(Some("0801"));
1122        let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1123
1124        assert!(
1125            matches!(result, Err(Diagnostic::ServerError(_))),
1126            "list_data_models error must propagate, got {:?}",
1127            result
1128        );
1129    }
1130
1131    #[test]
1132    fn test_list_data_models_filter_case_insensitive() {
1133        let items = vec![
1134            make_data_model("beol", Some("The BEOL data-model")),
1135            make_data_model("images", Some("Image collection")),
1136            make_data_model("admin", None),
1137        ];
1138
1139        let client = MockDspClient::new()
1140            .with_resolve_project(Ok(make_project_ref()))
1141            .with_list_data_models(Ok(items));
1142
1143        let mut renderer = RecordingRenderer::new();
1144        let mut args = make_args(Some("0801"));
1145        args.filter = Some("beol".to_string());
1146        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1147
1148        let view = renderer
1149            .data_models_view
1150            .expect("data_models must have been called");
1151
1152        // total = 3 (before filter), shown = 1
1153        assert_eq!(view.total, 3, "total must be pre-filter count");
1154        assert_eq!(view.items.len(), 1, "only the matching item should remain");
1155        assert_eq!(view.items[0].name, "beol");
1156        assert_eq!(view.filter.as_deref(), Some("beol"));
1157    }
1158
1159    #[test]
1160    fn test_list_data_models_filter_matches_label() {
1161        let items = vec![
1162            make_data_model("ont1", Some("Awesome Label")),
1163            make_data_model("ont2", Some("Other label")),
1164            make_data_model("ont3", None),
1165        ];
1166
1167        let client = MockDspClient::new()
1168            .with_resolve_project(Ok(make_project_ref()))
1169            .with_list_data_models(Ok(items));
1170
1171        let mut renderer = RecordingRenderer::new();
1172        let mut args = make_args(Some("0801"));
1173        // "awesome" only matches ont1 via label
1174        args.filter = Some("AWESOME".to_string());
1175        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1176
1177        let view = renderer
1178            .data_models_view
1179            .expect("data_models must have been called");
1180        assert_eq!(view.total, 3);
1181        assert_eq!(view.items.len(), 1);
1182        assert_eq!(view.items[0].name, "ont1");
1183    }
1184
1185    #[test]
1186    fn test_list_data_models_filter_no_match_yields_empty() {
1187        let items = vec![make_data_model("beol", None)];
1188
1189        let client = MockDspClient::new()
1190            .with_resolve_project(Ok(make_project_ref()))
1191            .with_list_data_models(Ok(items));
1192
1193        let mut renderer = RecordingRenderer::new();
1194        let mut args = make_args(Some("0801"));
1195        args.filter = Some("zzznomatch".to_string());
1196        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1197
1198        let view = renderer
1199            .data_models_view
1200            .expect("data_models must have been called");
1201        assert_eq!(view.total, 1, "total is pre-filter");
1202        assert_eq!(view.items.len(), 0, "no items match the filter");
1203    }
1204
1205    #[test]
1206    fn test_list_data_models_filter_all_pass_still_renders() {
1207        let items = vec![
1208            make_data_model("beol", Some("beol label")),
1209            make_data_model("images", Some("images label")),
1210        ];
1211
1212        let client = MockDspClient::new()
1213            .with_resolve_project(Ok(make_project_ref()))
1214            .with_list_data_models(Ok(items));
1215
1216        let mut renderer = RecordingRenderer::new();
1217        let mut args = make_args(Some("0801"));
1218        // "label" appears in all items' labels
1219        args.filter = Some("label".to_string());
1220        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1221
1222        let view = renderer
1223            .data_models_view
1224            .expect("data_models must have been called");
1225        // Both items pass — shown == total (the "(m of total matching …)" prose branch)
1226        assert_eq!(view.total, 2);
1227        assert_eq!(view.items.len(), 2);
1228        assert!(view.filter.is_some());
1229    }
1230
1231    #[test]
1232    fn test_list_data_models_filter_matches_only_builtins() {
1233        let items = vec![
1234            make_data_model("beol", None),
1235            make_data_model("images", None),
1236        ];
1237
1238        let client = MockDspClient::new()
1239            .with_resolve_project(Ok(make_project_ref()))
1240            .with_list_data_models(Ok(items));
1241
1242        let mut renderer = RecordingRenderer::new();
1243        let mut args = make_args(Some("0801"));
1244        args.include_builtins = true;
1245        // "knora" only matches the knora-api builtin name
1246        args.filter = Some("knora".to_string());
1247        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1248
1249        let view = renderer
1250            .data_models_view
1251            .expect("data_models must have been called");
1252        // total = 2 project + 3 builtins = 5 (before filter)
1253        assert_eq!(view.total, 5, "total must include builtins before filter");
1254        // Only knora-api survives the filter
1255        assert_eq!(view.items.len(), 1);
1256        assert_eq!(view.items[0].name, "knora-api");
1257        assert!(view.items[0].is_builtin);
1258    }
1259
1260    #[test]
1261    fn test_list_data_models_filter_excludes_builtins_total_still_counts_them() {
1262        let items = vec![make_data_model("beol", None)];
1263
1264        let client = MockDspClient::new()
1265            .with_resolve_project(Ok(make_project_ref()))
1266            .with_list_data_models(Ok(items));
1267
1268        let mut renderer = RecordingRenderer::new();
1269        let mut args = make_args(Some("0801"));
1270        args.include_builtins = true;
1271        // "beol" only matches the project item, not builtins
1272        args.filter = Some("beol".to_string());
1273        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1274
1275        let view = renderer
1276            .data_models_view
1277            .expect("data_models must have been called");
1278        // total = 1 project + 3 builtins = 4 (before filter), even though builtins are filtered out
1279        assert_eq!(
1280            view.total, 4,
1281            "total must count builtins even when filtered out"
1282        );
1283        assert_eq!(view.items.len(), 1);
1284        assert_eq!(view.items[0].name, "beol");
1285    }
1286
1287    // ── auth-state seam tests ─────────────────────────────────────────────────
1288
1289    #[test]
1290    fn test_list_data_models_auth_anonymous_when_no_token() {
1291        let client = MockDspClient::new()
1292            .with_resolve_project(Ok(make_project_ref()))
1293            .with_list_data_models(Ok(vec![]));
1294
1295        let mut renderer = RecordingRenderer::new();
1296        let args = make_args(Some("0801"));
1297        run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1298
1299        let meta = renderer.data_models_meta.expect("meta must be present");
1300        assert_eq!(meta.auth_state, "anonymous");
1301        // Token passed to list_data_models must be None
1302        assert_eq!(client.list_data_models_token(), Some(None));
1303    }
1304
1305    #[test]
1306    fn test_list_data_models_auth_via_env_token() {
1307        let client = MockDspClient::new()
1308            .with_resolve_project(Ok(make_project_ref()))
1309            .with_list_data_models(Ok(vec![]));
1310
1311        let mut renderer = RecordingRenderer::new();
1312        let args = make_args(Some("0801"));
1313        // Pass env token via seam
1314        run_list_impl(
1315            &args,
1316            &make_cfg(),
1317            &client,
1318            &mut renderer,
1319            Some("env-jwt-token".to_string()),
1320            None,
1321        )
1322        .expect("expected Ok");
1323
1324        let meta = renderer.data_models_meta.expect("meta must be present");
1325        assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
1326        // Token passed to list_data_models must be the env token
1327        assert_eq!(
1328            client.list_data_models_token(),
1329            Some(Some("env-jwt-token".to_string()))
1330        );
1331    }
1332
1333    #[test]
1334    fn test_list_data_models_auth_via_cache_with_user() {
1335        let dir = TempDir::new().expect("tempdir");
1336        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1337        let path = write_cache(&dir, &cache);
1338
1339        let client = MockDspClient::new()
1340            .with_resolve_project(Ok(make_project_ref()))
1341            .with_list_data_models(Ok(vec![]));
1342
1343        let mut renderer = RecordingRenderer::new();
1344        let args = make_args(Some("0801"));
1345        run_list_impl(
1346            &args,
1347            &make_cfg(),
1348            &client,
1349            &mut renderer,
1350            None,
1351            Some(&path),
1352        )
1353        .expect("expected Ok");
1354
1355        let meta = renderer.data_models_meta.expect("meta must be present");
1356        assert_eq!(meta.auth_state, "authenticated as user@example.com");
1357        assert_eq!(
1358            client.list_data_models_token(),
1359            Some(Some("cache-token-xyz".to_string()))
1360        );
1361    }
1362
1363    #[test]
1364    fn test_list_data_models_corrupt_cache_falls_back_to_anonymous() {
1365        // Point cache_path at a non-existent path to simulate corrupt/missing cache
1366        let dir = TempDir::new().expect("tempdir");
1367        let bad_path = dir.path().join("nonexistent_auth.toml");
1368
1369        let client = MockDspClient::new()
1370            .with_resolve_project(Ok(make_project_ref()))
1371            .with_list_data_models(Ok(vec![]));
1372
1373        let mut renderer = RecordingRenderer::new();
1374        let args = make_args(Some("0801"));
1375        // Must succeed (no Err) even with missing cache
1376        let result = run_list_impl(
1377            &args,
1378            &make_cfg(),
1379            &client,
1380            &mut renderer,
1381            None,
1382            Some(&bad_path),
1383        );
1384        assert!(
1385            result.is_ok(),
1386            "corrupt/missing cache must not fail: {:?}",
1387            result
1388        );
1389
1390        let meta = renderer.data_models_meta.expect("meta must be present");
1391        assert_eq!(
1392            meta.auth_state, "anonymous",
1393            "corrupt cache + no env token must fall back to anonymous"
1394        );
1395    }
1396
1397    // ── describe helpers ──────────────────────────────────────────────────────
1398
1399    fn make_describe_args(
1400        project: Option<&str>,
1401        data_model: Option<&str>,
1402    ) -> DataModelDescribeArgs {
1403        DataModelDescribeArgs {
1404            server: Some(SERVER.to_string()),
1405            project: project.map(str::to_owned),
1406            data_model: data_model.map(str::to_owned),
1407            format: FormatArgs {
1408                format: Format::Prose,
1409                json: false,
1410                lines: false,
1411                columns: None,
1412                no_header: false,
1413                header_only: false,
1414            },
1415        }
1416    }
1417
1418    fn make_data_model_detail(name: &str) -> DataModelDetail {
1419        DataModelDetail {
1420            name: name.to_string(),
1421            iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
1422            label: Some(format!("The {name} data-model")),
1423            last_modified: Some("2024-05-27T12:00:00Z".to_string()),
1424            resource_types: vec![ResourceTypeSummary {
1425                name: "Letter".to_string(),
1426                iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2#Letter"),
1427                label: Some("Letter".to_string()),
1428            }],
1429        }
1430    }
1431
1432    // ── describe tests ────────────────────────────────────────────────────────
1433
1434    #[test]
1435    fn test_describe_data_model_success() {
1436        let data_model_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1437        let detail = make_data_model_detail("beol");
1438        let client = MockDspClient::new()
1439            .with_resolve_project(Ok(make_project_ref()))
1440            .with_list_data_models(Ok(vec![make_data_model(
1441                "beol",
1442                Some("The BEOL data-model"),
1443            )]))
1444            .with_describe_data_model(Ok(detail.clone()));
1445
1446        let mut renderer = RecordingRenderer::new();
1447        let args = make_describe_args(Some("0801"), Some("beol"));
1448        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1449
1450        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1451        assert_eq!(client.describe_data_model_calls(), 1);
1452        assert_eq!(
1453            client.describe_data_model_iri().as_deref(),
1454            Some(data_model_iri),
1455            "describe_data_model must be called with the matched IRI"
1456        );
1457        let recorded = renderer
1458            .data_model_describe_detail
1459            .expect("data_model_describe must have been called");
1460        assert_eq!(recorded, detail);
1461    }
1462
1463    #[test]
1464    fn test_describe_data_model_match_by_name_case_insensitive() {
1465        // "BEOL" (uppercase) should match the data-model named "beol"
1466        let data_model_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1467        let client = MockDspClient::new()
1468            .with_resolve_project(Ok(make_project_ref()))
1469            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1470            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1471
1472        let mut renderer = RecordingRenderer::new();
1473        let args = make_describe_args(Some("0801"), Some("BEOL"));
1474        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1475
1476        assert!(
1477            result.is_ok(),
1478            "expected Ok for case-insensitive match, got {:?}",
1479            result
1480        );
1481        assert_eq!(
1482            client.describe_data_model_iri().as_deref(),
1483            Some(data_model_iri)
1484        );
1485    }
1486
1487    #[test]
1488    fn test_describe_data_model_match_by_exact_iri() {
1489        let data_model_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1490        let client = MockDspClient::new()
1491            .with_resolve_project(Ok(make_project_ref()))
1492            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1493            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1494
1495        let mut renderer = RecordingRenderer::new();
1496        // Pass the full IRI as --data-model
1497        let args = make_describe_args(Some("0801"), Some(data_model_iri));
1498        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1499
1500        assert!(
1501            result.is_ok(),
1502            "expected Ok for exact IRI match, got {:?}",
1503            result
1504        );
1505        assert_eq!(
1506            client.describe_data_model_iri().as_deref(),
1507            Some(data_model_iri)
1508        );
1509    }
1510
1511    #[test]
1512    fn test_describe_data_model_not_found() {
1513        let client = MockDspClient::new()
1514            .with_resolve_project(Ok(make_project_ref()))
1515            .with_list_data_models(Ok(vec![make_data_model("beol", None)]));
1516
1517        let mut renderer = RecordingRenderer::new();
1518        let args = make_describe_args(Some("0801"), Some("nonexistent-dm"));
1519        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1520
1521        assert!(
1522            matches!(result, Err(Diagnostic::NotFound(_))),
1523            "unknown data-model must yield NotFound, got {:?}",
1524            result
1525        );
1526        assert_eq!(
1527            client.describe_data_model_calls(),
1528            0,
1529            "describe_data_model must not be called when the data-model is not found"
1530        );
1531    }
1532
1533    #[test]
1534    fn test_describe_data_model_missing_project_is_usage_error() {
1535        let client = MockDspClient::new();
1536        let mut renderer = RecordingRenderer::new();
1537        let args = make_describe_args(None, Some("beol")); // no --project
1538        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1539
1540        assert!(
1541            matches!(result, Err(Diagnostic::Usage(_))),
1542            "missing --project must yield Diagnostic::Usage, got {:?}",
1543            result
1544        );
1545        assert_eq!(
1546            client.resolve_calls(),
1547            0,
1548            "resolve_project must not be called when --project is missing"
1549        );
1550        assert_eq!(
1551            client.list_data_models_calls(),
1552            0,
1553            "list_data_models must not be called when --project is missing"
1554        );
1555        assert_eq!(
1556            client.describe_data_model_calls(),
1557            0,
1558            "describe_data_model must not be called when --project is missing"
1559        );
1560    }
1561
1562    #[test]
1563    fn test_describe_data_model_missing_data_model_is_usage_error() {
1564        let client = MockDspClient::new();
1565        let mut renderer = RecordingRenderer::new();
1566        let args = make_describe_args(Some("0801"), None); // no --data-model
1567        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1568
1569        assert!(
1570            matches!(result, Err(Diagnostic::Usage(_))),
1571            "missing --data-model must yield Diagnostic::Usage, got {:?}",
1572            result
1573        );
1574        assert_eq!(
1575            client.resolve_calls(),
1576            0,
1577            "resolve_project must not be called when --data-model is missing"
1578        );
1579        assert_eq!(
1580            client.list_data_models_calls(),
1581            0,
1582            "list_data_models must not be called when --data-model is missing"
1583        );
1584        assert_eq!(
1585            client.describe_data_model_calls(),
1586            0,
1587            "describe_data_model must not be called when --data-model is missing"
1588        );
1589    }
1590
1591    #[test]
1592    fn test_describe_data_model_resolve_project_not_found_propagates() {
1593        let client = MockDspClient::new()
1594            .with_resolve_project(Err(Diagnostic::NotFound("project '9999' not found".into())));
1595
1596        let mut renderer = RecordingRenderer::new();
1597        let args = make_describe_args(Some("9999"), Some("beol"));
1598        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1599
1600        assert!(
1601            matches!(result, Err(Diagnostic::NotFound(_))),
1602            "resolve_project NotFound must propagate, got {:?}",
1603            result
1604        );
1605        assert_eq!(
1606            client.list_data_models_calls(),
1607            0,
1608            "list_data_models must not be called when resolve_project fails"
1609        );
1610        assert_eq!(
1611            client.describe_data_model_calls(),
1612            0,
1613            "describe_data_model must not be called when resolve_project fails"
1614        );
1615    }
1616
1617    #[test]
1618    fn test_describe_data_model_list_error_propagates() {
1619        let client = MockDspClient::new()
1620            .with_resolve_project(Ok(make_project_ref()))
1621            .with_list_data_models(Err(Diagnostic::ServerError("server is broken".into())));
1622
1623        let mut renderer = RecordingRenderer::new();
1624        let args = make_describe_args(Some("0801"), Some("beol"));
1625        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1626
1627        assert!(
1628            matches!(result, Err(Diagnostic::ServerError(_))),
1629            "list_data_models error must propagate, got {:?}",
1630            result
1631        );
1632        assert_eq!(
1633            client.describe_data_model_calls(),
1634            0,
1635            "describe_data_model must not be called when list_data_models fails"
1636        );
1637    }
1638
1639    #[test]
1640    fn test_describe_data_model_describe_error_propagates() {
1641        let client = MockDspClient::new()
1642            .with_resolve_project(Ok(make_project_ref()))
1643            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1644            .with_describe_data_model(Err(Diagnostic::ServerError("allentities failed".into())));
1645
1646        let mut renderer = RecordingRenderer::new();
1647        let args = make_describe_args(Some("0801"), Some("beol"));
1648        let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1649
1650        assert!(
1651            matches!(result, Err(Diagnostic::ServerError(_))),
1652            "describe_data_model error must propagate, got {:?}",
1653            result
1654        );
1655    }
1656
1657    #[test]
1658    fn test_describe_data_model_auth_anonymous() {
1659        let client = MockDspClient::new()
1660            .with_resolve_project(Ok(make_project_ref()))
1661            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1662            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1663
1664        let mut renderer = RecordingRenderer::new();
1665        let args = make_describe_args(Some("0801"), Some("beol"));
1666        run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1667            .expect("expected Ok");
1668
1669        let meta = renderer
1670            .data_model_describe_meta
1671            .expect("meta must be present");
1672        assert_eq!(meta.auth_state, "anonymous");
1673    }
1674
1675    #[test]
1676    fn test_describe_data_model_auth_via_env_token() {
1677        let client = MockDspClient::new()
1678            .with_resolve_project(Ok(make_project_ref()))
1679            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1680            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1681
1682        let mut renderer = RecordingRenderer::new();
1683        let args = make_describe_args(Some("0801"), Some("beol"));
1684        run_describe_impl(
1685            &args,
1686            &make_cfg(),
1687            &client,
1688            &mut renderer,
1689            Some("env-jwt-token".to_string()),
1690            None,
1691        )
1692        .expect("expected Ok");
1693
1694        let meta = renderer
1695            .data_model_describe_meta
1696            .expect("meta must be present");
1697        assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
1698    }
1699
1700    #[test]
1701    fn test_describe_data_model_auth_via_cache_with_user() {
1702        let dir = TempDir::new().expect("tempdir");
1703        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1704        let path = write_cache(&dir, &cache);
1705
1706        let client = MockDspClient::new()
1707            .with_resolve_project(Ok(make_project_ref()))
1708            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1709            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1710
1711        let mut renderer = RecordingRenderer::new();
1712        let args = make_describe_args(Some("0801"), Some("beol"));
1713        run_describe_impl(
1714            &args,
1715            &make_cfg(),
1716            &client,
1717            &mut renderer,
1718            None,
1719            Some(&path),
1720        )
1721        .expect("expected Ok");
1722
1723        let meta = renderer
1724            .data_model_describe_meta
1725            .expect("meta must be present");
1726        assert_eq!(meta.auth_state, "authenticated as user@example.com");
1727    }
1728
1729    #[test]
1730    fn test_describe_data_model_corrupt_cache_falls_back_to_anonymous() {
1731        let dir = TempDir::new().expect("tempdir");
1732        let bad_path = dir.path().join("nonexistent_auth.toml");
1733
1734        let client = MockDspClient::new()
1735            .with_resolve_project(Ok(make_project_ref()))
1736            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1737            .with_describe_data_model(Ok(make_data_model_detail("beol")));
1738
1739        let mut renderer = RecordingRenderer::new();
1740        let args = make_describe_args(Some("0801"), Some("beol"));
1741        let result = run_describe_impl(
1742            &args,
1743            &make_cfg(),
1744            &client,
1745            &mut renderer,
1746            None,
1747            Some(&bad_path),
1748        );
1749        assert!(
1750            result.is_ok(),
1751            "corrupt/missing cache must not fail: {:?}",
1752            result
1753        );
1754
1755        let meta = renderer
1756            .data_model_describe_meta
1757            .expect("meta must be present");
1758        assert_eq!(
1759            meta.auth_state, "anonymous",
1760            "corrupt cache + no env token must fall back to anonymous"
1761        );
1762    }
1763
1764    // ── structure helpers ─────────────────────────────────────────────────────
1765
1766    fn make_structure_args(
1767        project: Option<&str>,
1768        data_model: Option<&str>,
1769        include_builtins: bool,
1770    ) -> DataModelStructureArgs {
1771        DataModelStructureArgs {
1772            server: Some(SERVER.to_string()),
1773            project: project.map(str::to_owned),
1774            data_model: data_model.map(str::to_owned),
1775            include_builtins,
1776            format: FormatArgs {
1777                format: Format::Prose,
1778                json: false,
1779                lines: false,
1780                columns: None,
1781                no_header: false,
1782                header_only: false,
1783            },
1784        }
1785    }
1786
1787    fn make_link_relation(
1788        source: &str,
1789        field: &str,
1790        target: &str,
1791        target_data_model: Option<&str>,
1792        is_builtin: bool,
1793    ) -> crate::model::Relation {
1794        crate::model::Relation {
1795            source: source.to_string(),
1796            target: target.to_string(),
1797            kind: crate::model::RelationKind::Link,
1798            field: Some(field.to_string()),
1799            target_data_model: target_data_model.map(str::to_owned),
1800            is_builtin,
1801        }
1802    }
1803
1804    fn make_inherits_relation(
1805        source: &str,
1806        target: &str,
1807        target_data_model: Option<&str>,
1808        is_builtin: bool,
1809    ) -> crate::model::Relation {
1810        crate::model::Relation {
1811            source: source.to_string(),
1812            target: target.to_string(),
1813            kind: crate::model::RelationKind::Inherits,
1814            field: None,
1815            target_data_model: target_data_model.map(str::to_owned),
1816            is_builtin,
1817        }
1818    }
1819
1820    fn make_structure(
1821        data_model: &str,
1822        relations: Vec<crate::model::Relation>,
1823    ) -> DataModelStructure {
1824        DataModelStructure {
1825            data_model: data_model.to_string(),
1826            relations,
1827        }
1828    }
1829
1830    // ── structure tests ───────────────────────────────────────────────────────
1831
1832    #[test]
1833    fn test_structure_link_only() {
1834        let structure = make_structure(
1835            "beol",
1836            vec![
1837                make_link_relation("letter", "hasSender", "person", None, false),
1838                make_link_relation("letter", "hasRecipient", "person", None, false),
1839            ],
1840        );
1841        let client = MockDspClient::new()
1842            .with_resolve_project(Ok(make_project_ref()))
1843            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1844            .with_data_model_structure(Ok(structure.clone()));
1845
1846        let mut renderer = RecordingRenderer::new();
1847        let args = make_structure_args(Some("0801"), Some("beol"), false);
1848        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1849
1850        assert!(result.is_ok(), "expected Ok, got {:?}", result);
1851        assert_eq!(client.data_model_structure_calls(), 1);
1852        let recorded = renderer
1853            .data_model_structure_val
1854            .expect("data_model_structure must have been called");
1855        assert_eq!(recorded.relations.len(), 2);
1856        assert!(
1857            recorded
1858                .relations
1859                .iter()
1860                .all(|r| r.kind == crate::model::RelationKind::Link)
1861        );
1862    }
1863
1864    #[test]
1865    fn test_structure_inherits_only() {
1866        let structure = make_structure(
1867            "beol",
1868            vec![make_inherits_relation(
1869                "letter",
1870                "writtenSource",
1871                None,
1872                false,
1873            )],
1874        );
1875        let client = MockDspClient::new()
1876            .with_resolve_project(Ok(make_project_ref()))
1877            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1878            .with_data_model_structure(Ok(structure.clone()));
1879
1880        let mut renderer = RecordingRenderer::new();
1881        let args = make_structure_args(Some("0801"), Some("beol"), false);
1882        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1883            .expect("expected Ok");
1884
1885        let recorded = renderer
1886            .data_model_structure_val
1887            .expect("data_model_structure must have been called");
1888        assert_eq!(recorded.relations.len(), 1);
1889        assert_eq!(
1890            recorded.relations[0].kind,
1891            crate::model::RelationKind::Inherits
1892        );
1893    }
1894
1895    #[test]
1896    fn test_structure_mixed_link_and_inherits() {
1897        let structure = make_structure(
1898            "beol",
1899            vec![
1900                make_link_relation("letter", "hasSender", "person", None, false),
1901                make_inherits_relation("letter", "writtenSource", None, false),
1902            ],
1903        );
1904        let client = MockDspClient::new()
1905            .with_resolve_project(Ok(make_project_ref()))
1906            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1907            .with_data_model_structure(Ok(structure.clone()));
1908
1909        let mut renderer = RecordingRenderer::new();
1910        let args = make_structure_args(Some("0801"), Some("beol"), false);
1911        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1912            .expect("expected Ok");
1913
1914        let recorded = renderer
1915            .data_model_structure_val
1916            .expect("data_model_structure must have been called");
1917        assert_eq!(recorded.relations.len(), 2);
1918        let kinds: Vec<_> = recorded.relations.iter().map(|r| r.kind).collect();
1919        assert!(kinds.contains(&crate::model::RelationKind::Link));
1920        assert!(kinds.contains(&crate::model::RelationKind::Inherits));
1921    }
1922
1923    #[test]
1924    fn test_structure_cross_model_target_tag_carried_through() {
1925        // A link field pointing to a target in a sibling data-model carries
1926        // target_data_model = Some("biblio"), which the renderer will tag [to biblio].
1927        let structure = make_structure(
1928            "beol",
1929            vec![make_link_relation(
1930                "letter",
1931                "cites",
1932                "Book",
1933                Some("biblio"),
1934                false,
1935            )],
1936        );
1937        let client = MockDspClient::new()
1938            .with_resolve_project(Ok(make_project_ref()))
1939            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1940            .with_data_model_structure(Ok(structure.clone()));
1941
1942        let mut renderer = RecordingRenderer::new();
1943        let args = make_structure_args(Some("0801"), Some("beol"), false);
1944        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1945            .expect("expected Ok");
1946
1947        let recorded = renderer
1948            .data_model_structure_val
1949            .expect("data_model_structure must have been called");
1950        assert_eq!(recorded.relations.len(), 1);
1951        assert_eq!(
1952            recorded.relations[0].target_data_model.as_deref(),
1953            Some("biblio"),
1954            "cross-model target_data_model must be preserved through the action"
1955        );
1956    }
1957
1958    #[test]
1959    fn test_structure_asymmetric_builtin_project_link_to_builtin_target_shown_by_default() {
1960        // A project-defined link field pointing to a built-in target has is_builtin=false
1961        // (keyed off the FIELD's prefix). It must NOT be filtered by default.
1962        let project_link_to_builtin = make_link_relation(
1963            "letter",
1964            "hasRelation",
1965            "Resource",
1966            None,  // system target → target_data_model None
1967            false, // is_builtin=false: the FIELD is project-defined
1968        );
1969        // An inherits relation to a system super is is_builtin=true, filtered by default.
1970        let system_inherit = make_inherits_relation(
1971            "letter", "Resource", None, // system target → target_data_model None
1972            true, // is_builtin=true: system superclass
1973        );
1974        let structure = make_structure("beol", vec![project_link_to_builtin, system_inherit]);
1975        let client = MockDspClient::new()
1976            .with_resolve_project(Ok(make_project_ref()))
1977            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1978            .with_data_model_structure(Ok(structure));
1979
1980        let mut renderer = RecordingRenderer::new();
1981        let args = make_structure_args(Some("0801"), Some("beol"), false); // no --include-builtins
1982        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1983            .expect("expected Ok");
1984
1985        let recorded = renderer
1986            .data_model_structure_val
1987            .expect("data_model_structure must have been called");
1988        // Only the project link field (is_builtin=false) must survive; the system inherit is dropped.
1989        assert_eq!(
1990            recorded.relations.len(),
1991            1,
1992            "only the project link (is_builtin=false) should survive default filtering"
1993        );
1994        assert_eq!(recorded.relations[0].kind, crate::model::RelationKind::Link);
1995        assert_eq!(recorded.relations[0].field.as_deref(), Some("hasRelation"));
1996    }
1997
1998    #[test]
1999    fn test_structure_include_builtins_shows_all() {
2000        // With --include-builtins, both the project link AND the system inherit are shown.
2001        let project_link_to_builtin =
2002            make_link_relation("letter", "hasRelation", "Resource", None, false);
2003        let system_inherit = make_inherits_relation("letter", "Resource", None, true);
2004        let structure = make_structure("beol", vec![project_link_to_builtin, system_inherit]);
2005        let client = MockDspClient::new()
2006            .with_resolve_project(Ok(make_project_ref()))
2007            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2008            .with_data_model_structure(Ok(structure));
2009
2010        let mut renderer = RecordingRenderer::new();
2011        let args = make_structure_args(Some("0801"), Some("beol"), true); // --include-builtins
2012        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
2013            .expect("expected Ok");
2014
2015        let recorded = renderer
2016            .data_model_structure_val
2017            .expect("data_model_structure must have been called");
2018        assert_eq!(
2019            recorded.relations.len(),
2020            2,
2021            "--include-builtins must show all relations including is_builtin=true ones"
2022        );
2023    }
2024
2025    #[test]
2026    fn test_structure_zero_relations() {
2027        let structure = make_structure("beol", vec![]);
2028        let client = MockDspClient::new()
2029            .with_resolve_project(Ok(make_project_ref()))
2030            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2031            .with_data_model_structure(Ok(structure));
2032
2033        let mut renderer = RecordingRenderer::new();
2034        let args = make_structure_args(Some("0801"), Some("beol"), false);
2035        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2036
2037        assert!(
2038            result.is_ok(),
2039            "zero relations must render OK, got {:?}",
2040            result
2041        );
2042        let recorded = renderer
2043            .data_model_structure_val
2044            .expect("data_model_structure must have been called");
2045        assert!(
2046            recorded.relations.is_empty(),
2047            "zero relations must be empty after filtering"
2048        );
2049    }
2050
2051    #[test]
2052    fn test_structure_missing_project_is_usage_error() {
2053        let client = MockDspClient::new();
2054        let mut renderer = RecordingRenderer::new();
2055        let args = make_structure_args(None, Some("beol"), false);
2056        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2057
2058        assert!(
2059            matches!(result, Err(Diagnostic::Usage(_))),
2060            "missing --project must yield Diagnostic::Usage, got {:?}",
2061            result
2062        );
2063        assert_eq!(
2064            client.resolve_calls(),
2065            0,
2066            "resolve_project must not be called when --project is missing"
2067        );
2068        assert_eq!(
2069            client.data_model_structure_calls(),
2070            0,
2071            "data_model_structure must not be called when --project is missing"
2072        );
2073    }
2074
2075    #[test]
2076    fn test_structure_missing_data_model_is_usage_error() {
2077        let client = MockDspClient::new();
2078        let mut renderer = RecordingRenderer::new();
2079        let args = make_structure_args(Some("0801"), None, false);
2080        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2081
2082        assert!(
2083            matches!(result, Err(Diagnostic::Usage(_))),
2084            "missing --data-model must yield Diagnostic::Usage, got {:?}",
2085            result
2086        );
2087        assert_eq!(
2088            client.resolve_calls(),
2089            0,
2090            "resolve_project must not be called when --data-model is missing"
2091        );
2092        assert_eq!(
2093            client.data_model_structure_calls(),
2094            0,
2095            "data_model_structure must not be called when --data-model is missing"
2096        );
2097    }
2098
2099    #[test]
2100    fn test_structure_project_not_found_propagates() {
2101        let client = MockDspClient::new()
2102            .with_resolve_project(Err(Diagnostic::NotFound("project '9999' not found".into())));
2103
2104        let mut renderer = RecordingRenderer::new();
2105        let args = make_structure_args(Some("9999"), Some("beol"), false);
2106        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2107
2108        assert!(
2109            matches!(result, Err(Diagnostic::NotFound(_))),
2110            "resolve_project NotFound must propagate, got {:?}",
2111            result
2112        );
2113        assert_eq!(
2114            client.list_data_models_calls(),
2115            0,
2116            "list_data_models must not be called when resolve_project fails"
2117        );
2118        assert_eq!(
2119            client.data_model_structure_calls(),
2120            0,
2121            "data_model_structure must not be called when resolve_project fails"
2122        );
2123    }
2124
2125    #[test]
2126    fn test_structure_data_model_not_found_reframed_to_not_found_diagnostic() {
2127        // When the data-model name is not in the project's data-model list, the action
2128        // reframes the miss as a NotFound Diagnostic (not a server error or panic).
2129        let client = MockDspClient::new()
2130            .with_resolve_project(Ok(make_project_ref()))
2131            .with_list_data_models(Ok(vec![make_data_model("beol", None)]));
2132
2133        let mut renderer = RecordingRenderer::new();
2134        let args = make_structure_args(Some("0801"), Some("nonexistent-dm"), false);
2135        let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2136
2137        assert!(
2138            matches!(result, Err(Diagnostic::NotFound(_))),
2139            "unknown data-model must yield NotFound Diagnostic, got {:?}",
2140            result
2141        );
2142        assert_eq!(
2143            client.data_model_structure_calls(),
2144            0,
2145            "data_model_structure must not be called when the data-model is not found"
2146        );
2147    }
2148
2149    #[test]
2150    fn test_structure_data_model_iri_forwarded() {
2151        // Confirm the resolved IRI (not the user-provided name) is passed to data_model_structure.
2152        let dm_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
2153        let structure = make_structure("beol", vec![]);
2154        let client = MockDspClient::new()
2155            .with_resolve_project(Ok(make_project_ref()))
2156            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2157            .with_data_model_structure(Ok(structure));
2158
2159        let mut renderer = RecordingRenderer::new();
2160        let args = make_structure_args(Some("0801"), Some("beol"), false);
2161        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
2162            .expect("expected Ok");
2163
2164        assert_eq!(
2165            client.data_model_structure_iri().as_deref(),
2166            Some(dm_iri),
2167            "data_model_structure must be called with the resolved IRI"
2168        );
2169    }
2170
2171    #[test]
2172    fn test_structure_token_forwarded_to_client() {
2173        // The resolved token (from env seam) must be forwarded to data_model_structure.
2174        let structure = make_structure("beol", vec![]);
2175        let client = MockDspClient::new()
2176            .with_resolve_project(Ok(make_project_ref()))
2177            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2178            .with_data_model_structure(Ok(structure));
2179
2180        let mut renderer = RecordingRenderer::new();
2181        let args = make_structure_args(Some("0801"), Some("beol"), false);
2182        run_structure_impl(
2183            &args,
2184            &make_cfg(),
2185            &client,
2186            &mut renderer,
2187            Some("env-jwt-token".to_string()),
2188            None,
2189        )
2190        .expect("expected Ok");
2191
2192        assert_eq!(
2193            client.data_model_structure_token(),
2194            Some(Some("env-jwt-token".to_string())),
2195            "env token must be forwarded to data_model_structure"
2196        );
2197        let meta = renderer
2198            .data_model_structure_meta
2199            .expect("meta must be present");
2200        assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
2201    }
2202
2203    #[test]
2204    fn test_structure_auth_anonymous_when_no_token() {
2205        let structure = make_structure("beol", vec![]);
2206        let client = MockDspClient::new()
2207            .with_resolve_project(Ok(make_project_ref()))
2208            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2209            .with_data_model_structure(Ok(structure));
2210
2211        let mut renderer = RecordingRenderer::new();
2212        let args = make_structure_args(Some("0801"), Some("beol"), false);
2213        run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
2214            .expect("expected Ok");
2215
2216        let meta = renderer
2217            .data_model_structure_meta
2218            .expect("meta must be present");
2219        assert_eq!(meta.auth_state, "anonymous");
2220        assert_eq!(client.data_model_structure_token(), Some(None));
2221    }
2222
2223    #[test]
2224    fn test_structure_auth_via_cache_with_user() {
2225        // Parallel to test_describe_data_model_auth_via_cache_with_user and
2226        // test_list_data_models_auth_via_cache_with_user: the cached token must be
2227        // resolved and forwarded to data_model_structure, and the auth-state disclosure
2228        // must reflect the cached user identity.
2229        let dir = TempDir::new().expect("tempdir");
2230        let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
2231        let path = write_cache(&dir, &cache);
2232
2233        let structure = make_structure("beol", vec![]);
2234        let client = MockDspClient::new()
2235            .with_resolve_project(Ok(make_project_ref()))
2236            .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2237            .with_data_model_structure(Ok(structure));
2238
2239        let mut renderer = RecordingRenderer::new();
2240        let args = make_structure_args(Some("0801"), Some("beol"), false);
2241        run_structure_impl(
2242            &args,
2243            &make_cfg(),
2244            &client,
2245            &mut renderer,
2246            None,
2247            Some(&path),
2248        )
2249        .expect("expected Ok");
2250
2251        let meta = renderer
2252            .data_model_structure_meta
2253            .expect("meta must be present");
2254        assert_eq!(
2255            meta.auth_state, "authenticated as user@example.com",
2256            "cached-token path must produce 'authenticated as <user>' auth state"
2257        );
2258        assert_eq!(
2259            client.data_model_structure_token(),
2260            Some(Some("cache-token-xyz".to_string())),
2261            "cached token must be forwarded to data_model_structure"
2262        );
2263    }
2264}