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
661 struct RecordingRenderer {
664 data_models_view: Option<DataModelListView>,
665 data_models_meta: Option<MetaContext>,
666 data_model_describe_detail: Option<DataModelDetail>,
668 data_model_describe_meta: Option<MetaContext>,
670 data_model_structure_val: Option<crate::model::DataModelStructure>,
672 data_model_structure_meta: Option<MetaContext>,
674 }
675
676 impl RecordingRenderer {
677 fn new() -> Self {
678 Self {
679 data_models_view: None,
680 data_models_meta: None,
681 data_model_describe_detail: None,
682 data_model_describe_meta: None,
683 data_model_structure_val: None,
684 data_model_structure_meta: None,
685 }
686 }
687 }
688
689 impl Renderer for RecordingRenderer {
690 fn diagnostic(
691 &mut self,
692 _diag: &Diagnostic,
693 _meta: &MetaContext,
694 ) -> Result<(), Diagnostic> {
695 Ok(())
696 }
697
698 fn auth_login(
699 &mut self,
700 _outcome: &AuthLoginOutcome,
701 _meta: &MetaContext,
702 ) -> Result<(), Diagnostic> {
703 Ok(())
704 }
705
706 fn auth_status(
707 &mut self,
708 _outcome: &AuthStatusOutcome,
709 _meta: &MetaContext,
710 ) -> Result<(), Diagnostic> {
711 Ok(())
712 }
713
714 fn auth_logout(
715 &mut self,
716 _outcome: &AuthLogoutOutcome,
717 _meta: &MetaContext,
718 ) -> Result<(), Diagnostic> {
719 Ok(())
720 }
721
722 fn auth_set_token(
723 &mut self,
724 _outcome: &AuthSetTokenOutcome,
725 _meta: &MetaContext,
726 ) -> Result<(), Diagnostic> {
727 Ok(())
728 }
729
730 fn project_dump(
731 &mut self,
732 _outcome: &DumpOutcome,
733 _meta: &MetaContext,
734 ) -> Result<(), Diagnostic> {
735 Ok(())
736 }
737
738 fn project_dump_deleted(
739 &mut self,
740 _outcome: &DumpDeleteOutcome,
741 _meta: &MetaContext,
742 ) -> Result<(), Diagnostic> {
743 Ok(())
744 }
745
746 fn projects(
747 &mut self,
748 _view: &ProjectListView,
749 _meta: &MetaContext,
750 ) -> Result<(), Diagnostic> {
751 Ok(())
752 }
753
754 fn project_describe(
755 &mut self,
756 _project: &ProjectDetail,
757 _meta: &MetaContext,
758 ) -> Result<(), Diagnostic> {
759 Ok(())
760 }
761
762 fn data_models(
763 &mut self,
764 view: &DataModelListView,
765 meta: &MetaContext,
766 ) -> Result<(), Diagnostic> {
767 self.data_models_view = Some(view.clone());
768 self.data_models_meta = Some(meta.clone());
769 Ok(())
770 }
771
772 fn data_model_describe(
773 &mut self,
774 detail: &crate::model::DataModelDetail,
775 meta: &MetaContext,
776 ) -> Result<(), Diagnostic> {
777 self.data_model_describe_detail = Some(detail.clone());
778 self.data_model_describe_meta = Some(meta.clone());
779 Ok(())
780 }
781
782 fn resource_types(
783 &mut self,
784 _view: &crate::render::ResourceTypeListView,
785 _meta: &MetaContext,
786 ) -> Result<(), Diagnostic> {
787 Ok(())
788 }
789
790 fn resource_type_describe(
791 &mut self,
792 _detail: &crate::model::ResourceTypeDetail,
793 _meta: &MetaContext,
794 ) -> Result<(), Diagnostic> {
795 unimplemented!("resource_type_describe not used in data-model action tests")
796 }
797
798 fn data_model_structure(
799 &mut self,
800 structure: &crate::model::DataModelStructure,
801 meta: &MetaContext,
802 ) -> Result<(), Diagnostic> {
803 self.data_model_structure_val = Some(structure.clone());
804 self.data_model_structure_meta = Some(meta.clone());
805 Ok(())
806 }
807
808 fn resources(
809 &mut self,
810 _view: &crate::render::ResourceListView,
811 _meta: &MetaContext,
812 ) -> Result<(), Diagnostic> {
813 Ok(())
814 }
815
816 fn resource_describe(
817 &mut self,
818 _detail: &crate::model::ResourceDetail,
819 _meta: &MetaContext,
820 ) -> Result<(), Diagnostic> {
821 Ok(())
822 }
823
824 fn vocabularies(
825 &mut self,
826 _view: &crate::render::VocabularyListView,
827 _meta: &MetaContext,
828 ) -> Result<(), Diagnostic> {
829 unimplemented!("not exercised by this file's tests")
830 }
831
832 fn vocabulary_describe(
833 &mut self,
834 _detail: &crate::model::VocabularyDetail,
835 _meta: &MetaContext,
836 ) -> Result<(), Diagnostic> {
837 unimplemented!("not exercised by this file's tests")
838 }
839 }
840
841 const SERVER: &str = "https://api.test.dasch.swiss";
844
845 fn make_cfg() -> Config {
846 Config {
847 server: SERVER.to_string(),
848 }
849 }
850
851 fn make_project_ref() -> ProjectRef {
852 ProjectRef {
853 iri: "http://rdfh.ch/projects/0801".to_string(),
854 shortcode: "0801".to_string(),
855 shortname: "beol".to_string(),
856 }
857 }
858
859 fn make_args(project: Option<&str>) -> DataModelListArgs {
860 DataModelListArgs {
861 server: Some(SERVER.to_string()),
862 project: project.map(str::to_owned),
863 filter: None,
864 include_builtins: false,
865 format: FormatArgs {
866 format: Format::Prose,
867 json: false,
868 lines: false,
869 columns: None,
870 no_header: false,
871 header_only: false,
872 },
873 }
874 }
875
876 fn make_data_model(name: &str, label: Option<&str>) -> DataModel {
877 DataModel {
878 name: name.to_string(),
879 iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
880 label: label.map(str::to_owned),
881 last_modified: None,
882 is_builtin: false,
883 }
884 }
885
886 fn cache_with_entry(server: &str, token: &str, user: &str) -> AuthCache {
887 let mut cache = AuthCache::default();
888 cache.set_entry(
889 server,
890 ServerEntry {
891 token: token.to_string(),
892 user: Some(user.to_string()),
893 acquired_at: None,
894 expires_at: None,
895 },
896 );
897 cache
898 }
899
900 fn write_cache(dir: &TempDir, cache: &AuthCache) -> std::path::PathBuf {
901 let path = dir.path().join("auth.toml");
902 cache.save_to(&path).expect("failed to write test cache");
903 path
904 }
905
906 #[test]
909 fn test_list_data_models_success_sorted_view() {
910 let project_ref = make_project_ref();
911 let items = vec![
912 make_data_model("zebra", None),
913 make_data_model("apple", Some("Apple data-model")),
914 ];
915
916 let client = MockDspClient::new()
917 .with_resolve_project(Ok(project_ref.clone()))
918 .with_list_data_models(Ok(items));
919
920 let mut renderer = RecordingRenderer::new();
921 let args = make_args(Some("0801"));
922 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
923
924 assert!(result.is_ok(), "expected Ok, got {:?}", result);
925
926 assert_eq!(client.list_data_models_calls(), 1);
928 assert_eq!(
929 client.list_data_models_iri().as_deref(),
930 Some(project_ref.iri.as_str())
931 );
932 assert_eq!(
934 client.list_data_models_token(),
935 Some(None),
936 "token should be None for anonymous call"
937 );
938
939 let view = renderer
940 .data_models_view
941 .expect("data_models must have been called");
942 assert_eq!(view.items.len(), 2);
944 assert_eq!(view.items[0].name, "apple");
945 assert_eq!(view.items[1].name, "zebra");
946 assert_eq!(view.total, 2);
947 assert!(view.filter.is_none());
948 }
949
950 #[test]
954 fn test_list_data_models_excludes_builtins() {
955 let items = vec![
956 make_data_model("beol", Some("The BEOL data-model")),
957 make_data_model("images", None),
958 ];
959
960 let client = MockDspClient::new()
961 .with_resolve_project(Ok(make_project_ref()))
962 .with_list_data_models(Ok(items));
963
964 let mut renderer = RecordingRenderer::new();
965 let mut args = make_args(Some("0801"));
966 args.include_builtins = false;
967 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
968
969 let view = renderer
970 .data_models_view
971 .expect("data_models must have been called");
972
973 assert_eq!(
976 view.total, 2,
977 "total must equal the number of project data-models (2); \
978 a regression capturing total after filtering would break this"
979 );
980
981 for item in &view.items {
983 assert!(
984 !item.is_builtin,
985 "item '{}' has is_builtin == true but should be false",
986 item.name
987 );
988 }
989
990 let names: Vec<&str> = view.items.iter().map(|i| i.name.as_str()).collect();
992 assert!(
993 !names.contains(&"knora-api"),
994 "knora-api must not appear without --include-builtins"
995 );
996 assert!(
997 !names.contains(&"standoff"),
998 "standoff must not appear without --include-builtins"
999 );
1000 assert!(
1001 !names.contains(&"salsah-gui"),
1002 "salsah-gui must not appear without --include-builtins"
1003 );
1004 }
1005
1006 #[test]
1007 fn test_list_data_models_include_builtins_appended_and_sorted() {
1008 let items = vec![
1009 make_data_model("limc", Some("LIMC")),
1010 make_data_model("rosetta", None),
1011 ];
1012
1013 let client = MockDspClient::new()
1014 .with_resolve_project(Ok(make_project_ref()))
1015 .with_list_data_models(Ok(items));
1016
1017 let mut renderer = RecordingRenderer::new();
1018 let mut args = make_args(Some("0801"));
1019 args.include_builtins = true;
1020 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1021
1022 let view = renderer
1023 .data_models_view
1024 .expect("data_models must have been called");
1025
1026 assert_eq!(view.total, 5, "total must include builtins before filter");
1028 assert_eq!(view.items.len(), 5);
1029
1030 let names: Vec<&str> = view.items.iter().map(|i| i.name.as_str()).collect();
1032 assert_eq!(
1033 names,
1034 vec!["knora-api", "limc", "rosetta", "salsah-gui", "standoff"]
1035 );
1036
1037 for item in &view.items {
1039 if matches!(item.name.as_str(), "knora-api" | "standoff" | "salsah-gui") {
1040 assert!(
1041 item.is_builtin,
1042 "'{}' should have is_builtin == true",
1043 item.name
1044 );
1045 } else {
1046 assert!(
1047 !item.is_builtin,
1048 "'{}' should have is_builtin == false",
1049 item.name
1050 );
1051 }
1052 }
1053 }
1054
1055 #[test]
1056 fn test_list_data_models_missing_project_is_usage_error() {
1057 let client = MockDspClient::new();
1058 let mut renderer = RecordingRenderer::new();
1059 let args = make_args(None); let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1061
1062 assert!(
1063 matches!(result, Err(Diagnostic::Usage(_))),
1064 "missing --project must yield Diagnostic::Usage, got {:?}",
1065 result
1066 );
1067 assert_eq!(
1069 client.resolve_calls(),
1070 0,
1071 "resolve_project must not be called when --project is missing"
1072 );
1073 assert_eq!(
1074 client.list_data_models_calls(),
1075 0,
1076 "list_data_models must not be called when --project is missing"
1077 );
1078 assert!(renderer.data_models_view.is_none());
1080 }
1081
1082 #[test]
1083 fn test_list_data_models_resolve_not_found_propagates() {
1084 let client = MockDspClient::new()
1085 .with_resolve_project(Err(Diagnostic::NotFound("project '9999' not found".into())));
1086
1087 let mut renderer = RecordingRenderer::new();
1088 let args = make_args(Some("9999"));
1089 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1090
1091 assert!(
1092 matches!(result, Err(Diagnostic::NotFound(_))),
1093 "resolve_project NotFound must propagate, got {:?}",
1094 result
1095 );
1096 assert_eq!(
1097 client.list_data_models_calls(),
1098 0,
1099 "list_data_models must not be called when resolve_project fails"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_list_data_models_list_error_propagates() {
1105 let client = MockDspClient::new()
1106 .with_resolve_project(Ok(make_project_ref()))
1107 .with_list_data_models(Err(Diagnostic::ServerError("server is broken".into())));
1108
1109 let mut renderer = RecordingRenderer::new();
1110 let args = make_args(Some("0801"));
1111 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1112
1113 assert!(
1114 matches!(result, Err(Diagnostic::ServerError(_))),
1115 "list_data_models error must propagate, got {:?}",
1116 result
1117 );
1118 }
1119
1120 #[test]
1121 fn test_list_data_models_filter_case_insensitive() {
1122 let items = vec![
1123 make_data_model("beol", Some("The BEOL data-model")),
1124 make_data_model("images", Some("Image collection")),
1125 make_data_model("admin", None),
1126 ];
1127
1128 let client = MockDspClient::new()
1129 .with_resolve_project(Ok(make_project_ref()))
1130 .with_list_data_models(Ok(items));
1131
1132 let mut renderer = RecordingRenderer::new();
1133 let mut args = make_args(Some("0801"));
1134 args.filter = Some("beol".to_string());
1135 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1136
1137 let view = renderer
1138 .data_models_view
1139 .expect("data_models must have been called");
1140
1141 assert_eq!(view.total, 3, "total must be pre-filter count");
1143 assert_eq!(view.items.len(), 1, "only the matching item should remain");
1144 assert_eq!(view.items[0].name, "beol");
1145 assert_eq!(view.filter.as_deref(), Some("beol"));
1146 }
1147
1148 #[test]
1149 fn test_list_data_models_filter_matches_label() {
1150 let items = vec![
1151 make_data_model("ont1", Some("Awesome Label")),
1152 make_data_model("ont2", Some("Other label")),
1153 make_data_model("ont3", None),
1154 ];
1155
1156 let client = MockDspClient::new()
1157 .with_resolve_project(Ok(make_project_ref()))
1158 .with_list_data_models(Ok(items));
1159
1160 let mut renderer = RecordingRenderer::new();
1161 let mut args = make_args(Some("0801"));
1162 args.filter = Some("AWESOME".to_string());
1164 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1165
1166 let view = renderer
1167 .data_models_view
1168 .expect("data_models must have been called");
1169 assert_eq!(view.total, 3);
1170 assert_eq!(view.items.len(), 1);
1171 assert_eq!(view.items[0].name, "ont1");
1172 }
1173
1174 #[test]
1175 fn test_list_data_models_filter_no_match_yields_empty() {
1176 let items = vec![make_data_model("beol", None)];
1177
1178 let client = MockDspClient::new()
1179 .with_resolve_project(Ok(make_project_ref()))
1180 .with_list_data_models(Ok(items));
1181
1182 let mut renderer = RecordingRenderer::new();
1183 let mut args = make_args(Some("0801"));
1184 args.filter = Some("zzznomatch".to_string());
1185 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1186
1187 let view = renderer
1188 .data_models_view
1189 .expect("data_models must have been called");
1190 assert_eq!(view.total, 1, "total is pre-filter");
1191 assert_eq!(view.items.len(), 0, "no items match the filter");
1192 }
1193
1194 #[test]
1195 fn test_list_data_models_filter_all_pass_still_renders() {
1196 let items = vec![
1197 make_data_model("beol", Some("beol label")),
1198 make_data_model("images", Some("images label")),
1199 ];
1200
1201 let client = MockDspClient::new()
1202 .with_resolve_project(Ok(make_project_ref()))
1203 .with_list_data_models(Ok(items));
1204
1205 let mut renderer = RecordingRenderer::new();
1206 let mut args = make_args(Some("0801"));
1207 args.filter = Some("label".to_string());
1209 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1210
1211 let view = renderer
1212 .data_models_view
1213 .expect("data_models must have been called");
1214 assert_eq!(view.total, 2);
1216 assert_eq!(view.items.len(), 2);
1217 assert!(view.filter.is_some());
1218 }
1219
1220 #[test]
1221 fn test_list_data_models_filter_matches_only_builtins() {
1222 let items = vec![
1223 make_data_model("beol", None),
1224 make_data_model("images", None),
1225 ];
1226
1227 let client = MockDspClient::new()
1228 .with_resolve_project(Ok(make_project_ref()))
1229 .with_list_data_models(Ok(items));
1230
1231 let mut renderer = RecordingRenderer::new();
1232 let mut args = make_args(Some("0801"));
1233 args.include_builtins = true;
1234 args.filter = Some("knora".to_string());
1236 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1237
1238 let view = renderer
1239 .data_models_view
1240 .expect("data_models must have been called");
1241 assert_eq!(view.total, 5, "total must include builtins before filter");
1243 assert_eq!(view.items.len(), 1);
1245 assert_eq!(view.items[0].name, "knora-api");
1246 assert!(view.items[0].is_builtin);
1247 }
1248
1249 #[test]
1250 fn test_list_data_models_filter_excludes_builtins_total_still_counts_them() {
1251 let items = vec![make_data_model("beol", None)];
1252
1253 let client = MockDspClient::new()
1254 .with_resolve_project(Ok(make_project_ref()))
1255 .with_list_data_models(Ok(items));
1256
1257 let mut renderer = RecordingRenderer::new();
1258 let mut args = make_args(Some("0801"));
1259 args.include_builtins = true;
1260 args.filter = Some("beol".to_string());
1262 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1263
1264 let view = renderer
1265 .data_models_view
1266 .expect("data_models must have been called");
1267 assert_eq!(
1269 view.total, 4,
1270 "total must count builtins even when filtered out"
1271 );
1272 assert_eq!(view.items.len(), 1);
1273 assert_eq!(view.items[0].name, "beol");
1274 }
1275
1276 #[test]
1279 fn test_list_data_models_auth_anonymous_when_no_token() {
1280 let client = MockDspClient::new()
1281 .with_resolve_project(Ok(make_project_ref()))
1282 .with_list_data_models(Ok(vec![]));
1283
1284 let mut renderer = RecordingRenderer::new();
1285 let args = make_args(Some("0801"));
1286 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1287
1288 let meta = renderer.data_models_meta.expect("meta must be present");
1289 assert_eq!(meta.auth_state, "anonymous");
1290 assert_eq!(client.list_data_models_token(), Some(None));
1292 }
1293
1294 #[test]
1295 fn test_list_data_models_auth_via_env_token() {
1296 let client = MockDspClient::new()
1297 .with_resolve_project(Ok(make_project_ref()))
1298 .with_list_data_models(Ok(vec![]));
1299
1300 let mut renderer = RecordingRenderer::new();
1301 let args = make_args(Some("0801"));
1302 run_list_impl(
1304 &args,
1305 &make_cfg(),
1306 &client,
1307 &mut renderer,
1308 Some("env-jwt-token".to_string()),
1309 None,
1310 )
1311 .expect("expected Ok");
1312
1313 let meta = renderer.data_models_meta.expect("meta must be present");
1314 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
1315 assert_eq!(
1317 client.list_data_models_token(),
1318 Some(Some("env-jwt-token".to_string()))
1319 );
1320 }
1321
1322 #[test]
1323 fn test_list_data_models_auth_via_cache_with_user() {
1324 let dir = TempDir::new().expect("tempdir");
1325 let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1326 let path = write_cache(&dir, &cache);
1327
1328 let client = MockDspClient::new()
1329 .with_resolve_project(Ok(make_project_ref()))
1330 .with_list_data_models(Ok(vec![]));
1331
1332 let mut renderer = RecordingRenderer::new();
1333 let args = make_args(Some("0801"));
1334 run_list_impl(
1335 &args,
1336 &make_cfg(),
1337 &client,
1338 &mut renderer,
1339 None,
1340 Some(&path),
1341 )
1342 .expect("expected Ok");
1343
1344 let meta = renderer.data_models_meta.expect("meta must be present");
1345 assert_eq!(meta.auth_state, "authenticated as user@example.com");
1346 assert_eq!(
1347 client.list_data_models_token(),
1348 Some(Some("cache-token-xyz".to_string()))
1349 );
1350 }
1351
1352 #[test]
1353 fn test_list_data_models_corrupt_cache_falls_back_to_anonymous() {
1354 let dir = TempDir::new().expect("tempdir");
1356 let bad_path = dir.path().join("nonexistent_auth.toml");
1357
1358 let client = MockDspClient::new()
1359 .with_resolve_project(Ok(make_project_ref()))
1360 .with_list_data_models(Ok(vec![]));
1361
1362 let mut renderer = RecordingRenderer::new();
1363 let args = make_args(Some("0801"));
1364 let result = run_list_impl(
1366 &args,
1367 &make_cfg(),
1368 &client,
1369 &mut renderer,
1370 None,
1371 Some(&bad_path),
1372 );
1373 assert!(
1374 result.is_ok(),
1375 "corrupt/missing cache must not fail: {:?}",
1376 result
1377 );
1378
1379 let meta = renderer.data_models_meta.expect("meta must be present");
1380 assert_eq!(
1381 meta.auth_state, "anonymous",
1382 "corrupt cache + no env token must fall back to anonymous"
1383 );
1384 }
1385
1386 fn make_describe_args(
1389 project: Option<&str>,
1390 data_model: Option<&str>,
1391 ) -> DataModelDescribeArgs {
1392 DataModelDescribeArgs {
1393 server: Some(SERVER.to_string()),
1394 project: project.map(str::to_owned),
1395 data_model: data_model.map(str::to_owned),
1396 format: FormatArgs {
1397 format: Format::Prose,
1398 json: false,
1399 lines: false,
1400 columns: None,
1401 no_header: false,
1402 header_only: false,
1403 },
1404 }
1405 }
1406
1407 fn make_data_model_detail(name: &str) -> DataModelDetail {
1408 DataModelDetail {
1409 name: name.to_string(),
1410 iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
1411 label: Some(format!("The {name} data-model")),
1412 last_modified: Some("2024-05-27T12:00:00Z".to_string()),
1413 resource_types: vec![ResourceTypeSummary {
1414 name: "Letter".to_string(),
1415 iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2#Letter"),
1416 label: Some("Letter".to_string()),
1417 }],
1418 }
1419 }
1420
1421 #[test]
1424 fn test_describe_data_model_success() {
1425 let data_model_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1426 let detail = make_data_model_detail("beol");
1427 let client = MockDspClient::new()
1428 .with_resolve_project(Ok(make_project_ref()))
1429 .with_list_data_models(Ok(vec![make_data_model(
1430 "beol",
1431 Some("The BEOL data-model"),
1432 )]))
1433 .with_describe_data_model(Ok(detail.clone()));
1434
1435 let mut renderer = RecordingRenderer::new();
1436 let args = make_describe_args(Some("0801"), Some("beol"));
1437 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1438
1439 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1440 assert_eq!(client.describe_data_model_calls(), 1);
1441 assert_eq!(
1442 client.describe_data_model_iri().as_deref(),
1443 Some(data_model_iri),
1444 "describe_data_model must be called with the matched IRI"
1445 );
1446 let recorded = renderer
1447 .data_model_describe_detail
1448 .expect("data_model_describe must have been called");
1449 assert_eq!(recorded, detail);
1450 }
1451
1452 #[test]
1453 fn test_describe_data_model_match_by_name_case_insensitive() {
1454 let data_model_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1456 let client = MockDspClient::new()
1457 .with_resolve_project(Ok(make_project_ref()))
1458 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1459 .with_describe_data_model(Ok(make_data_model_detail("beol")));
1460
1461 let mut renderer = RecordingRenderer::new();
1462 let args = make_describe_args(Some("0801"), Some("BEOL"));
1463 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1464
1465 assert!(
1466 result.is_ok(),
1467 "expected Ok for case-insensitive match, got {:?}",
1468 result
1469 );
1470 assert_eq!(
1471 client.describe_data_model_iri().as_deref(),
1472 Some(data_model_iri)
1473 );
1474 }
1475
1476 #[test]
1477 fn test_describe_data_model_match_by_exact_iri() {
1478 let data_model_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1479 let client = MockDspClient::new()
1480 .with_resolve_project(Ok(make_project_ref()))
1481 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1482 .with_describe_data_model(Ok(make_data_model_detail("beol")));
1483
1484 let mut renderer = RecordingRenderer::new();
1485 let args = make_describe_args(Some("0801"), Some(data_model_iri));
1487 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1488
1489 assert!(
1490 result.is_ok(),
1491 "expected Ok for exact IRI match, got {:?}",
1492 result
1493 );
1494 assert_eq!(
1495 client.describe_data_model_iri().as_deref(),
1496 Some(data_model_iri)
1497 );
1498 }
1499
1500 #[test]
1501 fn test_describe_data_model_not_found() {
1502 let client = MockDspClient::new()
1503 .with_resolve_project(Ok(make_project_ref()))
1504 .with_list_data_models(Ok(vec![make_data_model("beol", None)]));
1505
1506 let mut renderer = RecordingRenderer::new();
1507 let args = make_describe_args(Some("0801"), Some("nonexistent-dm"));
1508 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1509
1510 assert!(
1511 matches!(result, Err(Diagnostic::NotFound(_))),
1512 "unknown data-model must yield NotFound, got {:?}",
1513 result
1514 );
1515 assert_eq!(
1516 client.describe_data_model_calls(),
1517 0,
1518 "describe_data_model must not be called when the data-model is not found"
1519 );
1520 }
1521
1522 #[test]
1523 fn test_describe_data_model_missing_project_is_usage_error() {
1524 let client = MockDspClient::new();
1525 let mut renderer = RecordingRenderer::new();
1526 let args = make_describe_args(None, Some("beol")); let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1528
1529 assert!(
1530 matches!(result, Err(Diagnostic::Usage(_))),
1531 "missing --project must yield Diagnostic::Usage, got {:?}",
1532 result
1533 );
1534 assert_eq!(
1535 client.resolve_calls(),
1536 0,
1537 "resolve_project must not be called when --project is missing"
1538 );
1539 assert_eq!(
1540 client.list_data_models_calls(),
1541 0,
1542 "list_data_models must not be called when --project is missing"
1543 );
1544 assert_eq!(
1545 client.describe_data_model_calls(),
1546 0,
1547 "describe_data_model must not be called when --project is missing"
1548 );
1549 }
1550
1551 #[test]
1552 fn test_describe_data_model_missing_data_model_is_usage_error() {
1553 let client = MockDspClient::new();
1554 let mut renderer = RecordingRenderer::new();
1555 let args = make_describe_args(Some("0801"), None); let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1557
1558 assert!(
1559 matches!(result, Err(Diagnostic::Usage(_))),
1560 "missing --data-model must yield Diagnostic::Usage, got {:?}",
1561 result
1562 );
1563 assert_eq!(
1564 client.resolve_calls(),
1565 0,
1566 "resolve_project must not be called when --data-model is missing"
1567 );
1568 assert_eq!(
1569 client.list_data_models_calls(),
1570 0,
1571 "list_data_models must not be called when --data-model is missing"
1572 );
1573 assert_eq!(
1574 client.describe_data_model_calls(),
1575 0,
1576 "describe_data_model must not be called when --data-model is missing"
1577 );
1578 }
1579
1580 #[test]
1581 fn test_describe_data_model_resolve_project_not_found_propagates() {
1582 let client = MockDspClient::new()
1583 .with_resolve_project(Err(Diagnostic::NotFound("project '9999' not found".into())));
1584
1585 let mut renderer = RecordingRenderer::new();
1586 let args = make_describe_args(Some("9999"), Some("beol"));
1587 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1588
1589 assert!(
1590 matches!(result, Err(Diagnostic::NotFound(_))),
1591 "resolve_project NotFound must propagate, got {:?}",
1592 result
1593 );
1594 assert_eq!(
1595 client.list_data_models_calls(),
1596 0,
1597 "list_data_models must not be called when resolve_project fails"
1598 );
1599 assert_eq!(
1600 client.describe_data_model_calls(),
1601 0,
1602 "describe_data_model must not be called when resolve_project fails"
1603 );
1604 }
1605
1606 #[test]
1607 fn test_describe_data_model_list_error_propagates() {
1608 let client = MockDspClient::new()
1609 .with_resolve_project(Ok(make_project_ref()))
1610 .with_list_data_models(Err(Diagnostic::ServerError("server is broken".into())));
1611
1612 let mut renderer = RecordingRenderer::new();
1613 let args = make_describe_args(Some("0801"), Some("beol"));
1614 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1615
1616 assert!(
1617 matches!(result, Err(Diagnostic::ServerError(_))),
1618 "list_data_models error must propagate, got {:?}",
1619 result
1620 );
1621 assert_eq!(
1622 client.describe_data_model_calls(),
1623 0,
1624 "describe_data_model must not be called when list_data_models fails"
1625 );
1626 }
1627
1628 #[test]
1629 fn test_describe_data_model_describe_error_propagates() {
1630 let client = MockDspClient::new()
1631 .with_resolve_project(Ok(make_project_ref()))
1632 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1633 .with_describe_data_model(Err(Diagnostic::ServerError("allentities failed".into())));
1634
1635 let mut renderer = RecordingRenderer::new();
1636 let args = make_describe_args(Some("0801"), Some("beol"));
1637 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1638
1639 assert!(
1640 matches!(result, Err(Diagnostic::ServerError(_))),
1641 "describe_data_model error must propagate, got {:?}",
1642 result
1643 );
1644 }
1645
1646 #[test]
1647 fn test_describe_data_model_auth_anonymous() {
1648 let client = MockDspClient::new()
1649 .with_resolve_project(Ok(make_project_ref()))
1650 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1651 .with_describe_data_model(Ok(make_data_model_detail("beol")));
1652
1653 let mut renderer = RecordingRenderer::new();
1654 let args = make_describe_args(Some("0801"), Some("beol"));
1655 run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1656 .expect("expected Ok");
1657
1658 let meta = renderer
1659 .data_model_describe_meta
1660 .expect("meta must be present");
1661 assert_eq!(meta.auth_state, "anonymous");
1662 }
1663
1664 #[test]
1665 fn test_describe_data_model_auth_via_env_token() {
1666 let client = MockDspClient::new()
1667 .with_resolve_project(Ok(make_project_ref()))
1668 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1669 .with_describe_data_model(Ok(make_data_model_detail("beol")));
1670
1671 let mut renderer = RecordingRenderer::new();
1672 let args = make_describe_args(Some("0801"), Some("beol"));
1673 run_describe_impl(
1674 &args,
1675 &make_cfg(),
1676 &client,
1677 &mut renderer,
1678 Some("env-jwt-token".to_string()),
1679 None,
1680 )
1681 .expect("expected Ok");
1682
1683 let meta = renderer
1684 .data_model_describe_meta
1685 .expect("meta must be present");
1686 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
1687 }
1688
1689 #[test]
1690 fn test_describe_data_model_auth_via_cache_with_user() {
1691 let dir = TempDir::new().expect("tempdir");
1692 let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1693 let path = write_cache(&dir, &cache);
1694
1695 let client = MockDspClient::new()
1696 .with_resolve_project(Ok(make_project_ref()))
1697 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1698 .with_describe_data_model(Ok(make_data_model_detail("beol")));
1699
1700 let mut renderer = RecordingRenderer::new();
1701 let args = make_describe_args(Some("0801"), Some("beol"));
1702 run_describe_impl(
1703 &args,
1704 &make_cfg(),
1705 &client,
1706 &mut renderer,
1707 None,
1708 Some(&path),
1709 )
1710 .expect("expected Ok");
1711
1712 let meta = renderer
1713 .data_model_describe_meta
1714 .expect("meta must be present");
1715 assert_eq!(meta.auth_state, "authenticated as user@example.com");
1716 }
1717
1718 #[test]
1719 fn test_describe_data_model_corrupt_cache_falls_back_to_anonymous() {
1720 let dir = TempDir::new().expect("tempdir");
1721 let bad_path = dir.path().join("nonexistent_auth.toml");
1722
1723 let client = MockDspClient::new()
1724 .with_resolve_project(Ok(make_project_ref()))
1725 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1726 .with_describe_data_model(Ok(make_data_model_detail("beol")));
1727
1728 let mut renderer = RecordingRenderer::new();
1729 let args = make_describe_args(Some("0801"), Some("beol"));
1730 let result = run_describe_impl(
1731 &args,
1732 &make_cfg(),
1733 &client,
1734 &mut renderer,
1735 None,
1736 Some(&bad_path),
1737 );
1738 assert!(
1739 result.is_ok(),
1740 "corrupt/missing cache must not fail: {:?}",
1741 result
1742 );
1743
1744 let meta = renderer
1745 .data_model_describe_meta
1746 .expect("meta must be present");
1747 assert_eq!(
1748 meta.auth_state, "anonymous",
1749 "corrupt cache + no env token must fall back to anonymous"
1750 );
1751 }
1752
1753 fn make_structure_args(
1756 project: Option<&str>,
1757 data_model: Option<&str>,
1758 include_builtins: bool,
1759 ) -> DataModelStructureArgs {
1760 DataModelStructureArgs {
1761 server: Some(SERVER.to_string()),
1762 project: project.map(str::to_owned),
1763 data_model: data_model.map(str::to_owned),
1764 include_builtins,
1765 format: FormatArgs {
1766 format: Format::Prose,
1767 json: false,
1768 lines: false,
1769 columns: None,
1770 no_header: false,
1771 header_only: false,
1772 },
1773 }
1774 }
1775
1776 fn make_link_relation(
1777 source: &str,
1778 field: &str,
1779 target: &str,
1780 target_data_model: Option<&str>,
1781 is_builtin: bool,
1782 ) -> crate::model::Relation {
1783 crate::model::Relation {
1784 source: source.to_string(),
1785 target: target.to_string(),
1786 kind: crate::model::RelationKind::Link,
1787 field: Some(field.to_string()),
1788 target_data_model: target_data_model.map(str::to_owned),
1789 is_builtin,
1790 }
1791 }
1792
1793 fn make_inherits_relation(
1794 source: &str,
1795 target: &str,
1796 target_data_model: Option<&str>,
1797 is_builtin: bool,
1798 ) -> crate::model::Relation {
1799 crate::model::Relation {
1800 source: source.to_string(),
1801 target: target.to_string(),
1802 kind: crate::model::RelationKind::Inherits,
1803 field: None,
1804 target_data_model: target_data_model.map(str::to_owned),
1805 is_builtin,
1806 }
1807 }
1808
1809 fn make_structure(
1810 data_model: &str,
1811 relations: Vec<crate::model::Relation>,
1812 ) -> DataModelStructure {
1813 DataModelStructure {
1814 data_model: data_model.to_string(),
1815 relations,
1816 }
1817 }
1818
1819 #[test]
1822 fn test_structure_link_only() {
1823 let structure = make_structure(
1824 "beol",
1825 vec![
1826 make_link_relation("letter", "hasSender", "person", None, false),
1827 make_link_relation("letter", "hasRecipient", "person", None, false),
1828 ],
1829 );
1830 let client = MockDspClient::new()
1831 .with_resolve_project(Ok(make_project_ref()))
1832 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1833 .with_data_model_structure(Ok(structure.clone()));
1834
1835 let mut renderer = RecordingRenderer::new();
1836 let args = make_structure_args(Some("0801"), Some("beol"), false);
1837 let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1838
1839 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1840 assert_eq!(client.data_model_structure_calls(), 1);
1841 let recorded = renderer
1842 .data_model_structure_val
1843 .expect("data_model_structure must have been called");
1844 assert_eq!(recorded.relations.len(), 2);
1845 assert!(
1846 recorded
1847 .relations
1848 .iter()
1849 .all(|r| r.kind == crate::model::RelationKind::Link)
1850 );
1851 }
1852
1853 #[test]
1854 fn test_structure_inherits_only() {
1855 let structure = make_structure(
1856 "beol",
1857 vec![make_inherits_relation(
1858 "letter",
1859 "writtenSource",
1860 None,
1861 false,
1862 )],
1863 );
1864 let client = MockDspClient::new()
1865 .with_resolve_project(Ok(make_project_ref()))
1866 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1867 .with_data_model_structure(Ok(structure.clone()));
1868
1869 let mut renderer = RecordingRenderer::new();
1870 let args = make_structure_args(Some("0801"), Some("beol"), false);
1871 run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1872 .expect("expected Ok");
1873
1874 let recorded = renderer
1875 .data_model_structure_val
1876 .expect("data_model_structure must have been called");
1877 assert_eq!(recorded.relations.len(), 1);
1878 assert_eq!(
1879 recorded.relations[0].kind,
1880 crate::model::RelationKind::Inherits
1881 );
1882 }
1883
1884 #[test]
1885 fn test_structure_mixed_link_and_inherits() {
1886 let structure = make_structure(
1887 "beol",
1888 vec![
1889 make_link_relation("letter", "hasSender", "person", None, false),
1890 make_inherits_relation("letter", "writtenSource", None, false),
1891 ],
1892 );
1893 let client = MockDspClient::new()
1894 .with_resolve_project(Ok(make_project_ref()))
1895 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1896 .with_data_model_structure(Ok(structure.clone()));
1897
1898 let mut renderer = RecordingRenderer::new();
1899 let args = make_structure_args(Some("0801"), Some("beol"), false);
1900 run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1901 .expect("expected Ok");
1902
1903 let recorded = renderer
1904 .data_model_structure_val
1905 .expect("data_model_structure must have been called");
1906 assert_eq!(recorded.relations.len(), 2);
1907 let kinds: Vec<_> = recorded.relations.iter().map(|r| r.kind).collect();
1908 assert!(kinds.contains(&crate::model::RelationKind::Link));
1909 assert!(kinds.contains(&crate::model::RelationKind::Inherits));
1910 }
1911
1912 #[test]
1913 fn test_structure_cross_model_target_tag_carried_through() {
1914 let structure = make_structure(
1917 "beol",
1918 vec![make_link_relation(
1919 "letter",
1920 "cites",
1921 "Book",
1922 Some("biblio"),
1923 false,
1924 )],
1925 );
1926 let client = MockDspClient::new()
1927 .with_resolve_project(Ok(make_project_ref()))
1928 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1929 .with_data_model_structure(Ok(structure.clone()));
1930
1931 let mut renderer = RecordingRenderer::new();
1932 let args = make_structure_args(Some("0801"), Some("beol"), false);
1933 run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1934 .expect("expected Ok");
1935
1936 let recorded = renderer
1937 .data_model_structure_val
1938 .expect("data_model_structure must have been called");
1939 assert_eq!(recorded.relations.len(), 1);
1940 assert_eq!(
1941 recorded.relations[0].target_data_model.as_deref(),
1942 Some("biblio"),
1943 "cross-model target_data_model must be preserved through the action"
1944 );
1945 }
1946
1947 #[test]
1948 fn test_structure_asymmetric_builtin_project_link_to_builtin_target_shown_by_default() {
1949 let project_link_to_builtin = make_link_relation(
1952 "letter",
1953 "hasRelation",
1954 "Resource",
1955 None, false, );
1958 let system_inherit = make_inherits_relation(
1960 "letter", "Resource", None, true, );
1963 let structure = make_structure("beol", vec![project_link_to_builtin, system_inherit]);
1964 let client = MockDspClient::new()
1965 .with_resolve_project(Ok(make_project_ref()))
1966 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1967 .with_data_model_structure(Ok(structure));
1968
1969 let mut renderer = RecordingRenderer::new();
1970 let args = make_structure_args(Some("0801"), Some("beol"), false); run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1972 .expect("expected Ok");
1973
1974 let recorded = renderer
1975 .data_model_structure_val
1976 .expect("data_model_structure must have been called");
1977 assert_eq!(
1979 recorded.relations.len(),
1980 1,
1981 "only the project link (is_builtin=false) should survive default filtering"
1982 );
1983 assert_eq!(recorded.relations[0].kind, crate::model::RelationKind::Link);
1984 assert_eq!(recorded.relations[0].field.as_deref(), Some("hasRelation"));
1985 }
1986
1987 #[test]
1988 fn test_structure_include_builtins_shows_all() {
1989 let project_link_to_builtin =
1991 make_link_relation("letter", "hasRelation", "Resource", None, false);
1992 let system_inherit = make_inherits_relation("letter", "Resource", None, true);
1993 let structure = make_structure("beol", vec![project_link_to_builtin, system_inherit]);
1994 let client = MockDspClient::new()
1995 .with_resolve_project(Ok(make_project_ref()))
1996 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
1997 .with_data_model_structure(Ok(structure));
1998
1999 let mut renderer = RecordingRenderer::new();
2000 let args = make_structure_args(Some("0801"), Some("beol"), true); run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
2002 .expect("expected Ok");
2003
2004 let recorded = renderer
2005 .data_model_structure_val
2006 .expect("data_model_structure must have been called");
2007 assert_eq!(
2008 recorded.relations.len(),
2009 2,
2010 "--include-builtins must show all relations including is_builtin=true ones"
2011 );
2012 }
2013
2014 #[test]
2015 fn test_structure_zero_relations() {
2016 let structure = make_structure("beol", vec![]);
2017 let client = MockDspClient::new()
2018 .with_resolve_project(Ok(make_project_ref()))
2019 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2020 .with_data_model_structure(Ok(structure));
2021
2022 let mut renderer = RecordingRenderer::new();
2023 let args = make_structure_args(Some("0801"), Some("beol"), false);
2024 let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2025
2026 assert!(
2027 result.is_ok(),
2028 "zero relations must render OK, got {:?}",
2029 result
2030 );
2031 let recorded = renderer
2032 .data_model_structure_val
2033 .expect("data_model_structure must have been called");
2034 assert!(
2035 recorded.relations.is_empty(),
2036 "zero relations must be empty after filtering"
2037 );
2038 }
2039
2040 #[test]
2041 fn test_structure_missing_project_is_usage_error() {
2042 let client = MockDspClient::new();
2043 let mut renderer = RecordingRenderer::new();
2044 let args = make_structure_args(None, Some("beol"), false);
2045 let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2046
2047 assert!(
2048 matches!(result, Err(Diagnostic::Usage(_))),
2049 "missing --project must yield Diagnostic::Usage, got {:?}",
2050 result
2051 );
2052 assert_eq!(
2053 client.resolve_calls(),
2054 0,
2055 "resolve_project must not be called when --project is missing"
2056 );
2057 assert_eq!(
2058 client.data_model_structure_calls(),
2059 0,
2060 "data_model_structure must not be called when --project is missing"
2061 );
2062 }
2063
2064 #[test]
2065 fn test_structure_missing_data_model_is_usage_error() {
2066 let client = MockDspClient::new();
2067 let mut renderer = RecordingRenderer::new();
2068 let args = make_structure_args(Some("0801"), None, false);
2069 let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2070
2071 assert!(
2072 matches!(result, Err(Diagnostic::Usage(_))),
2073 "missing --data-model must yield Diagnostic::Usage, got {:?}",
2074 result
2075 );
2076 assert_eq!(
2077 client.resolve_calls(),
2078 0,
2079 "resolve_project must not be called when --data-model is missing"
2080 );
2081 assert_eq!(
2082 client.data_model_structure_calls(),
2083 0,
2084 "data_model_structure must not be called when --data-model is missing"
2085 );
2086 }
2087
2088 #[test]
2089 fn test_structure_project_not_found_propagates() {
2090 let client = MockDspClient::new()
2091 .with_resolve_project(Err(Diagnostic::NotFound("project '9999' not found".into())));
2092
2093 let mut renderer = RecordingRenderer::new();
2094 let args = make_structure_args(Some("9999"), Some("beol"), false);
2095 let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2096
2097 assert!(
2098 matches!(result, Err(Diagnostic::NotFound(_))),
2099 "resolve_project NotFound must propagate, got {:?}",
2100 result
2101 );
2102 assert_eq!(
2103 client.list_data_models_calls(),
2104 0,
2105 "list_data_models must not be called when resolve_project fails"
2106 );
2107 assert_eq!(
2108 client.data_model_structure_calls(),
2109 0,
2110 "data_model_structure must not be called when resolve_project fails"
2111 );
2112 }
2113
2114 #[test]
2115 fn test_structure_data_model_not_found_reframed_to_not_found_diagnostic() {
2116 let client = MockDspClient::new()
2119 .with_resolve_project(Ok(make_project_ref()))
2120 .with_list_data_models(Ok(vec![make_data_model("beol", None)]));
2121
2122 let mut renderer = RecordingRenderer::new();
2123 let args = make_structure_args(Some("0801"), Some("nonexistent-dm"), false);
2124 let result = run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2125
2126 assert!(
2127 matches!(result, Err(Diagnostic::NotFound(_))),
2128 "unknown data-model must yield NotFound Diagnostic, got {:?}",
2129 result
2130 );
2131 assert_eq!(
2132 client.data_model_structure_calls(),
2133 0,
2134 "data_model_structure must not be called when the data-model is not found"
2135 );
2136 }
2137
2138 #[test]
2139 fn test_structure_data_model_iri_forwarded() {
2140 let dm_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
2142 let structure = make_structure("beol", vec![]);
2143 let client = MockDspClient::new()
2144 .with_resolve_project(Ok(make_project_ref()))
2145 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2146 .with_data_model_structure(Ok(structure));
2147
2148 let mut renderer = RecordingRenderer::new();
2149 let args = make_structure_args(Some("0801"), Some("beol"), false);
2150 run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
2151 .expect("expected Ok");
2152
2153 assert_eq!(
2154 client.data_model_structure_iri().as_deref(),
2155 Some(dm_iri),
2156 "data_model_structure must be called with the resolved IRI"
2157 );
2158 }
2159
2160 #[test]
2161 fn test_structure_token_forwarded_to_client() {
2162 let structure = make_structure("beol", vec![]);
2164 let client = MockDspClient::new()
2165 .with_resolve_project(Ok(make_project_ref()))
2166 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2167 .with_data_model_structure(Ok(structure));
2168
2169 let mut renderer = RecordingRenderer::new();
2170 let args = make_structure_args(Some("0801"), Some("beol"), false);
2171 run_structure_impl(
2172 &args,
2173 &make_cfg(),
2174 &client,
2175 &mut renderer,
2176 Some("env-jwt-token".to_string()),
2177 None,
2178 )
2179 .expect("expected Ok");
2180
2181 assert_eq!(
2182 client.data_model_structure_token(),
2183 Some(Some("env-jwt-token".to_string())),
2184 "env token must be forwarded to data_model_structure"
2185 );
2186 let meta = renderer
2187 .data_model_structure_meta
2188 .expect("meta must be present");
2189 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
2190 }
2191
2192 #[test]
2193 fn test_structure_auth_anonymous_when_no_token() {
2194 let structure = make_structure("beol", vec![]);
2195 let client = MockDspClient::new()
2196 .with_resolve_project(Ok(make_project_ref()))
2197 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2198 .with_data_model_structure(Ok(structure));
2199
2200 let mut renderer = RecordingRenderer::new();
2201 let args = make_structure_args(Some("0801"), Some("beol"), false);
2202 run_structure_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
2203 .expect("expected Ok");
2204
2205 let meta = renderer
2206 .data_model_structure_meta
2207 .expect("meta must be present");
2208 assert_eq!(meta.auth_state, "anonymous");
2209 assert_eq!(client.data_model_structure_token(), Some(None));
2210 }
2211
2212 #[test]
2213 fn test_structure_auth_via_cache_with_user() {
2214 let dir = TempDir::new().expect("tempdir");
2219 let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
2220 let path = write_cache(&dir, &cache);
2221
2222 let structure = make_structure("beol", vec![]);
2223 let client = MockDspClient::new()
2224 .with_resolve_project(Ok(make_project_ref()))
2225 .with_list_data_models(Ok(vec![make_data_model("beol", None)]))
2226 .with_data_model_structure(Ok(structure));
2227
2228 let mut renderer = RecordingRenderer::new();
2229 let args = make_structure_args(Some("0801"), Some("beol"), false);
2230 run_structure_impl(
2231 &args,
2232 &make_cfg(),
2233 &client,
2234 &mut renderer,
2235 None,
2236 Some(&path),
2237 )
2238 .expect("expected Ok");
2239
2240 let meta = renderer
2241 .data_model_structure_meta
2242 .expect("meta must be present");
2243 assert_eq!(
2244 meta.auth_state, "authenticated as user@example.com",
2245 "cached-token path must produce 'authenticated as <user>' auth state"
2246 );
2247 assert_eq!(
2248 client.data_model_structure_token(),
2249 Some(Some("cache-token-xyz".to_string())),
2250 "cached token must be forwarded to data_model_structure"
2251 );
2252 }
2253}