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