1use std::io::{self, Write};
14
15use crate::diagnostic::Diagnostic;
16use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail, VocabularyDetail};
17use crate::render::auth::{
18 AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
19};
20use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
21use crate::render::table::render_table_disclosure;
22use crate::render::value::{build_metadata_row, build_value_rows};
23use crate::render::vocabulary::{build_vocabulary_list_rows, build_vocabulary_rows};
24use crate::render::{
25 AUTH_LOGIN_COLUMNS, AUTH_LOGOUT_COLUMNS, DATA_MODEL_DESCRIBE_COLUMNS,
26 DATA_MODEL_STRUCTURE_COLUMNS, DATA_MODELS_COLUMNS, DataModelListView, MetaContext,
27 PROJECT_DUMP_COLUMNS, PROJECT_DUMP_DELETED_COLUMNS, PROJECTS_COLUMNS, ProjectListView,
28 QuoteMode, RESOURCE_DESCRIBE_COLUMNS, RESOURCE_DESCRIBE_VALUES_COLUMNS,
29 RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS, RESOURCE_LIST_COLUMNS,
30 RESOURCE_TYPE_DESCRIBE_COLUMNS, RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS, RESOURCE_TYPES_COLUMNS,
31 RESOURCE_TYPES_DEFAULT_COLUMNS, Renderer, ResourceListView, ResourceTypeListView, TableOptions,
32 TableSpec, VOCABULARIES_COLUMNS, VOCABULARIES_COUNTED_DEFAULT_COLUMNS,
33 VOCABULARIES_DEFAULT_COLUMNS, VOCABULARY_DESCRIBE_COLUMNS, VOCABULARY_DESCRIBE_DEFAULT_COLUMNS,
34 VocabularyListView, render_table,
35};
36
37pub struct CsvRenderer {
39 out: Box<dyn Write>,
40 err: Box<dyn Write>,
45 options: TableOptions,
48}
49
50impl CsvRenderer {
51 pub fn new() -> Self {
53 Self {
54 out: Box::new(io::stdout()),
55 err: Box::new(io::stderr()),
56 options: TableOptions::default(),
57 }
58 }
59
60 pub fn with_writer(w: impl Write + 'static) -> Self {
64 Self {
65 out: Box::new(w),
66 err: Box::new(io::sink()),
67 options: TableOptions::default(),
68 }
69 }
70
71 pub fn with_writers(out: impl Write + 'static, err: impl Write + 'static) -> Self {
74 Self {
75 out: Box::new(out),
76 err: Box::new(err),
77 options: TableOptions::default(),
78 }
79 }
80
81 pub fn with_options(mut self, opts: TableOptions) -> Self {
84 self.options = opts;
85 self
86 }
87}
88
89impl Default for CsvRenderer {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95impl Renderer for CsvRenderer {
96 fn diagnostic(&mut self, diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
97 eprintln!("Error: {diag}"); Ok(())
99 }
100
101 fn auth_login(
102 &mut self,
103 outcome: &AuthLoginOutcome,
104 _meta: &MetaContext,
105 ) -> Result<(), Diagnostic> {
106 let expires = outcome
107 .expires_at
108 .map(|dt| dt.to_rfc3339())
109 .unwrap_or_default();
110 let rows = vec![vec![
111 outcome.server.clone(),
112 outcome.user.clone(),
113 expires,
114 "login_success".to_string(),
115 ]];
116 let projected = self.options.projected();
117 let spec = TableSpec {
118 all_columns: AUTH_LOGIN_COLUMNS,
119 rows: &rows,
120 default_columns: None,
121 projected,
122 quote: QuoteMode::Csv,
123 header: self.options.header,
124 };
125 render_table(&mut *self.out, &spec)
126 }
127
128 fn auth_status(
129 &mut self,
130 outcome: &AuthStatusOutcome,
131 meta: &MetaContext,
132 ) -> Result<(), Diagnostic> {
133 let row = match outcome {
134 AuthStatusOutcome::LoggedIn {
135 server,
136 user,
137 expires_at,
138 expired,
139 } => {
140 let user_str = user.as_deref().unwrap_or("").to_string();
141 let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
142 let state = if *expired { "expired" } else { "logged_in" };
143 vec![server.clone(), user_str, expires, state.to_string()]
144 }
145 AuthStatusOutcome::AuthenticatedViaEnv {
149 server,
150 expires_at,
151 expired,
152 } => {
153 let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
154 let state = if *expired { "expired" } else { "logged_in" };
155 vec![server.clone(), String::new(), expires, state.to_string()]
156 }
157 AuthStatusOutcome::NotLoggedIn { server } => {
158 vec![
159 server.clone(),
160 String::new(),
161 String::new(),
162 "not_logged_in".to_string(),
163 ]
164 }
165 };
166 let rows = vec![row];
167 let projected = self.options.projected();
168 let spec = TableSpec {
169 all_columns: AUTH_LOGIN_COLUMNS,
170 rows: &rows,
171 default_columns: None,
172 projected,
173 quote: QuoteMode::Csv,
174 header: self.options.header,
175 };
176 render_table(&mut *self.out, &spec)?;
177 render_table_disclosure(&mut *self.err, meta)?;
178 Ok(())
179 }
180
181 fn auth_logout(
182 &mut self,
183 outcome: &AuthLogoutOutcome,
184 _meta: &MetaContext,
185 ) -> Result<(), Diagnostic> {
186 let rows = vec![vec![outcome.server.clone(), outcome.was_cached.to_string()]];
187 let projected = self.options.projected();
188 let spec = TableSpec {
189 all_columns: AUTH_LOGOUT_COLUMNS,
190 rows: &rows,
191 default_columns: None,
192 projected,
193 quote: QuoteMode::Csv,
194 header: self.options.header,
195 };
196 render_table(&mut *self.out, &spec)
197 }
198
199 fn auth_set_token(
200 &mut self,
201 outcome: &AuthSetTokenOutcome,
202 _meta: &MetaContext,
203 ) -> Result<(), Diagnostic> {
204 let user_str = outcome.user.as_deref().unwrap_or("").to_string();
205 let expires = outcome
206 .expires_at
207 .map(|dt| dt.to_rfc3339())
208 .unwrap_or_default();
209 let rows = vec![vec![
210 outcome.server.clone(),
211 user_str,
212 expires,
213 "token_cached".to_string(),
214 ]];
215 let projected = self.options.projected();
216 let spec = TableSpec {
217 all_columns: AUTH_LOGIN_COLUMNS,
218 rows: &rows,
219 default_columns: None,
220 projected,
221 quote: QuoteMode::Csv,
222 header: self.options.header,
223 };
224 render_table(&mut *self.out, &spec)
225 }
226
227 fn project_dump(
228 &mut self,
229 outcome: &DumpOutcome,
230 _meta: &MetaContext,
231 ) -> Result<(), Diagnostic> {
232 let rows = vec![vec![outcome.path.display().to_string()]];
236 let projected = self.options.projected();
237 let spec = TableSpec {
238 all_columns: PROJECT_DUMP_COLUMNS,
239 rows: &rows,
240 default_columns: None,
241 projected,
242 quote: QuoteMode::Csv,
243 header: self.options.header,
244 };
245 render_table(&mut *self.out, &spec)
246 }
247
248 fn project_dump_deleted(
249 &mut self,
250 outcome: &DumpDeleteOutcome,
251 _meta: &MetaContext,
252 ) -> Result<(), Diagnostic> {
253 let rows = vec![vec![outcome.deleted.to_string()]];
255 let projected = self.options.projected();
256 let spec = TableSpec {
257 all_columns: PROJECT_DUMP_DELETED_COLUMNS,
258 rows: &rows,
259 default_columns: None,
260 projected,
261 quote: QuoteMode::Csv,
262 header: self.options.header,
263 };
264 render_table(&mut *self.out, &spec)
265 }
266
267 fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
268 let rows: Vec<Vec<String>> = view
269 .items
270 .iter()
271 .map(|item| {
272 let longname = item.longname.as_deref().unwrap_or("").to_string();
273 let data_models_str = item.data_models.to_string();
274 vec![
275 item.shortcode.clone(),
276 item.shortname.clone(),
277 longname,
278 item.status.as_str().to_string(),
279 data_models_str,
280 item.iri.clone(),
281 ]
282 })
283 .collect();
284 let projected = self.options.projected();
285 let spec = TableSpec {
286 all_columns: PROJECTS_COLUMNS,
287 rows: &rows,
288 default_columns: None,
289 projected,
290 quote: QuoteMode::Csv,
291 header: self.options.header,
292 };
293 render_table(&mut *self.out, &spec)?;
294 render_table_disclosure(&mut *self.err, meta)?;
295 Ok(())
296 }
297
298 fn project_describe(
299 &mut self,
300 project: &ProjectDetail,
301 meta: &MetaContext,
302 ) -> Result<(), Diagnostic> {
303 let longname = project.longname.as_deref().unwrap_or("").to_string();
306 let data_models_str = project.data_models.len().to_string();
307 let rows = vec![vec![
308 project.shortcode.clone(),
309 project.shortname.clone(),
310 longname,
311 project.status.as_str().to_string(),
312 data_models_str,
313 project.iri.clone(),
314 ]];
315 let projected = self.options.projected();
316 let spec = TableSpec {
317 all_columns: PROJECTS_COLUMNS,
318 rows: &rows,
319 default_columns: None,
320 projected,
321 quote: QuoteMode::Csv,
322 header: self.options.header,
323 };
324 render_table(&mut *self.out, &spec)?;
325 render_table_disclosure(&mut *self.err, meta)?;
326 Ok(())
327 }
328
329 fn data_model_describe(
330 &mut self,
331 detail: &DataModelDetail,
332 meta: &MetaContext,
333 ) -> Result<(), Diagnostic> {
334 let label = detail.label.as_deref().unwrap_or("").to_string();
338 let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
339 let resource_types_str = detail.resource_types.len().to_string();
340 let rows = vec![vec![
341 detail.name.clone(),
342 detail.iri.clone(),
343 label,
344 last_modified,
345 resource_types_str,
346 ]];
347 let projected = self.options.projected();
348 let spec = TableSpec {
349 all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
350 rows: &rows,
351 default_columns: None,
352 projected,
353 quote: QuoteMode::Csv,
354 header: self.options.header,
355 };
356 render_table(&mut *self.out, &spec)?;
357 render_table_disclosure(&mut *self.err, meta)?;
358 Ok(())
359 }
360
361 fn data_models(
362 &mut self,
363 view: &DataModelListView,
364 meta: &MetaContext,
365 ) -> Result<(), Diagnostic> {
366 let rows: Vec<Vec<String>> = view
367 .items
368 .iter()
369 .map(|item| {
370 let label = item.label.as_deref().unwrap_or("").to_string();
371 let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
372 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
373 vec![
374 item.name.clone(),
375 item.iri.clone(),
376 label,
377 last_modified,
378 is_builtin_str.to_string(),
379 ]
380 })
381 .collect();
382 let projected = self.options.projected();
383 let spec = TableSpec {
384 all_columns: DATA_MODELS_COLUMNS,
385 rows: &rows,
386 default_columns: None,
387 projected,
388 quote: QuoteMode::Csv,
389 header: self.options.header,
390 };
391 render_table(&mut *self.out, &spec)?;
392 render_table_disclosure(&mut *self.err, meta)?;
393 Ok(())
394 }
395
396 fn resource_types(
397 &mut self,
398 view: &ResourceTypeListView,
399 meta: &MetaContext,
400 ) -> Result<(), Diagnostic> {
401 let rows: Vec<Vec<String>> = view
405 .items
406 .iter()
407 .map(|item| {
408 let label = item.label.as_deref().unwrap_or("").to_string();
409 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
410 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
411 vec![
412 item.name.clone(),
413 item.iri.clone(),
414 label,
415 is_builtin_str.to_string(),
416 count_str,
417 ]
418 })
419 .collect();
420 let has_counts = view.items.iter().any(|rt| rt.count.is_some());
421 let default_columns = if has_counts {
422 None
423 } else {
424 Some(RESOURCE_TYPES_DEFAULT_COLUMNS)
425 };
426 let projected = self.options.projected();
427 let spec = TableSpec {
428 all_columns: RESOURCE_TYPES_COLUMNS,
429 rows: &rows,
430 default_columns,
431 projected,
432 quote: QuoteMode::Csv,
433 header: self.options.header,
434 };
435 render_table(&mut *self.out, &spec)?;
436 render_table_disclosure(&mut *self.err, meta)?;
437 Ok(())
438 }
439
440 fn resource_type_describe(
441 &mut self,
442 detail: &crate::model::ResourceTypeDetail,
443 meta: &MetaContext,
444 ) -> Result<(), Diagnostic> {
445 let rows: Vec<Vec<String>> = detail
452 .fields
453 .iter()
454 .map(|field| {
455 let value_type_str = field.value_type.to_string();
456 let link_target = field.link_target.as_deref().unwrap_or("").to_string();
457 let card_str = field.cardinality.to_string();
458 let label = field.label.as_deref().unwrap_or("").to_string();
459 let is_builtin_str = if field.is_builtin { "true" } else { "false" };
460 let data_model = field.data_model.as_deref().unwrap_or("").to_string();
461 vec![
462 field.name.clone(),
463 field.iri.clone(),
464 value_type_str,
465 link_target,
466 card_str,
467 label,
468 is_builtin_str.to_string(),
469 data_model,
470 ]
471 })
472 .collect();
473 let projected = self.options.projected();
474 let spec = TableSpec {
475 all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
476 rows: &rows,
477 default_columns: Some(RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS),
478 projected,
479 quote: QuoteMode::Csv,
480 header: self.options.header,
481 };
482 render_table(&mut *self.out, &spec)?;
483 render_table_disclosure(&mut *self.err, meta)?;
484 Ok(())
485 }
486
487 fn data_model_structure(
488 &mut self,
489 structure: &DataModelStructure,
490 meta: &MetaContext,
491 ) -> Result<(), Diagnostic> {
492 let rows: Vec<Vec<String>> = structure
496 .relations
497 .iter()
498 .map(|rel| {
499 let kind_str = rel.kind.to_string();
500 let field = rel.field.as_deref().unwrap_or("").to_string();
501 let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
502 vec![
503 rel.source.clone(),
504 rel.target.clone(),
505 kind_str,
506 field,
507 target_dm,
508 ]
509 })
510 .collect();
511 let projected = self.options.projected();
512 let spec = TableSpec {
513 all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
514 rows: &rows,
515 default_columns: None,
516 projected,
517 quote: QuoteMode::Csv,
518 header: self.options.header,
519 };
520 render_table(&mut *self.out, &spec)?;
521 render_table_disclosure(&mut *self.err, meta)?;
522 Ok(())
523 }
524
525 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
526 let rows: Vec<Vec<String>> = view
528 .items
529 .iter()
530 .map(|item| {
531 vec![
532 item.label.clone(),
533 item.iri.clone(),
534 item.ark_url.as_deref().unwrap_or("").to_string(),
535 item.creation_date.as_deref().unwrap_or("").to_string(),
536 item.last_modified.as_deref().unwrap_or("").to_string(),
537 item.resource_type.clone(),
538 ]
539 })
540 .collect();
541 let projected = self.options.projected();
542 let spec = TableSpec {
543 all_columns: RESOURCE_LIST_COLUMNS,
544 rows: &rows,
545 default_columns: None,
546 projected,
547 quote: QuoteMode::Csv,
548 header: self.options.header,
549 };
550 render_table(&mut *self.out, &spec)?;
551 render_table_disclosure(&mut *self.err, meta)?;
552 Ok(())
553 }
554
555 fn resource_describe(
556 &mut self,
557 detail: &crate::model::ResourceDetail,
558 meta: &MetaContext,
559 ) -> Result<(), Diagnostic> {
560 match &detail.values {
564 None => {
565 let rows = vec![build_metadata_row(detail)];
566 let projected = self.options.projected();
567 let spec = TableSpec {
568 all_columns: RESOURCE_DESCRIBE_COLUMNS,
569 rows: &rows,
570 default_columns: None,
571 projected,
572 quote: QuoteMode::Csv,
573 header: self.options.header,
574 };
575 render_table(&mut *self.out, &spec)?;
576 }
577 Some(fields) => {
578 let rows = build_value_rows(&detail.label, &detail.iri, fields);
579 let projected = self.options.projected();
580 let spec = TableSpec {
581 all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
582 rows: &rows,
583 default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
584 projected,
585 quote: QuoteMode::Csv,
586 header: self.options.header,
587 };
588 render_table(&mut *self.out, &spec)?;
589 }
590 }
591 render_table_disclosure(&mut *self.err, meta)?;
592 Ok(())
593 }
594
595 fn vocabularies(
596 &mut self,
597 view: &VocabularyListView,
598 meta: &MetaContext,
599 ) -> Result<(), Diagnostic> {
600 let rows = build_vocabulary_list_rows(&view.items);
601 let has_counts = view.items.iter().any(|v| v.node_count.is_some());
606 let default_columns = if has_counts {
607 Some(VOCABULARIES_COUNTED_DEFAULT_COLUMNS)
608 } else {
609 Some(VOCABULARIES_DEFAULT_COLUMNS)
610 };
611 let projected = self.options.projected();
612 let spec = TableSpec {
613 all_columns: VOCABULARIES_COLUMNS,
614 rows: &rows,
615 default_columns,
616 projected,
617 quote: QuoteMode::Csv,
618 header: self.options.header,
619 };
620 render_table(&mut *self.out, &spec)?;
621 render_table_disclosure(&mut *self.err, meta)?;
622 Ok(())
623 }
624
625 fn vocabulary_describe(
626 &mut self,
627 detail: &VocabularyDetail,
628 meta: &MetaContext,
629 ) -> Result<(), Diagnostic> {
630 let rows = build_vocabulary_rows(detail);
631 let projected = self.options.projected();
632 let spec = TableSpec {
633 all_columns: VOCABULARY_DESCRIBE_COLUMNS,
634 rows: &rows,
635 default_columns: Some(VOCABULARY_DESCRIBE_DEFAULT_COLUMNS),
636 projected,
637 quote: QuoteMode::Csv,
638 header: self.options.header,
639 };
640 render_table(&mut *self.out, &spec)?;
641 render_table_disclosure(&mut *self.err, meta)?;
642 Ok(())
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use crate::model::{
650 DataModel, DataModelDetail, DataModelSummary, Project, ProjectDetail, ProjectStatus,
651 ResourceType, ResourceTypeSummary,
652 };
653 use crate::render::test_support::{SharedBuf, make_meta};
654 use crate::render::{DataModelListView, ResourceTypeListView};
655
656 fn make_fixture() -> Vec<Project> {
657 vec![
658 Project {
659 iri: "http://rdfh.ch/projects/0001".into(),
660 shortcode: "0001".into(),
661 shortname: "anything".into(),
662 longname: Some("Anything Project".into()),
663 status: ProjectStatus::Active,
664 data_models: 2,
665 },
666 Project {
667 iri: "http://rdfh.ch/projects/0002".into(),
668 shortcode: "0002".into(),
669 shortname: "images".into(),
670 longname: None,
671 status: ProjectStatus::Inactive,
672 data_models: 0,
673 },
674 Project {
675 iri: "http://rdfh.ch/projects/0803".into(),
676 shortcode: "0803".into(),
677 shortname: "daschland".into(),
678 longname: Some("=Formula,Project".into()),
679 status: ProjectStatus::Active,
680 data_models: 1,
681 },
682 ]
683 }
684
685 #[test]
686 fn projects_csv_output() {
687 let out = SharedBuf::new();
688 let err = SharedBuf::new();
689 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
690 let view = ProjectListView {
691 items: make_fixture(),
692 total: 3,
693 filter: None,
694 };
695 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
696 renderer.projects(&view, &meta).unwrap();
697
698 let stdout = out.string();
699 assert!(stdout.starts_with("shortcode,shortname,longname,status,data_models,iri\n"));
700 assert!(
702 stdout.contains("0001,anything,Anything Project,active,2,http://rdfh.ch/projects/0001")
703 );
704 assert!(stdout.contains("0002,images,,inactive,0,http://rdfh.ch/projects/0002"));
706 assert!(stdout.contains(
708 r#"0803,daschland,"=Formula,Project",active,1,http://rdfh.ch/projects/0803"#
709 ));
710 }
711
712 #[test]
713 fn projects_csv_stderr_disclosure() {
714 let out = SharedBuf::new();
715 let err = SharedBuf::new();
716 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
717 let view = ProjectListView {
718 items: make_fixture(),
719 total: 3,
720 filter: None,
721 };
722 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
723 renderer.projects(&view, &meta).unwrap();
724
725 let err_str = err.string();
727 assert_eq!(
728 err_str.trim(),
729 "[anonymous on https://api.test.dasch.swiss]"
730 );
731 assert!(!out.string().contains("[anonymous"));
733 }
734
735 #[test]
736 fn auth_status_csv_stderr_disclosure() {
737 let out = SharedBuf::new();
739 let err = SharedBuf::new();
740 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
741 let meta = make_meta(
742 "authenticated via DSP_TOKEN",
743 "https://api.prod.dasch.swiss",
744 );
745 renderer
746 .auth_status(
747 &AuthStatusOutcome::AuthenticatedViaEnv {
748 server: "https://api.prod.dasch.swiss".to_string(),
749 expires_at: None,
750 expired: false,
751 },
752 &meta,
753 )
754 .unwrap();
755 let err_str = err.string();
756 assert_eq!(
757 err_str.trim(),
758 "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
759 );
760 assert!(!out.string().contains('['));
762
763 let out2 = SharedBuf::new();
765 let err2 = SharedBuf::new();
766 let mut renderer2 = CsvRenderer::with_writers(out2.clone(), err2.clone());
767 let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
768 renderer2
769 .auth_status(
770 &AuthStatusOutcome::NotLoggedIn {
771 server: "https://api.prod.dasch.swiss".to_string(),
772 },
773 &meta2,
774 )
775 .unwrap();
776 let err2_str = err2.string();
777 assert_eq!(
778 err2_str.trim(),
779 "[anonymous on https://api.prod.dasch.swiss]"
780 );
781 assert!(!out2.string().contains('['));
783 }
784
785 #[test]
786 fn projects_csv_with_writer_no_stderr_leak() {
787 let out = SharedBuf::new();
789 let mut renderer = CsvRenderer::with_writer(out.clone());
790 let view = ProjectListView {
791 items: vec![],
792 total: 0,
793 filter: None,
794 };
795 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
796 renderer.projects(&view, &meta).unwrap();
797 assert_eq!(
799 out.string(),
800 "shortcode,shortname,longname,status,data_models,iri\n"
801 );
802 }
803
804 #[test]
805 fn project_describe_csv_output() {
806 let out = SharedBuf::new();
807 let err = SharedBuf::new();
808 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
809 let detail = ProjectDetail {
810 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
811 shortcode: "0801".into(),
812 shortname: "beol".into(),
813 longname: Some("Bernoulli-Euler Online".into()),
814 status: ProjectStatus::Active,
815 description: vec![],
816 keywords: vec![],
817 data_models: vec![
818 DataModelSummary {
819 name: "beol".into(),
820 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
821 },
822 DataModelSummary {
823 name: "biblio".into(),
824 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
825 },
826 ],
827 };
828 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
829 renderer.project_describe(&detail, &meta).unwrap();
830
831 let stdout = out.string();
832 assert!(stdout.starts_with("shortcode,shortname,longname,status,data_models,iri\n"));
834 assert!(stdout.contains(
836 "0801,beol,Bernoulli-Euler Online,active,2,http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"
837 ));
838 }
839
840 #[test]
841 fn project_describe_csv_no_longname() {
842 let out = SharedBuf::new();
843 let mut renderer = CsvRenderer::with_writer(out.clone());
844 let detail = ProjectDetail {
845 iri: "http://rdfh.ch/projects/0000".into(),
846 shortcode: "0000".into(),
847 shortname: "minimal".into(),
848 longname: None,
849 status: ProjectStatus::Inactive,
850 description: vec![],
851 keywords: vec![],
852 data_models: vec![],
853 };
854 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
855 renderer.project_describe(&detail, &meta).unwrap();
856
857 let stdout = out.string();
858 assert!(stdout.contains("0000,minimal,,inactive,0,http://rdfh.ch/projects/0000"));
860 }
861
862 #[test]
863 fn project_describe_csv_formula_injection_longname() {
864 let out = SharedBuf::new();
866 let mut renderer = CsvRenderer::with_writer(out.clone());
867 let detail = ProjectDetail {
868 iri: "http://rdfh.ch/projects/0803".into(),
869 shortcode: "0803".into(),
870 shortname: "daschland".into(),
871 longname: Some("=Formula,Project".into()),
872 status: ProjectStatus::Active,
873 description: vec![],
874 keywords: vec![],
875 data_models: vec![],
876 };
877 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
878 renderer.project_describe(&detail, &meta).unwrap();
879
880 let stdout = out.string();
881 assert!(stdout.contains(r#"0803,daschland,"=Formula,Project",active,0"#));
883 }
884
885 #[test]
886 fn project_describe_csv_stderr_disclosure() {
887 let out = SharedBuf::new();
888 let err = SharedBuf::new();
889 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
890 let detail = ProjectDetail {
891 iri: "http://rdfh.ch/projects/0001".into(),
892 shortcode: "0001".into(),
893 shortname: "test".into(),
894 longname: None,
895 status: ProjectStatus::Active,
896 description: vec![],
897 keywords: vec![],
898 data_models: vec![],
899 };
900 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
901 renderer.project_describe(&detail, &meta).unwrap();
902
903 let err_str = err.string();
904 assert_eq!(
905 err_str.trim(),
906 "[anonymous on https://api.test.dasch.swiss]"
907 );
908 assert!(!out.string().contains("[anonymous"));
909 }
910
911 fn make_data_model_fixture() -> Vec<DataModel> {
912 vec![
913 DataModel {
914 name: "beol".into(),
915 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
916 label: Some("The BEOL data-model".into()),
917 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
918 is_builtin: false,
919 },
920 DataModel {
921 name: "biblio".into(),
922 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
923 label: None,
924 last_modified: None,
925 is_builtin: false,
926 },
927 DataModel {
928 name: "knora-api".into(),
929 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
930 label: None,
931 last_modified: None,
932 is_builtin: true,
933 },
934 ]
935 }
936
937 #[test]
938 fn data_models_csv_output() {
939 let out = SharedBuf::new();
940 let err = SharedBuf::new();
941 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
942 let view = DataModelListView {
943 items: make_data_model_fixture(),
944 total: 3,
945 filter: None,
946 };
947 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
948 renderer.data_models(&view, &meta).unwrap();
949
950 let stdout = out.string();
951 assert!(stdout.starts_with("name,iri,label,last_modified,is_builtin\n"));
953 assert!(stdout.contains(
955 "beol,http://api.dasch.swiss/ontology/0801/beol/v2,The BEOL data-model,2024-05-27T13:43:26.233048Z,false"
956 ));
957 assert!(stdout.contains("biblio,http://api.dasch.swiss/ontology/0801/biblio/v2,,,false"));
959 assert!(stdout.contains("knora-api,http://api.knora.org/ontology/knora-api/v2,,,true"));
961 }
962
963 #[test]
964 fn data_models_csv_formula_injection_label() {
965 let out = SharedBuf::new();
967 let mut renderer = CsvRenderer::with_writer(out.clone());
968 let view = DataModelListView {
969 items: vec![DataModel {
970 name: "test".into(),
971 iri: "http://api.dasch.swiss/ontology/0001/test/v2".into(),
972 label: Some("=Formula,Label".into()),
973 last_modified: None,
974 is_builtin: false,
975 }],
976 total: 1,
977 filter: None,
978 };
979 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
980 renderer.data_models(&view, &meta).unwrap();
981
982 let stdout = out.string();
983 assert!(
985 stdout.contains(
986 r#"test,http://api.dasch.swiss/ontology/0001/test/v2,"=Formula,Label",,false"#
987 ),
988 "formula-injected label must be quoted; got:\n{stdout}"
989 );
990 }
991
992 #[test]
993 fn data_models_csv_stderr_disclosure() {
994 let out = SharedBuf::new();
995 let err = SharedBuf::new();
996 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
997 let view = DataModelListView {
998 items: make_data_model_fixture(),
999 total: 3,
1000 filter: None,
1001 };
1002 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1003 renderer.data_models(&view, &meta).unwrap();
1004
1005 let err_str = err.string();
1006 assert_eq!(
1007 err_str.trim(),
1008 "[anonymous on https://api.test.dasch.swiss]"
1009 );
1010 assert!(!out.string().contains("[anonymous"));
1011 }
1012
1013 fn make_beol_dm_detail() -> DataModelDetail {
1016 DataModelDetail {
1017 name: "beol".into(),
1018 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1019 label: Some("The BEOL data-model".into()),
1020 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1021 resource_types: vec![
1022 ResourceTypeSummary {
1023 name: "Archive".into(),
1024 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1025 label: Some("Archive".into()),
1026 },
1027 ResourceTypeSummary {
1028 name: "basicLetter".into(),
1029 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1030 label: None,
1031 },
1032 ResourceTypeSummary {
1033 name: "letter".into(),
1034 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1035 label: Some("Letter".into()),
1036 },
1037 ],
1038 }
1039 }
1040
1041 #[test]
1042 fn data_model_describe_csv_output() {
1043 let out = SharedBuf::new();
1044 let err = SharedBuf::new();
1045 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1046 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1047 renderer
1048 .data_model_describe(&make_beol_dm_detail(), &meta)
1049 .unwrap();
1050
1051 let stdout = out.string();
1052 assert!(
1054 stdout.starts_with("name,iri,label,last_modified,resource_types\n"),
1055 "header row missing; got:\n{stdout}"
1056 );
1057 assert!(
1059 stdout.contains(
1060 "beol,http://api.dasch.swiss/ontology/0801/beol/v2,The BEOL data-model,2024-05-27T13:43:26.233048Z,3"
1061 ),
1062 "data row missing; got:\n{stdout}"
1063 );
1064 }
1065
1066 #[test]
1067 fn data_model_describe_csv_no_label_no_last_modified() {
1068 let out = SharedBuf::new();
1069 let mut renderer = CsvRenderer::with_writer(out.clone());
1070 let detail = DataModelDetail {
1071 name: "minimal".into(),
1072 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1073 label: None,
1074 last_modified: None,
1075 resource_types: vec![],
1076 };
1077 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1078 renderer.data_model_describe(&detail, &meta).unwrap();
1079
1080 let stdout = out.string();
1081 assert!(
1083 stdout.contains("minimal,http://api.dasch.swiss/ontology/0000/minimal/v2,,,0"),
1084 "None → empty fields must be handled; got:\n{stdout}"
1085 );
1086 }
1087
1088 #[test]
1089 fn data_model_describe_csv_formula_injection_label() {
1090 let out = SharedBuf::new();
1092 let mut renderer = CsvRenderer::with_writer(out.clone());
1093 let detail = DataModelDetail {
1094 name: "test".into(),
1095 iri: "http://api.dasch.swiss/ontology/0001/test/v2".into(),
1096 label: Some("=Formula,Label".into()),
1097 last_modified: None,
1098 resource_types: vec![],
1099 };
1100 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1101 renderer.data_model_describe(&detail, &meta).unwrap();
1102
1103 let stdout = out.string();
1104 assert!(
1106 stdout.contains(
1107 r#"test,http://api.dasch.swiss/ontology/0001/test/v2,"=Formula,Label",,0"#
1108 ),
1109 "formula-injected label must be quoted; got:\n{stdout}"
1110 );
1111 }
1112
1113 #[test]
1114 fn data_model_describe_csv_stderr_disclosure() {
1115 let out = SharedBuf::new();
1116 let err = SharedBuf::new();
1117 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1118 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1119 renderer
1120 .data_model_describe(&make_beol_dm_detail(), &meta)
1121 .unwrap();
1122
1123 let err_str = err.string();
1124 assert_eq!(
1125 err_str.trim(),
1126 "[anonymous on https://api.test.dasch.swiss]"
1127 );
1128 assert!(!out.string().contains("[anonymous"));
1129 }
1130
1131 fn make_resource_type_fixture() -> Vec<ResourceType> {
1134 vec![
1135 ResourceType {
1136 name: "Archive".into(),
1137 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1138 label: Some("Archive".into()),
1139 is_builtin: false,
1140 count: None,
1141 },
1142 ResourceType {
1143 name: "letter".into(),
1144 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1145 label: None,
1146 is_builtin: false,
1147 count: None,
1148 },
1149 ]
1150 }
1151
1152 #[test]
1153 fn resource_types_csv_output() {
1154 let out = SharedBuf::new();
1155 let err = SharedBuf::new();
1156 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1157 let view = ResourceTypeListView {
1158 items: make_resource_type_fixture(),
1159 total: 2,
1160 filter: None,
1161 data_model: "beol".into(),
1162 };
1163 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1164 renderer.resource_types(&view, &meta).unwrap();
1165
1166 let stdout = out.string();
1167 assert!(
1169 stdout.starts_with("name,iri,label,is_builtin\n"),
1170 "header row missing; got:\n{stdout}"
1171 );
1172 assert!(
1174 stdout.contains(
1175 "Archive,http://api.dasch.swiss/ontology/0801/beol/v2#Archive,Archive,false"
1176 ),
1177 "Archive row missing; got:\n{stdout}"
1178 );
1179 assert!(
1181 stdout.contains("letter,http://api.dasch.swiss/ontology/0801/beol/v2#letter,,false"),
1182 "letter row missing; got:\n{stdout}"
1183 );
1184 }
1185
1186 #[test]
1187 fn resource_types_csv_with_builtins() {
1188 let out = SharedBuf::new();
1189 let mut renderer = CsvRenderer::with_writer(out.clone());
1190 let view = ResourceTypeListView {
1191 items: vec![ResourceType {
1192 name: "Region".into(),
1193 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1194 label: Some("Region".into()),
1195 is_builtin: true,
1196 count: None,
1197 }],
1198 total: 1,
1199 filter: None,
1200 data_model: "beol".into(),
1201 };
1202 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1203 renderer.resource_types(&view, &meta).unwrap();
1204
1205 let stdout = out.string();
1206 assert!(
1208 stdout.contains("Region,http://api.knora.org/ontology/knora-api/v2#Region,Region,true"),
1209 "built-in row missing; got:\n{stdout}"
1210 );
1211 }
1212
1213 #[test]
1214 fn resource_types_csv_with_filter() {
1215 let out = SharedBuf::new();
1217 let mut renderer = CsvRenderer::with_writer(out.clone());
1218 let view = ResourceTypeListView {
1219 items: vec![make_resource_type_fixture().remove(0)], total: 2,
1221 filter: Some("arch".to_string()),
1222 data_model: "beol".into(),
1223 };
1224 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1225 renderer.resource_types(&view, &meta).unwrap();
1226
1227 let stdout = out.string();
1228 assert!(stdout.starts_with("name,iri,label,is_builtin\n"));
1229 assert!(stdout.contains("Archive"));
1230 }
1231
1232 #[test]
1233 fn resource_types_csv_empty() {
1234 let out = SharedBuf::new();
1235 let mut renderer = CsvRenderer::with_writer(out.clone());
1236 let view = ResourceTypeListView {
1237 items: vec![],
1238 total: 0,
1239 filter: None,
1240 data_model: "beol".into(),
1241 };
1242 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1243 renderer.resource_types(&view, &meta).unwrap();
1244
1245 assert_eq!(out.string(), "name,iri,label,is_builtin\n");
1247 }
1248
1249 #[test]
1250 fn resource_types_csv_stderr_disclosure() {
1251 let out = SharedBuf::new();
1252 let err = SharedBuf::new();
1253 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1254 let view = ResourceTypeListView {
1255 items: make_resource_type_fixture(),
1256 total: 2,
1257 filter: None,
1258 data_model: "beol".into(),
1259 };
1260 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1261 renderer.resource_types(&view, &meta).unwrap();
1262
1263 let err_str = err.string();
1264 assert_eq!(
1265 err_str.trim(),
1266 "[anonymous on https://api.test.dasch.swiss]"
1267 );
1268 assert!(!out.string().contains("[anonymous"));
1269 }
1270
1271 #[test]
1272 fn resource_types_csv_with_count_auto_shows_column() {
1273 let out = SharedBuf::new();
1277 let err = SharedBuf::new();
1278 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1279 let mut items = make_resource_type_fixture();
1280 items[0].count = Some(10);
1281 let view = ResourceTypeListView {
1282 items,
1283 total: 2,
1284 filter: None,
1285 data_model: "beol".into(),
1286 };
1287 let meta = crate::render::MetaContext {
1288 server_label: "https://api.test.dasch.swiss".into(),
1289 auth_state: "anonymous".into(),
1290 filter_warning: None,
1291 count_caveat: Some("counts are not permission-filtered".into()),
1292 count_cost: None,
1293 };
1294 renderer.resource_types(&view, &meta).unwrap();
1295
1296 let stdout = out.string();
1297 assert!(
1298 stdout.starts_with("name,iri,label,is_builtin,count\n"),
1299 "header must auto-show count column; got:\n{stdout}"
1300 );
1301 assert!(
1302 stdout.contains(
1303 "Archive,http://api.dasch.swiss/ontology/0801/beol/v2#Archive,Archive,false,10"
1304 ),
1305 "Archive row must show count 10; got:\n{stdout}"
1306 );
1307 assert!(
1309 stdout.contains("letter,http://api.dasch.swiss/ontology/0801/beol/v2#letter,,false,\n"),
1310 "letter row must show empty count cell; got:\n{stdout}"
1311 );
1312 let err_str = err.string();
1313 assert!(
1314 err_str.contains("counts are not permission-filtered"),
1315 "stderr must carry count_caveat; got: {err_str:?}"
1316 );
1317 }
1318
1319 #[test]
1320 fn resource_type_describe_csv_with_count_no_column_but_disclosure_carries_caveat() {
1321 let out = SharedBuf::new();
1327 let err = SharedBuf::new();
1328 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1329 let detail = crate::model::ResourceTypeDetail {
1330 name: "manuscript".into(),
1331 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1332 label: Some("Manuscript".into()),
1333 data_model: "beol".into(),
1334 representation: None,
1335 super_types: vec![],
1336 fields: vec![],
1337 count: Some(7),
1338 };
1339 let meta = crate::render::MetaContext {
1340 server_label: "https://api.dasch.swiss".into(),
1341 auth_state: "anonymous".into(),
1342 filter_warning: None,
1343 count_caveat: Some("counts exclude deleted resources".into()),
1344 count_cost: None,
1345 };
1346 renderer.resource_type_describe(&detail, &meta).unwrap();
1347
1348 assert!(
1350 out.string().starts_with(
1351 "name,value_type,link_target,cardinality,label,is_builtin,data_model\n"
1352 )
1353 );
1354 assert!(
1356 err.string().contains("counts exclude deleted resources"),
1357 "stderr must carry count_caveat; got: {:?}",
1358 err.string()
1359 );
1360 }
1361
1362 use crate::model::{
1365 FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
1366 };
1367
1368 fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1369 ResourceDetail {
1370 label: "Test Resource".into(),
1371 iri: "http://rdfh.ch/0803/abc123".into(),
1372 resource_type: "Page".into(),
1373 ark_url: None,
1374 creation_date: None,
1375 last_modified: None,
1376 attached_project: None,
1377 owner: None,
1378 visibility: Some(ResourceVisibility::Public),
1379 your_access: Some(ResourceAccess::View),
1380 values,
1381 }
1382 }
1383
1384 #[test]
1385 fn resource_describe_csv_some_values_no_note_renders_value_rows() {
1386 let out = SharedBuf::new();
1390 let err = SharedBuf::new();
1391 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1392 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1393 renderer
1394 .resource_describe(
1395 &make_resource_detail(Some(vec![FieldValues {
1396 name: "hasTitle".into(),
1397 label: None,
1398 values: vec![ValueContent::Text("Hello".into()).into()],
1399 }])),
1400 &meta,
1401 )
1402 .unwrap();
1403
1404 let err_str = err.string();
1405 assert!(
1406 !err_str.contains("values not shown"),
1407 "no values note must be emitted when values is Some; got:\n{err_str}"
1408 );
1409 let stdout = out.string();
1410 assert!(
1411 stdout.starts_with("field,field_label,value_type,value\n"),
1412 "csv values header must be the compact default set; got:\n{stdout}"
1413 );
1414 assert!(
1415 stdout.contains("hasTitle,,text,Hello\n"),
1416 "expected value row for hasTitle; got:\n{stdout}"
1417 );
1418 assert!(
1420 !stdout.contains("Test Resource"),
1421 "metadata row must not appear in values mode; got:\n{stdout}"
1422 );
1423 }
1424
1425 #[test]
1426 fn resource_describe_csv_no_values_no_note() {
1427 let out = SharedBuf::new();
1429 let err = SharedBuf::new();
1430 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1431 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1432 renderer
1433 .resource_describe(&make_resource_detail(None), &meta)
1434 .unwrap();
1435
1436 assert!(
1437 !err.string().contains("values not shown"),
1438 "no note expected when values is None; got:\n{}",
1439 err.string()
1440 );
1441 }
1442}