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