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 TsvRenderer {
36 out: Box<dyn Write>,
37 err: Box<dyn Write>,
42 options: TableOptions,
45}
46
47impl TsvRenderer {
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 TsvRenderer {
87 fn default() -> Self {
88 Self::new()
89 }
90}
91
92impl Renderer for TsvRenderer {
93 fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
94 Ok(())
97 }
98
99 fn auth_login(
100 &mut self,
101 outcome: &AuthLoginOutcome,
102 _meta: &MetaContext,
103 ) -> Result<(), Diagnostic> {
104 let expires = outcome
105 .expires_at
106 .map(|dt| dt.to_rfc3339())
107 .unwrap_or_default();
108 let rows = vec![vec![
109 outcome.server.clone(),
110 outcome.user.clone(),
111 expires,
112 "login_success".to_string(),
113 ]];
114 let projected = self.options.projected();
115 let spec = TableSpec {
116 all_columns: AUTH_LOGIN_COLUMNS,
117 rows: &rows,
118 default_columns: None,
119 projected,
120 quote: QuoteMode::Tsv,
121 header: self.options.header,
122 };
123 render_table(&mut *self.out, &spec)
124 }
125
126 fn auth_status(
127 &mut self,
128 outcome: &AuthStatusOutcome,
129 meta: &MetaContext,
130 ) -> Result<(), Diagnostic> {
131 let row = match outcome {
132 AuthStatusOutcome::LoggedIn {
133 server,
134 user,
135 expires_at,
136 expired,
137 } => {
138 let user_str = user.as_deref().unwrap_or("").to_string();
139 let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
140 let state = if *expired { "expired" } else { "logged_in" };
141 vec![server.clone(), user_str, expires, state.to_string()]
142 }
143 AuthStatusOutcome::AuthenticatedViaEnv {
147 server,
148 expires_at,
149 expired,
150 } => {
151 let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
152 let state = if *expired { "expired" } else { "logged_in" };
153 vec![server.clone(), String::new(), expires, state.to_string()]
154 }
155 AuthStatusOutcome::NotLoggedIn { server } => {
156 vec![
157 server.clone(),
158 String::new(),
159 String::new(),
160 "not_logged_in".to_string(),
161 ]
162 }
163 };
164 let rows = vec![row];
165 let projected = self.options.projected();
166 let spec = TableSpec {
167 all_columns: AUTH_LOGIN_COLUMNS,
168 rows: &rows,
169 default_columns: None,
170 projected,
171 quote: QuoteMode::Tsv,
172 header: self.options.header,
173 };
174 render_table(&mut *self.out, &spec)?;
175 render_table_disclosure(&mut *self.err, meta)?;
176 Ok(())
177 }
178
179 fn auth_logout(
180 &mut self,
181 outcome: &AuthLogoutOutcome,
182 _meta: &MetaContext,
183 ) -> Result<(), Diagnostic> {
184 let rows = vec![vec![outcome.server.clone(), outcome.was_cached.to_string()]];
185 let projected = self.options.projected();
186 let spec = TableSpec {
187 all_columns: AUTH_LOGOUT_COLUMNS,
188 rows: &rows,
189 default_columns: None,
190 projected,
191 quote: QuoteMode::Tsv,
192 header: self.options.header,
193 };
194 render_table(&mut *self.out, &spec)
195 }
196
197 fn auth_set_token(
198 &mut self,
199 outcome: &AuthSetTokenOutcome,
200 _meta: &MetaContext,
201 ) -> Result<(), Diagnostic> {
202 let user_str = outcome.user.as_deref().unwrap_or("").to_string();
203 let expires = outcome
204 .expires_at
205 .map(|dt| dt.to_rfc3339())
206 .unwrap_or_default();
207 let rows = vec![vec![
208 outcome.server.clone(),
209 user_str,
210 expires,
211 "token_cached".to_string(),
212 ]];
213 let projected = self.options.projected();
214 let spec = TableSpec {
215 all_columns: AUTH_LOGIN_COLUMNS,
216 rows: &rows,
217 default_columns: None,
218 projected,
219 quote: QuoteMode::Tsv,
220 header: self.options.header,
221 };
222 render_table(&mut *self.out, &spec)
223 }
224
225 fn project_dump(
226 &mut self,
227 outcome: &DumpOutcome,
228 _meta: &MetaContext,
229 ) -> Result<(), Diagnostic> {
230 let rows = vec![vec![outcome.path.display().to_string()]];
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::Tsv,
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::Tsv,
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::Tsv,
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::Tsv,
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();
334 let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
335 let resource_types_str = detail.resource_types.len().to_string();
336 let rows = vec![vec![
337 detail.name.clone(),
338 detail.iri.clone(),
339 label,
340 last_modified,
341 resource_types_str,
342 ]];
343 let projected = self.options.projected();
344 let spec = TableSpec {
345 all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
346 rows: &rows,
347 default_columns: None,
348 projected,
349 quote: QuoteMode::Tsv,
350 header: self.options.header,
351 };
352 render_table(&mut *self.out, &spec)?;
353 render_table_disclosure(&mut *self.err, meta)?;
354 Ok(())
355 }
356
357 fn data_models(
358 &mut self,
359 view: &DataModelListView,
360 meta: &MetaContext,
361 ) -> Result<(), Diagnostic> {
362 let rows: Vec<Vec<String>> = view
363 .items
364 .iter()
365 .map(|item| {
366 let label = item.label.as_deref().unwrap_or("").to_string();
367 let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
368 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
369 vec![
370 item.name.clone(),
371 item.iri.clone(),
372 label,
373 last_modified,
374 is_builtin_str.to_string(),
375 ]
376 })
377 .collect();
378 let projected = self.options.projected();
379 let spec = TableSpec {
380 all_columns: DATA_MODELS_COLUMNS,
381 rows: &rows,
382 default_columns: None,
383 projected,
384 quote: QuoteMode::Tsv,
385 header: self.options.header,
386 };
387 render_table(&mut *self.out, &spec)?;
388 render_table_disclosure(&mut *self.err, meta)?;
389 Ok(())
390 }
391
392 fn resource_types(
393 &mut self,
394 view: &ResourceTypeListView,
395 meta: &MetaContext,
396 ) -> Result<(), Diagnostic> {
397 let rows: Vec<Vec<String>> = view
401 .items
402 .iter()
403 .map(|item| {
404 let label = item.label.as_deref().unwrap_or("").to_string();
405 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
406 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
407 vec![
408 item.name.clone(),
409 item.iri.clone(),
410 label,
411 is_builtin_str.to_string(),
412 count_str,
413 ]
414 })
415 .collect();
416 let has_counts = view.items.iter().any(|rt| rt.count.is_some());
417 let default_columns = if has_counts {
418 None
419 } else {
420 Some(RESOURCE_TYPES_DEFAULT_COLUMNS)
421 };
422 let projected = self.options.projected();
423 let spec = TableSpec {
424 all_columns: RESOURCE_TYPES_COLUMNS,
425 rows: &rows,
426 default_columns,
427 projected,
428 quote: QuoteMode::Tsv,
429 header: self.options.header,
430 };
431 render_table(&mut *self.out, &spec)?;
432 render_table_disclosure(&mut *self.err, meta)?;
433 Ok(())
434 }
435
436 fn resource_type_describe(
437 &mut self,
438 detail: &crate::model::ResourceTypeDetail,
439 meta: &MetaContext,
440 ) -> Result<(), Diagnostic> {
441 let rows: Vec<Vec<String>> = detail
447 .fields
448 .iter()
449 .map(|field| {
450 let value_type_str = field.value_type.to_string();
451 let link_target = field.link_target.as_deref().unwrap_or("").to_string();
452 let card_str = field.cardinality.to_string();
453 let label = field.label.as_deref().unwrap_or("").to_string();
454 let is_builtin_str = if field.is_builtin { "true" } else { "false" };
455 let data_model = field.data_model.as_deref().unwrap_or("").to_string();
456 vec![
457 field.name.clone(),
458 field.iri.clone(),
459 value_type_str,
460 link_target,
461 card_str,
462 label,
463 is_builtin_str.to_string(),
464 data_model,
465 ]
466 })
467 .collect();
468 let projected = self.options.projected();
469 let spec = TableSpec {
470 all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
471 rows: &rows,
472 default_columns: Some(RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS),
473 projected,
474 quote: QuoteMode::Tsv,
475 header: self.options.header,
476 };
477 render_table(&mut *self.out, &spec)?;
478 render_table_disclosure(&mut *self.err, meta)?;
479 Ok(())
480 }
481
482 fn data_model_structure(
483 &mut self,
484 structure: &DataModelStructure,
485 meta: &MetaContext,
486 ) -> Result<(), Diagnostic> {
487 let rows: Vec<Vec<String>> = structure
491 .relations
492 .iter()
493 .map(|rel| {
494 let kind_str = rel.kind.to_string();
495 let field = rel.field.as_deref().unwrap_or("").to_string();
496 let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
497 vec![
498 rel.source.clone(),
499 rel.target.clone(),
500 kind_str,
501 field,
502 target_dm,
503 ]
504 })
505 .collect();
506 let projected = self.options.projected();
507 let spec = TableSpec {
508 all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
509 rows: &rows,
510 default_columns: None,
511 projected,
512 quote: QuoteMode::Tsv,
513 header: self.options.header,
514 };
515 render_table(&mut *self.out, &spec)?;
516 render_table_disclosure(&mut *self.err, meta)?;
517 Ok(())
518 }
519
520 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
521 let rows: Vec<Vec<String>> = view
522 .items
523 .iter()
524 .map(|item| {
525 vec![
526 item.label.clone(),
527 item.iri.clone(),
528 item.ark_url.as_deref().unwrap_or("").to_string(),
529 item.creation_date.as_deref().unwrap_or("").to_string(),
530 item.last_modified.as_deref().unwrap_or("").to_string(),
531 item.resource_type.clone(),
532 ]
533 })
534 .collect();
535 let projected = self.options.projected();
536 let spec = TableSpec {
537 all_columns: RESOURCE_LIST_COLUMNS,
538 rows: &rows,
539 default_columns: None,
540 projected,
541 quote: QuoteMode::Tsv,
542 header: self.options.header,
543 };
544 render_table(&mut *self.out, &spec)?;
545 render_table_disclosure(&mut *self.err, meta)?;
546 Ok(())
547 }
548
549 fn resource_describe(
550 &mut self,
551 detail: &crate::model::ResourceDetail,
552 meta: &MetaContext,
553 ) -> Result<(), Diagnostic> {
554 match &detail.values {
558 None => {
559 let rows = vec![build_metadata_row(detail)];
560 let projected = self.options.projected();
561 let spec = TableSpec {
562 all_columns: RESOURCE_DESCRIBE_COLUMNS,
563 rows: &rows,
564 default_columns: None,
565 projected,
566 quote: QuoteMode::Tsv,
567 header: self.options.header,
568 };
569 render_table(&mut *self.out, &spec)?;
570 }
571 Some(fields) => {
572 let rows = build_value_rows(&detail.label, &detail.iri, fields);
573 let projected = self.options.projected();
574 let spec = TableSpec {
575 all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
576 rows: &rows,
577 default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
578 projected,
579 quote: QuoteMode::Tsv,
580 header: self.options.header,
581 };
582 render_table(&mut *self.out, &spec)?;
583 }
584 }
585 render_table_disclosure(&mut *self.err, meta)?;
586 Ok(())
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::model::{
594 DataModel, DataModelDetail, DataModelSummary, Project, ProjectDetail, ProjectStatus,
595 ResourceType, ResourceTypeSummary,
596 };
597 use crate::render::test_support::{SharedBuf, make_meta};
598 use crate::render::{DataModelListView, ResourceTypeListView};
599
600 fn make_fixture() -> Vec<Project> {
601 vec![
602 Project {
603 iri: "http://rdfh.ch/projects/0001".into(),
604 shortcode: "0001".into(),
605 shortname: "anything".into(),
606 longname: Some("Anything Project".into()),
607 status: ProjectStatus::Active,
608 data_models: 2,
609 },
610 Project {
611 iri: "http://rdfh.ch/projects/0002".into(),
612 shortcode: "0002".into(),
613 shortname: "images".into(),
614 longname: None,
615 status: ProjectStatus::Inactive,
616 data_models: 0,
617 },
618 Project {
619 iri: "http://rdfh.ch/projects/0803".into(),
620 shortcode: "0803".into(),
621 shortname: "daschland".into(),
622 longname: Some("DaSCHland Project".into()),
623 status: ProjectStatus::Active,
624 data_models: 1,
625 },
626 ]
627 }
628
629 #[test]
630 fn projects_tsv_output() {
631 let out = SharedBuf::new();
632 let err = SharedBuf::new();
633 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
634 let view = ProjectListView {
635 items: make_fixture(),
636 total: 3,
637 filter: None,
638 };
639 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
640 renderer.projects(&view, &meta).unwrap();
641
642 let stdout = out.string();
643 assert!(stdout.starts_with("shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"));
644 assert!(
645 stdout.contains(
646 "0001\tanything\tAnything Project\tactive\t2\thttp://rdfh.ch/projects/0001"
647 )
648 );
649 assert!(stdout.contains("0002\timages\t\tinactive\t0\thttp://rdfh.ch/projects/0002"));
651 assert!(stdout.contains(
652 "0803\tdaschland\tDaSCHland Project\tactive\t1\thttp://rdfh.ch/projects/0803"
653 ));
654 }
655
656 #[test]
657 fn projects_tsv_stderr_disclosure() {
658 let out = SharedBuf::new();
659 let err = SharedBuf::new();
660 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
661 let view = ProjectListView {
662 items: make_fixture(),
663 total: 3,
664 filter: None,
665 };
666 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
667 renderer.projects(&view, &meta).unwrap();
668
669 let err_str = err.string();
670 assert_eq!(
671 err_str.trim(),
672 "[anonymous on https://api.test.dasch.swiss]"
673 );
674 assert!(!out.string().contains("[anonymous"));
675 }
676
677 #[test]
678 fn auth_status_tsv_stderr_disclosure() {
679 let out = SharedBuf::new();
681 let err = SharedBuf::new();
682 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
683 let meta = make_meta(
684 "authenticated via DSP_TOKEN",
685 "https://api.prod.dasch.swiss",
686 );
687 renderer
688 .auth_status(
689 &AuthStatusOutcome::AuthenticatedViaEnv {
690 server: "https://api.prod.dasch.swiss".to_string(),
691 expires_at: None,
692 expired: false,
693 },
694 &meta,
695 )
696 .unwrap();
697 let err_str = err.string();
698 assert_eq!(
699 err_str.trim(),
700 "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
701 );
702 assert!(!out.string().contains('['));
704
705 let out2 = SharedBuf::new();
707 let err2 = SharedBuf::new();
708 let mut renderer2 = TsvRenderer::with_writers(out2.clone(), err2.clone());
709 let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
710 renderer2
711 .auth_status(
712 &AuthStatusOutcome::NotLoggedIn {
713 server: "https://api.prod.dasch.swiss".to_string(),
714 },
715 &meta2,
716 )
717 .unwrap();
718 let err2_str = err2.string();
719 assert_eq!(
720 err2_str.trim(),
721 "[anonymous on https://api.prod.dasch.swiss]"
722 );
723 assert!(!out2.string().contains('['));
725 }
726
727 #[test]
728 fn projects_tsv_with_writer_no_stderr_leak() {
729 let out = SharedBuf::new();
730 let mut renderer = TsvRenderer::with_writer(out.clone());
731 let view = ProjectListView {
732 items: vec![],
733 total: 0,
734 filter: None,
735 };
736 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
737 renderer.projects(&view, &meta).unwrap();
738 assert_eq!(
739 out.string(),
740 "shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"
741 );
742 }
743
744 #[test]
745 fn project_describe_tsv_output() {
746 let out = SharedBuf::new();
747 let err = SharedBuf::new();
748 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
749 let detail = ProjectDetail {
750 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
751 shortcode: "0801".into(),
752 shortname: "beol".into(),
753 longname: Some("Bernoulli-Euler Online".into()),
754 status: ProjectStatus::Active,
755 description: vec![],
756 keywords: vec![],
757 data_models: vec![
758 DataModelSummary {
759 name: "beol".into(),
760 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
761 },
762 DataModelSummary {
763 name: "biblio".into(),
764 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
765 },
766 ],
767 };
768 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
769 renderer.project_describe(&detail, &meta).unwrap();
770
771 let stdout = out.string();
772 assert!(stdout.starts_with("shortcode\tshortname\tlongname\tstatus\tdata_models\tiri\n"));
774 assert!(stdout.contains(
776 "0801\tbeol\tBernoulli-Euler Online\tactive\t2\thttp://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"
777 ));
778 }
779
780 #[test]
781 fn project_describe_tsv_no_longname() {
782 let out = SharedBuf::new();
783 let mut renderer = TsvRenderer::with_writer(out.clone());
784 let detail = ProjectDetail {
785 iri: "http://rdfh.ch/projects/0000".into(),
786 shortcode: "0000".into(),
787 shortname: "minimal".into(),
788 longname: None,
789 status: ProjectStatus::Inactive,
790 description: vec![],
791 keywords: vec![],
792 data_models: vec![],
793 };
794 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
795 renderer.project_describe(&detail, &meta).unwrap();
796
797 let stdout = out.string();
798 assert!(stdout.contains("0000\tminimal\t\tinactive\t0\thttp://rdfh.ch/projects/0000"));
800 }
801
802 #[test]
803 fn project_describe_tsv_stderr_disclosure() {
804 let out = SharedBuf::new();
805 let err = SharedBuf::new();
806 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
807 let detail = ProjectDetail {
808 iri: "http://rdfh.ch/projects/0001".into(),
809 shortcode: "0001".into(),
810 shortname: "test".into(),
811 longname: None,
812 status: ProjectStatus::Active,
813 description: vec![],
814 keywords: vec![],
815 data_models: vec![],
816 };
817 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
818 renderer.project_describe(&detail, &meta).unwrap();
819
820 let err_str = err.string();
821 assert_eq!(
822 err_str.trim(),
823 "[anonymous on https://api.test.dasch.swiss]"
824 );
825 assert!(!out.string().contains("[anonymous"));
826 }
827
828 fn make_data_model_fixture() -> Vec<DataModel> {
829 vec![
830 DataModel {
831 name: "beol".into(),
832 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
833 label: Some("The BEOL data-model".into()),
834 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
835 is_builtin: false,
836 },
837 DataModel {
838 name: "biblio".into(),
839 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
840 label: None,
841 last_modified: None,
842 is_builtin: false,
843 },
844 DataModel {
845 name: "knora-api".into(),
846 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
847 label: None,
848 last_modified: None,
849 is_builtin: true,
850 },
851 ]
852 }
853
854 #[test]
855 fn data_models_tsv_output() {
856 let out = SharedBuf::new();
857 let err = SharedBuf::new();
858 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
859 let view = DataModelListView {
860 items: make_data_model_fixture(),
861 total: 3,
862 filter: None,
863 };
864 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
865 renderer.data_models(&view, &meta).unwrap();
866
867 let stdout = out.string();
868 assert!(stdout.starts_with("name\tiri\tlabel\tlast_modified\tis_builtin\n"));
870 assert!(stdout.contains(
872 "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\tfalse"
873 ));
874 assert!(
876 stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\t\t\tfalse")
877 );
878 assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\t\t\ttrue"));
880 }
881
882 #[test]
883 fn data_models_tsv_stderr_disclosure() {
884 let out = SharedBuf::new();
885 let err = SharedBuf::new();
886 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
887 let view = DataModelListView {
888 items: make_data_model_fixture(),
889 total: 3,
890 filter: None,
891 };
892 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
893 renderer.data_models(&view, &meta).unwrap();
894
895 let err_str = err.string();
896 assert_eq!(
897 err_str.trim(),
898 "[anonymous on https://api.test.dasch.swiss]"
899 );
900 assert!(!out.string().contains("[anonymous"));
901 }
902
903 fn make_beol_dm_detail() -> DataModelDetail {
906 DataModelDetail {
907 name: "beol".into(),
908 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
909 label: Some("The BEOL data-model".into()),
910 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
911 resource_types: vec![
912 ResourceTypeSummary {
913 name: "Archive".into(),
914 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
915 label: Some("Archive".into()),
916 },
917 ResourceTypeSummary {
918 name: "basicLetter".into(),
919 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
920 label: None,
921 },
922 ResourceTypeSummary {
923 name: "letter".into(),
924 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
925 label: Some("Letter".into()),
926 },
927 ],
928 }
929 }
930
931 #[test]
932 fn data_model_describe_tsv_output() {
933 let out = SharedBuf::new();
934 let err = SharedBuf::new();
935 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
936 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
937 renderer
938 .data_model_describe(&make_beol_dm_detail(), &meta)
939 .unwrap();
940
941 let stdout = out.string();
942 assert!(
944 stdout.starts_with("name\tiri\tlabel\tlast_modified\tresource_types\n"),
945 "header row missing; got:\n{stdout}"
946 );
947 assert!(
949 stdout.contains(
950 "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\t3"
951 ),
952 "data row missing; got:\n{stdout}"
953 );
954 }
955
956 #[test]
957 fn data_model_describe_tsv_no_label_no_last_modified() {
958 let out = SharedBuf::new();
959 let mut renderer = TsvRenderer::with_writer(out.clone());
960 let detail = DataModelDetail {
961 name: "minimal".into(),
962 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
963 label: None,
964 last_modified: None,
965 resource_types: vec![],
966 };
967 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
968 renderer.data_model_describe(&detail, &meta).unwrap();
969
970 let stdout = out.string();
971 assert!(
973 stdout.contains("minimal\thttp://api.dasch.swiss/ontology/0000/minimal/v2\t\t\t0"),
974 "None → empty fields must be handled; got:\n{stdout}"
975 );
976 }
977
978 #[test]
979 fn data_model_describe_tsv_stderr_disclosure() {
980 let out = SharedBuf::new();
981 let err = SharedBuf::new();
982 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
983 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
984 renderer
985 .data_model_describe(&make_beol_dm_detail(), &meta)
986 .unwrap();
987
988 let err_str = err.string();
989 assert_eq!(
990 err_str.trim(),
991 "[anonymous on https://api.test.dasch.swiss]"
992 );
993 assert!(!out.string().contains("[anonymous"));
994 }
995
996 fn make_resource_type_fixture() -> Vec<ResourceType> {
999 vec![
1000 ResourceType {
1001 name: "Archive".into(),
1002 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1003 label: Some("Archive".into()),
1004 is_builtin: false,
1005 count: None,
1006 },
1007 ResourceType {
1008 name: "letter".into(),
1009 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1010 label: None,
1011 is_builtin: false,
1012 count: None,
1013 },
1014 ]
1015 }
1016
1017 #[test]
1018 fn resource_types_tsv_output() {
1019 let out = SharedBuf::new();
1020 let err = SharedBuf::new();
1021 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1022 let view = ResourceTypeListView {
1023 items: make_resource_type_fixture(),
1024 total: 2,
1025 filter: None,
1026 data_model: "beol".into(),
1027 };
1028 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1029 renderer.resource_types(&view, &meta).unwrap();
1030
1031 let stdout = out.string();
1032 assert!(
1034 stdout.starts_with("name\tiri\tlabel\tis_builtin\n"),
1035 "header row missing; got:\n{stdout}"
1036 );
1037 assert!(
1039 stdout.contains(
1040 "Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\tArchive\tfalse"
1041 ),
1042 "Archive row missing; got:\n{stdout}"
1043 );
1044 assert!(
1046 stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\t\tfalse"),
1047 "letter row missing; got:\n{stdout}"
1048 );
1049 }
1050
1051 #[test]
1052 fn resource_types_tsv_with_builtins() {
1053 let out = SharedBuf::new();
1054 let mut renderer = TsvRenderer::with_writer(out.clone());
1055 let view = ResourceTypeListView {
1056 items: vec![ResourceType {
1057 name: "Region".into(),
1058 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1059 label: Some("Region".into()),
1060 is_builtin: true,
1061 count: None,
1062 }],
1063 total: 1,
1064 filter: None,
1065 data_model: "beol".into(),
1066 };
1067 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1068 renderer.resource_types(&view, &meta).unwrap();
1069
1070 let stdout = out.string();
1071 assert!(
1073 stdout.contains(
1074 "Region\thttp://api.knora.org/ontology/knora-api/v2#Region\tRegion\ttrue"
1075 ),
1076 "built-in row missing; got:\n{stdout}"
1077 );
1078 }
1079
1080 #[test]
1081 fn resource_types_tsv_with_filter() {
1082 let out = SharedBuf::new();
1084 let mut renderer = TsvRenderer::with_writer(out.clone());
1085 let view = ResourceTypeListView {
1086 items: vec![make_resource_type_fixture().remove(0)], total: 2,
1088 filter: Some("arch".to_string()),
1089 data_model: "beol".into(),
1090 };
1091 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1092 renderer.resource_types(&view, &meta).unwrap();
1093
1094 let stdout = out.string();
1095 assert!(stdout.starts_with("name\tiri\tlabel\tis_builtin\n"));
1096 assert!(stdout.contains("Archive"));
1097 }
1098
1099 #[test]
1100 fn resource_types_tsv_empty() {
1101 let out = SharedBuf::new();
1102 let mut renderer = TsvRenderer::with_writer(out.clone());
1103 let view = ResourceTypeListView {
1104 items: vec![],
1105 total: 0,
1106 filter: None,
1107 data_model: "beol".into(),
1108 };
1109 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1110 renderer.resource_types(&view, &meta).unwrap();
1111
1112 assert_eq!(out.string(), "name\tiri\tlabel\tis_builtin\n");
1114 }
1115
1116 #[test]
1117 fn resource_types_tsv_stderr_disclosure() {
1118 let out = SharedBuf::new();
1119 let err = SharedBuf::new();
1120 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1121 let view = ResourceTypeListView {
1122 items: make_resource_type_fixture(),
1123 total: 2,
1124 filter: None,
1125 data_model: "beol".into(),
1126 };
1127 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1128 renderer.resource_types(&view, &meta).unwrap();
1129
1130 let err_str = err.string();
1131 assert_eq!(
1132 err_str.trim(),
1133 "[anonymous on https://api.test.dasch.swiss]"
1134 );
1135 assert!(!out.string().contains("[anonymous"));
1136 }
1137
1138 #[test]
1139 fn resource_types_tsv_with_count_auto_shows_column() {
1140 let out = SharedBuf::new();
1144 let err = SharedBuf::new();
1145 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1146 let mut items = make_resource_type_fixture();
1147 items[0].count = Some(10);
1148 let view = ResourceTypeListView {
1149 items,
1150 total: 2,
1151 filter: None,
1152 data_model: "beol".into(),
1153 };
1154 let meta = crate::render::MetaContext {
1155 server_label: "https://api.test.dasch.swiss".into(),
1156 auth_state: "anonymous".into(),
1157 filter_warning: None,
1158 count_caveat: Some("counts are not permission-filtered".into()),
1159 };
1160 renderer.resource_types(&view, &meta).unwrap();
1161
1162 let stdout = out.string();
1163 assert!(
1164 stdout.starts_with("name\tiri\tlabel\tis_builtin\tcount\n"),
1165 "header must auto-show count column; got:\n{stdout}"
1166 );
1167 assert!(
1168 stdout.contains(
1169 "Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\tArchive\tfalse\t10"
1170 ),
1171 "Archive row must show count 10; got:\n{stdout}"
1172 );
1173 let err_str = err.string();
1174 assert!(
1175 err_str.contains("counts are not permission-filtered"),
1176 "stderr must carry count_caveat; got: {err_str:?}"
1177 );
1178 }
1179
1180 #[test]
1181 fn resource_type_describe_tsv_with_count_no_column_but_disclosure_carries_caveat() {
1182 let out = SharedBuf::new();
1188 let err = SharedBuf::new();
1189 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1190 let detail = crate::model::ResourceTypeDetail {
1191 name: "manuscript".into(),
1192 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1193 label: Some("Manuscript".into()),
1194 data_model: "beol".into(),
1195 representation: None,
1196 super_types: vec![],
1197 fields: vec![],
1198 count: Some(7),
1199 };
1200 let meta = crate::render::MetaContext {
1201 server_label: "https://api.dasch.swiss".into(),
1202 auth_state: "anonymous".into(),
1203 filter_warning: None,
1204 count_caveat: Some("counts exclude deleted resources".into()),
1205 };
1206 renderer.resource_type_describe(&detail, &meta).unwrap();
1207
1208 assert!(out.string().starts_with(
1210 "name\tvalue_type\tlink_target\tcardinality\tlabel\tis_builtin\tdata_model\n"
1211 ));
1212 assert!(
1214 err.string().contains("counts exclude deleted resources"),
1215 "stderr must carry count_caveat; got: {:?}",
1216 err.string()
1217 );
1218 }
1219
1220 use crate::model::{
1223 FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
1224 };
1225
1226 fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1227 ResourceDetail {
1228 label: "Test Resource".into(),
1229 iri: "http://rdfh.ch/0803/abc123".into(),
1230 resource_type: "Page".into(),
1231 ark_url: None,
1232 creation_date: None,
1233 last_modified: None,
1234 attached_project: None,
1235 owner: None,
1236 visibility: Some(ResourceVisibility::Public),
1237 your_access: Some(ResourceAccess::View),
1238 values,
1239 }
1240 }
1241
1242 #[test]
1243 fn resource_describe_tsv_some_values_no_note_renders_value_rows() {
1244 let out = SharedBuf::new();
1248 let err = SharedBuf::new();
1249 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1250 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1251 renderer
1252 .resource_describe(
1253 &make_resource_detail(Some(vec![FieldValues {
1254 name: "hasTitle".into(),
1255 label: None,
1256 values: vec![ValueContent::Text("Hello".into()).into()],
1257 }])),
1258 &meta,
1259 )
1260 .unwrap();
1261
1262 let err_str = err.string();
1263 assert!(
1264 !err_str.contains("values not shown"),
1265 "no values note must be emitted when values is Some; got:\n{err_str}"
1266 );
1267 let stdout = out.string();
1268 assert!(
1269 stdout.starts_with("field\tfield_label\tvalue_type\tvalue\n"),
1270 "tsv values header must be the compact default set; got:\n{stdout}"
1271 );
1272 assert!(
1273 stdout.contains("hasTitle\t\ttext\tHello\n"),
1274 "expected value row for hasTitle; got:\n{stdout}"
1275 );
1276 assert!(
1278 !stdout.contains("Test Resource"),
1279 "metadata row must not appear in values mode; got:\n{stdout}"
1280 );
1281 }
1282
1283 #[test]
1284 fn resource_describe_tsv_no_values_no_note() {
1285 let out = SharedBuf::new();
1287 let err = SharedBuf::new();
1288 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1289 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1290 renderer
1291 .resource_describe(&make_resource_detail(None), &meta)
1292 .unwrap();
1293
1294 assert!(
1295 !err.string().contains("values not shown"),
1296 "no note expected when values is None; got:\n{}",
1297 err.string()
1298 );
1299 }
1300}