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