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