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