1use std::path::Path;
13
14use crate::actions::auth_state::read_auth_state;
15use crate::cli::{ResourceDescribeArgs, ResourceListArgs};
16use crate::client::DspClient;
17use crate::config::{AuthCache, Config, resolve_token};
18use crate::diagnostic::Diagnostic;
19use crate::model::ResourceSummary;
20use crate::render::{MetaContext, Renderer, ResourceListPagination, ResourceListView};
21use crate::util::text::strip_control_chars;
22
23struct ResourceTypeRef {
26 iri: String,
27 name: String,
28 data_model_iri: Option<String>,
29}
30
31pub fn list(
38 args: &ResourceListArgs,
39 cfg: &Config,
40 client: &dyn DspClient,
41 renderer: &mut dyn Renderer,
42) -> Result<(), Diagnostic> {
43 let env_token = std::env::var("DSP_TOKEN").ok();
44 run_list_impl(args, cfg, client, renderer, env_token, None)
45}
46
47pub(crate) fn run_list_impl(
58 args: &ResourceListArgs,
59 cfg: &Config,
60 client: &dyn DspClient,
61 renderer: &mut dyn Renderer,
62 env_token: Option<String>,
63 cache_path: Option<&Path>,
64) -> Result<(), Diagnostic> {
65 let project = args
67 .project
68 .as_deref()
69 .ok_or_else(|| Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string()))?;
70
71 let resource_type_arg = args
73 .resource_type
74 .as_deref()
75 .ok_or_else(|| Diagnostic::Usage("--resource-type <name-or-IRI> is required".to_string()))?;
76
77 let cache_result = match cache_path {
79 Some(p) => AuthCache::load_from(p),
80 None => AuthCache::load(),
81 };
82 let cache = match cache_result {
83 Ok(c) => c,
84 Err(e) => {
85 crate::util::warn_auth_cache_load_failed(&e, "falling back to anonymous for resource list");
86 AuthCache::default()
87 }
88 };
89
90 let resolved = resolve_token(env_token, &cache, &cfg.server);
92 let token = resolved.as_ref().map(|r| r.token.as_str());
93
94 let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
96
97 let filter_warning = if resolved.is_none() {
102 Some("results may be filtered; login to see private resources".to_string())
103 } else {
104 Some("results limited to your permissions".to_string())
105 };
106
107 let pref = client.resolve_project(&cfg.server, project)?;
109
110 let rt_ref = resolve_resource_type_iri(
117 resource_type_arg,
118 args.data_model.as_deref(),
119 &cfg.server,
120 &pref.iri,
121 client,
122 token,
123 )?;
124
125 let order_by: Option<String> = if let Some(ob) = args.order_by.as_deref() {
127 Some(resolve_order_by_property(ob, &rt_ref, &cfg.server, client, token)?)
128 } else {
129 None
130 };
131
132 let (all_resources, pagination) = if args.all {
134 let mut all: Vec<ResourceSummary> = Vec::new();
136 let mut page = 0u32;
137 loop {
138 let page_result =
139 client.list_resources(&cfg.server, &pref.iri, &rt_ref.iri, order_by.as_deref(), page, token)?;
140 all.extend(page_result.resources);
141 if !page_result.may_have_more_results {
142 break;
143 }
144 page += 1;
145 }
146 let pages_fetched = page + 1;
147 (all, ResourceListPagination::AllPages { pages_fetched })
148 } else {
149 let page = args.page.unwrap_or(0);
151 let page_result =
152 client.list_resources(&cfg.server, &pref.iri, &rt_ref.iri, order_by.as_deref(), page, token)?;
153 let may_have_more = page_result.may_have_more_results;
154 (
155 page_result.resources,
156 ResourceListPagination::SinglePage { page, may_have_more },
157 )
158 };
159
160 let total = all_resources.len();
162
163 let mut items = all_resources;
165 if let Some(ref f) = args.filter {
166 let lower = f.to_lowercase();
167 items.retain(|r| r.label.to_lowercase().contains(&lower));
168 }
169
170 let view = ResourceListView {
172 items,
173 total,
174 filter: args.filter.clone(),
175 resource_type: rt_ref.name,
176 pagination,
177 };
178 let meta = MetaContext {
179 server_label: cfg.server.clone(),
180 auth_state,
181 filter_warning,
182 count_caveat: None,
183 count_cost: None,
184 };
185 renderer.resources(&view, &meta)
186}
187
188fn resolve_resource_type_iri(
196 resource_type_arg: &str,
197 data_model_arg: Option<&str>,
198 server: &str,
199 project_iri: &str,
200 client: &dyn DspClient,
201 token: Option<&str>,
202) -> Result<ResourceTypeRef, Diagnostic> {
203 if resource_type_arg.contains("://") {
205 let name = local_name(resource_type_arg);
207 let data_model_iri = resource_type_arg.find('#').map(|idx| resource_type_arg[..idx].to_string());
209 return Ok(ResourceTypeRef { iri: resource_type_arg.to_string(), name, data_model_iri });
210 }
211
212 if let Some(dm_arg) = data_model_arg {
214 let dm_iri = if dm_arg.contains("://") {
216 dm_arg.to_string()
218 } else {
219 let data_models = client.list_data_models(server, project_iri, token)?;
221 data_models
222 .iter()
223 .find(|dm| dm.iri == dm_arg || dm.name.eq_ignore_ascii_case(dm_arg))
224 .map(|dm| dm.iri.clone())
225 .ok_or_else(|| {
226 let dm_disp: String = dm_arg.chars().take(80).collect();
227 let dm_suffix = if dm_arg.chars().count() > 80 { "…" } else { "" };
228 Diagnostic::NotFound(format!("data-model '{dm_disp}{dm_suffix}' not found in project on {server}"))
229 })?
230 };
231
232 let detail = client.describe_data_model(server, &dm_iri, token)?;
234 let matched = detail
235 .resource_types
236 .iter()
237 .find(|rt| rt.name.eq_ignore_ascii_case(resource_type_arg) || rt.iri == resource_type_arg)
238 .ok_or_else(|| {
239 let rt_disp: String = resource_type_arg.chars().take(80).collect();
240 let rt_suffix = if resource_type_arg.chars().count() > 80 {
241 "…"
242 } else {
243 ""
244 };
245 Diagnostic::NotFound(format!(
246 "resource-type '{rt_disp}{rt_suffix}' not found in data-model '{name}' on {server}; \
247 run `dsp vre resource-type list --project <project> --data-model {name} --server {server}` \
248 to see available resource-types.",
249 name = detail.name,
250 ))
251 })?;
252 return Ok(ResourceTypeRef {
253 iri: matched.iri.clone(),
254 name: matched.name.clone(),
255 data_model_iri: Some(dm_iri),
256 });
257 }
258
259 let data_models = client.list_data_models(server, project_iri, token)?;
261 if data_models.len() > 10 {
262 tracing::warn!(
263 count = data_models.len(),
264 "bare-name resource-type resolution will issue {} describe_data_model calls; \
265 use --data-model or a full IRI to avoid scanning",
266 data_models.len()
267 );
268 }
269
270 let mut matches: Vec<(String, String, String, String)> = Vec::new(); for dm in &data_models {
276 let detail = client.describe_data_model(server, &dm.iri, token)?;
277 for rt in &detail.resource_types {
278 if rt.name.eq_ignore_ascii_case(resource_type_arg) || rt.iri == resource_type_arg {
279 matches.push((rt.iri.clone(), rt.name.clone(), dm.iri.clone(), dm.name.clone()));
280 }
281 }
282 }
283
284 match matches.len() {
285 0 => {
286 let rt_disp: String = resource_type_arg.chars().take(80).collect();
287 let rt_suffix = if resource_type_arg.chars().count() > 80 {
288 "…"
289 } else {
290 ""
291 };
292 Err(Diagnostic::NotFound(format!(
293 "resource-type '{rt_disp}{rt_suffix}' not found in any data-model of the project on {server}; \
294 run `dsp vre resource-type list --project <project> --server {server}` \
295 to see available resource-types."
296 )))
297 }
298 1 => {
299 let (resource_type_iri, rt_name, dm_iri, _dm_name) = matches.remove(0);
300 Ok(ResourceTypeRef {
301 iri: resource_type_iri,
302 name: rt_name,
303 data_model_iri: Some(dm_iri),
304 })
305 }
306 _ => {
307 let mut seen_dm_names: Vec<String> = Vec::new();
310 for (_, _, _, dm_name) in &matches {
311 if !seen_dm_names.contains(dm_name) {
312 seen_dm_names.push(dm_name.clone());
313 }
314 }
315 let dm_list = seen_dm_names.join(", ");
316 let rt_disp: String = resource_type_arg.chars().take(80).collect();
317 let rt_suffix = if resource_type_arg.chars().count() > 80 {
318 "…"
319 } else {
320 ""
321 };
322 Err(Diagnostic::Usage(format!(
323 "resource-type '{rt_disp}{rt_suffix}' is ambiguous: found in data-models [{dm_list}]; \
324 use --data-model to scope the search to one data-model."
325 )))
326 }
327 }
328}
329
330fn resolve_order_by_property(
343 order_by_arg: &str,
344 rt_ref: &ResourceTypeRef,
345 server: &str,
346 client: &dyn DspClient,
347 token: Option<&str>,
348) -> Result<String, Diagnostic> {
349 if order_by_arg.contains("://") {
351 return Ok(order_by_arg.to_string());
352 }
353
354 let dm_iri = rt_ref.data_model_iri.as_deref().ok_or_else(|| {
356 Diagnostic::Usage(
357 "cannot resolve --order-by as a field name when --resource-type is a full IRI \
358 that contains no '#' fragment (no data-model can be derived); \
359 pass --order-by as a full field IRI instead"
360 .to_string(),
361 )
362 })?;
363
364 let detail = client.describe_resource_type(server, dm_iri, &rt_ref.iri, token)?;
367
368 if let Some(field) = detail.fields.iter().find(|f| f.name.eq_ignore_ascii_case(order_by_arg)) {
370 return Ok(field.iri.clone());
371 }
372
373 let sanitised = strip_control_chars(order_by_arg);
376 let field_disp: String = sanitised.chars().take(80).collect();
377 let field_suffix = if sanitised.chars().count() > 80 { "…" } else { "" };
378 Err(Diagnostic::Usage(format!(
379 "field '{field_disp}{field_suffix}' not found in resource-type '{}'; \
380 run `dsp vre resource-type describe --server <s> --project <project> \
381 --resource-type {}` to see available field names.",
382 rt_ref.name, rt_ref.name,
383 )))
384}
385
386fn local_name(iri: &str) -> String {
396 iri.rsplit(['#', '/', ':']).next().unwrap_or(iri).to_string()
397}
398
399pub fn describe(
410 args: &ResourceDescribeArgs,
411 cfg: &Config,
412 client: &dyn DspClient,
413 renderer: &mut dyn Renderer,
414) -> Result<(), Diagnostic> {
415 let env_token = std::env::var("DSP_TOKEN").ok();
416 run_describe_impl(args, cfg, client, renderer, env_token, None)
417}
418
419pub(crate) fn run_describe_impl(
430 args: &ResourceDescribeArgs,
431 cfg: &Config,
432 client: &dyn DspClient,
433 renderer: &mut dyn Renderer,
434 env_token: Option<String>,
435 cache_path: Option<&Path>,
436) -> Result<(), Diagnostic> {
437 let resource_iri = args
439 .resource
440 .as_deref()
441 .ok_or_else(|| Diagnostic::Usage("--resource <iri> is required".to_string()))?;
442
443 let cache_result = match cache_path {
445 Some(p) => AuthCache::load_from(p),
446 None => AuthCache::load(),
447 };
448 let cache = match cache_result {
449 Ok(c) => c,
450 Err(e) => {
451 crate::util::warn_auth_cache_load_failed(&e, "falling back to anonymous for resource describe");
452 AuthCache::default()
453 }
454 };
455
456 let resolved = resolve_token(env_token, &cache, &cfg.server);
458 let token = resolved.as_ref().map(|r| r.token.as_str());
459
460 let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
462
463 let filter_warning = if resolved.is_none() {
468 Some("results may be filtered; login to see private resources".to_string())
469 } else {
470 Some("results limited to your permissions".to_string())
471 };
472
473 let resolved_project = if let Some(p) = args.project.as_deref() {
478 Some(client.resolve_project(&cfg.server, p)?)
479 } else {
480 None
481 };
482
483 let detail = client.describe_resource(&cfg.server, resource_iri, token, args.values)?;
485
486 if let Some(ref proj) = resolved_project
492 && let Some(ref actual) = detail.attached_project
493 && actual != &proj.iri
494 {
495 let actual_name = local_name(actual);
496 let expected_name = local_name(&proj.iri);
497 let display_iri: String = resource_iri.chars().take(80).collect();
500 let iri_suffix = if resource_iri.chars().count() > 80 { "…" } else { "" };
501 return Err(Diagnostic::Usage(format!(
502 "resource '{display_iri}{iri_suffix}' belongs to project {actual_name}, not {expected_name}"
503 )));
504 }
505
506 let meta = MetaContext {
508 server_label: cfg.server.clone(),
509 auth_state,
510 filter_warning,
511 count_caveat: None,
512 count_cost: None,
513 };
514 renderer.resource_describe(&detail, &meta)
515}
516
517#[cfg(test)]
522mod tests {
523 use std::cell::RefCell;
524
525 use tempfile::TempDir;
526
527 use super::{run_describe_impl, run_list_impl};
528 use crate::cli::{FormatArgs, ResourceDescribeArgs, ResourceListArgs};
529 use crate::client::DspClient;
530 use crate::config::auth_cache::ServerEntry;
531 use crate::config::{AuthCache, Config};
532 use crate::diagnostic::Diagnostic;
533 use crate::model::{
534 Cardinality, DataModel, DataModelDetail, Field, ProjectRef, ResourceAccess, ResourceDetail, ResourcePage,
535 ResourceSummary, ResourceTypeDetail, ResourceTypeSummary, ResourceVisibility,
536 };
537 use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
538 use crate::render::{
539 DataModelListView, DumpDeleteOutcome, DumpOutcome, Format, MetaContext, ProjectListView, Renderer,
540 ResourceListView, ResourceTypeListView,
541 };
542
543 type ListResourcesArg = (String, String, Option<String>, u32, Option<String>);
552
553 struct MockDspClient {
554 resolve_result: Option<Result<ProjectRef, Diagnostic>>,
555 resolve_calls: RefCell<u32>,
556 list_data_models_result: Option<Result<Vec<DataModel>, Diagnostic>>,
557 list_data_models_calls: RefCell<u32>,
558 describe_data_model_queue: RefCell<Vec<Result<DataModelDetail, Diagnostic>>>,
560 describe_data_model_calls: RefCell<u32>,
561 describe_resource_type_result: Option<ResourceTypeDetail>,
563 describe_resource_type_calls: RefCell<u32>,
564 list_resources_queue: RefCell<Vec<Result<ResourcePage, Diagnostic>>>,
566 list_resources_calls: RefCell<u32>,
567 list_resources_args: RefCell<Vec<ListResourcesArg>>,
568 describe_resource_queue: RefCell<Vec<Result<ResourceDetail, Diagnostic>>>,
570 describe_resource_calls: RefCell<u32>,
571 describe_resource_with_values_args: RefCell<Vec<bool>>,
572 }
573
574 impl MockDspClient {
575 fn new() -> Self {
576 Self {
577 resolve_result: None,
578 resolve_calls: RefCell::new(0),
579 list_data_models_result: None,
580 list_data_models_calls: RefCell::new(0),
581 describe_data_model_queue: RefCell::new(Vec::new()),
582 describe_data_model_calls: RefCell::new(0),
583 describe_resource_type_result: None,
584 describe_resource_type_calls: RefCell::new(0),
585 list_resources_queue: RefCell::new(Vec::new()),
586 list_resources_calls: RefCell::new(0),
587 list_resources_args: RefCell::new(Vec::new()),
588 describe_resource_queue: RefCell::new(Vec::new()),
589 describe_resource_calls: RefCell::new(0),
590 describe_resource_with_values_args: RefCell::new(Vec::new()),
591 }
592 }
593
594 fn with_resolve_project(mut self, result: Result<ProjectRef, Diagnostic>) -> Self {
595 self.resolve_result = Some(result);
596 self
597 }
598
599 fn with_list_data_models(mut self, result: Result<Vec<DataModel>, Diagnostic>) -> Self {
600 self.list_data_models_result = Some(result);
601 self
602 }
603
604 fn with_describe_data_model(self, result: Result<DataModelDetail, Diagnostic>) -> Self {
605 self.describe_data_model_queue.borrow_mut().push(result);
606 self
607 }
608
609 fn with_describe_resource_type(mut self, detail: ResourceTypeDetail) -> Self {
610 self.describe_resource_type_result = Some(detail);
611 self
612 }
613
614 fn with_list_resources(self, result: Result<ResourcePage, Diagnostic>) -> Self {
615 self.list_resources_queue.borrow_mut().push(result);
616 self
617 }
618
619 fn with_describe_resource(self, result: Result<ResourceDetail, Diagnostic>) -> Self {
620 self.describe_resource_queue.borrow_mut().push(result);
621 self
622 }
623
624 fn resolve_calls(&self) -> u32 {
625 *self.resolve_calls.borrow()
626 }
627
628 fn list_data_models_calls(&self) -> u32 {
629 *self.list_data_models_calls.borrow()
630 }
631
632 fn describe_resource_type_calls(&self) -> u32 {
633 *self.describe_resource_type_calls.borrow()
634 }
635
636 fn list_resources_calls(&self) -> u32 {
637 *self.list_resources_calls.borrow()
638 }
639
640 fn list_resources_args(&self) -> Vec<ListResourcesArg> {
641 self.list_resources_args.borrow().clone()
642 }
643
644 fn describe_resource_calls(&self) -> u32 {
645 *self.describe_resource_calls.borrow()
646 }
647
648 fn describe_resource_with_values_args(&self) -> Vec<bool> {
649 self.describe_resource_with_values_args.borrow().clone()
650 }
651 }
652
653 impl DspClient for MockDspClient {
654 fn login(
655 &self,
656 _server: &str,
657 _user: &str,
658 _password: &str,
659 ) -> Result<crate::model::LoginResponse, Diagnostic> {
660 unimplemented!("login not used in resource action tests")
661 }
662
663 fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
664 *self.resolve_calls.borrow_mut() += 1;
665 self.resolve_result
666 .clone()
667 .expect("resolve_result must be set when resolve_project is called")
668 }
669
670 fn create_project_dump(
671 &self,
672 _server: &str,
673 _project_iri: &str,
674 _skip_assets: bool,
675 _token: &str,
676 ) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
677 unimplemented!("create_project_dump not used in resource action tests")
678 }
679
680 fn get_project_dump_status(
681 &self,
682 _server: &str,
683 _project_iri: &str,
684 _dump_id: &str,
685 _token: &str,
686 ) -> Result<crate::model::DumpTask, Diagnostic> {
687 unimplemented!("get_project_dump_status not used in resource action tests")
688 }
689
690 fn download_project_dump(
691 &self,
692 _server: &str,
693 _project_iri: &str,
694 _dump_id: &str,
695 _token: &str,
696 _dest: &mut dyn std::io::Write,
697 ) -> Result<u64, Diagnostic> {
698 unimplemented!("download_project_dump not used in resource action tests")
699 }
700
701 fn delete_project_dump(
702 &self,
703 _server: &str,
704 _project_iri: &str,
705 _dump_id: &str,
706 _token: &str,
707 ) -> Result<(), Diagnostic> {
708 unimplemented!("delete_project_dump not used in resource action tests")
709 }
710
711 fn list_projects(&self, _server: &str, _token: Option<&str>) -> Result<Vec<crate::model::Project>, Diagnostic> {
712 unimplemented!("list_projects not used in resource action tests")
713 }
714
715 fn describe_project(
716 &self,
717 _server: &str,
718 _project: &str,
719 _token: Option<&str>,
720 ) -> Result<crate::model::ProjectDetail, Diagnostic> {
721 unimplemented!("describe_project not used in resource action tests")
722 }
723
724 fn list_data_models(
725 &self,
726 _server: &str,
727 _project_iri: &str,
728 _token: Option<&str>,
729 ) -> Result<Vec<DataModel>, Diagnostic> {
730 *self.list_data_models_calls.borrow_mut() += 1;
731 self.list_data_models_result
732 .clone()
733 .expect("list_data_models_result must be set when list_data_models is called")
734 }
735
736 fn describe_data_model(
737 &self,
738 _server: &str,
739 _data_model_iri: &str,
740 _token: Option<&str>,
741 ) -> Result<crate::model::DataModelDetail, Diagnostic> {
742 *self.describe_data_model_calls.borrow_mut() += 1;
743 let mut queue = self.describe_data_model_queue.borrow_mut();
744 if queue.is_empty() {
745 panic!("describe_data_model called more times than expected (queue is empty)");
746 }
747 queue.remove(0)
748 }
749
750 fn describe_resource_type(
751 &self,
752 _server: &str,
753 _data_model_iri: &str,
754 _resource_type: &str,
755 _token: Option<&str>,
756 ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
757 *self.describe_resource_type_calls.borrow_mut() += 1;
758 match &self.describe_resource_type_result {
759 Some(detail) => Ok(detail.clone()),
760 None => Err(Diagnostic::NotFound("describe_resource_type: no result configured".to_string())),
761 }
762 }
763
764 fn data_model_structure(
765 &self,
766 _server: &str,
767 _data_model_iri: &str,
768 _token: Option<&str>,
769 ) -> Result<crate::model::DataModelStructure, Diagnostic> {
770 unimplemented!("data_model_structure not used in resource action tests")
771 }
772
773 fn list_resources(
774 &self,
775 _server: &str,
776 project_iri: &str,
777 resource_type_iri: &str,
778 order_by: Option<&str>,
779 page: u32,
780 token: Option<&str>,
781 ) -> Result<ResourcePage, Diagnostic> {
782 *self.list_resources_calls.borrow_mut() += 1;
783 self.list_resources_args.borrow_mut().push((
784 project_iri.to_string(),
785 resource_type_iri.to_string(),
786 order_by.map(str::to_owned),
787 page,
788 token.map(str::to_owned),
789 ));
790 let mut queue = self.list_resources_queue.borrow_mut();
791 if queue.is_empty() {
792 panic!("list_resources called more times than expected (queue is empty)");
793 }
794 queue.remove(0)
795 }
796
797 fn describe_resource(
798 &self,
799 _server: &str,
800 _resource_iri: &str,
801 _token: Option<&str>,
802 with_values: bool,
803 ) -> Result<crate::model::ResourceDetail, Diagnostic> {
804 *self.describe_resource_calls.borrow_mut() += 1;
805 self.describe_resource_with_values_args.borrow_mut().push(with_values);
806 let mut queue = self.describe_resource_queue.borrow_mut();
807 if queue.is_empty() {
808 panic!("describe_resource called more times than expected (queue is empty)");
809 }
810 queue.remove(0)
811 }
812
813 fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
814 unimplemented!("verify_token not used in resource action tests")
815 }
816
817 fn resource_counts(
818 &self,
819 _server: &str,
820 _project_iri: &str,
821 _token: Option<&str>,
822 ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
823 Ok(std::collections::HashMap::new())
824 }
825
826 fn list_vocabularies(
827 &self,
828 _server: &str,
829 _project_iri: &str,
830 _token: Option<&str>,
831 ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
832 unimplemented!("not exercised by this file's tests")
833 }
834
835 fn describe_vocabulary(
836 &self,
837 _server: &str,
838 _iri: &str,
839 _token: Option<&str>,
840 ) -> Result<crate::model::VocabularyTree, Diagnostic> {
841 unimplemented!("not exercised by this file's tests")
842 }
843
844 fn sparql_query(
845 &self,
846 _server: &str,
847 _token: &str,
848 _query: &str,
849 _accept: &str,
850 _timeout_secs: u64,
851 ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
852 Err(Diagnostic::Internal("not used in this test".into()))
853 }
854 }
855
856 struct RecordingRenderer {
859 resources_view: Option<ResourceListView>,
860 resources_meta: Option<MetaContext>,
861 resource_describe_detail: Option<ResourceDetail>,
862 resource_describe_meta: Option<MetaContext>,
863 }
864
865 impl RecordingRenderer {
866 fn new() -> Self {
867 Self {
868 resources_view: None,
869 resources_meta: None,
870 resource_describe_detail: None,
871 resource_describe_meta: None,
872 }
873 }
874 }
875
876 impl Renderer for RecordingRenderer {
877 fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
878 Ok(())
879 }
880
881 fn auth_login(&mut self, _outcome: &AuthLoginOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
882 Ok(())
883 }
884
885 fn auth_status(&mut self, _outcome: &AuthStatusOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
886 Ok(())
887 }
888
889 fn auth_logout(&mut self, _outcome: &AuthLogoutOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
890 Ok(())
891 }
892
893 fn auth_set_token(&mut self, _outcome: &AuthSetTokenOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
894 Ok(())
895 }
896
897 fn project_dump(&mut self, _outcome: &DumpOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
898 Ok(())
899 }
900
901 fn project_dump_deleted(
902 &mut self,
903 _outcome: &DumpDeleteOutcome,
904 _meta: &MetaContext,
905 ) -> Result<(), Diagnostic> {
906 Ok(())
907 }
908
909 fn projects(&mut self, _view: &ProjectListView, _meta: &MetaContext) -> Result<(), Diagnostic> {
910 Ok(())
911 }
912
913 fn project_describe(
914 &mut self,
915 _project: &crate::model::ProjectDetail,
916 _meta: &MetaContext,
917 ) -> Result<(), Diagnostic> {
918 Ok(())
919 }
920
921 fn data_models(&mut self, _view: &DataModelListView, _meta: &MetaContext) -> Result<(), Diagnostic> {
922 Ok(())
923 }
924
925 fn data_model_describe(
926 &mut self,
927 _detail: &crate::model::DataModelDetail,
928 _meta: &MetaContext,
929 ) -> Result<(), Diagnostic> {
930 Ok(())
931 }
932
933 fn resource_types(&mut self, _view: &ResourceTypeListView, _meta: &MetaContext) -> Result<(), Diagnostic> {
934 Ok(())
935 }
936
937 fn resource_type_describe(
938 &mut self,
939 _detail: &crate::model::ResourceTypeDetail,
940 _meta: &MetaContext,
941 ) -> Result<(), Diagnostic> {
942 Ok(())
943 }
944
945 fn data_model_structure(
946 &mut self,
947 _structure: &crate::model::DataModelStructure,
948 _meta: &MetaContext,
949 ) -> Result<(), Diagnostic> {
950 Ok(())
951 }
952
953 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
954 self.resources_view = Some(view.clone());
955 self.resources_meta = Some(meta.clone());
956 Ok(())
957 }
958
959 fn resource_describe(
960 &mut self,
961 detail: &crate::model::ResourceDetail,
962 meta: &MetaContext,
963 ) -> Result<(), Diagnostic> {
964 self.resource_describe_detail = Some(detail.clone());
965 self.resource_describe_meta = Some(meta.clone());
966 Ok(())
967 }
968
969 fn vocabularies(
970 &mut self,
971 _view: &crate::render::VocabularyListView,
972 _meta: &MetaContext,
973 ) -> Result<(), Diagnostic> {
974 unimplemented!("not exercised by this file's tests")
975 }
976
977 fn vocabulary_describe(
978 &mut self,
979 _detail: &crate::model::VocabularyDetail,
980 _meta: &MetaContext,
981 ) -> Result<(), Diagnostic> {
982 unimplemented!("not exercised by this file's tests")
983 }
984 }
985
986 const SERVER: &str = "https://api.test.dasch.swiss";
989 const PROJECT_IRI: &str = "http://rdfh.ch/projects/0801";
990 const BEOL_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
991 const LETTER_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#Letter";
992
993 fn make_cfg() -> Config {
994 Config { server: SERVER.to_string() }
995 }
996
997 fn make_project_ref() -> ProjectRef {
998 ProjectRef {
999 iri: PROJECT_IRI.to_string(),
1000 shortcode: "0801".to_string(),
1001 shortname: "beol".to_string(),
1002 }
1003 }
1004
1005 fn make_args(
1006 project: Option<&str>,
1007 resource_type: Option<&str>,
1008 data_model: Option<&str>,
1009 page: Option<u32>,
1010 all: bool,
1011 filter: Option<&str>,
1012 ) -> ResourceListArgs {
1013 make_args_with_order_by(project, resource_type, data_model, page, all, filter, None)
1014 }
1015
1016 fn make_args_with_order_by(
1017 project: Option<&str>,
1018 resource_type: Option<&str>,
1019 data_model: Option<&str>,
1020 page: Option<u32>,
1021 all: bool,
1022 filter: Option<&str>,
1023 order_by: Option<&str>,
1024 ) -> ResourceListArgs {
1025 ResourceListArgs {
1026 server: Some(SERVER.to_string()),
1027 project: project.map(str::to_owned),
1028 resource_type: resource_type.map(str::to_owned),
1029 data_model: data_model.map(str::to_owned),
1030 page,
1031 all,
1032 filter: filter.map(str::to_owned),
1033 order_by: order_by.map(str::to_owned),
1034 format: FormatArgs {
1035 format: Format::Prose,
1036 json: false,
1037 lines: false,
1038 columns: None,
1039 no_header: false,
1040 header_only: false,
1041 },
1042 }
1043 }
1044
1045 fn make_data_model(name: &str) -> DataModel {
1046 DataModel {
1047 name: name.to_string(),
1048 iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
1049 label: None,
1050 last_modified: None,
1051 is_builtin: false,
1052 }
1053 }
1054
1055 fn make_detail_with_rts(dm_name: &str, rt_names: &[&str]) -> DataModelDetail {
1056 let resource_types = rt_names
1057 .iter()
1058 .map(|name| ResourceTypeSummary {
1059 name: name.to_string(),
1060 iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2#{name}"),
1061 label: None,
1062 })
1063 .collect();
1064 DataModelDetail {
1065 name: dm_name.to_string(),
1066 iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2"),
1067 label: None,
1068 last_modified: None,
1069 resource_types,
1070 }
1071 }
1072
1073 fn make_resource(label: &str) -> ResourceSummary {
1074 ResourceSummary {
1075 label: label.to_string(),
1076 iri: format!("http://rdfh.ch/0801/{label}"),
1077 ark_url: None,
1078 creation_date: None,
1079 last_modified: None,
1080 resource_type: "Letter".to_string(),
1081 }
1082 }
1083
1084 fn make_resource_page(labels: &[&str], may_have_more: bool) -> ResourcePage {
1085 ResourcePage {
1086 resources: labels.iter().map(|l| make_resource(l)).collect(),
1087 may_have_more_results: may_have_more,
1088 }
1089 }
1090
1091 fn cache_with_entry(server: &str, token: &str, user: &str) -> AuthCache {
1092 let mut cache = AuthCache::default();
1093 cache.set_entry(
1094 server,
1095 ServerEntry {
1096 token: token.to_string(),
1097 user: Some(user.to_string()),
1098 acquired_at: None,
1099 expires_at: None,
1100 },
1101 );
1102 cache
1103 }
1104
1105 fn write_cache(dir: &TempDir, cache: &AuthCache) -> std::path::PathBuf {
1106 let path = dir.path().join("auth.toml");
1107 cache.save_to(&path).expect("failed to write test cache");
1108 path
1109 }
1110
1111 const RESOURCE_IRI: &str = "http://rdfh.ch/0803/--6Esp4SVnGG1DBzFvYErw";
1112 const OTHER_PROJECT_IRI: &str = "http://rdfh.ch/projects/9999";
1113
1114 fn make_describe_args(resource: Option<&str>, project: Option<&str>) -> ResourceDescribeArgs {
1115 make_describe_args_with_values(resource, project, false)
1116 }
1117
1118 fn make_describe_args_with_values(
1119 resource: Option<&str>,
1120 project: Option<&str>,
1121 values: bool,
1122 ) -> ResourceDescribeArgs {
1123 ResourceDescribeArgs {
1124 server: Some(SERVER.to_string()),
1125 resource: resource.map(str::to_owned),
1126 project: project.map(str::to_owned),
1127 values,
1128 format: FormatArgs {
1129 format: Format::Prose,
1130 json: false,
1131 lines: false,
1132 columns: None,
1133 no_header: false,
1134 header_only: false,
1135 },
1136 }
1137 }
1138
1139 fn make_resource_detail() -> ResourceDetail {
1140 ResourceDetail {
1141 label: "Test Page".to_string(),
1142 iri: RESOURCE_IRI.to_string(),
1143 resource_type: "Page".to_string(),
1144 ark_url: Some("ark:/72163/1/0803/--6Esp4SVnGG1DBzFvYErw".to_string()),
1145 creation_date: Some("2021-01-01T00:00:00Z".to_string()),
1146 last_modified: Some("2021-06-15T12:00:00Z".to_string()),
1147 attached_project: Some(PROJECT_IRI.to_string()),
1148 owner: Some("http://rdfh.ch/users/root".to_string()),
1149 visibility: Some(ResourceVisibility::Public),
1150 your_access: Some(ResourceAccess::View),
1151 values: None,
1152 }
1153 }
1154
1155 #[test]
1161 fn test_describe_happy_path() {
1162 let detail = make_resource_detail();
1163 let client = MockDspClient::new().with_describe_resource(Ok(detail.clone()));
1164
1165 let mut renderer = RecordingRenderer::new();
1166 let args = make_describe_args(Some(RESOURCE_IRI), None);
1167 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1168
1169 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1170 assert_eq!(client.describe_resource_calls(), 1);
1171
1172 let recorded = renderer
1173 .resource_describe_detail
1174 .expect("resource_describe must have been called");
1175 assert_eq!(recorded.label, "Test Page");
1176 assert_eq!(recorded.iri, RESOURCE_IRI);
1177 assert_eq!(recorded.resource_type, "Page");
1178 assert_eq!(recorded.visibility, Some(ResourceVisibility::Public));
1179 assert_eq!(recorded.your_access, Some(ResourceAccess::View));
1180 }
1181
1182 #[test]
1184 fn test_describe_missing_resource_is_usage_error() {
1185 let client = MockDspClient::new();
1186 let mut renderer = RecordingRenderer::new();
1187 let args = make_describe_args(None, None);
1188 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1189
1190 assert!(
1191 matches!(result, Err(Diagnostic::Usage(_))),
1192 "missing --resource must yield Usage, got {:?}",
1193 result
1194 );
1195 assert_eq!(client.describe_resource_calls(), 0);
1196 }
1197
1198 #[test]
1200 fn test_describe_missing_resource_error_mentions_flag() {
1201 let client = MockDspClient::new();
1202 let mut renderer = RecordingRenderer::new();
1203 let args = make_describe_args(None, None);
1204 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1205
1206 if let Err(Diagnostic::Usage(msg)) = result {
1207 assert!(msg.contains("--resource"), "error message must mention --resource, got: {msg}");
1208 } else {
1209 panic!("expected Usage error");
1210 }
1211 }
1212
1213 #[test]
1215 fn test_describe_cross_project_guard_match_no_error() {
1216 let detail = make_resource_detail(); let client = MockDspClient::new()
1218 .with_resolve_project(Ok(make_project_ref())) .with_describe_resource(Ok(detail));
1220
1221 let mut renderer = RecordingRenderer::new();
1222 let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1223 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1224
1225 assert!(result.is_ok(), "matching project must not error, got {:?}", result);
1226 assert!(renderer.resource_describe_detail.is_some(), "renderer must have been called");
1227 }
1228
1229 #[test]
1236 fn test_describe_cross_project_guard_mismatch_usage_error() {
1237 let mut detail = make_resource_detail();
1238 detail.attached_project = Some(OTHER_PROJECT_IRI.to_string()); let client = MockDspClient::new()
1241 .with_resolve_project(Ok(make_project_ref())) .with_describe_resource(Ok(detail));
1243
1244 let mut renderer = RecordingRenderer::new();
1245 let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1246 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1247
1248 assert!(
1249 matches!(result, Err(Diagnostic::Usage(_))),
1250 "project mismatch must yield Usage, got {:?}",
1251 result
1252 );
1253 assert!(
1254 renderer.resource_describe_detail.is_none(),
1255 "renderer must NOT be called on mismatch"
1256 );
1257
1258 if let Err(Diagnostic::Usage(msg)) = result {
1261 assert!(
1262 msg.contains(RESOURCE_IRI),
1263 "mismatch error must mention the resource IRI; got: {msg}"
1264 );
1265 assert!(
1267 msg.contains("9999"),
1268 "mismatch error must mention the actual project's local name '9999'; got: {msg}"
1269 );
1270 assert!(
1272 msg.contains("0801"),
1273 "mismatch error must mention the expected project's local name '0801'; got: {msg}"
1274 );
1275 } else {
1276 panic!("expected Err(Diagnostic::Usage(_))");
1277 }
1278 }
1279
1280 #[test]
1284 fn test_describe_cross_project_guard_none_passes_through() {
1285 let mut detail = make_resource_detail();
1286 detail.attached_project = None; let client = MockDspClient::new()
1289 .with_resolve_project(Ok(make_project_ref()))
1290 .with_describe_resource(Ok(detail));
1291
1292 let mut renderer = RecordingRenderer::new();
1293 let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1294 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1295
1296 assert!(result.is_ok(), "None attached_project must not error, got {:?}", result);
1297 assert!(renderer.resource_describe_detail.is_some(), "renderer must have been called");
1298 }
1299
1300 #[test]
1302 fn test_describe_no_project_guard_skipped() {
1303 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1304
1305 let mut renderer = RecordingRenderer::new();
1306 let args = make_describe_args(Some(RESOURCE_IRI), None);
1307 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1308
1309 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1310 assert_eq!(
1311 client.resolve_calls(),
1312 0,
1313 "resolve_project must not be called when --project is omitted"
1314 );
1315 }
1316
1317 #[test]
1319 fn test_describe_filter_warning_anonymous() {
1320 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1321
1322 let mut renderer = RecordingRenderer::new();
1323 let args = make_describe_args(Some(RESOURCE_IRI), None);
1324 run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1325
1326 let meta = renderer.resource_describe_meta.expect("meta must be present");
1327 assert_eq!(meta.auth_state, "anonymous");
1328 let fw = meta.filter_warning.expect("filter_warning must be Some for anonymous");
1329 assert_eq!(fw, "results may be filtered; login to see private resources");
1330 }
1331
1332 #[test]
1334 fn test_describe_filter_warning_authenticated() {
1335 let dir = TempDir::new().expect("tempdir");
1336 let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1337 let path = write_cache(&dir, &cache);
1338
1339 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1340
1341 let mut renderer = RecordingRenderer::new();
1342 let args = make_describe_args(Some(RESOURCE_IRI), None);
1343 run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&path)).expect("expected Ok");
1344
1345 let meta = renderer.resource_describe_meta.expect("meta must be present");
1346 let fw = meta.filter_warning.expect("filter_warning must be Some for authenticated");
1347 assert_eq!(fw, "results limited to your permissions");
1348 }
1349
1350 #[test]
1352 fn test_describe_not_found_propagates() {
1353 let client = MockDspClient::new()
1354 .with_describe_resource(Err(Diagnostic::NotFound("resource not found on server".to_string())));
1355
1356 let mut renderer = RecordingRenderer::new();
1357 let args = make_describe_args(Some(RESOURCE_IRI), None);
1358 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1359
1360 assert!(
1361 matches!(result, Err(Diagnostic::NotFound(_))),
1362 "not-found from client must propagate, got {:?}",
1363 result
1364 );
1365 assert!(
1366 renderer.resource_describe_detail.is_none(),
1367 "renderer must not be called on error"
1368 );
1369 }
1370
1371 #[test]
1373 fn test_describe_token_forwarded() {
1374 let dir = TempDir::new().expect("tempdir");
1375 let cache = cache_with_entry(SERVER, "my-describe-token", "user@example.com");
1376 let path = write_cache(&dir, &cache);
1377
1378 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1382
1383 let mut renderer = RecordingRenderer::new();
1384 let args = make_describe_args(Some(RESOURCE_IRI), None);
1385 run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&path)).expect("expected Ok");
1386
1387 let meta = renderer.resource_describe_meta.expect("meta must be present");
1389 let fw = meta.filter_warning.expect("filter_warning must be present");
1391 assert_eq!(fw, "results limited to your permissions");
1392 }
1393
1394 #[test]
1396 fn test_describe_values_flag_absent_passes_false() {
1397 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1398 let mut renderer = RecordingRenderer::new();
1399 let args = make_describe_args(Some(RESOURCE_IRI), None); run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1401
1402 let with_values_args = client.describe_resource_with_values_args();
1403 assert_eq!(with_values_args.len(), 1);
1404 assert!(!with_values_args[0], "with_values must be false when --values is absent");
1405 }
1406
1407 #[test]
1409 fn test_describe_values_flag_present_passes_true() {
1410 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1411 let mut renderer = RecordingRenderer::new();
1412 let args = make_describe_args_with_values(Some(RESOURCE_IRI), None, true);
1413 run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1414
1415 let with_values_args = client.describe_resource_with_values_args();
1416 assert_eq!(with_values_args.len(), 1);
1417 assert!(with_values_args[0], "with_values must be true when --values is given");
1418 }
1419
1420 #[test]
1422 fn test_describe_corrupt_cache_falls_back_to_anonymous() {
1423 let dir = TempDir::new().expect("tempdir");
1424 let bad_path = dir.path().join("nonexistent_auth.toml");
1425
1426 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1427
1428 let mut renderer = RecordingRenderer::new();
1429 let args = make_describe_args(Some(RESOURCE_IRI), None);
1430 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&bad_path));
1431 assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
1432
1433 let meta = renderer.resource_describe_meta.expect("meta must be present");
1434 assert_eq!(meta.auth_state, "anonymous");
1435 }
1436
1437 #[test]
1441 fn test_list_full_iri_bypass_skips_scan() {
1442 let client = MockDspClient::new()
1443 .with_resolve_project(Ok(make_project_ref()))
1444 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1445
1446 let mut renderer = RecordingRenderer::new();
1447 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1449 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1450
1451 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1452 assert_eq!(
1453 client.list_data_models_calls(),
1454 0,
1455 "full IRI must not trigger a data-model scan"
1456 );
1457 assert_eq!(client.list_resources_calls(), 1);
1458
1459 let view = renderer.resources_view.expect("resources must have been called");
1460 assert_eq!(view.items.len(), 1);
1461 assert_eq!(view.resource_type, "Letter"); }
1463
1464 #[test]
1466 fn test_list_full_iri_passes_correct_params() {
1467 let client = MockDspClient::new()
1468 .with_resolve_project(Ok(make_project_ref()))
1469 .with_list_resources(Ok(make_resource_page(&[], false)));
1470
1471 let mut renderer = RecordingRenderer::new();
1472 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1473 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1474
1475 let calls = client.list_resources_args();
1476 assert_eq!(calls.len(), 1);
1477 assert_eq!(calls[0].0, PROJECT_IRI, "project_iri must be the resolved IRI");
1478 assert_eq!(calls[0].1, LETTER_IRI, "resource_type_iri must be the full IRI");
1479 assert_eq!(calls[0].2, None, "order_by is None (placeholder)");
1480 assert_eq!(calls[0].3, 0, "default page is 0");
1481 assert_eq!(calls[0].4, None, "no token for anonymous");
1482 }
1483
1484 #[test]
1486 fn test_list_bare_name_scan_single_match() {
1487 let client = MockDspClient::new()
1488 .with_resolve_project(Ok(make_project_ref()))
1489 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1490 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1491 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1492
1493 let mut renderer = RecordingRenderer::new();
1494 let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1495 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1496
1497 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1498 let view = renderer.resources_view.expect("must have been called");
1499 assert_eq!(view.resource_type, "Letter");
1500 assert_eq!(view.items.len(), 1);
1501 }
1502
1503 #[test]
1505 fn test_list_bare_name_scan_not_found() {
1506 let client = MockDspClient::new()
1507 .with_resolve_project(Ok(make_project_ref()))
1508 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1509 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])));
1510
1511 let mut renderer = RecordingRenderer::new();
1512 let args = make_args(Some("0801"), Some("Nonexistent"), None, None, false, None);
1513 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1514
1515 assert!(
1516 matches!(result, Err(Diagnostic::NotFound(_))),
1517 "unknown resource-type must yield NotFound, got {:?}",
1518 result
1519 );
1520 assert_eq!(client.list_resources_calls(), 0);
1521 }
1522
1523 #[test]
1525 fn test_list_bare_name_ambiguous_gives_usage_error() {
1526 let client = MockDspClient::new()
1527 .with_resolve_project(Ok(make_project_ref()))
1528 .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
1529 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1530 .with_describe_data_model(Ok(make_detail_with_rts("webern", &["Letter", "Score"])));
1531
1532 let mut renderer = RecordingRenderer::new();
1533 let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1535 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1536
1537 assert!(
1538 matches!(result, Err(Diagnostic::Usage(_))),
1539 "ambiguous resource-type must yield Usage error, got {:?}",
1540 result
1541 );
1542 if let Err(Diagnostic::Usage(msg)) = &result {
1543 assert!(
1544 msg.contains("beol") && msg.contains("webern"),
1545 "Usage error must list both data-models, got: {msg}"
1546 );
1547 assert!(
1548 msg.contains("--data-model"),
1549 "Usage error must suggest --data-model, got: {msg}"
1550 );
1551 }
1552 assert_eq!(client.list_resources_calls(), 0);
1553 }
1554
1555 #[test]
1557 fn test_list_data_model_name_scope() {
1558 let client = MockDspClient::new()
1559 .with_resolve_project(Ok(make_project_ref()))
1560 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1561 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1562 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1563
1564 let mut renderer = RecordingRenderer::new();
1565 let args = make_args(Some("0801"), Some("Letter"), Some("beol"), None, false, None);
1566 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1567
1568 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1569 let view = renderer.resources_view.expect("must have been called");
1570 assert_eq!(view.items.len(), 1);
1571 }
1572
1573 #[test]
1575 fn test_list_data_model_full_iri_scope_skips_list() {
1576 let client = MockDspClient::new()
1577 .with_resolve_project(Ok(make_project_ref()))
1578 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1579 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1580
1581 let mut renderer = RecordingRenderer::new();
1582 let args = make_args(Some("0801"), Some("Letter"), Some(BEOL_IRI), None, false, None);
1583 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1584
1585 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1586 assert_eq!(
1587 client.list_data_models_calls(),
1588 0,
1589 "--data-model as full IRI must skip list_data_models"
1590 );
1591 }
1592
1593 #[test]
1595 fn test_list_single_page_no_more() {
1596 let client = MockDspClient::new()
1597 .with_resolve_project(Ok(make_project_ref()))
1598 .with_list_resources(Ok(make_resource_page(&["A", "B"], false)));
1599
1600 let mut renderer = RecordingRenderer::new();
1601 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1602 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1603
1604 let view = renderer.resources_view.expect("must have been called");
1605 assert_eq!(view.items.len(), 2);
1606 assert_eq!(view.total, 2);
1607 match &view.pagination {
1608 crate::render::ResourceListPagination::SinglePage { page, may_have_more } => {
1609 assert_eq!(*page, 0);
1610 assert!(!may_have_more);
1611 }
1612 other => panic!("expected SinglePage, got {:?}", other),
1613 }
1614 }
1615
1616 #[test]
1618 fn test_list_single_page_has_more() {
1619 let client = MockDspClient::new()
1620 .with_resolve_project(Ok(make_project_ref()))
1621 .with_list_resources(Ok(make_resource_page(&["A", "B"], true)));
1622
1623 let mut renderer = RecordingRenderer::new();
1624 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1625 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1626
1627 let view = renderer.resources_view.expect("must have been called");
1628 match &view.pagination {
1629 crate::render::ResourceListPagination::SinglePage { may_have_more, .. } => {
1630 assert!(*may_have_more);
1631 }
1632 other => panic!("expected SinglePage, got {:?}", other),
1633 }
1634 }
1635
1636 #[test]
1638 fn test_list_explicit_page_passed_to_client() {
1639 let client = MockDspClient::new()
1640 .with_resolve_project(Ok(make_project_ref()))
1641 .with_list_resources(Ok(make_resource_page(&["A"], false)));
1642
1643 let mut renderer = RecordingRenderer::new();
1644 let args = make_args(Some("0801"), Some(LETTER_IRI), None, Some(3), false, None);
1645 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1646
1647 let calls = client.list_resources_args();
1648 assert_eq!(calls[0].3, 3, "--page 3 must be passed to list_resources");
1649
1650 let view = renderer.resources_view.expect("must have been called");
1651 match &view.pagination {
1652 crate::render::ResourceListPagination::SinglePage { page, .. } => {
1653 assert_eq!(*page, 3);
1654 }
1655 other => panic!("expected SinglePage, got {:?}", other),
1656 }
1657 }
1658
1659 #[test]
1661 fn test_list_all_multi_page() {
1662 let client = MockDspClient::new()
1663 .with_resolve_project(Ok(make_project_ref()))
1664 .with_list_resources(Ok(make_resource_page(&["A", "B"], true)))
1665 .with_list_resources(Ok(make_resource_page(&["C"], false)));
1666
1667 let mut renderer = RecordingRenderer::new();
1668 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1669 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1670
1671 assert_eq!(client.list_resources_calls(), 2);
1672 let calls = client.list_resources_args();
1674 assert_eq!(calls[0].3, 0);
1675 assert_eq!(calls[1].3, 1);
1676
1677 let view = renderer.resources_view.expect("must have been called");
1678 assert_eq!(view.items.len(), 3, "all pages merged");
1679 assert_eq!(view.total, 3);
1680 match &view.pagination {
1681 crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1682 assert_eq!(*pages_fetched, 2);
1683 }
1684 other => panic!("expected AllPages, got {:?}", other),
1685 }
1686 }
1687
1688 #[test]
1690 fn test_list_all_empty_final_page() {
1691 let client = MockDspClient::new()
1693 .with_resolve_project(Ok(make_project_ref()))
1694 .with_list_resources(Ok(make_resource_page(&["A"], true)))
1695 .with_list_resources(Ok(make_resource_page(&[], false)));
1696
1697 let mut renderer = RecordingRenderer::new();
1698 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1699 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1700
1701 assert_eq!(client.list_resources_calls(), 2);
1702 let view = renderer.resources_view.expect("must have been called");
1703 assert_eq!(view.items.len(), 1, "empty final page contributes zero resources");
1704 match &view.pagination {
1705 crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1706 assert_eq!(*pages_fetched, 2);
1707 }
1708 other => panic!("expected AllPages, got {:?}", other),
1709 }
1710 }
1711
1712 #[test]
1714 fn test_list_all_propagates_error_mid_loop() {
1715 let client = MockDspClient::new()
1716 .with_resolve_project(Ok(make_project_ref()))
1717 .with_list_resources(Ok(make_resource_page(&["A"], true)))
1718 .with_list_resources(Err(Diagnostic::ServerError("server exploded".into())));
1719
1720 let mut renderer = RecordingRenderer::new();
1721 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1722 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1723
1724 assert!(
1725 matches!(result, Err(Diagnostic::ServerError(_))),
1726 "mid-loop error must propagate, got {:?}",
1727 result
1728 );
1729 assert!(renderer.resources_view.is_none(), "no partial result must be rendered on error");
1730 }
1731
1732 #[test]
1734 fn test_list_empty_result() {
1735 let client = MockDspClient::new()
1736 .with_resolve_project(Ok(make_project_ref()))
1737 .with_list_resources(Ok(make_resource_page(&[], false)));
1738
1739 let mut renderer = RecordingRenderer::new();
1740 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1741 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1742
1743 let view = renderer.resources_view.expect("must have been called");
1744 assert!(view.items.is_empty());
1745 assert_eq!(view.total, 0);
1746 }
1747
1748 #[test]
1750 fn test_list_filter_narrows_total_is_prefilter() {
1751 let client = MockDspClient::new()
1752 .with_resolve_project(Ok(make_project_ref()))
1753 .with_list_resources(Ok(make_resource_page(&["AliceA", "BobB", "AliceC"], false)));
1754
1755 let mut renderer = RecordingRenderer::new();
1756 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, Some("alice"));
1757 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1758
1759 let view = renderer.resources_view.expect("must have been called");
1760 assert_eq!(view.total, 3, "total is pre-filter");
1761 assert_eq!(view.items.len(), 2, "filter keeps only Alice items");
1762 assert!(view.items.iter().all(|r| r.label.to_lowercase().contains("alice")));
1763 assert_eq!(view.filter.as_deref(), Some("alice"));
1764 }
1765
1766 #[test]
1768 fn test_list_missing_project_is_usage_error() {
1769 let client = MockDspClient::new();
1770 let mut renderer = RecordingRenderer::new();
1771 let args = make_args(None, Some(LETTER_IRI), None, None, false, None);
1772 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1773
1774 assert!(
1775 matches!(result, Err(Diagnostic::Usage(_))),
1776 "missing --project must yield Usage, got {:?}",
1777 result
1778 );
1779 assert_eq!(client.resolve_calls(), 0);
1780 }
1781
1782 #[test]
1784 fn test_list_missing_resource_type_is_usage_error() {
1785 let client = MockDspClient::new();
1786 let mut renderer = RecordingRenderer::new();
1787 let args = make_args(Some("0801"), None, None, None, false, None);
1788 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1789
1790 assert!(
1791 matches!(result, Err(Diagnostic::Usage(_))),
1792 "missing --resource-type must yield Usage, got {:?}",
1793 result
1794 );
1795 assert_eq!(client.resolve_calls(), 0);
1796 }
1797
1798 #[test]
1800 fn test_list_filter_warning_anonymous() {
1801 let client = MockDspClient::new()
1802 .with_resolve_project(Ok(make_project_ref()))
1803 .with_list_resources(Ok(make_resource_page(&[], false)));
1804
1805 let mut renderer = RecordingRenderer::new();
1806 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1807 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1808
1809 let meta = renderer.resources_meta.expect("meta must be present");
1810 assert_eq!(meta.auth_state, "anonymous");
1811 let fw = meta.filter_warning.expect("filter_warning must be Some for anonymous");
1812 assert_eq!(fw, "results may be filtered; login to see private resources");
1813 }
1814
1815 #[test]
1817 fn test_list_filter_warning_authenticated() {
1818 let dir = TempDir::new().expect("tempdir");
1819 let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1820 let path = write_cache(&dir, &cache);
1821
1822 let client = MockDspClient::new()
1823 .with_resolve_project(Ok(make_project_ref()))
1824 .with_list_resources(Ok(make_resource_page(&[], false)));
1825
1826 let mut renderer = RecordingRenderer::new();
1827 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1828 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&path)).expect("expected Ok");
1829
1830 let meta = renderer.resources_meta.expect("meta must be present");
1831 let fw = meta.filter_warning.expect("filter_warning must be Some for authenticated");
1832 assert_eq!(fw, "results limited to your permissions");
1833 }
1834
1835 #[test]
1837 fn test_list_corrupt_cache_falls_back_to_anonymous() {
1838 let dir = TempDir::new().expect("tempdir");
1839 let bad_path = dir.path().join("nonexistent_auth.toml");
1840
1841 let client = MockDspClient::new()
1842 .with_resolve_project(Ok(make_project_ref()))
1843 .with_list_resources(Ok(make_resource_page(&[], false)));
1844
1845 let mut renderer = RecordingRenderer::new();
1846 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1847 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&bad_path));
1848 assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
1849
1850 let meta = renderer.resources_meta.expect("meta must be present");
1851 assert_eq!(meta.auth_state, "anonymous");
1852 }
1853
1854 #[test]
1862 fn test_list_mid_scan_describe_error_propagates() {
1863 let client = MockDspClient::new()
1864 .with_resolve_project(Ok(make_project_ref()))
1865 .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
1866 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1868 .with_describe_data_model(Err(Diagnostic::ServerError("data-model service unavailable".into())));
1870
1871 let mut renderer = RecordingRenderer::new();
1872 let args = make_args(Some("0801"), Some("Score"), None, None, false, None);
1874 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1875
1876 assert!(
1877 matches!(result, Err(Diagnostic::ServerError(_))),
1878 "mid-scan describe_data_model error must propagate as ServerError, got {:?}",
1879 result
1880 );
1881 assert_eq!(
1882 client.list_resources_calls(),
1883 0,
1884 "list_resources must never be called when describe_data_model errors mid-scan"
1885 );
1886 }
1887
1888 #[test]
1890 fn test_list_token_forwarded_to_list_resources() {
1891 let dir = TempDir::new().expect("tempdir");
1892 let cache = cache_with_entry(SERVER, "my-token", "user@example.com");
1893 let path = write_cache(&dir, &cache);
1894
1895 let client = MockDspClient::new()
1896 .with_resolve_project(Ok(make_project_ref()))
1897 .with_list_resources(Ok(make_resource_page(&[], false)));
1898
1899 let mut renderer = RecordingRenderer::new();
1900 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1901 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, Some(&path)).expect("expected Ok");
1902
1903 let calls = client.list_resources_args();
1904 assert_eq!(
1905 calls[0].4,
1906 Some("my-token".to_string()),
1907 "token must be forwarded to list_resources"
1908 );
1909 }
1910
1911 fn make_rt_detail_with_fields(rt_name: &str, field_names: &[(&str, &str)]) -> ResourceTypeDetail {
1915 let fields = field_names
1916 .iter()
1917 .map(|(name, iri)| Field {
1918 name: name.to_string(),
1919 iri: iri.to_string(),
1920 label: None,
1921 value_type: crate::model::resource_type::ValueType::Text,
1922 link_target: None,
1923 cardinality: Cardinality::ZeroOrOne,
1924 is_builtin: false,
1925 data_model: Some("beol".to_string()),
1926 })
1927 .collect();
1928 ResourceTypeDetail {
1929 name: rt_name.to_string(),
1930 iri: LETTER_IRI.to_string(),
1931 label: None,
1932 data_model: "beol".to_string(),
1933 representation: None,
1934 super_types: vec![],
1935 fields,
1936 count: None,
1937 }
1938 }
1939
1940 const TITLE_PROP_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#hasTitle";
1941
1942 #[test]
1944 fn test_order_by_bare_name_resolves_to_property_iri() {
1945 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
1946 let client = MockDspClient::new()
1947 .with_resolve_project(Ok(make_project_ref()))
1948 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1949 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1950 .with_describe_resource_type(rt_detail)
1951 .with_list_resources(Ok(make_resource_page(&["A"], false)));
1952
1953 let mut renderer = RecordingRenderer::new();
1954 let args = make_args_with_order_by(Some("0801"), Some("Letter"), None, None, false, None, Some("hasTitle"));
1955 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1956
1957 assert_eq!(
1958 client.describe_resource_type_calls(),
1959 1,
1960 "bare field name must call describe_resource_type"
1961 );
1962 let calls = client.list_resources_args();
1963 assert_eq!(calls.len(), 1);
1964 assert_eq!(
1965 calls[0].2,
1966 Some(TITLE_PROP_IRI.to_string()),
1967 "order_by must be the resolved property IRI"
1968 );
1969 }
1970
1971 #[test]
1973 fn test_order_by_full_iri_bypass_skips_describe_resource_type() {
1974 let client = MockDspClient::new()
1975 .with_resolve_project(Ok(make_project_ref()))
1976 .with_list_resources(Ok(make_resource_page(&["A"], false)));
1977
1978 let mut renderer = RecordingRenderer::new();
1979 let args =
1980 make_args_with_order_by(Some("0801"), Some(LETTER_IRI), None, None, false, None, Some(TITLE_PROP_IRI));
1981 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1982
1983 assert_eq!(
1984 client.describe_resource_type_calls(),
1985 0,
1986 "full-IRI bypass must NOT call describe_resource_type"
1987 );
1988 let calls = client.list_resources_args();
1989 assert_eq!(calls.len(), 1);
1990 assert_eq!(
1991 calls[0].2,
1992 Some(TITLE_PROP_IRI.to_string()),
1993 "order_by must be the verbatim IRI"
1994 );
1995 }
1996
1997 #[test]
1999 fn test_order_by_path_a_with_fragment_resolves() {
2000 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2002 let client = MockDspClient::new()
2003 .with_resolve_project(Ok(make_project_ref()))
2004 .with_describe_resource_type(rt_detail)
2005 .with_list_resources(Ok(make_resource_page(&["A"], false)));
2006
2007 let mut renderer = RecordingRenderer::new();
2008 let args = make_args_with_order_by(
2009 Some("0801"),
2010 Some(LETTER_IRI), None,
2012 None,
2013 false,
2014 None,
2015 Some("hasTitle"),
2016 );
2017 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2018
2019 assert_eq!(
2020 client.describe_resource_type_calls(),
2021 1,
2022 "bare field name with path-A resource-type must call describe_resource_type"
2023 );
2024 let calls = client.list_resources_args();
2025 assert_eq!(
2026 calls[0].2,
2027 Some(TITLE_PROP_IRI.to_string()),
2028 "order_by must be the resolved property IRI"
2029 );
2030 }
2031
2032 #[test]
2034 fn test_order_by_path_a_no_fragment_bare_name_is_usage_error() {
2035 let no_fragment_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2/Letter";
2038 let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
2039
2040 let mut renderer = RecordingRenderer::new();
2041 let args =
2042 make_args_with_order_by(Some("0801"), Some(no_fragment_iri), None, None, false, None, Some("hasTitle"));
2043 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2044
2045 assert!(
2046 matches!(result, Err(Diagnostic::Usage(_))),
2047 "path-A IRI lacking '#' + bare order-by must yield Usage, got {:?}",
2048 result
2049 );
2050 assert_eq!(
2051 client.describe_resource_type_calls(),
2052 0,
2053 "describe_resource_type must not be called when error is expected"
2054 );
2055 }
2056
2057 #[test]
2059 fn test_order_by_unknown_field_is_usage_error() {
2060 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2061 let client = MockDspClient::new()
2063 .with_resolve_project(Ok(make_project_ref()))
2064 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2065 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2066 .with_describe_resource_type(rt_detail);
2067
2068 let mut renderer = RecordingRenderer::new();
2069 let args =
2070 make_args_with_order_by(Some("0801"), Some("Letter"), None, None, false, None, Some("nonExistentField"));
2071 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2072
2073 assert!(
2074 matches!(result, Err(Diagnostic::Usage(_))),
2075 "unknown field name must yield Usage, got {:?}",
2076 result
2077 );
2078 if let Err(Diagnostic::Usage(msg)) = &result {
2079 assert!(
2080 msg.contains("nonExistentField"),
2081 "Usage error must echo the field name, got: {msg}"
2082 );
2083 assert!(
2084 msg.contains("resource-type describe"),
2085 "Usage error must hint at resource-type describe, got: {msg}"
2086 );
2087 }
2088 assert_eq!(client.describe_resource_type_calls(), 1);
2090 assert_eq!(
2091 client.list_resources_calls(),
2092 0,
2093 "list_resources must not be called when order-by resolution fails"
2094 );
2095 }
2096
2097 #[test]
2099 fn test_order_by_absent_passes_none_to_list_resources() {
2100 let client = MockDspClient::new()
2101 .with_resolve_project(Ok(make_project_ref()))
2102 .with_list_resources(Ok(make_resource_page(&[], false)));
2103
2104 let mut renderer = RecordingRenderer::new();
2105 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2106 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2107
2108 let calls = client.list_resources_args();
2109 assert_eq!(calls[0].2, None, "--order-by absent must pass None to list_resources");
2110 assert_eq!(
2111 client.describe_resource_type_calls(),
2112 0,
2113 "describe_resource_type must not be called when --order-by is absent"
2114 );
2115 }
2116
2117 #[test]
2119 fn test_order_by_with_all_threads_iri_to_every_page() {
2120 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2121 let client = MockDspClient::new()
2122 .with_resolve_project(Ok(make_project_ref()))
2123 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2124 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2125 .with_describe_resource_type(rt_detail)
2126 .with_list_resources(Ok(make_resource_page(&["A"], true)))
2127 .with_list_resources(Ok(make_resource_page(&["B"], false)));
2128
2129 let mut renderer = RecordingRenderer::new();
2130 let args = make_args_with_order_by(
2131 Some("0801"),
2132 Some("Letter"),
2133 None,
2134 None,
2135 true, None,
2137 Some("hasTitle"),
2138 );
2139 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2140
2141 assert_eq!(client.list_resources_calls(), 2);
2142 let calls = client.list_resources_args();
2143 assert_eq!(
2144 calls[0].2,
2145 Some(TITLE_PROP_IRI.to_string()),
2146 "order_by must be threaded to page 0"
2147 );
2148 assert_eq!(
2149 calls[1].2,
2150 Some(TITLE_PROP_IRI.to_string()),
2151 "order_by must be threaded to page 1"
2152 );
2153 }
2154
2155 #[test]
2157 fn test_order_by_field_name_is_case_insensitive() {
2158 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2159 let client = MockDspClient::new()
2160 .with_resolve_project(Ok(make_project_ref()))
2161 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2162 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2163 .with_describe_resource_type(rt_detail)
2164 .with_list_resources(Ok(make_resource_page(&[], false)));
2165
2166 let mut renderer = RecordingRenderer::new();
2167 let args = make_args_with_order_by(Some("0801"), Some("Letter"), None, None, false, None, Some("HASTITLE"));
2168 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2169
2170 let calls = client.list_resources_args();
2171 assert_eq!(
2172 calls[0].2,
2173 Some(TITLE_PROP_IRI.to_string()),
2174 "case-insensitive field name match must resolve to the property IRI"
2175 );
2176 }
2177}