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 eprintln!("Error: {diag}"); 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
402 .items
403 .iter()
404 .map(|item| {
405 let label = item.label.as_deref().unwrap_or("").to_string();
406 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
407 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
408 vec![
409 item.name.clone(),
410 item.iri.clone(),
411 label,
412 is_builtin_str.to_string(),
413 count_str,
414 ]
415 })
416 .collect();
417 let has_counts = view.items.iter().any(|rt| rt.count.is_some());
418 let default_columns = if has_counts {
419 None
420 } else {
421 Some(RESOURCE_TYPES_DEFAULT_COLUMNS)
422 };
423 let projected = self.options.projected();
424 let spec = TableSpec {
425 all_columns: RESOURCE_TYPES_COLUMNS,
426 rows: &rows,
427 default_columns,
428 projected,
429 quote: QuoteMode::Csv,
430 header: self.options.header,
431 };
432 render_table(&mut *self.out, &spec)?;
433 render_table_disclosure(&mut *self.err, meta)?;
434 Ok(())
435 }
436
437 fn resource_type_describe(
438 &mut self,
439 detail: &crate::model::ResourceTypeDetail,
440 meta: &MetaContext,
441 ) -> Result<(), Diagnostic> {
442 let rows: Vec<Vec<String>> = detail
449 .fields
450 .iter()
451 .map(|field| {
452 let value_type_str = field.value_type.to_string();
453 let link_target = field.link_target.as_deref().unwrap_or("").to_string();
454 let card_str = field.cardinality.to_string();
455 let label = field.label.as_deref().unwrap_or("").to_string();
456 let is_builtin_str = if field.is_builtin { "true" } else { "false" };
457 let data_model = field.data_model.as_deref().unwrap_or("").to_string();
458 vec![
459 field.name.clone(),
460 field.iri.clone(),
461 value_type_str,
462 link_target,
463 card_str,
464 label,
465 is_builtin_str.to_string(),
466 data_model,
467 ]
468 })
469 .collect();
470 let projected = self.options.projected();
471 let spec = TableSpec {
472 all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
473 rows: &rows,
474 default_columns: Some(RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS),
475 projected,
476 quote: QuoteMode::Csv,
477 header: self.options.header,
478 };
479 render_table(&mut *self.out, &spec)?;
480 render_table_disclosure(&mut *self.err, meta)?;
481 Ok(())
482 }
483
484 fn data_model_structure(
485 &mut self,
486 structure: &DataModelStructure,
487 meta: &MetaContext,
488 ) -> Result<(), Diagnostic> {
489 let rows: Vec<Vec<String>> = structure
493 .relations
494 .iter()
495 .map(|rel| {
496 let kind_str = rel.kind.to_string();
497 let field = rel.field.as_deref().unwrap_or("").to_string();
498 let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
499 vec![
500 rel.source.clone(),
501 rel.target.clone(),
502 kind_str,
503 field,
504 target_dm,
505 ]
506 })
507 .collect();
508 let projected = self.options.projected();
509 let spec = TableSpec {
510 all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
511 rows: &rows,
512 default_columns: None,
513 projected,
514 quote: QuoteMode::Csv,
515 header: self.options.header,
516 };
517 render_table(&mut *self.out, &spec)?;
518 render_table_disclosure(&mut *self.err, meta)?;
519 Ok(())
520 }
521
522 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
523 let rows: Vec<Vec<String>> = view
525 .items
526 .iter()
527 .map(|item| {
528 vec![
529 item.label.clone(),
530 item.iri.clone(),
531 item.ark_url.as_deref().unwrap_or("").to_string(),
532 item.creation_date.as_deref().unwrap_or("").to_string(),
533 item.last_modified.as_deref().unwrap_or("").to_string(),
534 item.resource_type.clone(),
535 ]
536 })
537 .collect();
538 let projected = self.options.projected();
539 let spec = TableSpec {
540 all_columns: RESOURCE_LIST_COLUMNS,
541 rows: &rows,
542 default_columns: None,
543 projected,
544 quote: QuoteMode::Csv,
545 header: self.options.header,
546 };
547 render_table(&mut *self.out, &spec)?;
548 render_table_disclosure(&mut *self.err, meta)?;
549 Ok(())
550 }
551
552 fn resource_describe(
553 &mut self,
554 detail: &crate::model::ResourceDetail,
555 meta: &MetaContext,
556 ) -> Result<(), Diagnostic> {
557 match &detail.values {
561 None => {
562 let rows = vec![build_metadata_row(detail)];
563 let projected = self.options.projected();
564 let spec = TableSpec {
565 all_columns: RESOURCE_DESCRIBE_COLUMNS,
566 rows: &rows,
567 default_columns: None,
568 projected,
569 quote: QuoteMode::Csv,
570 header: self.options.header,
571 };
572 render_table(&mut *self.out, &spec)?;
573 }
574 Some(fields) => {
575 let rows = build_value_rows(&detail.label, &detail.iri, fields);
576 let projected = self.options.projected();
577 let spec = TableSpec {
578 all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
579 rows: &rows,
580 default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
581 projected,
582 quote: QuoteMode::Csv,
583 header: self.options.header,
584 };
585 render_table(&mut *self.out, &spec)?;
586 }
587 }
588 render_table_disclosure(&mut *self.err, meta)?;
589 Ok(())
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::model::{
597 DataModel, DataModelDetail, DataModelSummary, Project, ProjectDetail, ProjectStatus,
598 ResourceType, ResourceTypeSummary,
599 };
600 use crate::render::test_support::{SharedBuf, make_meta};
601 use crate::render::{DataModelListView, ResourceTypeListView};
602
603 fn make_fixture() -> Vec<Project> {
604 vec![
605 Project {
606 iri: "http://rdfh.ch/projects/0001".into(),
607 shortcode: "0001".into(),
608 shortname: "anything".into(),
609 longname: Some("Anything Project".into()),
610 status: ProjectStatus::Active,
611 data_models: 2,
612 },
613 Project {
614 iri: "http://rdfh.ch/projects/0002".into(),
615 shortcode: "0002".into(),
616 shortname: "images".into(),
617 longname: None,
618 status: ProjectStatus::Inactive,
619 data_models: 0,
620 },
621 Project {
622 iri: "http://rdfh.ch/projects/0803".into(),
623 shortcode: "0803".into(),
624 shortname: "daschland".into(),
625 longname: Some("=Formula,Project".into()),
626 status: ProjectStatus::Active,
627 data_models: 1,
628 },
629 ]
630 }
631
632 #[test]
633 fn projects_csv_output() {
634 let out = SharedBuf::new();
635 let err = SharedBuf::new();
636 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
637 let view = ProjectListView {
638 items: make_fixture(),
639 total: 3,
640 filter: None,
641 };
642 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
643 renderer.projects(&view, &meta).unwrap();
644
645 let stdout = out.string();
646 assert!(stdout.starts_with("shortcode,shortname,longname,status,data_models,iri\n"));
647 assert!(
649 stdout.contains("0001,anything,Anything Project,active,2,http://rdfh.ch/projects/0001")
650 );
651 assert!(stdout.contains("0002,images,,inactive,0,http://rdfh.ch/projects/0002"));
653 assert!(stdout.contains(
655 r#"0803,daschland,"=Formula,Project",active,1,http://rdfh.ch/projects/0803"#
656 ));
657 }
658
659 #[test]
660 fn projects_csv_stderr_disclosure() {
661 let out = SharedBuf::new();
662 let err = SharedBuf::new();
663 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
664 let view = ProjectListView {
665 items: make_fixture(),
666 total: 3,
667 filter: None,
668 };
669 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
670 renderer.projects(&view, &meta).unwrap();
671
672 let err_str = err.string();
674 assert_eq!(
675 err_str.trim(),
676 "[anonymous on https://api.test.dasch.swiss]"
677 );
678 assert!(!out.string().contains("[anonymous"));
680 }
681
682 #[test]
683 fn auth_status_csv_stderr_disclosure() {
684 let out = SharedBuf::new();
686 let err = SharedBuf::new();
687 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
688 let meta = make_meta(
689 "authenticated via DSP_TOKEN",
690 "https://api.prod.dasch.swiss",
691 );
692 renderer
693 .auth_status(
694 &AuthStatusOutcome::AuthenticatedViaEnv {
695 server: "https://api.prod.dasch.swiss".to_string(),
696 expires_at: None,
697 expired: false,
698 },
699 &meta,
700 )
701 .unwrap();
702 let err_str = err.string();
703 assert_eq!(
704 err_str.trim(),
705 "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
706 );
707 assert!(!out.string().contains('['));
709
710 let out2 = SharedBuf::new();
712 let err2 = SharedBuf::new();
713 let mut renderer2 = CsvRenderer::with_writers(out2.clone(), err2.clone());
714 let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
715 renderer2
716 .auth_status(
717 &AuthStatusOutcome::NotLoggedIn {
718 server: "https://api.prod.dasch.swiss".to_string(),
719 },
720 &meta2,
721 )
722 .unwrap();
723 let err2_str = err2.string();
724 assert_eq!(
725 err2_str.trim(),
726 "[anonymous on https://api.prod.dasch.swiss]"
727 );
728 assert!(!out2.string().contains('['));
730 }
731
732 #[test]
733 fn projects_csv_with_writer_no_stderr_leak() {
734 let out = SharedBuf::new();
736 let mut renderer = CsvRenderer::with_writer(out.clone());
737 let view = ProjectListView {
738 items: vec![],
739 total: 0,
740 filter: None,
741 };
742 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
743 renderer.projects(&view, &meta).unwrap();
744 assert_eq!(
746 out.string(),
747 "shortcode,shortname,longname,status,data_models,iri\n"
748 );
749 }
750
751 #[test]
752 fn project_describe_csv_output() {
753 let out = SharedBuf::new();
754 let err = SharedBuf::new();
755 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
756 let detail = ProjectDetail {
757 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
758 shortcode: "0801".into(),
759 shortname: "beol".into(),
760 longname: Some("Bernoulli-Euler Online".into()),
761 status: ProjectStatus::Active,
762 description: vec![],
763 keywords: vec![],
764 data_models: vec![
765 DataModelSummary {
766 name: "beol".into(),
767 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
768 },
769 DataModelSummary {
770 name: "biblio".into(),
771 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
772 },
773 ],
774 };
775 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
776 renderer.project_describe(&detail, &meta).unwrap();
777
778 let stdout = out.string();
779 assert!(stdout.starts_with("shortcode,shortname,longname,status,data_models,iri\n"));
781 assert!(stdout.contains(
783 "0801,beol,Bernoulli-Euler Online,active,2,http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"
784 ));
785 }
786
787 #[test]
788 fn project_describe_csv_no_longname() {
789 let out = SharedBuf::new();
790 let mut renderer = CsvRenderer::with_writer(out.clone());
791 let detail = ProjectDetail {
792 iri: "http://rdfh.ch/projects/0000".into(),
793 shortcode: "0000".into(),
794 shortname: "minimal".into(),
795 longname: None,
796 status: ProjectStatus::Inactive,
797 description: vec![],
798 keywords: vec![],
799 data_models: vec![],
800 };
801 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
802 renderer.project_describe(&detail, &meta).unwrap();
803
804 let stdout = out.string();
805 assert!(stdout.contains("0000,minimal,,inactive,0,http://rdfh.ch/projects/0000"));
807 }
808
809 #[test]
810 fn project_describe_csv_formula_injection_longname() {
811 let out = SharedBuf::new();
813 let mut renderer = CsvRenderer::with_writer(out.clone());
814 let detail = ProjectDetail {
815 iri: "http://rdfh.ch/projects/0803".into(),
816 shortcode: "0803".into(),
817 shortname: "daschland".into(),
818 longname: Some("=Formula,Project".into()),
819 status: ProjectStatus::Active,
820 description: vec![],
821 keywords: vec![],
822 data_models: vec![],
823 };
824 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
825 renderer.project_describe(&detail, &meta).unwrap();
826
827 let stdout = out.string();
828 assert!(stdout.contains(r#"0803,daschland,"=Formula,Project",active,0"#));
830 }
831
832 #[test]
833 fn project_describe_csv_stderr_disclosure() {
834 let out = SharedBuf::new();
835 let err = SharedBuf::new();
836 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
837 let detail = ProjectDetail {
838 iri: "http://rdfh.ch/projects/0001".into(),
839 shortcode: "0001".into(),
840 shortname: "test".into(),
841 longname: None,
842 status: ProjectStatus::Active,
843 description: vec![],
844 keywords: vec![],
845 data_models: vec![],
846 };
847 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
848 renderer.project_describe(&detail, &meta).unwrap();
849
850 let err_str = err.string();
851 assert_eq!(
852 err_str.trim(),
853 "[anonymous on https://api.test.dasch.swiss]"
854 );
855 assert!(!out.string().contains("[anonymous"));
856 }
857
858 fn make_data_model_fixture() -> Vec<DataModel> {
859 vec![
860 DataModel {
861 name: "beol".into(),
862 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
863 label: Some("The BEOL data-model".into()),
864 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
865 is_builtin: false,
866 },
867 DataModel {
868 name: "biblio".into(),
869 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
870 label: None,
871 last_modified: None,
872 is_builtin: false,
873 },
874 DataModel {
875 name: "knora-api".into(),
876 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
877 label: None,
878 last_modified: None,
879 is_builtin: true,
880 },
881 ]
882 }
883
884 #[test]
885 fn data_models_csv_output() {
886 let out = SharedBuf::new();
887 let err = SharedBuf::new();
888 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
889 let view = DataModelListView {
890 items: make_data_model_fixture(),
891 total: 3,
892 filter: None,
893 };
894 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
895 renderer.data_models(&view, &meta).unwrap();
896
897 let stdout = out.string();
898 assert!(stdout.starts_with("name,iri,label,last_modified,is_builtin\n"));
900 assert!(stdout.contains(
902 "beol,http://api.dasch.swiss/ontology/0801/beol/v2,The BEOL data-model,2024-05-27T13:43:26.233048Z,false"
903 ));
904 assert!(stdout.contains("biblio,http://api.dasch.swiss/ontology/0801/biblio/v2,,,false"));
906 assert!(stdout.contains("knora-api,http://api.knora.org/ontology/knora-api/v2,,,true"));
908 }
909
910 #[test]
911 fn data_models_csv_formula_injection_label() {
912 let out = SharedBuf::new();
914 let mut renderer = CsvRenderer::with_writer(out.clone());
915 let view = DataModelListView {
916 items: vec![DataModel {
917 name: "test".into(),
918 iri: "http://api.dasch.swiss/ontology/0001/test/v2".into(),
919 label: Some("=Formula,Label".into()),
920 last_modified: None,
921 is_builtin: false,
922 }],
923 total: 1,
924 filter: None,
925 };
926 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
927 renderer.data_models(&view, &meta).unwrap();
928
929 let stdout = out.string();
930 assert!(
932 stdout.contains(
933 r#"test,http://api.dasch.swiss/ontology/0001/test/v2,"=Formula,Label",,false"#
934 ),
935 "formula-injected label must be quoted; got:\n{stdout}"
936 );
937 }
938
939 #[test]
940 fn data_models_csv_stderr_disclosure() {
941 let out = SharedBuf::new();
942 let err = SharedBuf::new();
943 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
944 let view = DataModelListView {
945 items: make_data_model_fixture(),
946 total: 3,
947 filter: None,
948 };
949 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
950 renderer.data_models(&view, &meta).unwrap();
951
952 let err_str = err.string();
953 assert_eq!(
954 err_str.trim(),
955 "[anonymous on https://api.test.dasch.swiss]"
956 );
957 assert!(!out.string().contains("[anonymous"));
958 }
959
960 fn make_beol_dm_detail() -> DataModelDetail {
963 DataModelDetail {
964 name: "beol".into(),
965 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
966 label: Some("The BEOL data-model".into()),
967 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
968 resource_types: vec![
969 ResourceTypeSummary {
970 name: "Archive".into(),
971 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
972 label: Some("Archive".into()),
973 },
974 ResourceTypeSummary {
975 name: "basicLetter".into(),
976 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
977 label: None,
978 },
979 ResourceTypeSummary {
980 name: "letter".into(),
981 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
982 label: Some("Letter".into()),
983 },
984 ],
985 }
986 }
987
988 #[test]
989 fn data_model_describe_csv_output() {
990 let out = SharedBuf::new();
991 let err = SharedBuf::new();
992 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
993 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
994 renderer
995 .data_model_describe(&make_beol_dm_detail(), &meta)
996 .unwrap();
997
998 let stdout = out.string();
999 assert!(
1001 stdout.starts_with("name,iri,label,last_modified,resource_types\n"),
1002 "header row missing; got:\n{stdout}"
1003 );
1004 assert!(
1006 stdout.contains(
1007 "beol,http://api.dasch.swiss/ontology/0801/beol/v2,The BEOL data-model,2024-05-27T13:43:26.233048Z,3"
1008 ),
1009 "data row missing; got:\n{stdout}"
1010 );
1011 }
1012
1013 #[test]
1014 fn data_model_describe_csv_no_label_no_last_modified() {
1015 let out = SharedBuf::new();
1016 let mut renderer = CsvRenderer::with_writer(out.clone());
1017 let detail = DataModelDetail {
1018 name: "minimal".into(),
1019 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1020 label: None,
1021 last_modified: None,
1022 resource_types: vec![],
1023 };
1024 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1025 renderer.data_model_describe(&detail, &meta).unwrap();
1026
1027 let stdout = out.string();
1028 assert!(
1030 stdout.contains("minimal,http://api.dasch.swiss/ontology/0000/minimal/v2,,,0"),
1031 "None → empty fields must be handled; got:\n{stdout}"
1032 );
1033 }
1034
1035 #[test]
1036 fn data_model_describe_csv_formula_injection_label() {
1037 let out = SharedBuf::new();
1039 let mut renderer = CsvRenderer::with_writer(out.clone());
1040 let detail = DataModelDetail {
1041 name: "test".into(),
1042 iri: "http://api.dasch.swiss/ontology/0001/test/v2".into(),
1043 label: Some("=Formula,Label".into()),
1044 last_modified: None,
1045 resource_types: vec![],
1046 };
1047 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1048 renderer.data_model_describe(&detail, &meta).unwrap();
1049
1050 let stdout = out.string();
1051 assert!(
1053 stdout.contains(
1054 r#"test,http://api.dasch.swiss/ontology/0001/test/v2,"=Formula,Label",,0"#
1055 ),
1056 "formula-injected label must be quoted; got:\n{stdout}"
1057 );
1058 }
1059
1060 #[test]
1061 fn data_model_describe_csv_stderr_disclosure() {
1062 let out = SharedBuf::new();
1063 let err = SharedBuf::new();
1064 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1065 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1066 renderer
1067 .data_model_describe(&make_beol_dm_detail(), &meta)
1068 .unwrap();
1069
1070 let err_str = err.string();
1071 assert_eq!(
1072 err_str.trim(),
1073 "[anonymous on https://api.test.dasch.swiss]"
1074 );
1075 assert!(!out.string().contains("[anonymous"));
1076 }
1077
1078 fn make_resource_type_fixture() -> Vec<ResourceType> {
1081 vec![
1082 ResourceType {
1083 name: "Archive".into(),
1084 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1085 label: Some("Archive".into()),
1086 is_builtin: false,
1087 count: None,
1088 },
1089 ResourceType {
1090 name: "letter".into(),
1091 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1092 label: None,
1093 is_builtin: false,
1094 count: None,
1095 },
1096 ]
1097 }
1098
1099 #[test]
1100 fn resource_types_csv_output() {
1101 let out = SharedBuf::new();
1102 let err = SharedBuf::new();
1103 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1104 let view = ResourceTypeListView {
1105 items: make_resource_type_fixture(),
1106 total: 2,
1107 filter: None,
1108 data_model: "beol".into(),
1109 };
1110 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1111 renderer.resource_types(&view, &meta).unwrap();
1112
1113 let stdout = out.string();
1114 assert!(
1116 stdout.starts_with("name,iri,label,is_builtin\n"),
1117 "header row missing; got:\n{stdout}"
1118 );
1119 assert!(
1121 stdout.contains(
1122 "Archive,http://api.dasch.swiss/ontology/0801/beol/v2#Archive,Archive,false"
1123 ),
1124 "Archive row missing; got:\n{stdout}"
1125 );
1126 assert!(
1128 stdout.contains("letter,http://api.dasch.swiss/ontology/0801/beol/v2#letter,,false"),
1129 "letter row missing; got:\n{stdout}"
1130 );
1131 }
1132
1133 #[test]
1134 fn resource_types_csv_with_builtins() {
1135 let out = SharedBuf::new();
1136 let mut renderer = CsvRenderer::with_writer(out.clone());
1137 let view = ResourceTypeListView {
1138 items: vec![ResourceType {
1139 name: "Region".into(),
1140 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1141 label: Some("Region".into()),
1142 is_builtin: true,
1143 count: None,
1144 }],
1145 total: 1,
1146 filter: None,
1147 data_model: "beol".into(),
1148 };
1149 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1150 renderer.resource_types(&view, &meta).unwrap();
1151
1152 let stdout = out.string();
1153 assert!(
1155 stdout.contains("Region,http://api.knora.org/ontology/knora-api/v2#Region,Region,true"),
1156 "built-in row missing; got:\n{stdout}"
1157 );
1158 }
1159
1160 #[test]
1161 fn resource_types_csv_with_filter() {
1162 let out = SharedBuf::new();
1164 let mut renderer = CsvRenderer::with_writer(out.clone());
1165 let view = ResourceTypeListView {
1166 items: vec![make_resource_type_fixture().remove(0)], total: 2,
1168 filter: Some("arch".to_string()),
1169 data_model: "beol".into(),
1170 };
1171 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1172 renderer.resource_types(&view, &meta).unwrap();
1173
1174 let stdout = out.string();
1175 assert!(stdout.starts_with("name,iri,label,is_builtin\n"));
1176 assert!(stdout.contains("Archive"));
1177 }
1178
1179 #[test]
1180 fn resource_types_csv_empty() {
1181 let out = SharedBuf::new();
1182 let mut renderer = CsvRenderer::with_writer(out.clone());
1183 let view = ResourceTypeListView {
1184 items: vec![],
1185 total: 0,
1186 filter: None,
1187 data_model: "beol".into(),
1188 };
1189 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1190 renderer.resource_types(&view, &meta).unwrap();
1191
1192 assert_eq!(out.string(), "name,iri,label,is_builtin\n");
1194 }
1195
1196 #[test]
1197 fn resource_types_csv_stderr_disclosure() {
1198 let out = SharedBuf::new();
1199 let err = SharedBuf::new();
1200 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1201 let view = ResourceTypeListView {
1202 items: make_resource_type_fixture(),
1203 total: 2,
1204 filter: None,
1205 data_model: "beol".into(),
1206 };
1207 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1208 renderer.resource_types(&view, &meta).unwrap();
1209
1210 let err_str = err.string();
1211 assert_eq!(
1212 err_str.trim(),
1213 "[anonymous on https://api.test.dasch.swiss]"
1214 );
1215 assert!(!out.string().contains("[anonymous"));
1216 }
1217
1218 #[test]
1219 fn resource_types_csv_with_count_auto_shows_column() {
1220 let out = SharedBuf::new();
1224 let err = SharedBuf::new();
1225 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1226 let mut items = make_resource_type_fixture();
1227 items[0].count = Some(10);
1228 let view = ResourceTypeListView {
1229 items,
1230 total: 2,
1231 filter: None,
1232 data_model: "beol".into(),
1233 };
1234 let meta = crate::render::MetaContext {
1235 server_label: "https://api.test.dasch.swiss".into(),
1236 auth_state: "anonymous".into(),
1237 filter_warning: None,
1238 count_caveat: Some("counts are not permission-filtered".into()),
1239 };
1240 renderer.resource_types(&view, &meta).unwrap();
1241
1242 let stdout = out.string();
1243 assert!(
1244 stdout.starts_with("name,iri,label,is_builtin,count\n"),
1245 "header must auto-show count column; got:\n{stdout}"
1246 );
1247 assert!(
1248 stdout.contains(
1249 "Archive,http://api.dasch.swiss/ontology/0801/beol/v2#Archive,Archive,false,10"
1250 ),
1251 "Archive row must show count 10; got:\n{stdout}"
1252 );
1253 assert!(
1255 stdout.contains("letter,http://api.dasch.swiss/ontology/0801/beol/v2#letter,,false,\n"),
1256 "letter row must show empty count cell; got:\n{stdout}"
1257 );
1258 let err_str = err.string();
1259 assert!(
1260 err_str.contains("counts are not permission-filtered"),
1261 "stderr must carry count_caveat; got: {err_str:?}"
1262 );
1263 }
1264
1265 #[test]
1266 fn resource_type_describe_csv_with_count_no_column_but_disclosure_carries_caveat() {
1267 let out = SharedBuf::new();
1273 let err = SharedBuf::new();
1274 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1275 let detail = crate::model::ResourceTypeDetail {
1276 name: "manuscript".into(),
1277 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1278 label: Some("Manuscript".into()),
1279 data_model: "beol".into(),
1280 representation: None,
1281 super_types: vec![],
1282 fields: vec![],
1283 count: Some(7),
1284 };
1285 let meta = crate::render::MetaContext {
1286 server_label: "https://api.dasch.swiss".into(),
1287 auth_state: "anonymous".into(),
1288 filter_warning: None,
1289 count_caveat: Some("counts exclude deleted resources".into()),
1290 };
1291 renderer.resource_type_describe(&detail, &meta).unwrap();
1292
1293 assert!(
1295 out.string().starts_with(
1296 "name,value_type,link_target,cardinality,label,is_builtin,data_model\n"
1297 )
1298 );
1299 assert!(
1301 err.string().contains("counts exclude deleted resources"),
1302 "stderr must carry count_caveat; got: {:?}",
1303 err.string()
1304 );
1305 }
1306
1307 use crate::model::{
1310 FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
1311 };
1312
1313 fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1314 ResourceDetail {
1315 label: "Test Resource".into(),
1316 iri: "http://rdfh.ch/0803/abc123".into(),
1317 resource_type: "Page".into(),
1318 ark_url: None,
1319 creation_date: None,
1320 last_modified: None,
1321 attached_project: None,
1322 owner: None,
1323 visibility: Some(ResourceVisibility::Public),
1324 your_access: Some(ResourceAccess::View),
1325 values,
1326 }
1327 }
1328
1329 #[test]
1330 fn resource_describe_csv_some_values_no_note_renders_value_rows() {
1331 let out = SharedBuf::new();
1335 let err = SharedBuf::new();
1336 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1337 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1338 renderer
1339 .resource_describe(
1340 &make_resource_detail(Some(vec![FieldValues {
1341 name: "hasTitle".into(),
1342 label: None,
1343 values: vec![ValueContent::Text("Hello".into()).into()],
1344 }])),
1345 &meta,
1346 )
1347 .unwrap();
1348
1349 let err_str = err.string();
1350 assert!(
1351 !err_str.contains("values not shown"),
1352 "no values note must be emitted when values is Some; got:\n{err_str}"
1353 );
1354 let stdout = out.string();
1355 assert!(
1356 stdout.starts_with("field,field_label,value_type,value\n"),
1357 "csv values header must be the compact default set; got:\n{stdout}"
1358 );
1359 assert!(
1360 stdout.contains("hasTitle,,text,Hello\n"),
1361 "expected value row for hasTitle; got:\n{stdout}"
1362 );
1363 assert!(
1365 !stdout.contains("Test Resource"),
1366 "metadata row must not appear in values mode; got:\n{stdout}"
1367 );
1368 }
1369
1370 #[test]
1371 fn resource_describe_csv_no_values_no_note() {
1372 let out = SharedBuf::new();
1374 let err = SharedBuf::new();
1375 let mut renderer = CsvRenderer::with_writers(out.clone(), err.clone());
1376 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1377 renderer
1378 .resource_describe(&make_resource_detail(None), &meta)
1379 .unwrap();
1380
1381 assert!(
1382 !err.string().contains("values not shown"),
1383 "no note expected when values is None; got:\n{}",
1384 err.string()
1385 );
1386 }
1387}