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