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