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