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