1use std::path::Path;
13
14use crate::cli::{ResourceDescribeArgs, ResourceListArgs};
15use crate::client::DspClient;
16use crate::config::{AuthCache, Config, resolve_token};
17use crate::diagnostic::Diagnostic;
18use crate::model::ResourceSummary;
19use crate::render::{MetaContext, Renderer, ResourceListPagination, ResourceListView};
20
21use crate::actions::auth_state::read_auth_state;
22use crate::util::text::strip_control_chars;
23
24struct ResourceTypeRef {
27 iri: String,
28 name: String,
29 data_model_iri: Option<String>,
30}
31
32pub fn list(
39 args: &ResourceListArgs,
40 cfg: &Config,
41 client: &dyn DspClient,
42 renderer: &mut dyn Renderer,
43) -> Result<(), Diagnostic> {
44 let env_token = std::env::var("DSP_TOKEN").ok();
45 run_list_impl(args, cfg, client, renderer, env_token, None)
46}
47
48pub(crate) fn run_list_impl(
59 args: &ResourceListArgs,
60 cfg: &Config,
61 client: &dyn DspClient,
62 renderer: &mut dyn Renderer,
63 env_token: Option<String>,
64 cache_path: Option<&Path>,
65) -> Result<(), Diagnostic> {
66 let project = args.project.as_deref().ok_or_else(|| {
68 Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
69 })?;
70
71 let resource_type_arg = args.resource_type.as_deref().ok_or_else(|| {
73 Diagnostic::Usage("--resource-type <name-or-IRI> is required".to_string())
74 })?;
75
76 let cache_result = match cache_path {
78 Some(p) => AuthCache::load_from(p),
79 None => AuthCache::load(),
80 };
81 let cache = match cache_result {
82 Ok(c) => c,
83 Err(e) => {
84 tracing::warn!(
85 error = %e,
86 "auth cache load failed; falling back to anonymous for resource list"
87 );
88 AuthCache::default()
89 }
90 };
91
92 let resolved = resolve_token(env_token, &cache, &cfg.server);
94 let token = resolved.as_ref().map(|r| r.token.as_str());
95
96 let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
98
99 let filter_warning = if resolved.is_none() {
104 Some("results may be filtered; login to see private resources".to_string())
105 } else {
106 Some("results limited to your permissions".to_string())
107 };
108
109 let pref = client.resolve_project(&cfg.server, project)?;
111
112 let rt_ref = resolve_resource_type_iri(
119 resource_type_arg,
120 args.data_model.as_deref(),
121 &cfg.server,
122 &pref.iri,
123 client,
124 token,
125 )?;
126
127 let order_by: Option<String> = if let Some(ob) = args.order_by.as_deref() {
129 Some(resolve_order_by_property(
130 ob,
131 &rt_ref,
132 &cfg.server,
133 client,
134 token,
135 )?)
136 } else {
137 None
138 };
139
140 let (all_resources, pagination) = if args.all {
142 let mut all: Vec<ResourceSummary> = Vec::new();
144 let mut page = 0u32;
145 loop {
146 let page_result = client.list_resources(
147 &cfg.server,
148 &pref.iri,
149 &rt_ref.iri,
150 order_by.as_deref(),
151 page,
152 token,
153 )?;
154 all.extend(page_result.resources);
155 if !page_result.may_have_more_results {
156 break;
157 }
158 page += 1;
159 }
160 let pages_fetched = page + 1;
161 (all, ResourceListPagination::AllPages { pages_fetched })
162 } else {
163 let page = args.page.unwrap_or(0);
165 let page_result = client.list_resources(
166 &cfg.server,
167 &pref.iri,
168 &rt_ref.iri,
169 order_by.as_deref(),
170 page,
171 token,
172 )?;
173 let may_have_more = page_result.may_have_more_results;
174 (
175 page_result.resources,
176 ResourceListPagination::SinglePage {
177 page,
178 may_have_more,
179 },
180 )
181 };
182
183 let total = all_resources.len();
185
186 let mut items = all_resources;
188 if let Some(ref f) = args.filter {
189 let lower = f.to_lowercase();
190 items.retain(|r| r.label.to_lowercase().contains(&lower));
191 }
192
193 let view = ResourceListView {
195 items,
196 total,
197 filter: args.filter.clone(),
198 resource_type: rt_ref.name,
199 pagination,
200 };
201 let meta = MetaContext {
202 server_label: cfg.server.clone(),
203 auth_state,
204 filter_warning,
205 };
206 renderer.resources(&view, &meta)
207}
208
209fn resolve_resource_type_iri(
217 resource_type_arg: &str,
218 data_model_arg: Option<&str>,
219 server: &str,
220 project_iri: &str,
221 client: &dyn DspClient,
222 token: Option<&str>,
223) -> Result<ResourceTypeRef, Diagnostic> {
224 if resource_type_arg.contains("://") {
226 let name = local_name(resource_type_arg);
228 let data_model_iri = resource_type_arg
230 .find('#')
231 .map(|idx| resource_type_arg[..idx].to_string());
232 return Ok(ResourceTypeRef {
233 iri: resource_type_arg.to_string(),
234 name,
235 data_model_iri,
236 });
237 }
238
239 if let Some(dm_arg) = data_model_arg {
241 let dm_iri = if dm_arg.contains("://") {
243 dm_arg.to_string()
245 } else {
246 let data_models = client.list_data_models(server, project_iri, token)?;
248 data_models
249 .iter()
250 .find(|dm| dm.iri == dm_arg || dm.name.eq_ignore_ascii_case(dm_arg))
251 .map(|dm| dm.iri.clone())
252 .ok_or_else(|| {
253 let dm_disp: String = dm_arg.chars().take(80).collect();
254 let dm_suffix = if dm_arg.chars().count() > 80 {
255 "…"
256 } else {
257 ""
258 };
259 Diagnostic::NotFound(format!(
260 "data-model '{dm_disp}{dm_suffix}' not found in project on {server}"
261 ))
262 })?
263 };
264
265 let detail = client.describe_data_model(server, &dm_iri, token)?;
267 let matched = detail
268 .resource_types
269 .iter()
270 .find(|rt| rt.name.eq_ignore_ascii_case(resource_type_arg) || rt.iri == resource_type_arg)
271 .ok_or_else(|| {
272 let rt_disp: String = resource_type_arg.chars().take(80).collect();
273 let rt_suffix = if resource_type_arg.chars().count() > 80 { "…" } else { "" };
274 Diagnostic::NotFound(format!(
275 "resource-type '{rt_disp}{rt_suffix}' not found in data-model '{name}' on {server}; \
276 run `dsp vre resource-type list --project <project> --data-model {name} --server {server}` \
277 to see available resource-types.",
278 name = detail.name,
279 ))
280 })?;
281 return Ok(ResourceTypeRef {
282 iri: matched.iri.clone(),
283 name: matched.name.clone(),
284 data_model_iri: Some(dm_iri),
285 });
286 }
287
288 let data_models = client.list_data_models(server, project_iri, token)?;
290 if data_models.len() > 10 {
291 tracing::warn!(
292 count = data_models.len(),
293 "bare-name resource-type resolution will issue {} describe_data_model calls; \
294 use --data-model or a full IRI to avoid scanning",
295 data_models.len()
296 );
297 }
298
299 let mut matches: Vec<(String, String, String, String)> = Vec::new(); for dm in &data_models {
305 let detail = client.describe_data_model(server, &dm.iri, token)?;
306 for rt in &detail.resource_types {
307 if rt.name.eq_ignore_ascii_case(resource_type_arg) || rt.iri == resource_type_arg {
308 matches.push((
309 rt.iri.clone(),
310 rt.name.clone(),
311 dm.iri.clone(),
312 dm.name.clone(),
313 ));
314 }
315 }
316 }
317
318 match matches.len() {
319 0 => {
320 let rt_disp: String = resource_type_arg.chars().take(80).collect();
321 let rt_suffix = if resource_type_arg.chars().count() > 80 {
322 "…"
323 } else {
324 ""
325 };
326 Err(Diagnostic::NotFound(format!(
327 "resource-type '{rt_disp}{rt_suffix}' not found in any data-model of the project on {server}; \
328 run `dsp vre resource-type list --project <project> --server {server}` \
329 to see available resource-types."
330 )))
331 }
332 1 => {
333 let (resource_type_iri, rt_name, dm_iri, _dm_name) = matches.remove(0);
334 Ok(ResourceTypeRef {
335 iri: resource_type_iri,
336 name: rt_name,
337 data_model_iri: Some(dm_iri),
338 })
339 }
340 _ => {
341 let mut seen_dm_names: Vec<String> = Vec::new();
344 for (_, _, _, dm_name) in &matches {
345 if !seen_dm_names.contains(dm_name) {
346 seen_dm_names.push(dm_name.clone());
347 }
348 }
349 let dm_list = seen_dm_names.join(", ");
350 let rt_disp: String = resource_type_arg.chars().take(80).collect();
351 let rt_suffix = if resource_type_arg.chars().count() > 80 {
352 "…"
353 } else {
354 ""
355 };
356 Err(Diagnostic::Usage(format!(
357 "resource-type '{rt_disp}{rt_suffix}' is ambiguous: found in data-models [{dm_list}]; \
358 use --data-model to scope the search to one data-model."
359 )))
360 }
361 }
362}
363
364fn resolve_order_by_property(
378 order_by_arg: &str,
379 rt_ref: &ResourceTypeRef,
380 server: &str,
381 client: &dyn DspClient,
382 token: Option<&str>,
383) -> Result<String, Diagnostic> {
384 if order_by_arg.contains("://") {
386 return Ok(order_by_arg.to_string());
387 }
388
389 let dm_iri = rt_ref.data_model_iri.as_deref().ok_or_else(|| {
391 Diagnostic::Usage(
392 "cannot resolve --order-by as a field name when --resource-type is a full IRI \
393 that contains no '#' fragment (no data-model can be derived); \
394 pass --order-by as a full field IRI instead"
395 .to_string(),
396 )
397 })?;
398
399 let detail = client.describe_resource_type(server, dm_iri, &rt_ref.iri, token)?;
402
403 if let Some(field) = detail
405 .fields
406 .iter()
407 .find(|f| f.name.eq_ignore_ascii_case(order_by_arg))
408 {
409 return Ok(field.iri.clone());
410 }
411
412 let sanitised = strip_control_chars(order_by_arg);
415 let field_disp: String = sanitised.chars().take(80).collect();
416 let field_suffix = if sanitised.chars().count() > 80 {
417 "…"
418 } else {
419 ""
420 };
421 Err(Diagnostic::Usage(format!(
422 "field '{field_disp}{field_suffix}' not found in resource-type '{}'; \
423 run `dsp vre resource-type describe --server <s> --project <project> \
424 --resource-type {}` to see available field names.",
425 rt_ref.name, rt_ref.name,
426 )))
427}
428
429fn local_name(iri: &str) -> String {
439 iri.rsplit(['#', '/', ':'])
440 .next()
441 .unwrap_or(iri)
442 .to_string()
443}
444
445pub fn describe(
456 args: &ResourceDescribeArgs,
457 cfg: &Config,
458 client: &dyn DspClient,
459 renderer: &mut dyn Renderer,
460) -> Result<(), Diagnostic> {
461 let env_token = std::env::var("DSP_TOKEN").ok();
462 run_describe_impl(args, cfg, client, renderer, env_token, None)
463}
464
465pub(crate) fn run_describe_impl(
476 args: &ResourceDescribeArgs,
477 cfg: &Config,
478 client: &dyn DspClient,
479 renderer: &mut dyn Renderer,
480 env_token: Option<String>,
481 cache_path: Option<&Path>,
482) -> Result<(), Diagnostic> {
483 let resource_iri = args
485 .resource
486 .as_deref()
487 .ok_or_else(|| Diagnostic::Usage("--resource <iri> is required".to_string()))?;
488
489 let cache_result = match cache_path {
491 Some(p) => AuthCache::load_from(p),
492 None => AuthCache::load(),
493 };
494 let cache = match cache_result {
495 Ok(c) => c,
496 Err(e) => {
497 tracing::warn!(
498 error = %e,
499 "auth cache load failed; falling back to anonymous for resource describe"
500 );
501 AuthCache::default()
502 }
503 };
504
505 let resolved = resolve_token(env_token, &cache, &cfg.server);
507 let token = resolved.as_ref().map(|r| r.token.as_str());
508
509 let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
511
512 let filter_warning = if resolved.is_none() {
517 Some("results may be filtered; login to see private resources".to_string())
518 } else {
519 Some("results limited to your permissions".to_string())
520 };
521
522 let resolved_project = if let Some(p) = args.project.as_deref() {
527 Some(client.resolve_project(&cfg.server, p)?)
528 } else {
529 None
530 };
531
532 let detail = client.describe_resource(&cfg.server, resource_iri, token, args.values)?;
534
535 if let Some(ref proj) = resolved_project
541 && let Some(ref actual) = detail.attached_project
542 && actual != &proj.iri
543 {
544 let actual_name = local_name(actual);
545 let expected_name = local_name(&proj.iri);
546 let display_iri: String = resource_iri.chars().take(80).collect();
549 let iri_suffix = if resource_iri.chars().count() > 80 {
550 "…"
551 } else {
552 ""
553 };
554 return Err(Diagnostic::Usage(format!(
555 "resource '{display_iri}{iri_suffix}' belongs to project {actual_name}, not {expected_name}"
556 )));
557 }
558
559 let meta = MetaContext {
561 server_label: cfg.server.clone(),
562 auth_state,
563 filter_warning,
564 };
565 renderer.resource_describe(&detail, &meta)
566}
567
568#[cfg(test)]
573mod tests {
574 use std::cell::RefCell;
575
576 use tempfile::TempDir;
577
578 use super::{run_describe_impl, run_list_impl};
579 use crate::cli::{FormatArgs, ResourceDescribeArgs, ResourceListArgs};
580 use crate::client::DspClient;
581 use crate::config::auth_cache::ServerEntry;
582 use crate::config::{AuthCache, Config};
583 use crate::diagnostic::Diagnostic;
584 use crate::model::{
585 Cardinality, DataModel, DataModelDetail, Field, ProjectRef, ResourceAccess, ResourceDetail,
586 ResourcePage, ResourceSummary, ResourceTypeDetail, ResourceTypeSummary, ResourceVisibility,
587 };
588 use crate::render::Format;
589 use crate::render::auth::{
590 AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
591 };
592 use crate::render::{
593 DataModelListView, DumpDeleteOutcome, DumpOutcome, MetaContext, ProjectListView, Renderer,
594 ResourceListView, ResourceTypeListView,
595 };
596
597 type ListResourcesArg = (String, String, Option<String>, u32, Option<String>);
605
606 struct MockDspClient {
607 resolve_result: Option<Result<ProjectRef, Diagnostic>>,
608 resolve_calls: RefCell<u32>,
609 list_data_models_result: Option<Result<Vec<DataModel>, Diagnostic>>,
610 list_data_models_calls: RefCell<u32>,
611 describe_data_model_queue: RefCell<Vec<Result<DataModelDetail, Diagnostic>>>,
613 describe_data_model_calls: RefCell<u32>,
614 describe_resource_type_result: Option<ResourceTypeDetail>,
616 describe_resource_type_calls: RefCell<u32>,
617 list_resources_queue: RefCell<Vec<Result<ResourcePage, Diagnostic>>>,
619 list_resources_calls: RefCell<u32>,
620 list_resources_args: RefCell<Vec<ListResourcesArg>>,
621 describe_resource_queue: RefCell<Vec<Result<ResourceDetail, Diagnostic>>>,
623 describe_resource_calls: RefCell<u32>,
624 describe_resource_with_values_args: RefCell<Vec<bool>>,
625 }
626
627 impl MockDspClient {
628 fn new() -> Self {
629 Self {
630 resolve_result: None,
631 resolve_calls: RefCell::new(0),
632 list_data_models_result: None,
633 list_data_models_calls: RefCell::new(0),
634 describe_data_model_queue: RefCell::new(Vec::new()),
635 describe_data_model_calls: RefCell::new(0),
636 describe_resource_type_result: None,
637 describe_resource_type_calls: RefCell::new(0),
638 list_resources_queue: RefCell::new(Vec::new()),
639 list_resources_calls: RefCell::new(0),
640 list_resources_args: RefCell::new(Vec::new()),
641 describe_resource_queue: RefCell::new(Vec::new()),
642 describe_resource_calls: RefCell::new(0),
643 describe_resource_with_values_args: RefCell::new(Vec::new()),
644 }
645 }
646
647 fn with_resolve_project(mut self, result: Result<ProjectRef, Diagnostic>) -> Self {
648 self.resolve_result = Some(result);
649 self
650 }
651
652 fn with_list_data_models(mut self, result: Result<Vec<DataModel>, Diagnostic>) -> Self {
653 self.list_data_models_result = Some(result);
654 self
655 }
656
657 fn with_describe_data_model(self, result: Result<DataModelDetail, Diagnostic>) -> Self {
658 self.describe_data_model_queue.borrow_mut().push(result);
659 self
660 }
661
662 fn with_describe_resource_type(mut self, detail: ResourceTypeDetail) -> Self {
663 self.describe_resource_type_result = Some(detail);
664 self
665 }
666
667 fn with_list_resources(self, result: Result<ResourcePage, Diagnostic>) -> Self {
668 self.list_resources_queue.borrow_mut().push(result);
669 self
670 }
671
672 fn with_describe_resource(self, result: Result<ResourceDetail, Diagnostic>) -> Self {
673 self.describe_resource_queue.borrow_mut().push(result);
674 self
675 }
676
677 fn resolve_calls(&self) -> u32 {
678 *self.resolve_calls.borrow()
679 }
680
681 fn list_data_models_calls(&self) -> u32 {
682 *self.list_data_models_calls.borrow()
683 }
684
685 fn describe_resource_type_calls(&self) -> u32 {
686 *self.describe_resource_type_calls.borrow()
687 }
688
689 fn list_resources_calls(&self) -> u32 {
690 *self.list_resources_calls.borrow()
691 }
692
693 fn list_resources_args(&self) -> Vec<ListResourcesArg> {
694 self.list_resources_args.borrow().clone()
695 }
696
697 fn describe_resource_calls(&self) -> u32 {
698 *self.describe_resource_calls.borrow()
699 }
700
701 fn describe_resource_with_values_args(&self) -> Vec<bool> {
702 self.describe_resource_with_values_args.borrow().clone()
703 }
704 }
705
706 impl DspClient for MockDspClient {
707 fn login(
708 &self,
709 _server: &str,
710 _user: &str,
711 _password: &str,
712 ) -> Result<crate::model::LoginResponse, Diagnostic> {
713 unimplemented!("login not used in resource action tests")
714 }
715
716 fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
717 *self.resolve_calls.borrow_mut() += 1;
718 self.resolve_result
719 .clone()
720 .expect("resolve_result must be set when resolve_project is called")
721 }
722
723 fn create_project_dump(
724 &self,
725 _server: &str,
726 _project_iri: &str,
727 _skip_assets: bool,
728 _token: &str,
729 ) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
730 unimplemented!("create_project_dump not used in resource action tests")
731 }
732
733 fn get_project_dump_status(
734 &self,
735 _server: &str,
736 _project_iri: &str,
737 _dump_id: &str,
738 _token: &str,
739 ) -> Result<crate::model::DumpTask, Diagnostic> {
740 unimplemented!("get_project_dump_status not used in resource action tests")
741 }
742
743 fn download_project_dump(
744 &self,
745 _server: &str,
746 _project_iri: &str,
747 _dump_id: &str,
748 _token: &str,
749 _dest: &mut dyn std::io::Write,
750 ) -> Result<u64, Diagnostic> {
751 unimplemented!("download_project_dump not used in resource action tests")
752 }
753
754 fn delete_project_dump(
755 &self,
756 _server: &str,
757 _project_iri: &str,
758 _dump_id: &str,
759 _token: &str,
760 ) -> Result<(), Diagnostic> {
761 unimplemented!("delete_project_dump not used in resource action tests")
762 }
763
764 fn list_projects(
765 &self,
766 _server: &str,
767 _token: Option<&str>,
768 ) -> Result<Vec<crate::model::Project>, Diagnostic> {
769 unimplemented!("list_projects not used in resource action tests")
770 }
771
772 fn describe_project(
773 &self,
774 _server: &str,
775 _project: &str,
776 _token: Option<&str>,
777 ) -> Result<crate::model::ProjectDetail, Diagnostic> {
778 unimplemented!("describe_project not used in resource action tests")
779 }
780
781 fn list_data_models(
782 &self,
783 _server: &str,
784 _project_iri: &str,
785 _token: Option<&str>,
786 ) -> Result<Vec<DataModel>, Diagnostic> {
787 *self.list_data_models_calls.borrow_mut() += 1;
788 self.list_data_models_result
789 .clone()
790 .expect("list_data_models_result must be set when list_data_models is called")
791 }
792
793 fn describe_data_model(
794 &self,
795 _server: &str,
796 _data_model_iri: &str,
797 _token: Option<&str>,
798 ) -> Result<crate::model::DataModelDetail, Diagnostic> {
799 *self.describe_data_model_calls.borrow_mut() += 1;
800 let mut queue = self.describe_data_model_queue.borrow_mut();
801 if queue.is_empty() {
802 panic!("describe_data_model called more times than expected (queue is empty)");
803 }
804 queue.remove(0)
805 }
806
807 fn describe_resource_type(
808 &self,
809 _server: &str,
810 _data_model_iri: &str,
811 _resource_type: &str,
812 _token: Option<&str>,
813 ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
814 *self.describe_resource_type_calls.borrow_mut() += 1;
815 match &self.describe_resource_type_result {
816 Some(detail) => Ok(detail.clone()),
817 None => Err(Diagnostic::NotFound(
818 "describe_resource_type: no result configured".to_string(),
819 )),
820 }
821 }
822
823 fn data_model_structure(
824 &self,
825 _server: &str,
826 _data_model_iri: &str,
827 _token: Option<&str>,
828 ) -> Result<crate::model::DataModelStructure, Diagnostic> {
829 unimplemented!("data_model_structure not used in resource action tests")
830 }
831
832 fn list_resources(
833 &self,
834 _server: &str,
835 project_iri: &str,
836 resource_type_iri: &str,
837 order_by: Option<&str>,
838 page: u32,
839 token: Option<&str>,
840 ) -> Result<ResourcePage, Diagnostic> {
841 *self.list_resources_calls.borrow_mut() += 1;
842 self.list_resources_args.borrow_mut().push((
843 project_iri.to_string(),
844 resource_type_iri.to_string(),
845 order_by.map(str::to_owned),
846 page,
847 token.map(str::to_owned),
848 ));
849 let mut queue = self.list_resources_queue.borrow_mut();
850 if queue.is_empty() {
851 panic!("list_resources called more times than expected (queue is empty)");
852 }
853 queue.remove(0)
854 }
855
856 fn describe_resource(
857 &self,
858 _server: &str,
859 _resource_iri: &str,
860 _token: Option<&str>,
861 with_values: bool,
862 ) -> Result<crate::model::ResourceDetail, Diagnostic> {
863 *self.describe_resource_calls.borrow_mut() += 1;
864 self.describe_resource_with_values_args
865 .borrow_mut()
866 .push(with_values);
867 let mut queue = self.describe_resource_queue.borrow_mut();
868 if queue.is_empty() {
869 panic!("describe_resource called more times than expected (queue is empty)");
870 }
871 queue.remove(0)
872 }
873
874 fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
875 unimplemented!("verify_token not used in resource action tests")
876 }
877 }
878
879 struct RecordingRenderer {
882 resources_view: Option<ResourceListView>,
883 resources_meta: Option<MetaContext>,
884 resource_describe_detail: Option<ResourceDetail>,
885 resource_describe_meta: Option<MetaContext>,
886 }
887
888 impl RecordingRenderer {
889 fn new() -> Self {
890 Self {
891 resources_view: None,
892 resources_meta: None,
893 resource_describe_detail: None,
894 resource_describe_meta: None,
895 }
896 }
897 }
898
899 impl Renderer for RecordingRenderer {
900 fn diagnostic(
901 &mut self,
902 _diag: &Diagnostic,
903 _meta: &MetaContext,
904 ) -> Result<(), Diagnostic> {
905 Ok(())
906 }
907
908 fn auth_login(
909 &mut self,
910 _outcome: &AuthLoginOutcome,
911 _meta: &MetaContext,
912 ) -> Result<(), Diagnostic> {
913 Ok(())
914 }
915
916 fn auth_status(
917 &mut self,
918 _outcome: &AuthStatusOutcome,
919 _meta: &MetaContext,
920 ) -> Result<(), Diagnostic> {
921 Ok(())
922 }
923
924 fn auth_logout(
925 &mut self,
926 _outcome: &AuthLogoutOutcome,
927 _meta: &MetaContext,
928 ) -> Result<(), Diagnostic> {
929 Ok(())
930 }
931
932 fn auth_set_token(
933 &mut self,
934 _outcome: &AuthSetTokenOutcome,
935 _meta: &MetaContext,
936 ) -> Result<(), Diagnostic> {
937 Ok(())
938 }
939
940 fn project_dump(
941 &mut self,
942 _outcome: &DumpOutcome,
943 _meta: &MetaContext,
944 ) -> Result<(), Diagnostic> {
945 Ok(())
946 }
947
948 fn project_dump_deleted(
949 &mut self,
950 _outcome: &DumpDeleteOutcome,
951 _meta: &MetaContext,
952 ) -> Result<(), Diagnostic> {
953 Ok(())
954 }
955
956 fn projects(
957 &mut self,
958 _view: &ProjectListView,
959 _meta: &MetaContext,
960 ) -> Result<(), Diagnostic> {
961 Ok(())
962 }
963
964 fn project_describe(
965 &mut self,
966 _project: &crate::model::ProjectDetail,
967 _meta: &MetaContext,
968 ) -> Result<(), Diagnostic> {
969 Ok(())
970 }
971
972 fn data_models(
973 &mut self,
974 _view: &DataModelListView,
975 _meta: &MetaContext,
976 ) -> Result<(), Diagnostic> {
977 Ok(())
978 }
979
980 fn data_model_describe(
981 &mut self,
982 _detail: &crate::model::DataModelDetail,
983 _meta: &MetaContext,
984 ) -> Result<(), Diagnostic> {
985 Ok(())
986 }
987
988 fn resource_types(
989 &mut self,
990 _view: &ResourceTypeListView,
991 _meta: &MetaContext,
992 ) -> Result<(), Diagnostic> {
993 Ok(())
994 }
995
996 fn resource_type_describe(
997 &mut self,
998 _detail: &crate::model::ResourceTypeDetail,
999 _meta: &MetaContext,
1000 ) -> Result<(), Diagnostic> {
1001 Ok(())
1002 }
1003
1004 fn data_model_structure(
1005 &mut self,
1006 _structure: &crate::model::DataModelStructure,
1007 _meta: &MetaContext,
1008 ) -> Result<(), Diagnostic> {
1009 Ok(())
1010 }
1011
1012 fn resources(
1013 &mut self,
1014 view: &ResourceListView,
1015 meta: &MetaContext,
1016 ) -> Result<(), Diagnostic> {
1017 self.resources_view = Some(view.clone());
1018 self.resources_meta = Some(meta.clone());
1019 Ok(())
1020 }
1021
1022 fn resource_describe(
1023 &mut self,
1024 detail: &crate::model::ResourceDetail,
1025 meta: &MetaContext,
1026 ) -> Result<(), Diagnostic> {
1027 self.resource_describe_detail = Some(detail.clone());
1028 self.resource_describe_meta = Some(meta.clone());
1029 Ok(())
1030 }
1031 }
1032
1033 const SERVER: &str = "https://api.test.dasch.swiss";
1036 const PROJECT_IRI: &str = "http://rdfh.ch/projects/0801";
1037 const BEOL_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1038 const LETTER_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#Letter";
1039
1040 fn make_cfg() -> Config {
1041 Config {
1042 server: SERVER.to_string(),
1043 }
1044 }
1045
1046 fn make_project_ref() -> ProjectRef {
1047 ProjectRef {
1048 iri: PROJECT_IRI.to_string(),
1049 shortcode: "0801".to_string(),
1050 shortname: "beol".to_string(),
1051 }
1052 }
1053
1054 fn make_args(
1055 project: Option<&str>,
1056 resource_type: Option<&str>,
1057 data_model: Option<&str>,
1058 page: Option<u32>,
1059 all: bool,
1060 filter: Option<&str>,
1061 ) -> ResourceListArgs {
1062 make_args_with_order_by(project, resource_type, data_model, page, all, filter, None)
1063 }
1064
1065 fn make_args_with_order_by(
1066 project: Option<&str>,
1067 resource_type: Option<&str>,
1068 data_model: Option<&str>,
1069 page: Option<u32>,
1070 all: bool,
1071 filter: Option<&str>,
1072 order_by: Option<&str>,
1073 ) -> ResourceListArgs {
1074 ResourceListArgs {
1075 server: Some(SERVER.to_string()),
1076 project: project.map(str::to_owned),
1077 resource_type: resource_type.map(str::to_owned),
1078 data_model: data_model.map(str::to_owned),
1079 page,
1080 all,
1081 filter: filter.map(str::to_owned),
1082 order_by: order_by.map(str::to_owned),
1083 format: FormatArgs {
1084 format: Format::Prose,
1085 json: false,
1086 lines: false,
1087 columns: None,
1088 no_header: false,
1089 header_only: false,
1090 },
1091 }
1092 }
1093
1094 fn make_data_model(name: &str) -> DataModel {
1095 DataModel {
1096 name: name.to_string(),
1097 iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
1098 label: None,
1099 last_modified: None,
1100 is_builtin: false,
1101 }
1102 }
1103
1104 fn make_detail_with_rts(dm_name: &str, rt_names: &[&str]) -> DataModelDetail {
1105 let resource_types = rt_names
1106 .iter()
1107 .map(|name| ResourceTypeSummary {
1108 name: name.to_string(),
1109 iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2#{name}"),
1110 label: None,
1111 })
1112 .collect();
1113 DataModelDetail {
1114 name: dm_name.to_string(),
1115 iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2"),
1116 label: None,
1117 last_modified: None,
1118 resource_types,
1119 }
1120 }
1121
1122 fn make_resource(label: &str) -> ResourceSummary {
1123 ResourceSummary {
1124 label: label.to_string(),
1125 iri: format!("http://rdfh.ch/0801/{label}"),
1126 ark_url: None,
1127 creation_date: None,
1128 last_modified: None,
1129 resource_type: "Letter".to_string(),
1130 }
1131 }
1132
1133 fn make_resource_page(labels: &[&str], may_have_more: bool) -> ResourcePage {
1134 ResourcePage {
1135 resources: labels.iter().map(|l| make_resource(l)).collect(),
1136 may_have_more_results: may_have_more,
1137 }
1138 }
1139
1140 fn cache_with_entry(server: &str, token: &str, user: &str) -> AuthCache {
1141 let mut cache = AuthCache::default();
1142 cache.set_entry(
1143 server,
1144 ServerEntry {
1145 token: token.to_string(),
1146 user: Some(user.to_string()),
1147 acquired_at: None,
1148 expires_at: None,
1149 },
1150 );
1151 cache
1152 }
1153
1154 fn write_cache(dir: &TempDir, cache: &AuthCache) -> std::path::PathBuf {
1155 let path = dir.path().join("auth.toml");
1156 cache.save_to(&path).expect("failed to write test cache");
1157 path
1158 }
1159
1160 const RESOURCE_IRI: &str = "http://rdfh.ch/0803/--6Esp4SVnGG1DBzFvYErw";
1161 const OTHER_PROJECT_IRI: &str = "http://rdfh.ch/projects/9999";
1162
1163 fn make_describe_args(resource: Option<&str>, project: Option<&str>) -> ResourceDescribeArgs {
1164 make_describe_args_with_values(resource, project, false)
1165 }
1166
1167 fn make_describe_args_with_values(
1168 resource: Option<&str>,
1169 project: Option<&str>,
1170 values: bool,
1171 ) -> ResourceDescribeArgs {
1172 ResourceDescribeArgs {
1173 server: Some(SERVER.to_string()),
1174 resource: resource.map(str::to_owned),
1175 project: project.map(str::to_owned),
1176 values,
1177 format: FormatArgs {
1178 format: Format::Prose,
1179 json: false,
1180 lines: false,
1181 columns: None,
1182 no_header: false,
1183 header_only: false,
1184 },
1185 }
1186 }
1187
1188 fn make_resource_detail() -> ResourceDetail {
1189 ResourceDetail {
1190 label: "Test Page".to_string(),
1191 iri: RESOURCE_IRI.to_string(),
1192 resource_type: "Page".to_string(),
1193 ark_url: Some("ark:/72163/1/0803/--6Esp4SVnGG1DBzFvYErw".to_string()),
1194 creation_date: Some("2021-01-01T00:00:00Z".to_string()),
1195 last_modified: Some("2021-06-15T12:00:00Z".to_string()),
1196 attached_project: Some(PROJECT_IRI.to_string()),
1197 owner: Some("http://rdfh.ch/users/root".to_string()),
1198 visibility: Some(ResourceVisibility::Public),
1199 your_access: Some(ResourceAccess::View),
1200 values: None,
1201 }
1202 }
1203
1204 #[test]
1210 fn test_describe_happy_path() {
1211 let detail = make_resource_detail();
1212 let client = MockDspClient::new().with_describe_resource(Ok(detail.clone()));
1213
1214 let mut renderer = RecordingRenderer::new();
1215 let args = make_describe_args(Some(RESOURCE_IRI), None);
1216 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1217
1218 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1219 assert_eq!(client.describe_resource_calls(), 1);
1220
1221 let recorded = renderer
1222 .resource_describe_detail
1223 .expect("resource_describe must have been called");
1224 assert_eq!(recorded.label, "Test Page");
1225 assert_eq!(recorded.iri, RESOURCE_IRI);
1226 assert_eq!(recorded.resource_type, "Page");
1227 assert_eq!(recorded.visibility, Some(ResourceVisibility::Public));
1228 assert_eq!(recorded.your_access, Some(ResourceAccess::View));
1229 }
1230
1231 #[test]
1233 fn test_describe_missing_resource_is_usage_error() {
1234 let client = MockDspClient::new();
1235 let mut renderer = RecordingRenderer::new();
1236 let args = make_describe_args(None, None);
1237 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1238
1239 assert!(
1240 matches!(result, Err(Diagnostic::Usage(_))),
1241 "missing --resource must yield Usage, got {:?}",
1242 result
1243 );
1244 assert_eq!(client.describe_resource_calls(), 0);
1245 }
1246
1247 #[test]
1249 fn test_describe_missing_resource_error_mentions_flag() {
1250 let client = MockDspClient::new();
1251 let mut renderer = RecordingRenderer::new();
1252 let args = make_describe_args(None, None);
1253 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1254
1255 if let Err(Diagnostic::Usage(msg)) = result {
1256 assert!(
1257 msg.contains("--resource"),
1258 "error message must mention --resource, got: {msg}"
1259 );
1260 } else {
1261 panic!("expected Usage error");
1262 }
1263 }
1264
1265 #[test]
1267 fn test_describe_cross_project_guard_match_no_error() {
1268 let detail = make_resource_detail(); let client = MockDspClient::new()
1270 .with_resolve_project(Ok(make_project_ref())) .with_describe_resource(Ok(detail));
1272
1273 let mut renderer = RecordingRenderer::new();
1274 let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1275 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1276
1277 assert!(
1278 result.is_ok(),
1279 "matching project must not error, got {:?}",
1280 result
1281 );
1282 assert!(
1283 renderer.resource_describe_detail.is_some(),
1284 "renderer must have been called"
1285 );
1286 }
1287
1288 #[test]
1294 fn test_describe_cross_project_guard_mismatch_usage_error() {
1295 let mut detail = make_resource_detail();
1296 detail.attached_project = Some(OTHER_PROJECT_IRI.to_string()); let client = MockDspClient::new()
1299 .with_resolve_project(Ok(make_project_ref())) .with_describe_resource(Ok(detail));
1301
1302 let mut renderer = RecordingRenderer::new();
1303 let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1304 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1305
1306 assert!(
1307 matches!(result, Err(Diagnostic::Usage(_))),
1308 "project mismatch must yield Usage, got {:?}",
1309 result
1310 );
1311 assert!(
1312 renderer.resource_describe_detail.is_none(),
1313 "renderer must NOT be called on mismatch"
1314 );
1315
1316 if let Err(Diagnostic::Usage(msg)) = result {
1319 assert!(
1320 msg.contains(RESOURCE_IRI),
1321 "mismatch error must mention the resource IRI; got: {msg}"
1322 );
1323 assert!(
1325 msg.contains("9999"),
1326 "mismatch error must mention the actual project's local name '9999'; got: {msg}"
1327 );
1328 assert!(
1330 msg.contains("0801"),
1331 "mismatch error must mention the expected project's local name '0801'; got: {msg}"
1332 );
1333 } else {
1334 panic!("expected Err(Diagnostic::Usage(_))");
1335 }
1336 }
1337
1338 #[test]
1342 fn test_describe_cross_project_guard_none_passes_through() {
1343 let mut detail = make_resource_detail();
1344 detail.attached_project = None; let client = MockDspClient::new()
1347 .with_resolve_project(Ok(make_project_ref()))
1348 .with_describe_resource(Ok(detail));
1349
1350 let mut renderer = RecordingRenderer::new();
1351 let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1352 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1353
1354 assert!(
1355 result.is_ok(),
1356 "None attached_project must not error, got {:?}",
1357 result
1358 );
1359 assert!(
1360 renderer.resource_describe_detail.is_some(),
1361 "renderer must have been called"
1362 );
1363 }
1364
1365 #[test]
1367 fn test_describe_no_project_guard_skipped() {
1368 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1369
1370 let mut renderer = RecordingRenderer::new();
1371 let args = make_describe_args(Some(RESOURCE_IRI), None);
1372 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1373
1374 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1375 assert_eq!(
1376 client.resolve_calls(),
1377 0,
1378 "resolve_project must not be called when --project is omitted"
1379 );
1380 }
1381
1382 #[test]
1384 fn test_describe_filter_warning_anonymous() {
1385 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1386
1387 let mut renderer = RecordingRenderer::new();
1388 let args = make_describe_args(Some(RESOURCE_IRI), None);
1389 run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1390 .expect("expected Ok");
1391
1392 let meta = renderer
1393 .resource_describe_meta
1394 .expect("meta must be present");
1395 assert_eq!(meta.auth_state, "anonymous");
1396 let fw = meta
1397 .filter_warning
1398 .expect("filter_warning must be Some for anonymous");
1399 assert_eq!(
1400 fw,
1401 "results may be filtered; login to see private resources"
1402 );
1403 }
1404
1405 #[test]
1407 fn test_describe_filter_warning_authenticated() {
1408 let dir = TempDir::new().expect("tempdir");
1409 let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1410 let path = write_cache(&dir, &cache);
1411
1412 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1413
1414 let mut renderer = RecordingRenderer::new();
1415 let args = make_describe_args(Some(RESOURCE_IRI), None);
1416 run_describe_impl(
1417 &args,
1418 &make_cfg(),
1419 &client,
1420 &mut renderer,
1421 None,
1422 Some(&path),
1423 )
1424 .expect("expected Ok");
1425
1426 let meta = renderer
1427 .resource_describe_meta
1428 .expect("meta must be present");
1429 let fw = meta
1430 .filter_warning
1431 .expect("filter_warning must be Some for authenticated");
1432 assert_eq!(fw, "results limited to your permissions");
1433 }
1434
1435 #[test]
1437 fn test_describe_not_found_propagates() {
1438 let client = MockDspClient::new().with_describe_resource(Err(Diagnostic::NotFound(
1439 "resource not found on server".to_string(),
1440 )));
1441
1442 let mut renderer = RecordingRenderer::new();
1443 let args = make_describe_args(Some(RESOURCE_IRI), None);
1444 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1445
1446 assert!(
1447 matches!(result, Err(Diagnostic::NotFound(_))),
1448 "not-found from client must propagate, got {:?}",
1449 result
1450 );
1451 assert!(
1452 renderer.resource_describe_detail.is_none(),
1453 "renderer must not be called on error"
1454 );
1455 }
1456
1457 #[test]
1459 fn test_describe_token_forwarded() {
1460 let dir = TempDir::new().expect("tempdir");
1461 let cache = cache_with_entry(SERVER, "my-describe-token", "user@example.com");
1462 let path = write_cache(&dir, &cache);
1463
1464 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1468
1469 let mut renderer = RecordingRenderer::new();
1470 let args = make_describe_args(Some(RESOURCE_IRI), None);
1471 run_describe_impl(
1472 &args,
1473 &make_cfg(),
1474 &client,
1475 &mut renderer,
1476 None,
1477 Some(&path),
1478 )
1479 .expect("expected Ok");
1480
1481 let meta = renderer
1483 .resource_describe_meta
1484 .expect("meta must be present");
1485 let fw = meta.filter_warning.expect("filter_warning must be present");
1487 assert_eq!(fw, "results limited to your permissions");
1488 }
1489
1490 #[test]
1492 fn test_describe_values_flag_absent_passes_false() {
1493 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1494 let mut renderer = RecordingRenderer::new();
1495 let args = make_describe_args(Some(RESOURCE_IRI), None); run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1497 .expect("expected Ok");
1498
1499 let with_values_args = client.describe_resource_with_values_args();
1500 assert_eq!(with_values_args.len(), 1);
1501 assert!(
1502 !with_values_args[0],
1503 "with_values must be false when --values is absent"
1504 );
1505 }
1506
1507 #[test]
1509 fn test_describe_values_flag_present_passes_true() {
1510 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1511 let mut renderer = RecordingRenderer::new();
1512 let args = make_describe_args_with_values(Some(RESOURCE_IRI), None, true);
1513 run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1514 .expect("expected Ok");
1515
1516 let with_values_args = client.describe_resource_with_values_args();
1517 assert_eq!(with_values_args.len(), 1);
1518 assert!(
1519 with_values_args[0],
1520 "with_values must be true when --values is given"
1521 );
1522 }
1523
1524 #[test]
1526 fn test_describe_corrupt_cache_falls_back_to_anonymous() {
1527 let dir = TempDir::new().expect("tempdir");
1528 let bad_path = dir.path().join("nonexistent_auth.toml");
1529
1530 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1531
1532 let mut renderer = RecordingRenderer::new();
1533 let args = make_describe_args(Some(RESOURCE_IRI), None);
1534 let result = run_describe_impl(
1535 &args,
1536 &make_cfg(),
1537 &client,
1538 &mut renderer,
1539 None,
1540 Some(&bad_path),
1541 );
1542 assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
1543
1544 let meta = renderer
1545 .resource_describe_meta
1546 .expect("meta must be present");
1547 assert_eq!(meta.auth_state, "anonymous");
1548 }
1549
1550 #[test]
1554 fn test_list_full_iri_bypass_skips_scan() {
1555 let client = MockDspClient::new()
1556 .with_resolve_project(Ok(make_project_ref()))
1557 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1558
1559 let mut renderer = RecordingRenderer::new();
1560 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1562 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1563
1564 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1565 assert_eq!(
1566 client.list_data_models_calls(),
1567 0,
1568 "full IRI must not trigger a data-model scan"
1569 );
1570 assert_eq!(client.list_resources_calls(), 1);
1571
1572 let view = renderer
1573 .resources_view
1574 .expect("resources must have been called");
1575 assert_eq!(view.items.len(), 1);
1576 assert_eq!(view.resource_type, "Letter"); }
1578
1579 #[test]
1581 fn test_list_full_iri_passes_correct_params() {
1582 let client = MockDspClient::new()
1583 .with_resolve_project(Ok(make_project_ref()))
1584 .with_list_resources(Ok(make_resource_page(&[], false)));
1585
1586 let mut renderer = RecordingRenderer::new();
1587 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1588 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1589
1590 let calls = client.list_resources_args();
1591 assert_eq!(calls.len(), 1);
1592 assert_eq!(
1593 calls[0].0, PROJECT_IRI,
1594 "project_iri must be the resolved IRI"
1595 );
1596 assert_eq!(
1597 calls[0].1, LETTER_IRI,
1598 "resource_type_iri must be the full IRI"
1599 );
1600 assert_eq!(calls[0].2, None, "order_by is None (placeholder)");
1601 assert_eq!(calls[0].3, 0, "default page is 0");
1602 assert_eq!(calls[0].4, None, "no token for anonymous");
1603 }
1604
1605 #[test]
1607 fn test_list_bare_name_scan_single_match() {
1608 let client = MockDspClient::new()
1609 .with_resolve_project(Ok(make_project_ref()))
1610 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1611 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1612 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1613
1614 let mut renderer = RecordingRenderer::new();
1615 let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1616 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1617
1618 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1619 let view = renderer.resources_view.expect("must have been called");
1620 assert_eq!(view.resource_type, "Letter");
1621 assert_eq!(view.items.len(), 1);
1622 }
1623
1624 #[test]
1626 fn test_list_bare_name_scan_not_found() {
1627 let client = MockDspClient::new()
1628 .with_resolve_project(Ok(make_project_ref()))
1629 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1630 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])));
1631
1632 let mut renderer = RecordingRenderer::new();
1633 let args = make_args(Some("0801"), Some("Nonexistent"), None, None, false, None);
1634 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1635
1636 assert!(
1637 matches!(result, Err(Diagnostic::NotFound(_))),
1638 "unknown resource-type must yield NotFound, got {:?}",
1639 result
1640 );
1641 assert_eq!(client.list_resources_calls(), 0);
1642 }
1643
1644 #[test]
1646 fn test_list_bare_name_ambiguous_gives_usage_error() {
1647 let client = MockDspClient::new()
1648 .with_resolve_project(Ok(make_project_ref()))
1649 .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
1650 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1651 .with_describe_data_model(Ok(make_detail_with_rts("webern", &["Letter", "Score"])));
1652
1653 let mut renderer = RecordingRenderer::new();
1654 let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1656 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1657
1658 assert!(
1659 matches!(result, Err(Diagnostic::Usage(_))),
1660 "ambiguous resource-type must yield Usage error, got {:?}",
1661 result
1662 );
1663 if let Err(Diagnostic::Usage(msg)) = &result {
1664 assert!(
1665 msg.contains("beol") && msg.contains("webern"),
1666 "Usage error must list both data-models, got: {msg}"
1667 );
1668 assert!(
1669 msg.contains("--data-model"),
1670 "Usage error must suggest --data-model, got: {msg}"
1671 );
1672 }
1673 assert_eq!(client.list_resources_calls(), 0);
1674 }
1675
1676 #[test]
1678 fn test_list_data_model_name_scope() {
1679 let client = MockDspClient::new()
1680 .with_resolve_project(Ok(make_project_ref()))
1681 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1682 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1683 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1684
1685 let mut renderer = RecordingRenderer::new();
1686 let args = make_args(
1687 Some("0801"),
1688 Some("Letter"),
1689 Some("beol"),
1690 None,
1691 false,
1692 None,
1693 );
1694 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1695
1696 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1697 let view = renderer.resources_view.expect("must have been called");
1698 assert_eq!(view.items.len(), 1);
1699 }
1700
1701 #[test]
1703 fn test_list_data_model_full_iri_scope_skips_list() {
1704 let client = MockDspClient::new()
1705 .with_resolve_project(Ok(make_project_ref()))
1706 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1707 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1708
1709 let mut renderer = RecordingRenderer::new();
1710 let args = make_args(
1711 Some("0801"),
1712 Some("Letter"),
1713 Some(BEOL_IRI),
1714 None,
1715 false,
1716 None,
1717 );
1718 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1719
1720 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1721 assert_eq!(
1722 client.list_data_models_calls(),
1723 0,
1724 "--data-model as full IRI must skip list_data_models"
1725 );
1726 }
1727
1728 #[test]
1730 fn test_list_single_page_no_more() {
1731 let client = MockDspClient::new()
1732 .with_resolve_project(Ok(make_project_ref()))
1733 .with_list_resources(Ok(make_resource_page(&["A", "B"], false)));
1734
1735 let mut renderer = RecordingRenderer::new();
1736 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1737 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1738
1739 let view = renderer.resources_view.expect("must have been called");
1740 assert_eq!(view.items.len(), 2);
1741 assert_eq!(view.total, 2);
1742 match &view.pagination {
1743 crate::render::ResourceListPagination::SinglePage {
1744 page,
1745 may_have_more,
1746 } => {
1747 assert_eq!(*page, 0);
1748 assert!(!may_have_more);
1749 }
1750 other => panic!("expected SinglePage, got {:?}", other),
1751 }
1752 }
1753
1754 #[test]
1756 fn test_list_single_page_has_more() {
1757 let client = MockDspClient::new()
1758 .with_resolve_project(Ok(make_project_ref()))
1759 .with_list_resources(Ok(make_resource_page(&["A", "B"], true)));
1760
1761 let mut renderer = RecordingRenderer::new();
1762 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1763 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1764
1765 let view = renderer.resources_view.expect("must have been called");
1766 match &view.pagination {
1767 crate::render::ResourceListPagination::SinglePage { may_have_more, .. } => {
1768 assert!(*may_have_more);
1769 }
1770 other => panic!("expected SinglePage, got {:?}", other),
1771 }
1772 }
1773
1774 #[test]
1776 fn test_list_explicit_page_passed_to_client() {
1777 let client = MockDspClient::new()
1778 .with_resolve_project(Ok(make_project_ref()))
1779 .with_list_resources(Ok(make_resource_page(&["A"], false)));
1780
1781 let mut renderer = RecordingRenderer::new();
1782 let args = make_args(Some("0801"), Some(LETTER_IRI), None, Some(3), false, None);
1783 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1784
1785 let calls = client.list_resources_args();
1786 assert_eq!(calls[0].3, 3, "--page 3 must be passed to list_resources");
1787
1788 let view = renderer.resources_view.expect("must have been called");
1789 match &view.pagination {
1790 crate::render::ResourceListPagination::SinglePage { page, .. } => {
1791 assert_eq!(*page, 3);
1792 }
1793 other => panic!("expected SinglePage, got {:?}", other),
1794 }
1795 }
1796
1797 #[test]
1799 fn test_list_all_multi_page() {
1800 let client = MockDspClient::new()
1801 .with_resolve_project(Ok(make_project_ref()))
1802 .with_list_resources(Ok(make_resource_page(&["A", "B"], true)))
1803 .with_list_resources(Ok(make_resource_page(&["C"], false)));
1804
1805 let mut renderer = RecordingRenderer::new();
1806 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1807 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1808
1809 assert_eq!(client.list_resources_calls(), 2);
1810 let calls = client.list_resources_args();
1812 assert_eq!(calls[0].3, 0);
1813 assert_eq!(calls[1].3, 1);
1814
1815 let view = renderer.resources_view.expect("must have been called");
1816 assert_eq!(view.items.len(), 3, "all pages merged");
1817 assert_eq!(view.total, 3);
1818 match &view.pagination {
1819 crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1820 assert_eq!(*pages_fetched, 2);
1821 }
1822 other => panic!("expected AllPages, got {:?}", other),
1823 }
1824 }
1825
1826 #[test]
1828 fn test_list_all_empty_final_page() {
1829 let client = MockDspClient::new()
1831 .with_resolve_project(Ok(make_project_ref()))
1832 .with_list_resources(Ok(make_resource_page(&["A"], true)))
1833 .with_list_resources(Ok(make_resource_page(&[], false)));
1834
1835 let mut renderer = RecordingRenderer::new();
1836 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1837 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1838
1839 assert_eq!(client.list_resources_calls(), 2);
1840 let view = renderer.resources_view.expect("must have been called");
1841 assert_eq!(
1842 view.items.len(),
1843 1,
1844 "empty final page contributes zero resources"
1845 );
1846 match &view.pagination {
1847 crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1848 assert_eq!(*pages_fetched, 2);
1849 }
1850 other => panic!("expected AllPages, got {:?}", other),
1851 }
1852 }
1853
1854 #[test]
1856 fn test_list_all_propagates_error_mid_loop() {
1857 let client = MockDspClient::new()
1858 .with_resolve_project(Ok(make_project_ref()))
1859 .with_list_resources(Ok(make_resource_page(&["A"], true)))
1860 .with_list_resources(Err(Diagnostic::ServerError("server exploded".into())));
1861
1862 let mut renderer = RecordingRenderer::new();
1863 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1864 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1865
1866 assert!(
1867 matches!(result, Err(Diagnostic::ServerError(_))),
1868 "mid-loop error must propagate, got {:?}",
1869 result
1870 );
1871 assert!(
1872 renderer.resources_view.is_none(),
1873 "no partial result must be rendered on error"
1874 );
1875 }
1876
1877 #[test]
1879 fn test_list_empty_result() {
1880 let client = MockDspClient::new()
1881 .with_resolve_project(Ok(make_project_ref()))
1882 .with_list_resources(Ok(make_resource_page(&[], false)));
1883
1884 let mut renderer = RecordingRenderer::new();
1885 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1886 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1887
1888 let view = renderer.resources_view.expect("must have been called");
1889 assert!(view.items.is_empty());
1890 assert_eq!(view.total, 0);
1891 }
1892
1893 #[test]
1895 fn test_list_filter_narrows_total_is_prefilter() {
1896 let client = MockDspClient::new()
1897 .with_resolve_project(Ok(make_project_ref()))
1898 .with_list_resources(Ok(make_resource_page(&["AliceA", "BobB", "AliceC"], false)));
1899
1900 let mut renderer = RecordingRenderer::new();
1901 let args = make_args(
1902 Some("0801"),
1903 Some(LETTER_IRI),
1904 None,
1905 None,
1906 false,
1907 Some("alice"),
1908 );
1909 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1910
1911 let view = renderer.resources_view.expect("must have been called");
1912 assert_eq!(view.total, 3, "total is pre-filter");
1913 assert_eq!(view.items.len(), 2, "filter keeps only Alice items");
1914 assert!(
1915 view.items
1916 .iter()
1917 .all(|r| r.label.to_lowercase().contains("alice"))
1918 );
1919 assert_eq!(view.filter.as_deref(), Some("alice"));
1920 }
1921
1922 #[test]
1924 fn test_list_missing_project_is_usage_error() {
1925 let client = MockDspClient::new();
1926 let mut renderer = RecordingRenderer::new();
1927 let args = make_args(None, Some(LETTER_IRI), None, None, false, None);
1928 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1929
1930 assert!(
1931 matches!(result, Err(Diagnostic::Usage(_))),
1932 "missing --project must yield Usage, got {:?}",
1933 result
1934 );
1935 assert_eq!(client.resolve_calls(), 0);
1936 }
1937
1938 #[test]
1940 fn test_list_missing_resource_type_is_usage_error() {
1941 let client = MockDspClient::new();
1942 let mut renderer = RecordingRenderer::new();
1943 let args = make_args(Some("0801"), None, None, None, false, None);
1944 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1945
1946 assert!(
1947 matches!(result, Err(Diagnostic::Usage(_))),
1948 "missing --resource-type must yield Usage, got {:?}",
1949 result
1950 );
1951 assert_eq!(client.resolve_calls(), 0);
1952 }
1953
1954 #[test]
1956 fn test_list_filter_warning_anonymous() {
1957 let client = MockDspClient::new()
1958 .with_resolve_project(Ok(make_project_ref()))
1959 .with_list_resources(Ok(make_resource_page(&[], false)));
1960
1961 let mut renderer = RecordingRenderer::new();
1962 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1963 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1964
1965 let meta = renderer.resources_meta.expect("meta must be present");
1966 assert_eq!(meta.auth_state, "anonymous");
1967 let fw = meta
1968 .filter_warning
1969 .expect("filter_warning must be Some for anonymous");
1970 assert_eq!(
1971 fw,
1972 "results may be filtered; login to see private resources"
1973 );
1974 }
1975
1976 #[test]
1978 fn test_list_filter_warning_authenticated() {
1979 let dir = TempDir::new().expect("tempdir");
1980 let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1981 let path = write_cache(&dir, &cache);
1982
1983 let client = MockDspClient::new()
1984 .with_resolve_project(Ok(make_project_ref()))
1985 .with_list_resources(Ok(make_resource_page(&[], false)));
1986
1987 let mut renderer = RecordingRenderer::new();
1988 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1989 run_list_impl(
1990 &args,
1991 &make_cfg(),
1992 &client,
1993 &mut renderer,
1994 None,
1995 Some(&path),
1996 )
1997 .expect("expected Ok");
1998
1999 let meta = renderer.resources_meta.expect("meta must be present");
2000 let fw = meta
2001 .filter_warning
2002 .expect("filter_warning must be Some for authenticated");
2003 assert_eq!(fw, "results limited to your permissions");
2004 }
2005
2006 #[test]
2008 fn test_list_corrupt_cache_falls_back_to_anonymous() {
2009 let dir = TempDir::new().expect("tempdir");
2010 let bad_path = dir.path().join("nonexistent_auth.toml");
2011
2012 let client = MockDspClient::new()
2013 .with_resolve_project(Ok(make_project_ref()))
2014 .with_list_resources(Ok(make_resource_page(&[], false)));
2015
2016 let mut renderer = RecordingRenderer::new();
2017 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2018 let result = run_list_impl(
2019 &args,
2020 &make_cfg(),
2021 &client,
2022 &mut renderer,
2023 None,
2024 Some(&bad_path),
2025 );
2026 assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
2027
2028 let meta = renderer.resources_meta.expect("meta must be present");
2029 assert_eq!(meta.auth_state, "anonymous");
2030 }
2031
2032 #[test]
2040 fn test_list_mid_scan_describe_error_propagates() {
2041 let client = MockDspClient::new()
2042 .with_resolve_project(Ok(make_project_ref()))
2043 .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
2044 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
2046 .with_describe_data_model(Err(Diagnostic::ServerError(
2048 "data-model service unavailable".into(),
2049 )));
2050
2051 let mut renderer = RecordingRenderer::new();
2052 let args = make_args(Some("0801"), Some("Score"), None, None, false, None);
2054 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2055
2056 assert!(
2057 matches!(result, Err(Diagnostic::ServerError(_))),
2058 "mid-scan describe_data_model error must propagate as ServerError, got {:?}",
2059 result
2060 );
2061 assert_eq!(
2062 client.list_resources_calls(),
2063 0,
2064 "list_resources must never be called when describe_data_model errors mid-scan"
2065 );
2066 }
2067
2068 #[test]
2070 fn test_list_token_forwarded_to_list_resources() {
2071 let dir = TempDir::new().expect("tempdir");
2072 let cache = cache_with_entry(SERVER, "my-token", "user@example.com");
2073 let path = write_cache(&dir, &cache);
2074
2075 let client = MockDspClient::new()
2076 .with_resolve_project(Ok(make_project_ref()))
2077 .with_list_resources(Ok(make_resource_page(&[], false)));
2078
2079 let mut renderer = RecordingRenderer::new();
2080 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2081 run_list_impl(
2082 &args,
2083 &make_cfg(),
2084 &client,
2085 &mut renderer,
2086 None,
2087 Some(&path),
2088 )
2089 .expect("expected Ok");
2090
2091 let calls = client.list_resources_args();
2092 assert_eq!(
2093 calls[0].4,
2094 Some("my-token".to_string()),
2095 "token must be forwarded to list_resources"
2096 );
2097 }
2098
2099 fn make_rt_detail_with_fields(
2103 rt_name: &str,
2104 field_names: &[(&str, &str)],
2105 ) -> ResourceTypeDetail {
2106 let fields = field_names
2107 .iter()
2108 .map(|(name, iri)| Field {
2109 name: name.to_string(),
2110 iri: iri.to_string(),
2111 label: None,
2112 value_type: crate::model::resource_type::ValueType::Text,
2113 link_target: None,
2114 cardinality: Cardinality::ZeroOrOne,
2115 is_builtin: false,
2116 data_model: Some("beol".to_string()),
2117 })
2118 .collect();
2119 ResourceTypeDetail {
2120 name: rt_name.to_string(),
2121 iri: LETTER_IRI.to_string(),
2122 label: None,
2123 data_model: "beol".to_string(),
2124 representation: None,
2125 super_types: vec![],
2126 fields,
2127 }
2128 }
2129
2130 const TITLE_PROP_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#hasTitle";
2131
2132 #[test]
2134 fn test_order_by_bare_name_resolves_to_property_iri() {
2135 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2136 let client = MockDspClient::new()
2137 .with_resolve_project(Ok(make_project_ref()))
2138 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2139 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2140 .with_describe_resource_type(rt_detail)
2141 .with_list_resources(Ok(make_resource_page(&["A"], false)));
2142
2143 let mut renderer = RecordingRenderer::new();
2144 let args = make_args_with_order_by(
2145 Some("0801"),
2146 Some("Letter"),
2147 None,
2148 None,
2149 false,
2150 None,
2151 Some("hasTitle"),
2152 );
2153 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2154
2155 assert_eq!(
2156 client.describe_resource_type_calls(),
2157 1,
2158 "bare field name must call describe_resource_type"
2159 );
2160 let calls = client.list_resources_args();
2161 assert_eq!(calls.len(), 1);
2162 assert_eq!(
2163 calls[0].2,
2164 Some(TITLE_PROP_IRI.to_string()),
2165 "order_by must be the resolved property IRI"
2166 );
2167 }
2168
2169 #[test]
2171 fn test_order_by_full_iri_bypass_skips_describe_resource_type() {
2172 let client = MockDspClient::new()
2173 .with_resolve_project(Ok(make_project_ref()))
2174 .with_list_resources(Ok(make_resource_page(&["A"], false)));
2175
2176 let mut renderer = RecordingRenderer::new();
2177 let args = make_args_with_order_by(
2178 Some("0801"),
2179 Some(LETTER_IRI),
2180 None,
2181 None,
2182 false,
2183 None,
2184 Some(TITLE_PROP_IRI),
2185 );
2186 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2187
2188 assert_eq!(
2189 client.describe_resource_type_calls(),
2190 0,
2191 "full-IRI bypass must NOT call describe_resource_type"
2192 );
2193 let calls = client.list_resources_args();
2194 assert_eq!(calls.len(), 1);
2195 assert_eq!(
2196 calls[0].2,
2197 Some(TITLE_PROP_IRI.to_string()),
2198 "order_by must be the verbatim IRI"
2199 );
2200 }
2201
2202 #[test]
2204 fn test_order_by_path_a_with_fragment_resolves() {
2205 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2207 let client = MockDspClient::new()
2208 .with_resolve_project(Ok(make_project_ref()))
2209 .with_describe_resource_type(rt_detail)
2210 .with_list_resources(Ok(make_resource_page(&["A"], false)));
2211
2212 let mut renderer = RecordingRenderer::new();
2213 let args = make_args_with_order_by(
2214 Some("0801"),
2215 Some(LETTER_IRI), None,
2217 None,
2218 false,
2219 None,
2220 Some("hasTitle"),
2221 );
2222 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2223
2224 assert_eq!(
2225 client.describe_resource_type_calls(),
2226 1,
2227 "bare field name with path-A resource-type must call describe_resource_type"
2228 );
2229 let calls = client.list_resources_args();
2230 assert_eq!(
2231 calls[0].2,
2232 Some(TITLE_PROP_IRI.to_string()),
2233 "order_by must be the resolved property IRI"
2234 );
2235 }
2236
2237 #[test]
2239 fn test_order_by_path_a_no_fragment_bare_name_is_usage_error() {
2240 let no_fragment_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2/Letter";
2243 let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
2244
2245 let mut renderer = RecordingRenderer::new();
2246 let args = make_args_with_order_by(
2247 Some("0801"),
2248 Some(no_fragment_iri),
2249 None,
2250 None,
2251 false,
2252 None,
2253 Some("hasTitle"),
2254 );
2255 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2256
2257 assert!(
2258 matches!(result, Err(Diagnostic::Usage(_))),
2259 "path-A IRI lacking '#' + bare order-by must yield Usage, got {:?}",
2260 result
2261 );
2262 assert_eq!(
2263 client.describe_resource_type_calls(),
2264 0,
2265 "describe_resource_type must not be called when error is expected"
2266 );
2267 }
2268
2269 #[test]
2271 fn test_order_by_unknown_field_is_usage_error() {
2272 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2273 let client = MockDspClient::new()
2275 .with_resolve_project(Ok(make_project_ref()))
2276 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2277 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2278 .with_describe_resource_type(rt_detail);
2279
2280 let mut renderer = RecordingRenderer::new();
2281 let args = make_args_with_order_by(
2282 Some("0801"),
2283 Some("Letter"),
2284 None,
2285 None,
2286 false,
2287 None,
2288 Some("nonExistentField"),
2289 );
2290 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2291
2292 assert!(
2293 matches!(result, Err(Diagnostic::Usage(_))),
2294 "unknown field name must yield Usage, got {:?}",
2295 result
2296 );
2297 if let Err(Diagnostic::Usage(msg)) = &result {
2298 assert!(
2299 msg.contains("nonExistentField"),
2300 "Usage error must echo the field name, got: {msg}"
2301 );
2302 assert!(
2303 msg.contains("resource-type describe"),
2304 "Usage error must hint at resource-type describe, got: {msg}"
2305 );
2306 }
2307 assert_eq!(client.describe_resource_type_calls(), 1);
2309 assert_eq!(
2310 client.list_resources_calls(),
2311 0,
2312 "list_resources must not be called when order-by resolution fails"
2313 );
2314 }
2315
2316 #[test]
2318 fn test_order_by_absent_passes_none_to_list_resources() {
2319 let client = MockDspClient::new()
2320 .with_resolve_project(Ok(make_project_ref()))
2321 .with_list_resources(Ok(make_resource_page(&[], false)));
2322
2323 let mut renderer = RecordingRenderer::new();
2324 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2325 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2326
2327 let calls = client.list_resources_args();
2328 assert_eq!(
2329 calls[0].2, None,
2330 "--order-by absent must pass None to list_resources"
2331 );
2332 assert_eq!(
2333 client.describe_resource_type_calls(),
2334 0,
2335 "describe_resource_type must not be called when --order-by is absent"
2336 );
2337 }
2338
2339 #[test]
2341 fn test_order_by_with_all_threads_iri_to_every_page() {
2342 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2343 let client = MockDspClient::new()
2344 .with_resolve_project(Ok(make_project_ref()))
2345 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2346 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2347 .with_describe_resource_type(rt_detail)
2348 .with_list_resources(Ok(make_resource_page(&["A"], true)))
2349 .with_list_resources(Ok(make_resource_page(&["B"], false)));
2350
2351 let mut renderer = RecordingRenderer::new();
2352 let args = make_args_with_order_by(
2353 Some("0801"),
2354 Some("Letter"),
2355 None,
2356 None,
2357 true, None,
2359 Some("hasTitle"),
2360 );
2361 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2362
2363 assert_eq!(client.list_resources_calls(), 2);
2364 let calls = client.list_resources_args();
2365 assert_eq!(
2366 calls[0].2,
2367 Some(TITLE_PROP_IRI.to_string()),
2368 "order_by must be threaded to page 0"
2369 );
2370 assert_eq!(
2371 calls[1].2,
2372 Some(TITLE_PROP_IRI.to_string()),
2373 "order_by must be threaded to page 1"
2374 );
2375 }
2376
2377 #[test]
2379 fn test_order_by_field_name_is_case_insensitive() {
2380 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2381 let client = MockDspClient::new()
2382 .with_resolve_project(Ok(make_project_ref()))
2383 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2384 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2385 .with_describe_resource_type(rt_detail)
2386 .with_list_resources(Ok(make_resource_page(&[], false)));
2387
2388 let mut renderer = RecordingRenderer::new();
2389 let args = make_args_with_order_by(
2390 Some("0801"),
2391 Some("Letter"),
2392 None,
2393 None,
2394 false,
2395 None,
2396 Some("HASTITLE"),
2397 );
2398 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2399
2400 let calls = client.list_resources_args();
2401 assert_eq!(
2402 calls[0].2,
2403 Some(TITLE_PROP_IRI.to_string()),
2404 "case-insensitive field name match must resolve to the property IRI"
2405 );
2406 }
2407}