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