1use std::io::{self, Write};
14
15use crate::diagnostic::Diagnostic;
16use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail};
17use crate::render::auth::{
18 AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
19};
20use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
21use crate::render::table::render_table_disclosure;
22use crate::render::value::{build_metadata_row, build_value_rows};
23use crate::render::{
24 AUTH_LOGIN_COLUMNS, AUTH_LOGOUT_COLUMNS, DATA_MODEL_DESCRIBE_COLUMNS,
25 DATA_MODEL_STRUCTURE_COLUMNS, DATA_MODELS_COLUMNS, DataModelListView, MetaContext,
26 PROJECT_DUMP_COLUMNS, PROJECT_DUMP_DELETED_COLUMNS, PROJECTS_COLUMNS, ProjectListView,
27 QuoteMode, RESOURCE_DESCRIBE_COLUMNS, RESOURCE_DESCRIBE_VALUES_COLUMNS,
28 RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS, RESOURCE_LIST_COLUMNS,
29 RESOURCE_TYPE_DESCRIBE_COLUMNS, RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS, RESOURCE_TYPES_COLUMNS,
30 Renderer, ResourceListView, ResourceTypeListView, TableOptions, TableSpec, render_table,
31};
32
33pub struct TsvRenderer {
35 out: Box<dyn Write>,
36 err: Box<dyn Write>,
41 options: TableOptions,
44}
45
46impl TsvRenderer {
47 pub fn new() -> Self {
49 Self {
50 out: Box::new(io::stdout()),
51 err: Box::new(io::stderr()),
52 options: TableOptions::default(),
53 }
54 }
55
56 pub fn with_writer(w: impl Write + 'static) -> Self {
60 Self {
61 out: Box::new(w),
62 err: Box::new(io::sink()),
63 options: TableOptions::default(),
64 }
65 }
66
67 pub fn with_writers(out: impl Write + 'static, err: impl Write + 'static) -> Self {
70 Self {
71 out: Box::new(out),
72 err: Box::new(err),
73 options: TableOptions::default(),
74 }
75 }
76
77 pub fn with_options(mut self, opts: TableOptions) -> Self {
80 self.options = opts;
81 self
82 }
83}
84
85impl Default for TsvRenderer {
86 fn default() -> Self {
87 Self::new()
88 }
89}
90
91impl Renderer for TsvRenderer {
92 fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
93 Ok(())
96 }
97
98 fn auth_login(
99 &mut self,
100 outcome: &AuthLoginOutcome,
101 _meta: &MetaContext,
102 ) -> Result<(), Diagnostic> {
103 let expires = outcome
104 .expires_at
105 .map(|dt| dt.to_rfc3339())
106 .unwrap_or_default();
107 let rows = vec![vec![
108 outcome.server.clone(),
109 outcome.user.clone(),
110 expires,
111 "login_success".to_string(),
112 ]];
113 let projected = self.options.projected();
114 let spec = TableSpec {
115 all_columns: AUTH_LOGIN_COLUMNS,
116 rows: &rows,
117 default_columns: None,
118 projected,
119 quote: QuoteMode::Tsv,
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::Tsv,
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::Tsv,
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::Tsv,
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()]];
232 let projected = self.options.projected();
233 let spec = TableSpec {
234 all_columns: PROJECT_DUMP_COLUMNS,
235 rows: &rows,
236 default_columns: None,
237 projected,
238 quote: QuoteMode::Tsv,
239 header: self.options.header,
240 };
241 render_table(&mut *self.out, &spec)
242 }
243
244 fn project_dump_deleted(
245 &mut self,
246 outcome: &DumpDeleteOutcome,
247 _meta: &MetaContext,
248 ) -> Result<(), Diagnostic> {
249 let rows = vec![vec![outcome.deleted.to_string()]];
251 let projected = self.options.projected();
252 let spec = TableSpec {
253 all_columns: PROJECT_DUMP_DELETED_COLUMNS,
254 rows: &rows,
255 default_columns: None,
256 projected,
257 quote: QuoteMode::Tsv,
258 header: self.options.header,
259 };
260 render_table(&mut *self.out, &spec)
261 }
262
263 fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
264 let rows: Vec<Vec<String>> = view
265 .items
266 .iter()
267 .map(|item| {
268 let longname = item.longname.as_deref().unwrap_or("").to_string();
269 let data_models_str = item.data_models.to_string();
270 vec![
271 item.shortcode.clone(),
272 item.shortname.clone(),
273 longname,
274 item.status.as_str().to_string(),
275 data_models_str,
276 item.iri.clone(),
277 ]
278 })
279 .collect();
280 let projected = self.options.projected();
281 let spec = TableSpec {
282 all_columns: PROJECTS_COLUMNS,
283 rows: &rows,
284 default_columns: None,
285 projected,
286 quote: QuoteMode::Tsv,
287 header: self.options.header,
288 };
289 render_table(&mut *self.out, &spec)?;
290 render_table_disclosure(&mut *self.err, meta)?;
291 Ok(())
292 }
293
294 fn project_describe(
295 &mut self,
296 project: &ProjectDetail,
297 meta: &MetaContext,
298 ) -> Result<(), Diagnostic> {
299 let longname = project.longname.as_deref().unwrap_or("").to_string();
302 let data_models_str = project.data_models.len().to_string();
303 let rows = vec![vec![
304 project.shortcode.clone(),
305 project.shortname.clone(),
306 longname,
307 project.status.as_str().to_string(),
308 data_models_str,
309 project.iri.clone(),
310 ]];
311 let projected = self.options.projected();
312 let spec = TableSpec {
313 all_columns: PROJECTS_COLUMNS,
314 rows: &rows,
315 default_columns: None,
316 projected,
317 quote: QuoteMode::Tsv,
318 header: self.options.header,
319 };
320 render_table(&mut *self.out, &spec)?;
321 render_table_disclosure(&mut *self.err, meta)?;
322 Ok(())
323 }
324
325 fn data_model_describe(
326 &mut self,
327 detail: &DataModelDetail,
328 meta: &MetaContext,
329 ) -> Result<(), Diagnostic> {
330 let label = detail.label.as_deref().unwrap_or("").to_string();
333 let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
334 let resource_types_str = detail.resource_types.len().to_string();
335 let rows = vec![vec![
336 detail.name.clone(),
337 detail.iri.clone(),
338 label,
339 last_modified,
340 resource_types_str,
341 ]];
342 let projected = self.options.projected();
343 let spec = TableSpec {
344 all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
345 rows: &rows,
346 default_columns: None,
347 projected,
348 quote: QuoteMode::Tsv,
349 header: self.options.header,
350 };
351 render_table(&mut *self.out, &spec)?;
352 render_table_disclosure(&mut *self.err, meta)?;
353 Ok(())
354 }
355
356 fn data_models(
357 &mut self,
358 view: &DataModelListView,
359 meta: &MetaContext,
360 ) -> Result<(), Diagnostic> {
361 let rows: Vec<Vec<String>> = view
362 .items
363 .iter()
364 .map(|item| {
365 let label = item.label.as_deref().unwrap_or("").to_string();
366 let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
367 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
368 vec![
369 item.name.clone(),
370 item.iri.clone(),
371 label,
372 last_modified,
373 is_builtin_str.to_string(),
374 ]
375 })
376 .collect();
377 let projected = self.options.projected();
378 let spec = TableSpec {
379 all_columns: DATA_MODELS_COLUMNS,
380 rows: &rows,
381 default_columns: None,
382 projected,
383 quote: QuoteMode::Tsv,
384 header: self.options.header,
385 };
386 render_table(&mut *self.out, &spec)?;
387 render_table_disclosure(&mut *self.err, meta)?;
388 Ok(())
389 }
390
391 fn resource_types(
392 &mut self,
393 view: &ResourceTypeListView,
394 meta: &MetaContext,
395 ) -> Result<(), Diagnostic> {
396 let rows: Vec<Vec<String>> = view
398 .items
399 .iter()
400 .map(|item| {
401 let label = item.label.as_deref().unwrap_or("").to_string();
402 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
403 vec![
404 item.name.clone(),
405 item.iri.clone(),
406 label,
407 is_builtin_str.to_string(),
408 ]
409 })
410 .collect();
411 let projected = self.options.projected();
412 let spec = TableSpec {
413 all_columns: RESOURCE_TYPES_COLUMNS,
414 rows: &rows,
415 default_columns: None,
416 projected,
417 quote: QuoteMode::Tsv,
418 header: self.options.header,
419 };
420 render_table(&mut *self.out, &spec)?;
421 render_table_disclosure(&mut *self.err, meta)?;
422 Ok(())
423 }
424
425 fn resource_type_describe(
426 &mut self,
427 detail: &crate::model::ResourceTypeDetail,
428 meta: &MetaContext,
429 ) -> Result<(), Diagnostic> {
430 let rows: Vec<Vec<String>> = detail
436 .fields
437 .iter()
438 .map(|field| {
439 let value_type_str = field.value_type.to_string();
440 let link_target = field.link_target.as_deref().unwrap_or("").to_string();
441 let card_str = field.cardinality.to_string();
442 let label = field.label.as_deref().unwrap_or("").to_string();
443 let is_builtin_str = if field.is_builtin { "true" } else { "false" };
444 let data_model = field.data_model.as_deref().unwrap_or("").to_string();
445 vec![
446 field.name.clone(),
447 field.iri.clone(),
448 value_type_str,
449 link_target,
450 card_str,
451 label,
452 is_builtin_str.to_string(),
453 data_model,
454 ]
455 })
456 .collect();
457 let projected = self.options.projected();
458 let spec = TableSpec {
459 all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
460 rows: &rows,
461 default_columns: Some(RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS),
462 projected,
463 quote: QuoteMode::Tsv,
464 header: self.options.header,
465 };
466 render_table(&mut *self.out, &spec)?;
467 render_table_disclosure(&mut *self.err, meta)?;
468 Ok(())
469 }
470
471 fn data_model_structure(
472 &mut self,
473 structure: &DataModelStructure,
474 meta: &MetaContext,
475 ) -> Result<(), Diagnostic> {
476 let rows: Vec<Vec<String>> = structure
480 .relations
481 .iter()
482 .map(|rel| {
483 let kind_str = rel.kind.to_string();
484 let field = rel.field.as_deref().unwrap_or("").to_string();
485 let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
486 vec![
487 rel.source.clone(),
488 rel.target.clone(),
489 kind_str,
490 field,
491 target_dm,
492 ]
493 })
494 .collect();
495 let projected = self.options.projected();
496 let spec = TableSpec {
497 all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
498 rows: &rows,
499 default_columns: None,
500 projected,
501 quote: QuoteMode::Tsv,
502 header: self.options.header,
503 };
504 render_table(&mut *self.out, &spec)?;
505 render_table_disclosure(&mut *self.err, meta)?;
506 Ok(())
507 }
508
509 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
510 let rows: Vec<Vec<String>> = view
511 .items
512 .iter()
513 .map(|item| {
514 vec![
515 item.label.clone(),
516 item.iri.clone(),
517 item.ark_url.as_deref().unwrap_or("").to_string(),
518 item.creation_date.as_deref().unwrap_or("").to_string(),
519 item.last_modified.as_deref().unwrap_or("").to_string(),
520 item.resource_type.clone(),
521 ]
522 })
523 .collect();
524 let projected = self.options.projected();
525 let spec = TableSpec {
526 all_columns: RESOURCE_LIST_COLUMNS,
527 rows: &rows,
528 default_columns: None,
529 projected,
530 quote: QuoteMode::Tsv,
531 header: self.options.header,
532 };
533 render_table(&mut *self.out, &spec)?;
534 render_table_disclosure(&mut *self.err, meta)?;
535 Ok(())
536 }
537
538 fn resource_describe(
539 &mut self,
540 detail: &crate::model::ResourceDetail,
541 meta: &MetaContext,
542 ) -> Result<(), Diagnostic> {
543 match &detail.values {
547 None => {
548 let rows = vec![build_metadata_row(detail)];
549 let projected = self.options.projected();
550 let spec = TableSpec {
551 all_columns: RESOURCE_DESCRIBE_COLUMNS,
552 rows: &rows,
553 default_columns: None,
554 projected,
555 quote: QuoteMode::Tsv,
556 header: self.options.header,
557 };
558 render_table(&mut *self.out, &spec)?;
559 }
560 Some(fields) => {
561 let rows = build_value_rows(&detail.label, &detail.iri, fields);
562 let projected = self.options.projected();
563 let spec = TableSpec {
564 all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
565 rows: &rows,
566 default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
567 projected,
568 quote: QuoteMode::Tsv,
569 header: self.options.header,
570 };
571 render_table(&mut *self.out, &spec)?;
572 }
573 }
574 render_table_disclosure(&mut *self.err, meta)?;
575 Ok(())
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use crate::model::{
583 DataModel, DataModelDetail, DataModelSummary, Project, ProjectDetail, ProjectStatus,
584 ResourceType, ResourceTypeSummary,
585 };
586 use crate::render::test_support::{SharedBuf, make_meta};
587 use crate::render::{DataModelListView, ResourceTypeListView};
588
589 fn make_fixture() -> Vec<Project> {
590 vec![
591 Project {
592 iri: "http://rdfh.ch/projects/0001".into(),
593 shortcode: "0001".into(),
594 shortname: "anything".into(),
595 longname: Some("Anything Project".into()),
596 status: ProjectStatus::Active,
597 data_models: 2,
598 },
599 Project {
600 iri: "http://rdfh.ch/projects/0002".into(),
601 shortcode: "0002".into(),
602 shortname: "images".into(),
603 longname: None,
604 status: ProjectStatus::Inactive,
605 data_models: 0,
606 },
607 Project {
608 iri: "http://rdfh.ch/projects/0803".into(),
609 shortcode: "0803".into(),
610 shortname: "daschland".into(),
611 longname: Some("DaSCHland Project".into()),
612 status: ProjectStatus::Active,
613 data_models: 1,
614 },
615 ]
616 }
617
618 #[test]
619 fn projects_tsv_output() {
620 let out = SharedBuf::new();
621 let err = SharedBuf::new();
622 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
623 let view = ProjectListView {
624 items: make_fixture(),
625 total: 3,
626 filter: None,
627 };
628 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
629 renderer.projects(&view, &meta).unwrap();
630
631 let stdout = out.string();
632 assert!(stdout.starts_with("shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"));
633 assert!(
634 stdout.contains(
635 "0001\tanything\tAnything Project\tactive\t2\thttp://rdfh.ch/projects/0001"
636 )
637 );
638 assert!(stdout.contains("0002\timages\t\tinactive\t0\thttp://rdfh.ch/projects/0002"));
640 assert!(stdout.contains(
641 "0803\tdaschland\tDaSCHland Project\tactive\t1\thttp://rdfh.ch/projects/0803"
642 ));
643 }
644
645 #[test]
646 fn projects_tsv_stderr_disclosure() {
647 let out = SharedBuf::new();
648 let err = SharedBuf::new();
649 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
650 let view = ProjectListView {
651 items: make_fixture(),
652 total: 3,
653 filter: None,
654 };
655 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
656 renderer.projects(&view, &meta).unwrap();
657
658 let err_str = err.string();
659 assert_eq!(
660 err_str.trim(),
661 "[anonymous on https://api.test.dasch.swiss]"
662 );
663 assert!(!out.string().contains("[anonymous"));
664 }
665
666 #[test]
667 fn auth_status_tsv_stderr_disclosure() {
668 let out = SharedBuf::new();
670 let err = SharedBuf::new();
671 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
672 let meta = make_meta(
673 "authenticated via DSP_TOKEN",
674 "https://api.prod.dasch.swiss",
675 );
676 renderer
677 .auth_status(
678 &AuthStatusOutcome::AuthenticatedViaEnv {
679 server: "https://api.prod.dasch.swiss".to_string(),
680 expires_at: None,
681 expired: false,
682 },
683 &meta,
684 )
685 .unwrap();
686 let err_str = err.string();
687 assert_eq!(
688 err_str.trim(),
689 "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
690 );
691 assert!(!out.string().contains('['));
693
694 let out2 = SharedBuf::new();
696 let err2 = SharedBuf::new();
697 let mut renderer2 = TsvRenderer::with_writers(out2.clone(), err2.clone());
698 let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
699 renderer2
700 .auth_status(
701 &AuthStatusOutcome::NotLoggedIn {
702 server: "https://api.prod.dasch.swiss".to_string(),
703 },
704 &meta2,
705 )
706 .unwrap();
707 let err2_str = err2.string();
708 assert_eq!(
709 err2_str.trim(),
710 "[anonymous on https://api.prod.dasch.swiss]"
711 );
712 assert!(!out2.string().contains('['));
714 }
715
716 #[test]
717 fn projects_tsv_with_writer_no_stderr_leak() {
718 let out = SharedBuf::new();
719 let mut renderer = TsvRenderer::with_writer(out.clone());
720 let view = ProjectListView {
721 items: vec![],
722 total: 0,
723 filter: None,
724 };
725 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
726 renderer.projects(&view, &meta).unwrap();
727 assert_eq!(
728 out.string(),
729 "shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"
730 );
731 }
732
733 #[test]
734 fn project_describe_tsv_output() {
735 let out = SharedBuf::new();
736 let err = SharedBuf::new();
737 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
738 let detail = ProjectDetail {
739 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
740 shortcode: "0801".into(),
741 shortname: "beol".into(),
742 longname: Some("Bernoulli-Euler Online".into()),
743 status: ProjectStatus::Active,
744 description: vec![],
745 keywords: vec![],
746 data_models: vec![
747 DataModelSummary {
748 name: "beol".into(),
749 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
750 },
751 DataModelSummary {
752 name: "biblio".into(),
753 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
754 },
755 ],
756 };
757 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
758 renderer.project_describe(&detail, &meta).unwrap();
759
760 let stdout = out.string();
761 assert!(stdout.starts_with("shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"));
763 assert!(stdout.contains(
765 "0801\tbeol\tBernoulli-Euler Online\tactive\t2\thttp://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"
766 ));
767 }
768
769 #[test]
770 fn project_describe_tsv_no_longname() {
771 let out = SharedBuf::new();
772 let mut renderer = TsvRenderer::with_writer(out.clone());
773 let detail = ProjectDetail {
774 iri: "http://rdfh.ch/projects/0000".into(),
775 shortcode: "0000".into(),
776 shortname: "minimal".into(),
777 longname: None,
778 status: ProjectStatus::Inactive,
779 description: vec![],
780 keywords: vec![],
781 data_models: vec![],
782 };
783 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
784 renderer.project_describe(&detail, &meta).unwrap();
785
786 let stdout = out.string();
787 assert!(stdout.contains("0000\tminimal\t\tinactive\t0\thttp://rdfh.ch/projects/0000"));
789 }
790
791 #[test]
792 fn project_describe_tsv_stderr_disclosure() {
793 let out = SharedBuf::new();
794 let err = SharedBuf::new();
795 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
796 let detail = ProjectDetail {
797 iri: "http://rdfh.ch/projects/0001".into(),
798 shortcode: "0001".into(),
799 shortname: "test".into(),
800 longname: None,
801 status: ProjectStatus::Active,
802 description: vec![],
803 keywords: vec![],
804 data_models: vec![],
805 };
806 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
807 renderer.project_describe(&detail, &meta).unwrap();
808
809 let err_str = err.string();
810 assert_eq!(
811 err_str.trim(),
812 "[anonymous on https://api.test.dasch.swiss]"
813 );
814 assert!(!out.string().contains("[anonymous"));
815 }
816
817 fn make_data_model_fixture() -> Vec<DataModel> {
818 vec![
819 DataModel {
820 name: "beol".into(),
821 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
822 label: Some("The BEOL data-model".into()),
823 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
824 is_builtin: false,
825 },
826 DataModel {
827 name: "biblio".into(),
828 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
829 label: None,
830 last_modified: None,
831 is_builtin: false,
832 },
833 DataModel {
834 name: "knora-api".into(),
835 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
836 label: None,
837 last_modified: None,
838 is_builtin: true,
839 },
840 ]
841 }
842
843 #[test]
844 fn data_models_tsv_output() {
845 let out = SharedBuf::new();
846 let err = SharedBuf::new();
847 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
848 let view = DataModelListView {
849 items: make_data_model_fixture(),
850 total: 3,
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!(stdout.starts_with("name\tiri\tlabel\tlast_modified\tis_builtin\n"));
859 assert!(stdout.contains(
861 "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\tfalse"
862 ));
863 assert!(
865 stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\t\t\tfalse")
866 );
867 assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\t\t\ttrue"));
869 }
870
871 #[test]
872 fn data_models_tsv_stderr_disclosure() {
873 let out = SharedBuf::new();
874 let err = SharedBuf::new();
875 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
876 let view = DataModelListView {
877 items: make_data_model_fixture(),
878 total: 3,
879 filter: None,
880 };
881 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
882 renderer.data_models(&view, &meta).unwrap();
883
884 let err_str = err.string();
885 assert_eq!(
886 err_str.trim(),
887 "[anonymous on https://api.test.dasch.swiss]"
888 );
889 assert!(!out.string().contains("[anonymous"));
890 }
891
892 fn make_beol_dm_detail() -> DataModelDetail {
895 DataModelDetail {
896 name: "beol".into(),
897 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
898 label: Some("The BEOL data-model".into()),
899 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
900 resource_types: vec![
901 ResourceTypeSummary {
902 name: "Archive".into(),
903 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
904 label: Some("Archive".into()),
905 },
906 ResourceTypeSummary {
907 name: "basicLetter".into(),
908 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
909 label: None,
910 },
911 ResourceTypeSummary {
912 name: "letter".into(),
913 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
914 label: Some("Letter".into()),
915 },
916 ],
917 }
918 }
919
920 #[test]
921 fn data_model_describe_tsv_output() {
922 let out = SharedBuf::new();
923 let err = SharedBuf::new();
924 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
925 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
926 renderer
927 .data_model_describe(&make_beol_dm_detail(), &meta)
928 .unwrap();
929
930 let stdout = out.string();
931 assert!(
933 stdout.starts_with("name\tiri\tlabel\tlast_modified\tresource_types\n"),
934 "header row missing; got:\n{stdout}"
935 );
936 assert!(
938 stdout.contains(
939 "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\t3"
940 ),
941 "data row missing; got:\n{stdout}"
942 );
943 }
944
945 #[test]
946 fn data_model_describe_tsv_no_label_no_last_modified() {
947 let out = SharedBuf::new();
948 let mut renderer = TsvRenderer::with_writer(out.clone());
949 let detail = DataModelDetail {
950 name: "minimal".into(),
951 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
952 label: None,
953 last_modified: None,
954 resource_types: vec![],
955 };
956 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
957 renderer.data_model_describe(&detail, &meta).unwrap();
958
959 let stdout = out.string();
960 assert!(
962 stdout.contains("minimal\thttp://api.dasch.swiss/ontology/0000/minimal/v2\t\t\t0"),
963 "None → empty fields must be handled; got:\n{stdout}"
964 );
965 }
966
967 #[test]
968 fn data_model_describe_tsv_stderr_disclosure() {
969 let out = SharedBuf::new();
970 let err = SharedBuf::new();
971 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
972 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
973 renderer
974 .data_model_describe(&make_beol_dm_detail(), &meta)
975 .unwrap();
976
977 let err_str = err.string();
978 assert_eq!(
979 err_str.trim(),
980 "[anonymous on https://api.test.dasch.swiss]"
981 );
982 assert!(!out.string().contains("[anonymous"));
983 }
984
985 fn make_resource_type_fixture() -> Vec<ResourceType> {
988 vec![
989 ResourceType {
990 name: "Archive".into(),
991 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
992 label: Some("Archive".into()),
993 is_builtin: false,
994 },
995 ResourceType {
996 name: "letter".into(),
997 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
998 label: None,
999 is_builtin: false,
1000 },
1001 ]
1002 }
1003
1004 #[test]
1005 fn resource_types_tsv_output() {
1006 let out = SharedBuf::new();
1007 let err = SharedBuf::new();
1008 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1009 let view = ResourceTypeListView {
1010 items: make_resource_type_fixture(),
1011 total: 2,
1012 filter: None,
1013 data_model: "beol".into(),
1014 };
1015 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1016 renderer.resource_types(&view, &meta).unwrap();
1017
1018 let stdout = out.string();
1019 assert!(
1021 stdout.starts_with("name\tiri\tlabel\tis_builtin\n"),
1022 "header row missing; got:\n{stdout}"
1023 );
1024 assert!(
1026 stdout.contains(
1027 "Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\tArchive\tfalse"
1028 ),
1029 "Archive row missing; got:\n{stdout}"
1030 );
1031 assert!(
1033 stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\t\tfalse"),
1034 "letter row missing; got:\n{stdout}"
1035 );
1036 }
1037
1038 #[test]
1039 fn resource_types_tsv_with_builtins() {
1040 let out = SharedBuf::new();
1041 let mut renderer = TsvRenderer::with_writer(out.clone());
1042 let view = ResourceTypeListView {
1043 items: vec![ResourceType {
1044 name: "Region".into(),
1045 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1046 label: Some("Region".into()),
1047 is_builtin: true,
1048 }],
1049 total: 1,
1050 filter: None,
1051 data_model: "beol".into(),
1052 };
1053 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1054 renderer.resource_types(&view, &meta).unwrap();
1055
1056 let stdout = out.string();
1057 assert!(
1059 stdout.contains(
1060 "Region\thttp://api.knora.org/ontology/knora-api/v2#Region\tRegion\ttrue"
1061 ),
1062 "built-in row missing; got:\n{stdout}"
1063 );
1064 }
1065
1066 #[test]
1067 fn resource_types_tsv_with_filter() {
1068 let out = SharedBuf::new();
1070 let mut renderer = TsvRenderer::with_writer(out.clone());
1071 let view = ResourceTypeListView {
1072 items: vec![make_resource_type_fixture().remove(0)], total: 2,
1074 filter: Some("arch".to_string()),
1075 data_model: "beol".into(),
1076 };
1077 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1078 renderer.resource_types(&view, &meta).unwrap();
1079
1080 let stdout = out.string();
1081 assert!(stdout.starts_with("name\tiri\tlabel\tis_builtin\n"));
1082 assert!(stdout.contains("Archive"));
1083 }
1084
1085 #[test]
1086 fn resource_types_tsv_empty() {
1087 let out = SharedBuf::new();
1088 let mut renderer = TsvRenderer::with_writer(out.clone());
1089 let view = ResourceTypeListView {
1090 items: vec![],
1091 total: 0,
1092 filter: None,
1093 data_model: "beol".into(),
1094 };
1095 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1096 renderer.resource_types(&view, &meta).unwrap();
1097
1098 assert_eq!(out.string(), "name\tiri\tlabel\tis_builtin\n");
1100 }
1101
1102 #[test]
1103 fn resource_types_tsv_stderr_disclosure() {
1104 let out = SharedBuf::new();
1105 let err = SharedBuf::new();
1106 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1107 let view = ResourceTypeListView {
1108 items: make_resource_type_fixture(),
1109 total: 2,
1110 filter: None,
1111 data_model: "beol".into(),
1112 };
1113 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1114 renderer.resource_types(&view, &meta).unwrap();
1115
1116 let err_str = err.string();
1117 assert_eq!(
1118 err_str.trim(),
1119 "[anonymous on https://api.test.dasch.swiss]"
1120 );
1121 assert!(!out.string().contains("[anonymous"));
1122 }
1123
1124 use crate::model::{
1127 FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
1128 };
1129
1130 fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1131 ResourceDetail {
1132 label: "Test Resource".into(),
1133 iri: "http://rdfh.ch/0803/abc123".into(),
1134 resource_type: "Page".into(),
1135 ark_url: None,
1136 creation_date: None,
1137 last_modified: None,
1138 attached_project: None,
1139 owner: None,
1140 visibility: Some(ResourceVisibility::Public),
1141 your_access: Some(ResourceAccess::View),
1142 values,
1143 }
1144 }
1145
1146 #[test]
1147 fn resource_describe_tsv_some_values_no_note_renders_value_rows() {
1148 let out = SharedBuf::new();
1152 let err = SharedBuf::new();
1153 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1154 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1155 renderer
1156 .resource_describe(
1157 &make_resource_detail(Some(vec![FieldValues {
1158 name: "hasTitle".into(),
1159 label: None,
1160 values: vec![ValueContent::Text("Hello".into()).into()],
1161 }])),
1162 &meta,
1163 )
1164 .unwrap();
1165
1166 let err_str = err.string();
1167 assert!(
1168 !err_str.contains("values not shown"),
1169 "no values note must be emitted when values is Some; got:\n{err_str}"
1170 );
1171 let stdout = out.string();
1172 assert!(
1173 stdout.starts_with("field\tfield_label\tvalue_type\tvalue\n"),
1174 "tsv values header must be the compact default set; got:\n{stdout}"
1175 );
1176 assert!(
1177 stdout.contains("hasTitle\t\ttext\tHello\n"),
1178 "expected value row for hasTitle; got:\n{stdout}"
1179 );
1180 assert!(
1182 !stdout.contains("Test Resource"),
1183 "metadata row must not appear in values mode; got:\n{stdout}"
1184 );
1185 }
1186
1187 #[test]
1188 fn resource_describe_tsv_no_values_no_note() {
1189 let out = SharedBuf::new();
1191 let err = SharedBuf::new();
1192 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1193 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1194 renderer
1195 .resource_describe(&make_resource_detail(None), &meta)
1196 .unwrap();
1197
1198 assert!(
1199 !err.string().contains("values not shown"),
1200 "no note expected when values is None; got:\n{}",
1201 err.string()
1202 );
1203 }
1204}