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