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