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