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