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
910 struct RecordingRenderer {
913 resources_view: Option<ResourceListView>,
914 resources_meta: Option<MetaContext>,
915 resource_describe_detail: Option<ResourceDetail>,
916 resource_describe_meta: Option<MetaContext>,
917 }
918
919 impl RecordingRenderer {
920 fn new() -> Self {
921 Self {
922 resources_view: None,
923 resources_meta: None,
924 resource_describe_detail: None,
925 resource_describe_meta: None,
926 }
927 }
928 }
929
930 impl Renderer for RecordingRenderer {
931 fn diagnostic(
932 &mut self,
933 _diag: &Diagnostic,
934 _meta: &MetaContext,
935 ) -> Result<(), Diagnostic> {
936 Ok(())
937 }
938
939 fn auth_login(
940 &mut self,
941 _outcome: &AuthLoginOutcome,
942 _meta: &MetaContext,
943 ) -> Result<(), Diagnostic> {
944 Ok(())
945 }
946
947 fn auth_status(
948 &mut self,
949 _outcome: &AuthStatusOutcome,
950 _meta: &MetaContext,
951 ) -> Result<(), Diagnostic> {
952 Ok(())
953 }
954
955 fn auth_logout(
956 &mut self,
957 _outcome: &AuthLogoutOutcome,
958 _meta: &MetaContext,
959 ) -> Result<(), Diagnostic> {
960 Ok(())
961 }
962
963 fn auth_set_token(
964 &mut self,
965 _outcome: &AuthSetTokenOutcome,
966 _meta: &MetaContext,
967 ) -> Result<(), Diagnostic> {
968 Ok(())
969 }
970
971 fn project_dump(
972 &mut self,
973 _outcome: &DumpOutcome,
974 _meta: &MetaContext,
975 ) -> Result<(), Diagnostic> {
976 Ok(())
977 }
978
979 fn project_dump_deleted(
980 &mut self,
981 _outcome: &DumpDeleteOutcome,
982 _meta: &MetaContext,
983 ) -> Result<(), Diagnostic> {
984 Ok(())
985 }
986
987 fn projects(
988 &mut self,
989 _view: &ProjectListView,
990 _meta: &MetaContext,
991 ) -> Result<(), Diagnostic> {
992 Ok(())
993 }
994
995 fn project_describe(
996 &mut self,
997 _project: &crate::model::ProjectDetail,
998 _meta: &MetaContext,
999 ) -> Result<(), Diagnostic> {
1000 Ok(())
1001 }
1002
1003 fn data_models(
1004 &mut self,
1005 _view: &DataModelListView,
1006 _meta: &MetaContext,
1007 ) -> Result<(), Diagnostic> {
1008 Ok(())
1009 }
1010
1011 fn data_model_describe(
1012 &mut self,
1013 _detail: &crate::model::DataModelDetail,
1014 _meta: &MetaContext,
1015 ) -> Result<(), Diagnostic> {
1016 Ok(())
1017 }
1018
1019 fn resource_types(
1020 &mut self,
1021 _view: &ResourceTypeListView,
1022 _meta: &MetaContext,
1023 ) -> Result<(), Diagnostic> {
1024 Ok(())
1025 }
1026
1027 fn resource_type_describe(
1028 &mut self,
1029 _detail: &crate::model::ResourceTypeDetail,
1030 _meta: &MetaContext,
1031 ) -> Result<(), Diagnostic> {
1032 Ok(())
1033 }
1034
1035 fn data_model_structure(
1036 &mut self,
1037 _structure: &crate::model::DataModelStructure,
1038 _meta: &MetaContext,
1039 ) -> Result<(), Diagnostic> {
1040 Ok(())
1041 }
1042
1043 fn resources(
1044 &mut self,
1045 view: &ResourceListView,
1046 meta: &MetaContext,
1047 ) -> Result<(), Diagnostic> {
1048 self.resources_view = Some(view.clone());
1049 self.resources_meta = Some(meta.clone());
1050 Ok(())
1051 }
1052
1053 fn resource_describe(
1054 &mut self,
1055 detail: &crate::model::ResourceDetail,
1056 meta: &MetaContext,
1057 ) -> Result<(), Diagnostic> {
1058 self.resource_describe_detail = Some(detail.clone());
1059 self.resource_describe_meta = Some(meta.clone());
1060 Ok(())
1061 }
1062
1063 fn vocabularies(
1064 &mut self,
1065 _view: &crate::render::VocabularyListView,
1066 _meta: &MetaContext,
1067 ) -> Result<(), Diagnostic> {
1068 unimplemented!("not exercised by this file's tests")
1069 }
1070
1071 fn vocabulary_describe(
1072 &mut self,
1073 _detail: &crate::model::VocabularyDetail,
1074 _meta: &MetaContext,
1075 ) -> Result<(), Diagnostic> {
1076 unimplemented!("not exercised by this file's tests")
1077 }
1078 }
1079
1080 const SERVER: &str = "https://api.test.dasch.swiss";
1083 const PROJECT_IRI: &str = "http://rdfh.ch/projects/0801";
1084 const BEOL_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2";
1085 const LETTER_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#Letter";
1086
1087 fn make_cfg() -> Config {
1088 Config {
1089 server: SERVER.to_string(),
1090 }
1091 }
1092
1093 fn make_project_ref() -> ProjectRef {
1094 ProjectRef {
1095 iri: PROJECT_IRI.to_string(),
1096 shortcode: "0801".to_string(),
1097 shortname: "beol".to_string(),
1098 }
1099 }
1100
1101 fn make_args(
1102 project: Option<&str>,
1103 resource_type: Option<&str>,
1104 data_model: Option<&str>,
1105 page: Option<u32>,
1106 all: bool,
1107 filter: Option<&str>,
1108 ) -> ResourceListArgs {
1109 make_args_with_order_by(project, resource_type, data_model, page, all, filter, None)
1110 }
1111
1112 fn make_args_with_order_by(
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 order_by: Option<&str>,
1120 ) -> ResourceListArgs {
1121 ResourceListArgs {
1122 server: Some(SERVER.to_string()),
1123 project: project.map(str::to_owned),
1124 resource_type: resource_type.map(str::to_owned),
1125 data_model: data_model.map(str::to_owned),
1126 page,
1127 all,
1128 filter: filter.map(str::to_owned),
1129 order_by: order_by.map(str::to_owned),
1130 format: FormatArgs {
1131 format: Format::Prose,
1132 json: false,
1133 lines: false,
1134 columns: None,
1135 no_header: false,
1136 header_only: false,
1137 },
1138 }
1139 }
1140
1141 fn make_data_model(name: &str) -> DataModel {
1142 DataModel {
1143 name: name.to_string(),
1144 iri: format!("http://api.test.dasch.swiss/ontology/0801/{name}/v2"),
1145 label: None,
1146 last_modified: None,
1147 is_builtin: false,
1148 }
1149 }
1150
1151 fn make_detail_with_rts(dm_name: &str, rt_names: &[&str]) -> DataModelDetail {
1152 let resource_types = rt_names
1153 .iter()
1154 .map(|name| ResourceTypeSummary {
1155 name: name.to_string(),
1156 iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2#{name}"),
1157 label: None,
1158 })
1159 .collect();
1160 DataModelDetail {
1161 name: dm_name.to_string(),
1162 iri: format!("http://api.test.dasch.swiss/ontology/0801/{dm_name}/v2"),
1163 label: None,
1164 last_modified: None,
1165 resource_types,
1166 }
1167 }
1168
1169 fn make_resource(label: &str) -> ResourceSummary {
1170 ResourceSummary {
1171 label: label.to_string(),
1172 iri: format!("http://rdfh.ch/0801/{label}"),
1173 ark_url: None,
1174 creation_date: None,
1175 last_modified: None,
1176 resource_type: "Letter".to_string(),
1177 }
1178 }
1179
1180 fn make_resource_page(labels: &[&str], may_have_more: bool) -> ResourcePage {
1181 ResourcePage {
1182 resources: labels.iter().map(|l| make_resource(l)).collect(),
1183 may_have_more_results: may_have_more,
1184 }
1185 }
1186
1187 fn cache_with_entry(server: &str, token: &str, user: &str) -> AuthCache {
1188 let mut cache = AuthCache::default();
1189 cache.set_entry(
1190 server,
1191 ServerEntry {
1192 token: token.to_string(),
1193 user: Some(user.to_string()),
1194 acquired_at: None,
1195 expires_at: None,
1196 },
1197 );
1198 cache
1199 }
1200
1201 fn write_cache(dir: &TempDir, cache: &AuthCache) -> std::path::PathBuf {
1202 let path = dir.path().join("auth.toml");
1203 cache.save_to(&path).expect("failed to write test cache");
1204 path
1205 }
1206
1207 const RESOURCE_IRI: &str = "http://rdfh.ch/0803/--6Esp4SVnGG1DBzFvYErw";
1208 const OTHER_PROJECT_IRI: &str = "http://rdfh.ch/projects/9999";
1209
1210 fn make_describe_args(resource: Option<&str>, project: Option<&str>) -> ResourceDescribeArgs {
1211 make_describe_args_with_values(resource, project, false)
1212 }
1213
1214 fn make_describe_args_with_values(
1215 resource: Option<&str>,
1216 project: Option<&str>,
1217 values: bool,
1218 ) -> ResourceDescribeArgs {
1219 ResourceDescribeArgs {
1220 server: Some(SERVER.to_string()),
1221 resource: resource.map(str::to_owned),
1222 project: project.map(str::to_owned),
1223 values,
1224 format: FormatArgs {
1225 format: Format::Prose,
1226 json: false,
1227 lines: false,
1228 columns: None,
1229 no_header: false,
1230 header_only: false,
1231 },
1232 }
1233 }
1234
1235 fn make_resource_detail() -> ResourceDetail {
1236 ResourceDetail {
1237 label: "Test Page".to_string(),
1238 iri: RESOURCE_IRI.to_string(),
1239 resource_type: "Page".to_string(),
1240 ark_url: Some("ark:/72163/1/0803/--6Esp4SVnGG1DBzFvYErw".to_string()),
1241 creation_date: Some("2021-01-01T00:00:00Z".to_string()),
1242 last_modified: Some("2021-06-15T12:00:00Z".to_string()),
1243 attached_project: Some(PROJECT_IRI.to_string()),
1244 owner: Some("http://rdfh.ch/users/root".to_string()),
1245 visibility: Some(ResourceVisibility::Public),
1246 your_access: Some(ResourceAccess::View),
1247 values: None,
1248 }
1249 }
1250
1251 #[test]
1257 fn test_describe_happy_path() {
1258 let detail = make_resource_detail();
1259 let client = MockDspClient::new().with_describe_resource(Ok(detail.clone()));
1260
1261 let mut renderer = RecordingRenderer::new();
1262 let args = make_describe_args(Some(RESOURCE_IRI), None);
1263 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1264
1265 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1266 assert_eq!(client.describe_resource_calls(), 1);
1267
1268 let recorded = renderer
1269 .resource_describe_detail
1270 .expect("resource_describe must have been called");
1271 assert_eq!(recorded.label, "Test Page");
1272 assert_eq!(recorded.iri, RESOURCE_IRI);
1273 assert_eq!(recorded.resource_type, "Page");
1274 assert_eq!(recorded.visibility, Some(ResourceVisibility::Public));
1275 assert_eq!(recorded.your_access, Some(ResourceAccess::View));
1276 }
1277
1278 #[test]
1280 fn test_describe_missing_resource_is_usage_error() {
1281 let client = MockDspClient::new();
1282 let mut renderer = RecordingRenderer::new();
1283 let args = make_describe_args(None, None);
1284 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1285
1286 assert!(
1287 matches!(result, Err(Diagnostic::Usage(_))),
1288 "missing --resource must yield Usage, got {:?}",
1289 result
1290 );
1291 assert_eq!(client.describe_resource_calls(), 0);
1292 }
1293
1294 #[test]
1296 fn test_describe_missing_resource_error_mentions_flag() {
1297 let client = MockDspClient::new();
1298 let mut renderer = RecordingRenderer::new();
1299 let args = make_describe_args(None, None);
1300 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1301
1302 if let Err(Diagnostic::Usage(msg)) = result {
1303 assert!(
1304 msg.contains("--resource"),
1305 "error message must mention --resource, got: {msg}"
1306 );
1307 } else {
1308 panic!("expected Usage error");
1309 }
1310 }
1311
1312 #[test]
1314 fn test_describe_cross_project_guard_match_no_error() {
1315 let detail = make_resource_detail(); let client = MockDspClient::new()
1317 .with_resolve_project(Ok(make_project_ref())) .with_describe_resource(Ok(detail));
1319
1320 let mut renderer = RecordingRenderer::new();
1321 let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1322 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1323
1324 assert!(
1325 result.is_ok(),
1326 "matching project must not error, got {:?}",
1327 result
1328 );
1329 assert!(
1330 renderer.resource_describe_detail.is_some(),
1331 "renderer must have been called"
1332 );
1333 }
1334
1335 #[test]
1341 fn test_describe_cross_project_guard_mismatch_usage_error() {
1342 let mut detail = make_resource_detail();
1343 detail.attached_project = Some(OTHER_PROJECT_IRI.to_string()); let client = MockDspClient::new()
1346 .with_resolve_project(Ok(make_project_ref())) .with_describe_resource(Ok(detail));
1348
1349 let mut renderer = RecordingRenderer::new();
1350 let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1351 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1352
1353 assert!(
1354 matches!(result, Err(Diagnostic::Usage(_))),
1355 "project mismatch must yield Usage, got {:?}",
1356 result
1357 );
1358 assert!(
1359 renderer.resource_describe_detail.is_none(),
1360 "renderer must NOT be called on mismatch"
1361 );
1362
1363 if let Err(Diagnostic::Usage(msg)) = result {
1366 assert!(
1367 msg.contains(RESOURCE_IRI),
1368 "mismatch error must mention the resource IRI; got: {msg}"
1369 );
1370 assert!(
1372 msg.contains("9999"),
1373 "mismatch error must mention the actual project's local name '9999'; got: {msg}"
1374 );
1375 assert!(
1377 msg.contains("0801"),
1378 "mismatch error must mention the expected project's local name '0801'; got: {msg}"
1379 );
1380 } else {
1381 panic!("expected Err(Diagnostic::Usage(_))");
1382 }
1383 }
1384
1385 #[test]
1389 fn test_describe_cross_project_guard_none_passes_through() {
1390 let mut detail = make_resource_detail();
1391 detail.attached_project = None; let client = MockDspClient::new()
1394 .with_resolve_project(Ok(make_project_ref()))
1395 .with_describe_resource(Ok(detail));
1396
1397 let mut renderer = RecordingRenderer::new();
1398 let args = make_describe_args(Some(RESOURCE_IRI), Some("0801"));
1399 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1400
1401 assert!(
1402 result.is_ok(),
1403 "None attached_project must not error, got {:?}",
1404 result
1405 );
1406 assert!(
1407 renderer.resource_describe_detail.is_some(),
1408 "renderer must have been called"
1409 );
1410 }
1411
1412 #[test]
1414 fn test_describe_no_project_guard_skipped() {
1415 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1416
1417 let mut renderer = RecordingRenderer::new();
1418 let args = make_describe_args(Some(RESOURCE_IRI), None);
1419 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1420
1421 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1422 assert_eq!(
1423 client.resolve_calls(),
1424 0,
1425 "resolve_project must not be called when --project is omitted"
1426 );
1427 }
1428
1429 #[test]
1431 fn test_describe_filter_warning_anonymous() {
1432 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1433
1434 let mut renderer = RecordingRenderer::new();
1435 let args = make_describe_args(Some(RESOURCE_IRI), None);
1436 run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1437 .expect("expected Ok");
1438
1439 let meta = renderer
1440 .resource_describe_meta
1441 .expect("meta must be present");
1442 assert_eq!(meta.auth_state, "anonymous");
1443 let fw = meta
1444 .filter_warning
1445 .expect("filter_warning must be Some for anonymous");
1446 assert_eq!(
1447 fw,
1448 "results may be filtered; login to see private resources"
1449 );
1450 }
1451
1452 #[test]
1454 fn test_describe_filter_warning_authenticated() {
1455 let dir = TempDir::new().expect("tempdir");
1456 let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
1457 let path = write_cache(&dir, &cache);
1458
1459 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1460
1461 let mut renderer = RecordingRenderer::new();
1462 let args = make_describe_args(Some(RESOURCE_IRI), None);
1463 run_describe_impl(
1464 &args,
1465 &make_cfg(),
1466 &client,
1467 &mut renderer,
1468 None,
1469 Some(&path),
1470 )
1471 .expect("expected Ok");
1472
1473 let meta = renderer
1474 .resource_describe_meta
1475 .expect("meta must be present");
1476 let fw = meta
1477 .filter_warning
1478 .expect("filter_warning must be Some for authenticated");
1479 assert_eq!(fw, "results limited to your permissions");
1480 }
1481
1482 #[test]
1484 fn test_describe_not_found_propagates() {
1485 let client = MockDspClient::new().with_describe_resource(Err(Diagnostic::NotFound(
1486 "resource not found on server".to_string(),
1487 )));
1488
1489 let mut renderer = RecordingRenderer::new();
1490 let args = make_describe_args(Some(RESOURCE_IRI), None);
1491 let result = run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1492
1493 assert!(
1494 matches!(result, Err(Diagnostic::NotFound(_))),
1495 "not-found from client must propagate, got {:?}",
1496 result
1497 );
1498 assert!(
1499 renderer.resource_describe_detail.is_none(),
1500 "renderer must not be called on error"
1501 );
1502 }
1503
1504 #[test]
1506 fn test_describe_token_forwarded() {
1507 let dir = TempDir::new().expect("tempdir");
1508 let cache = cache_with_entry(SERVER, "my-describe-token", "user@example.com");
1509 let path = write_cache(&dir, &cache);
1510
1511 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1515
1516 let mut renderer = RecordingRenderer::new();
1517 let args = make_describe_args(Some(RESOURCE_IRI), None);
1518 run_describe_impl(
1519 &args,
1520 &make_cfg(),
1521 &client,
1522 &mut renderer,
1523 None,
1524 Some(&path),
1525 )
1526 .expect("expected Ok");
1527
1528 let meta = renderer
1530 .resource_describe_meta
1531 .expect("meta must be present");
1532 let fw = meta.filter_warning.expect("filter_warning must be present");
1534 assert_eq!(fw, "results limited to your permissions");
1535 }
1536
1537 #[test]
1539 fn test_describe_values_flag_absent_passes_false() {
1540 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1541 let mut renderer = RecordingRenderer::new();
1542 let args = make_describe_args(Some(RESOURCE_IRI), None); run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1544 .expect("expected Ok");
1545
1546 let with_values_args = client.describe_resource_with_values_args();
1547 assert_eq!(with_values_args.len(), 1);
1548 assert!(
1549 !with_values_args[0],
1550 "with_values must be false when --values is absent"
1551 );
1552 }
1553
1554 #[test]
1556 fn test_describe_values_flag_present_passes_true() {
1557 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1558 let mut renderer = RecordingRenderer::new();
1559 let args = make_describe_args_with_values(Some(RESOURCE_IRI), None, true);
1560 run_describe_impl(&args, &make_cfg(), &client, &mut renderer, None, None)
1561 .expect("expected Ok");
1562
1563 let with_values_args = client.describe_resource_with_values_args();
1564 assert_eq!(with_values_args.len(), 1);
1565 assert!(
1566 with_values_args[0],
1567 "with_values must be true when --values is given"
1568 );
1569 }
1570
1571 #[test]
1573 fn test_describe_corrupt_cache_falls_back_to_anonymous() {
1574 let dir = TempDir::new().expect("tempdir");
1575 let bad_path = dir.path().join("nonexistent_auth.toml");
1576
1577 let client = MockDspClient::new().with_describe_resource(Ok(make_resource_detail()));
1578
1579 let mut renderer = RecordingRenderer::new();
1580 let args = make_describe_args(Some(RESOURCE_IRI), None);
1581 let result = run_describe_impl(
1582 &args,
1583 &make_cfg(),
1584 &client,
1585 &mut renderer,
1586 None,
1587 Some(&bad_path),
1588 );
1589 assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
1590
1591 let meta = renderer
1592 .resource_describe_meta
1593 .expect("meta must be present");
1594 assert_eq!(meta.auth_state, "anonymous");
1595 }
1596
1597 #[test]
1601 fn test_list_full_iri_bypass_skips_scan() {
1602 let client = MockDspClient::new()
1603 .with_resolve_project(Ok(make_project_ref()))
1604 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1605
1606 let mut renderer = RecordingRenderer::new();
1607 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1609 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1610
1611 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1612 assert_eq!(
1613 client.list_data_models_calls(),
1614 0,
1615 "full IRI must not trigger a data-model scan"
1616 );
1617 assert_eq!(client.list_resources_calls(), 1);
1618
1619 let view = renderer
1620 .resources_view
1621 .expect("resources must have been called");
1622 assert_eq!(view.items.len(), 1);
1623 assert_eq!(view.resource_type, "Letter"); }
1625
1626 #[test]
1628 fn test_list_full_iri_passes_correct_params() {
1629 let client = MockDspClient::new()
1630 .with_resolve_project(Ok(make_project_ref()))
1631 .with_list_resources(Ok(make_resource_page(&[], false)));
1632
1633 let mut renderer = RecordingRenderer::new();
1634 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1635 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1636
1637 let calls = client.list_resources_args();
1638 assert_eq!(calls.len(), 1);
1639 assert_eq!(
1640 calls[0].0, PROJECT_IRI,
1641 "project_iri must be the resolved IRI"
1642 );
1643 assert_eq!(
1644 calls[0].1, LETTER_IRI,
1645 "resource_type_iri must be the full IRI"
1646 );
1647 assert_eq!(calls[0].2, None, "order_by is None (placeholder)");
1648 assert_eq!(calls[0].3, 0, "default page is 0");
1649 assert_eq!(calls[0].4, None, "no token for anonymous");
1650 }
1651
1652 #[test]
1654 fn test_list_bare_name_scan_single_match() {
1655 let client = MockDspClient::new()
1656 .with_resolve_project(Ok(make_project_ref()))
1657 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1658 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1659 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1660
1661 let mut renderer = RecordingRenderer::new();
1662 let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1663 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1664
1665 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1666 let view = renderer.resources_view.expect("must have been called");
1667 assert_eq!(view.resource_type, "Letter");
1668 assert_eq!(view.items.len(), 1);
1669 }
1670
1671 #[test]
1673 fn test_list_bare_name_scan_not_found() {
1674 let client = MockDspClient::new()
1675 .with_resolve_project(Ok(make_project_ref()))
1676 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1677 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])));
1678
1679 let mut renderer = RecordingRenderer::new();
1680 let args = make_args(Some("0801"), Some("Nonexistent"), None, None, false, None);
1681 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1682
1683 assert!(
1684 matches!(result, Err(Diagnostic::NotFound(_))),
1685 "unknown resource-type must yield NotFound, got {:?}",
1686 result
1687 );
1688 assert_eq!(client.list_resources_calls(), 0);
1689 }
1690
1691 #[test]
1693 fn test_list_bare_name_ambiguous_gives_usage_error() {
1694 let client = MockDspClient::new()
1695 .with_resolve_project(Ok(make_project_ref()))
1696 .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
1697 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
1698 .with_describe_data_model(Ok(make_detail_with_rts("webern", &["Letter", "Score"])));
1699
1700 let mut renderer = RecordingRenderer::new();
1701 let args = make_args(Some("0801"), Some("Letter"), None, None, false, None);
1703 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1704
1705 assert!(
1706 matches!(result, Err(Diagnostic::Usage(_))),
1707 "ambiguous resource-type must yield Usage error, got {:?}",
1708 result
1709 );
1710 if let Err(Diagnostic::Usage(msg)) = &result {
1711 assert!(
1712 msg.contains("beol") && msg.contains("webern"),
1713 "Usage error must list both data-models, got: {msg}"
1714 );
1715 assert!(
1716 msg.contains("--data-model"),
1717 "Usage error must suggest --data-model, got: {msg}"
1718 );
1719 }
1720 assert_eq!(client.list_resources_calls(), 0);
1721 }
1722
1723 #[test]
1725 fn test_list_data_model_name_scope() {
1726 let client = MockDspClient::new()
1727 .with_resolve_project(Ok(make_project_ref()))
1728 .with_list_data_models(Ok(vec![make_data_model("beol")]))
1729 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1730 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1731
1732 let mut renderer = RecordingRenderer::new();
1733 let args = make_args(
1734 Some("0801"),
1735 Some("Letter"),
1736 Some("beol"),
1737 None,
1738 false,
1739 None,
1740 );
1741 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1742
1743 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1744 let view = renderer.resources_view.expect("must have been called");
1745 assert_eq!(view.items.len(), 1);
1746 }
1747
1748 #[test]
1750 fn test_list_data_model_full_iri_scope_skips_list() {
1751 let client = MockDspClient::new()
1752 .with_resolve_project(Ok(make_project_ref()))
1753 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
1754 .with_list_resources(Ok(make_resource_page(&["LetterA"], false)));
1755
1756 let mut renderer = RecordingRenderer::new();
1757 let args = make_args(
1758 Some("0801"),
1759 Some("Letter"),
1760 Some(BEOL_IRI),
1761 None,
1762 false,
1763 None,
1764 );
1765 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1766
1767 assert!(result.is_ok(), "expected Ok, got {:?}", result);
1768 assert_eq!(
1769 client.list_data_models_calls(),
1770 0,
1771 "--data-model as full IRI must skip list_data_models"
1772 );
1773 }
1774
1775 #[test]
1777 fn test_list_single_page_no_more() {
1778 let client = MockDspClient::new()
1779 .with_resolve_project(Ok(make_project_ref()))
1780 .with_list_resources(Ok(make_resource_page(&["A", "B"], false)));
1781
1782 let mut renderer = RecordingRenderer::new();
1783 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1784 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1785
1786 let view = renderer.resources_view.expect("must have been called");
1787 assert_eq!(view.items.len(), 2);
1788 assert_eq!(view.total, 2);
1789 match &view.pagination {
1790 crate::render::ResourceListPagination::SinglePage {
1791 page,
1792 may_have_more,
1793 } => {
1794 assert_eq!(*page, 0);
1795 assert!(!may_have_more);
1796 }
1797 other => panic!("expected SinglePage, got {:?}", other),
1798 }
1799 }
1800
1801 #[test]
1803 fn test_list_single_page_has_more() {
1804 let client = MockDspClient::new()
1805 .with_resolve_project(Ok(make_project_ref()))
1806 .with_list_resources(Ok(make_resource_page(&["A", "B"], true)));
1807
1808 let mut renderer = RecordingRenderer::new();
1809 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1810 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1811
1812 let view = renderer.resources_view.expect("must have been called");
1813 match &view.pagination {
1814 crate::render::ResourceListPagination::SinglePage { may_have_more, .. } => {
1815 assert!(*may_have_more);
1816 }
1817 other => panic!("expected SinglePage, got {:?}", other),
1818 }
1819 }
1820
1821 #[test]
1823 fn test_list_explicit_page_passed_to_client() {
1824 let client = MockDspClient::new()
1825 .with_resolve_project(Ok(make_project_ref()))
1826 .with_list_resources(Ok(make_resource_page(&["A"], false)));
1827
1828 let mut renderer = RecordingRenderer::new();
1829 let args = make_args(Some("0801"), Some(LETTER_IRI), None, Some(3), false, None);
1830 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1831
1832 let calls = client.list_resources_args();
1833 assert_eq!(calls[0].3, 3, "--page 3 must be passed to list_resources");
1834
1835 let view = renderer.resources_view.expect("must have been called");
1836 match &view.pagination {
1837 crate::render::ResourceListPagination::SinglePage { page, .. } => {
1838 assert_eq!(*page, 3);
1839 }
1840 other => panic!("expected SinglePage, got {:?}", other),
1841 }
1842 }
1843
1844 #[test]
1846 fn test_list_all_multi_page() {
1847 let client = MockDspClient::new()
1848 .with_resolve_project(Ok(make_project_ref()))
1849 .with_list_resources(Ok(make_resource_page(&["A", "B"], true)))
1850 .with_list_resources(Ok(make_resource_page(&["C"], false)));
1851
1852 let mut renderer = RecordingRenderer::new();
1853 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1854 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1855
1856 assert_eq!(client.list_resources_calls(), 2);
1857 let calls = client.list_resources_args();
1859 assert_eq!(calls[0].3, 0);
1860 assert_eq!(calls[1].3, 1);
1861
1862 let view = renderer.resources_view.expect("must have been called");
1863 assert_eq!(view.items.len(), 3, "all pages merged");
1864 assert_eq!(view.total, 3);
1865 match &view.pagination {
1866 crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1867 assert_eq!(*pages_fetched, 2);
1868 }
1869 other => panic!("expected AllPages, got {:?}", other),
1870 }
1871 }
1872
1873 #[test]
1875 fn test_list_all_empty_final_page() {
1876 let client = MockDspClient::new()
1878 .with_resolve_project(Ok(make_project_ref()))
1879 .with_list_resources(Ok(make_resource_page(&["A"], true)))
1880 .with_list_resources(Ok(make_resource_page(&[], false)));
1881
1882 let mut renderer = RecordingRenderer::new();
1883 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1884 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1885
1886 assert_eq!(client.list_resources_calls(), 2);
1887 let view = renderer.resources_view.expect("must have been called");
1888 assert_eq!(
1889 view.items.len(),
1890 1,
1891 "empty final page contributes zero resources"
1892 );
1893 match &view.pagination {
1894 crate::render::ResourceListPagination::AllPages { pages_fetched } => {
1895 assert_eq!(*pages_fetched, 2);
1896 }
1897 other => panic!("expected AllPages, got {:?}", other),
1898 }
1899 }
1900
1901 #[test]
1903 fn test_list_all_propagates_error_mid_loop() {
1904 let client = MockDspClient::new()
1905 .with_resolve_project(Ok(make_project_ref()))
1906 .with_list_resources(Ok(make_resource_page(&["A"], true)))
1907 .with_list_resources(Err(Diagnostic::ServerError("server exploded".into())));
1908
1909 let mut renderer = RecordingRenderer::new();
1910 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, true, None);
1911 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1912
1913 assert!(
1914 matches!(result, Err(Diagnostic::ServerError(_))),
1915 "mid-loop error must propagate, got {:?}",
1916 result
1917 );
1918 assert!(
1919 renderer.resources_view.is_none(),
1920 "no partial result must be rendered on error"
1921 );
1922 }
1923
1924 #[test]
1926 fn test_list_empty_result() {
1927 let client = MockDspClient::new()
1928 .with_resolve_project(Ok(make_project_ref()))
1929 .with_list_resources(Ok(make_resource_page(&[], false)));
1930
1931 let mut renderer = RecordingRenderer::new();
1932 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
1933 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1934
1935 let view = renderer.resources_view.expect("must have been called");
1936 assert!(view.items.is_empty());
1937 assert_eq!(view.total, 0);
1938 }
1939
1940 #[test]
1942 fn test_list_filter_narrows_total_is_prefilter() {
1943 let client = MockDspClient::new()
1944 .with_resolve_project(Ok(make_project_ref()))
1945 .with_list_resources(Ok(make_resource_page(&["AliceA", "BobB", "AliceC"], false)));
1946
1947 let mut renderer = RecordingRenderer::new();
1948 let args = make_args(
1949 Some("0801"),
1950 Some(LETTER_IRI),
1951 None,
1952 None,
1953 false,
1954 Some("alice"),
1955 );
1956 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
1957
1958 let view = renderer.resources_view.expect("must have been called");
1959 assert_eq!(view.total, 3, "total is pre-filter");
1960 assert_eq!(view.items.len(), 2, "filter keeps only Alice items");
1961 assert!(
1962 view.items
1963 .iter()
1964 .all(|r| r.label.to_lowercase().contains("alice"))
1965 );
1966 assert_eq!(view.filter.as_deref(), Some("alice"));
1967 }
1968
1969 #[test]
1971 fn test_list_missing_project_is_usage_error() {
1972 let client = MockDspClient::new();
1973 let mut renderer = RecordingRenderer::new();
1974 let args = make_args(None, Some(LETTER_IRI), None, None, false, None);
1975 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1976
1977 assert!(
1978 matches!(result, Err(Diagnostic::Usage(_))),
1979 "missing --project must yield Usage, got {:?}",
1980 result
1981 );
1982 assert_eq!(client.resolve_calls(), 0);
1983 }
1984
1985 #[test]
1987 fn test_list_missing_resource_type_is_usage_error() {
1988 let client = MockDspClient::new();
1989 let mut renderer = RecordingRenderer::new();
1990 let args = make_args(Some("0801"), None, None, None, false, None);
1991 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
1992
1993 assert!(
1994 matches!(result, Err(Diagnostic::Usage(_))),
1995 "missing --resource-type must yield Usage, got {:?}",
1996 result
1997 );
1998 assert_eq!(client.resolve_calls(), 0);
1999 }
2000
2001 #[test]
2003 fn test_list_filter_warning_anonymous() {
2004 let client = MockDspClient::new()
2005 .with_resolve_project(Ok(make_project_ref()))
2006 .with_list_resources(Ok(make_resource_page(&[], false)));
2007
2008 let mut renderer = RecordingRenderer::new();
2009 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2010 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2011
2012 let meta = renderer.resources_meta.expect("meta must be present");
2013 assert_eq!(meta.auth_state, "anonymous");
2014 let fw = meta
2015 .filter_warning
2016 .expect("filter_warning must be Some for anonymous");
2017 assert_eq!(
2018 fw,
2019 "results may be filtered; login to see private resources"
2020 );
2021 }
2022
2023 #[test]
2025 fn test_list_filter_warning_authenticated() {
2026 let dir = TempDir::new().expect("tempdir");
2027 let cache = cache_with_entry(SERVER, "cache-token-xyz", "user@example.com");
2028 let path = write_cache(&dir, &cache);
2029
2030 let client = MockDspClient::new()
2031 .with_resolve_project(Ok(make_project_ref()))
2032 .with_list_resources(Ok(make_resource_page(&[], false)));
2033
2034 let mut renderer = RecordingRenderer::new();
2035 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2036 run_list_impl(
2037 &args,
2038 &make_cfg(),
2039 &client,
2040 &mut renderer,
2041 None,
2042 Some(&path),
2043 )
2044 .expect("expected Ok");
2045
2046 let meta = renderer.resources_meta.expect("meta must be present");
2047 let fw = meta
2048 .filter_warning
2049 .expect("filter_warning must be Some for authenticated");
2050 assert_eq!(fw, "results limited to your permissions");
2051 }
2052
2053 #[test]
2055 fn test_list_corrupt_cache_falls_back_to_anonymous() {
2056 let dir = TempDir::new().expect("tempdir");
2057 let bad_path = dir.path().join("nonexistent_auth.toml");
2058
2059 let client = MockDspClient::new()
2060 .with_resolve_project(Ok(make_project_ref()))
2061 .with_list_resources(Ok(make_resource_page(&[], false)));
2062
2063 let mut renderer = RecordingRenderer::new();
2064 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2065 let result = run_list_impl(
2066 &args,
2067 &make_cfg(),
2068 &client,
2069 &mut renderer,
2070 None,
2071 Some(&bad_path),
2072 );
2073 assert!(result.is_ok(), "corrupt cache must not fail: {:?}", result);
2074
2075 let meta = renderer.resources_meta.expect("meta must be present");
2076 assert_eq!(meta.auth_state, "anonymous");
2077 }
2078
2079 #[test]
2087 fn test_list_mid_scan_describe_error_propagates() {
2088 let client = MockDspClient::new()
2089 .with_resolve_project(Ok(make_project_ref()))
2090 .with_list_data_models(Ok(vec![make_data_model("beol"), make_data_model("webern")]))
2091 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter", "Person"])))
2093 .with_describe_data_model(Err(Diagnostic::ServerError(
2095 "data-model service unavailable".into(),
2096 )));
2097
2098 let mut renderer = RecordingRenderer::new();
2099 let args = make_args(Some("0801"), Some("Score"), None, None, false, None);
2101 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2102
2103 assert!(
2104 matches!(result, Err(Diagnostic::ServerError(_))),
2105 "mid-scan describe_data_model error must propagate as ServerError, got {:?}",
2106 result
2107 );
2108 assert_eq!(
2109 client.list_resources_calls(),
2110 0,
2111 "list_resources must never be called when describe_data_model errors mid-scan"
2112 );
2113 }
2114
2115 #[test]
2117 fn test_list_token_forwarded_to_list_resources() {
2118 let dir = TempDir::new().expect("tempdir");
2119 let cache = cache_with_entry(SERVER, "my-token", "user@example.com");
2120 let path = write_cache(&dir, &cache);
2121
2122 let client = MockDspClient::new()
2123 .with_resolve_project(Ok(make_project_ref()))
2124 .with_list_resources(Ok(make_resource_page(&[], false)));
2125
2126 let mut renderer = RecordingRenderer::new();
2127 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2128 run_list_impl(
2129 &args,
2130 &make_cfg(),
2131 &client,
2132 &mut renderer,
2133 None,
2134 Some(&path),
2135 )
2136 .expect("expected Ok");
2137
2138 let calls = client.list_resources_args();
2139 assert_eq!(
2140 calls[0].4,
2141 Some("my-token".to_string()),
2142 "token must be forwarded to list_resources"
2143 );
2144 }
2145
2146 fn make_rt_detail_with_fields(
2150 rt_name: &str,
2151 field_names: &[(&str, &str)],
2152 ) -> ResourceTypeDetail {
2153 let fields = field_names
2154 .iter()
2155 .map(|(name, iri)| Field {
2156 name: name.to_string(),
2157 iri: iri.to_string(),
2158 label: None,
2159 value_type: crate::model::resource_type::ValueType::Text,
2160 link_target: None,
2161 cardinality: Cardinality::ZeroOrOne,
2162 is_builtin: false,
2163 data_model: Some("beol".to_string()),
2164 })
2165 .collect();
2166 ResourceTypeDetail {
2167 name: rt_name.to_string(),
2168 iri: LETTER_IRI.to_string(),
2169 label: None,
2170 data_model: "beol".to_string(),
2171 representation: None,
2172 super_types: vec![],
2173 fields,
2174 count: None,
2175 }
2176 }
2177
2178 const TITLE_PROP_IRI: &str = "http://api.test.dasch.swiss/ontology/0801/beol/v2#hasTitle";
2179
2180 #[test]
2182 fn test_order_by_bare_name_resolves_to_property_iri() {
2183 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2184 let client = MockDspClient::new()
2185 .with_resolve_project(Ok(make_project_ref()))
2186 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2187 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2188 .with_describe_resource_type(rt_detail)
2189 .with_list_resources(Ok(make_resource_page(&["A"], false)));
2190
2191 let mut renderer = RecordingRenderer::new();
2192 let args = make_args_with_order_by(
2193 Some("0801"),
2194 Some("Letter"),
2195 None,
2196 None,
2197 false,
2198 None,
2199 Some("hasTitle"),
2200 );
2201 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2202
2203 assert_eq!(
2204 client.describe_resource_type_calls(),
2205 1,
2206 "bare field name must call describe_resource_type"
2207 );
2208 let calls = client.list_resources_args();
2209 assert_eq!(calls.len(), 1);
2210 assert_eq!(
2211 calls[0].2,
2212 Some(TITLE_PROP_IRI.to_string()),
2213 "order_by must be the resolved property IRI"
2214 );
2215 }
2216
2217 #[test]
2219 fn test_order_by_full_iri_bypass_skips_describe_resource_type() {
2220 let client = MockDspClient::new()
2221 .with_resolve_project(Ok(make_project_ref()))
2222 .with_list_resources(Ok(make_resource_page(&["A"], false)));
2223
2224 let mut renderer = RecordingRenderer::new();
2225 let args = make_args_with_order_by(
2226 Some("0801"),
2227 Some(LETTER_IRI),
2228 None,
2229 None,
2230 false,
2231 None,
2232 Some(TITLE_PROP_IRI),
2233 );
2234 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2235
2236 assert_eq!(
2237 client.describe_resource_type_calls(),
2238 0,
2239 "full-IRI bypass must NOT call describe_resource_type"
2240 );
2241 let calls = client.list_resources_args();
2242 assert_eq!(calls.len(), 1);
2243 assert_eq!(
2244 calls[0].2,
2245 Some(TITLE_PROP_IRI.to_string()),
2246 "order_by must be the verbatim IRI"
2247 );
2248 }
2249
2250 #[test]
2252 fn test_order_by_path_a_with_fragment_resolves() {
2253 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2255 let client = MockDspClient::new()
2256 .with_resolve_project(Ok(make_project_ref()))
2257 .with_describe_resource_type(rt_detail)
2258 .with_list_resources(Ok(make_resource_page(&["A"], false)));
2259
2260 let mut renderer = RecordingRenderer::new();
2261 let args = make_args_with_order_by(
2262 Some("0801"),
2263 Some(LETTER_IRI), None,
2265 None,
2266 false,
2267 None,
2268 Some("hasTitle"),
2269 );
2270 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2271
2272 assert_eq!(
2273 client.describe_resource_type_calls(),
2274 1,
2275 "bare field name with path-A resource-type must call describe_resource_type"
2276 );
2277 let calls = client.list_resources_args();
2278 assert_eq!(
2279 calls[0].2,
2280 Some(TITLE_PROP_IRI.to_string()),
2281 "order_by must be the resolved property IRI"
2282 );
2283 }
2284
2285 #[test]
2287 fn test_order_by_path_a_no_fragment_bare_name_is_usage_error() {
2288 let no_fragment_iri = "http://api.test.dasch.swiss/ontology/0801/beol/v2/Letter";
2291 let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
2292
2293 let mut renderer = RecordingRenderer::new();
2294 let args = make_args_with_order_by(
2295 Some("0801"),
2296 Some(no_fragment_iri),
2297 None,
2298 None,
2299 false,
2300 None,
2301 Some("hasTitle"),
2302 );
2303 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2304
2305 assert!(
2306 matches!(result, Err(Diagnostic::Usage(_))),
2307 "path-A IRI lacking '#' + bare order-by must yield Usage, got {:?}",
2308 result
2309 );
2310 assert_eq!(
2311 client.describe_resource_type_calls(),
2312 0,
2313 "describe_resource_type must not be called when error is expected"
2314 );
2315 }
2316
2317 #[test]
2319 fn test_order_by_unknown_field_is_usage_error() {
2320 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2321 let client = MockDspClient::new()
2323 .with_resolve_project(Ok(make_project_ref()))
2324 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2325 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2326 .with_describe_resource_type(rt_detail);
2327
2328 let mut renderer = RecordingRenderer::new();
2329 let args = make_args_with_order_by(
2330 Some("0801"),
2331 Some("Letter"),
2332 None,
2333 None,
2334 false,
2335 None,
2336 Some("nonExistentField"),
2337 );
2338 let result = run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None);
2339
2340 assert!(
2341 matches!(result, Err(Diagnostic::Usage(_))),
2342 "unknown field name must yield Usage, got {:?}",
2343 result
2344 );
2345 if let Err(Diagnostic::Usage(msg)) = &result {
2346 assert!(
2347 msg.contains("nonExistentField"),
2348 "Usage error must echo the field name, got: {msg}"
2349 );
2350 assert!(
2351 msg.contains("resource-type describe"),
2352 "Usage error must hint at resource-type describe, got: {msg}"
2353 );
2354 }
2355 assert_eq!(client.describe_resource_type_calls(), 1);
2357 assert_eq!(
2358 client.list_resources_calls(),
2359 0,
2360 "list_resources must not be called when order-by resolution fails"
2361 );
2362 }
2363
2364 #[test]
2366 fn test_order_by_absent_passes_none_to_list_resources() {
2367 let client = MockDspClient::new()
2368 .with_resolve_project(Ok(make_project_ref()))
2369 .with_list_resources(Ok(make_resource_page(&[], false)));
2370
2371 let mut renderer = RecordingRenderer::new();
2372 let args = make_args(Some("0801"), Some(LETTER_IRI), None, None, false, None);
2373 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2374
2375 let calls = client.list_resources_args();
2376 assert_eq!(
2377 calls[0].2, None,
2378 "--order-by absent must pass None to list_resources"
2379 );
2380 assert_eq!(
2381 client.describe_resource_type_calls(),
2382 0,
2383 "describe_resource_type must not be called when --order-by is absent"
2384 );
2385 }
2386
2387 #[test]
2389 fn test_order_by_with_all_threads_iri_to_every_page() {
2390 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2391 let client = MockDspClient::new()
2392 .with_resolve_project(Ok(make_project_ref()))
2393 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2394 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2395 .with_describe_resource_type(rt_detail)
2396 .with_list_resources(Ok(make_resource_page(&["A"], true)))
2397 .with_list_resources(Ok(make_resource_page(&["B"], false)));
2398
2399 let mut renderer = RecordingRenderer::new();
2400 let args = make_args_with_order_by(
2401 Some("0801"),
2402 Some("Letter"),
2403 None,
2404 None,
2405 true, None,
2407 Some("hasTitle"),
2408 );
2409 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2410
2411 assert_eq!(client.list_resources_calls(), 2);
2412 let calls = client.list_resources_args();
2413 assert_eq!(
2414 calls[0].2,
2415 Some(TITLE_PROP_IRI.to_string()),
2416 "order_by must be threaded to page 0"
2417 );
2418 assert_eq!(
2419 calls[1].2,
2420 Some(TITLE_PROP_IRI.to_string()),
2421 "order_by must be threaded to page 1"
2422 );
2423 }
2424
2425 #[test]
2427 fn test_order_by_field_name_is_case_insensitive() {
2428 let rt_detail = make_rt_detail_with_fields("Letter", &[("hasTitle", TITLE_PROP_IRI)]);
2429 let client = MockDspClient::new()
2430 .with_resolve_project(Ok(make_project_ref()))
2431 .with_list_data_models(Ok(vec![make_data_model("beol")]))
2432 .with_describe_data_model(Ok(make_detail_with_rts("beol", &["Letter"])))
2433 .with_describe_resource_type(rt_detail)
2434 .with_list_resources(Ok(make_resource_page(&[], false)));
2435
2436 let mut renderer = RecordingRenderer::new();
2437 let args = make_args_with_order_by(
2438 Some("0801"),
2439 Some("Letter"),
2440 None,
2441 None,
2442 false,
2443 None,
2444 Some("HASTITLE"),
2445 );
2446 run_list_impl(&args, &make_cfg(), &client, &mut renderer, None, None).expect("expected Ok");
2447
2448 let calls = client.list_resources_args();
2449 assert_eq!(
2450 calls[0].2,
2451 Some(TITLE_PROP_IRI.to_string()),
2452 "case-insensitive field name match must resolve to the property IRI"
2453 );
2454 }
2455}