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 data_models_str,
238 item.iri.clone(),
239 ]
240 })
241 .collect();
242 let projected = self.options.projected();
243 let spec = TableSpec {
244 all_columns: PROJECTS_COLUMNS,
245 rows: &rows,
246 default_columns: None,
247 projected,
248 quote: QuoteMode::Tsv,
249 header: self.options.header,
250 };
251 render_table(&mut *self.out, &spec)?;
252 render_table_disclosure(&mut *self.err, meta)?;
253 Ok(())
254 }
255
256 fn project_describe(&mut self, project: &ProjectDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
257 let longname = project.longname.as_deref().unwrap_or("").to_string();
260 let data_models_str = project.data_models.len().to_string();
261 let rows = vec![vec![
262 project.shortcode.clone(),
263 project.shortname.clone(),
264 longname,
265 data_models_str,
266 project.iri.clone(),
267 ]];
268 let projected = self.options.projected();
269 let spec = TableSpec {
270 all_columns: PROJECTS_COLUMNS,
271 rows: &rows,
272 default_columns: None,
273 projected,
274 quote: QuoteMode::Tsv,
275 header: self.options.header,
276 };
277 render_table(&mut *self.out, &spec)?;
278 render_table_disclosure(&mut *self.err, meta)?;
279 Ok(())
280 }
281
282 fn data_model_describe(&mut self, detail: &DataModelDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
283 let label = detail.label.as_deref().unwrap_or("").to_string();
286 let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
287 let resource_types_str = detail.resource_types.len().to_string();
288 let rows = vec![vec![
289 detail.name.clone(),
290 detail.iri.clone(),
291 label,
292 last_modified,
293 resource_types_str,
294 ]];
295 let projected = self.options.projected();
296 let spec = TableSpec {
297 all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
298 rows: &rows,
299 default_columns: None,
300 projected,
301 quote: QuoteMode::Tsv,
302 header: self.options.header,
303 };
304 render_table(&mut *self.out, &spec)?;
305 render_table_disclosure(&mut *self.err, meta)?;
306 Ok(())
307 }
308
309 fn data_models(&mut self, view: &DataModelListView, meta: &MetaContext) -> Result<(), Diagnostic> {
310 let rows: Vec<Vec<String>> = view
311 .items
312 .iter()
313 .map(|item| {
314 let label = item.label.as_deref().unwrap_or("").to_string();
315 let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
316 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
317 vec![
318 item.name.clone(),
319 item.iri.clone(),
320 label,
321 last_modified,
322 is_builtin_str.to_string(),
323 ]
324 })
325 .collect();
326 let projected = self.options.projected();
327 let spec = TableSpec {
328 all_columns: DATA_MODELS_COLUMNS,
329 rows: &rows,
330 default_columns: None,
331 projected,
332 quote: QuoteMode::Tsv,
333 header: self.options.header,
334 };
335 render_table(&mut *self.out, &spec)?;
336 render_table_disclosure(&mut *self.err, meta)?;
337 Ok(())
338 }
339
340 fn resource_types(&mut self, view: &ResourceTypeListView, meta: &MetaContext) -> Result<(), Diagnostic> {
341 let rows: Vec<Vec<String>> = view
345 .items
346 .iter()
347 .map(|item| {
348 let label = item.label.as_deref().unwrap_or("").to_string();
349 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
350 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
351 vec![
352 item.name.clone(),
353 item.iri.clone(),
354 label,
355 is_builtin_str.to_string(),
356 count_str,
357 ]
358 })
359 .collect();
360 let has_counts = view.items.iter().any(|rt| rt.count.is_some());
361 let default_columns = if has_counts {
362 None
363 } else {
364 Some(RESOURCE_TYPES_DEFAULT_COLUMNS)
365 };
366 let projected = self.options.projected();
367 let spec = TableSpec {
368 all_columns: RESOURCE_TYPES_COLUMNS,
369 rows: &rows,
370 default_columns,
371 projected,
372 quote: QuoteMode::Tsv,
373 header: self.options.header,
374 };
375 render_table(&mut *self.out, &spec)?;
376 render_table_disclosure(&mut *self.err, meta)?;
377 Ok(())
378 }
379
380 fn resource_type_describe(
381 &mut self,
382 detail: &crate::model::ResourceTypeDetail,
383 meta: &MetaContext,
384 ) -> Result<(), Diagnostic> {
385 let rows: Vec<Vec<String>> = detail
391 .fields
392 .iter()
393 .map(|field| {
394 let value_type_str = field.value_type.to_string();
395 let link_target = field.link_target.as_deref().unwrap_or("").to_string();
396 let card_str = field.cardinality.to_string();
397 let label = field.label.as_deref().unwrap_or("").to_string();
398 let is_builtin_str = if field.is_builtin { "true" } else { "false" };
399 let data_model = field.data_model.as_deref().unwrap_or("").to_string();
400 vec![
401 field.name.clone(),
402 field.iri.clone(),
403 value_type_str,
404 link_target,
405 card_str,
406 label,
407 is_builtin_str.to_string(),
408 data_model,
409 ]
410 })
411 .collect();
412 let projected = self.options.projected();
413 let spec = TableSpec {
414 all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
415 rows: &rows,
416 default_columns: Some(RESOURCE_TYPE_DESCRIBE_DEFAULT_COLUMNS),
417 projected,
418 quote: QuoteMode::Tsv,
419 header: self.options.header,
420 };
421 render_table(&mut *self.out, &spec)?;
422 render_table_disclosure(&mut *self.err, meta)?;
423 Ok(())
424 }
425
426 fn data_model_structure(&mut self, structure: &DataModelStructure, meta: &MetaContext) -> Result<(), Diagnostic> {
427 let rows: Vec<Vec<String>> = structure
431 .relations
432 .iter()
433 .map(|rel| {
434 let kind_str = rel.kind.to_string();
435 let field = rel.field.as_deref().unwrap_or("").to_string();
436 let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
437 vec![rel.source.clone(), rel.target.clone(), kind_str, field, target_dm]
438 })
439 .collect();
440 let projected = self.options.projected();
441 let spec = TableSpec {
442 all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
443 rows: &rows,
444 default_columns: None,
445 projected,
446 quote: QuoteMode::Tsv,
447 header: self.options.header,
448 };
449 render_table(&mut *self.out, &spec)?;
450 render_table_disclosure(&mut *self.err, meta)?;
451 Ok(())
452 }
453
454 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
455 let rows: Vec<Vec<String>> = view
456 .items
457 .iter()
458 .map(|item| {
459 vec![
460 item.label.clone(),
461 item.iri.clone(),
462 item.ark_url.as_deref().unwrap_or("").to_string(),
463 item.creation_date.as_deref().unwrap_or("").to_string(),
464 item.last_modified.as_deref().unwrap_or("").to_string(),
465 item.resource_type.clone(),
466 ]
467 })
468 .collect();
469 let projected = self.options.projected();
470 let spec = TableSpec {
471 all_columns: RESOURCE_LIST_COLUMNS,
472 rows: &rows,
473 default_columns: None,
474 projected,
475 quote: QuoteMode::Tsv,
476 header: self.options.header,
477 };
478 render_table(&mut *self.out, &spec)?;
479 render_table_disclosure(&mut *self.err, meta)?;
480 Ok(())
481 }
482
483 fn resource_describe(
484 &mut self,
485 detail: &crate::model::ResourceDetail,
486 meta: &MetaContext,
487 ) -> Result<(), Diagnostic> {
488 match &detail.values {
492 None => {
493 let rows = vec![build_metadata_row(detail)];
494 let projected = self.options.projected();
495 let spec = TableSpec {
496 all_columns: RESOURCE_DESCRIBE_COLUMNS,
497 rows: &rows,
498 default_columns: None,
499 projected,
500 quote: QuoteMode::Tsv,
501 header: self.options.header,
502 };
503 render_table(&mut *self.out, &spec)?;
504 }
505 Some(fields) => {
506 let rows = build_value_rows(&detail.label, &detail.iri, fields);
507 let projected = self.options.projected();
508 let spec = TableSpec {
509 all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
510 rows: &rows,
511 default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
512 projected,
513 quote: QuoteMode::Tsv,
514 header: self.options.header,
515 };
516 render_table(&mut *self.out, &spec)?;
517 }
518 }
519 render_table_disclosure(&mut *self.err, meta)?;
520 Ok(())
521 }
522
523 fn vocabularies(&mut self, view: &VocabularyListView, meta: &MetaContext) -> Result<(), Diagnostic> {
524 let rows = build_vocabulary_list_rows(&view.items);
525 let has_counts = view.items.iter().any(|v| v.node_count.is_some());
530 let default_columns = if has_counts {
531 Some(VOCABULARIES_COUNTED_DEFAULT_COLUMNS)
532 } else {
533 Some(VOCABULARIES_DEFAULT_COLUMNS)
534 };
535 let projected = self.options.projected();
536 let spec = TableSpec {
537 all_columns: VOCABULARIES_COLUMNS,
538 rows: &rows,
539 default_columns,
540 projected,
541 quote: QuoteMode::Tsv,
542 header: self.options.header,
543 };
544 render_table(&mut *self.out, &spec)?;
545 render_table_disclosure(&mut *self.err, meta)?;
546 Ok(())
547 }
548
549 fn vocabulary_describe(&mut self, detail: &VocabularyDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
550 let rows = build_vocabulary_rows(detail);
551 let projected = self.options.projected();
552 let spec = TableSpec {
553 all_columns: VOCABULARY_DESCRIBE_COLUMNS,
554 rows: &rows,
555 default_columns: Some(VOCABULARY_DESCRIBE_DEFAULT_COLUMNS),
556 projected,
557 quote: QuoteMode::Tsv,
558 header: self.options.header,
559 };
560 render_table(&mut *self.out, &spec)?;
561 render_table_disclosure(&mut *self.err, meta)?;
562 Ok(())
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::model::{
570 DataModel, DataModelDetail, DataModelSummary, Project, ProjectDetail, ResourceType, ResourceTypeSummary,
571 };
572 use crate::render::test_support::{SharedBuf, make_meta};
573 use crate::render::{DataModelListView, ResourceTypeListView};
574
575 fn make_fixture() -> Vec<Project> {
576 vec![
577 Project {
578 iri: "http://rdfh.ch/projects/0001".into(),
579 shortcode: "0001".into(),
580 shortname: "anything".into(),
581 longname: Some("Anything Project".into()),
582 data_models: 2,
583 },
584 Project {
585 iri: "http://rdfh.ch/projects/0002".into(),
586 shortcode: "0002".into(),
587 shortname: "images".into(),
588 longname: None,
589 data_models: 0,
590 },
591 Project {
592 iri: "http://rdfh.ch/projects/0803".into(),
593 shortcode: "0803".into(),
594 shortname: "daschland".into(),
595 longname: Some("DaSCHland Project".into()),
596 data_models: 1,
597 },
598 ]
599 }
600
601 #[test]
602 fn projects_tsv_output() {
603 let out = SharedBuf::new();
604 let err = SharedBuf::new();
605 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
606 let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
607 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
608 renderer.projects(&view, &meta).unwrap();
609
610 let stdout = out.string();
611 assert!(stdout.starts_with("shortcode\tshortname\tlongname\tdata_models\tiri\n"));
612 assert!(stdout.contains("0001\tanything\tAnything Project\t2\thttp://rdfh.ch/projects/0001"));
613 assert!(stdout.contains("0002\timages\t\t0\thttp://rdfh.ch/projects/0002"));
615 assert!(stdout.contains("0803\tdaschland\tDaSCHland Project\t1\thttp://rdfh.ch/projects/0803"));
616 }
617
618 #[test]
619 fn projects_tsv_stderr_disclosure() {
620 let out = SharedBuf::new();
621 let err = SharedBuf::new();
622 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
623 let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
624 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
625 renderer.projects(&view, &meta).unwrap();
626
627 let err_str = err.string();
628 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
629 assert!(!out.string().contains("[anonymous"));
630 }
631
632 #[test]
633 fn auth_status_tsv_stderr_disclosure() {
634 let out = SharedBuf::new();
636 let err = SharedBuf::new();
637 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
638 let meta = make_meta("authenticated via DSP_TOKEN", "https://api.prod.dasch.swiss");
639 renderer
640 .auth_status(
641 &AuthStatusOutcome::AuthenticatedViaEnv {
642 server: "https://api.prod.dasch.swiss".to_string(),
643 expires_at: None,
644 expired: false,
645 },
646 &meta,
647 )
648 .unwrap();
649 let err_str = err.string();
650 assert_eq!(err_str.trim(), "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]");
651 assert!(!out.string().contains('['));
653
654 let out2 = SharedBuf::new();
656 let err2 = SharedBuf::new();
657 let mut renderer2 = TsvRenderer::with_writers(out2.clone(), err2.clone());
658 let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
659 renderer2
660 .auth_status(
661 &AuthStatusOutcome::NotLoggedIn { server: "https://api.prod.dasch.swiss".to_string() },
662 &meta2,
663 )
664 .unwrap();
665 let err2_str = err2.string();
666 assert_eq!(err2_str.trim(), "[anonymous on https://api.prod.dasch.swiss]");
667 assert!(!out2.string().contains('['));
669 }
670
671 #[test]
672 fn projects_tsv_with_writer_no_stderr_leak() {
673 let out = SharedBuf::new();
674 let mut renderer = TsvRenderer::with_writer(out.clone());
675 let view = ProjectListView { items: vec![], total: 0, filter: None };
676 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
677 renderer.projects(&view, &meta).unwrap();
678 assert_eq!(out.string(), "shortcode\tshortname\tlongname\tdata_models\tiri\n");
679 }
680
681 #[test]
682 fn project_describe_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 detail = ProjectDetail {
687 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
688 shortcode: "0801".into(),
689 shortname: "beol".into(),
690 longname: Some("Bernoulli-Euler Online".into()),
691 description: vec![],
692 keywords: vec![],
693 data_models: vec![
694 DataModelSummary {
695 name: "beol".into(),
696 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
697 },
698 DataModelSummary {
699 name: "biblio".into(),
700 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
701 },
702 ],
703 };
704 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
705 renderer.project_describe(&detail, &meta).unwrap();
706
707 let stdout = out.string();
708 assert!(stdout.starts_with("shortcode\tshortname\tlongname\tdata_models\tiri\n"));
710 assert!(
712 stdout.contains("0801\tbeol\tBernoulli-Euler Online\t2\thttp://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF")
713 );
714 }
715
716 #[test]
717 fn project_describe_tsv_no_longname() {
718 let out = SharedBuf::new();
719 let mut renderer = TsvRenderer::with_writer(out.clone());
720 let detail = ProjectDetail {
721 iri: "http://rdfh.ch/projects/0000".into(),
722 shortcode: "0000".into(),
723 shortname: "minimal".into(),
724 longname: None,
725 description: vec![],
726 keywords: vec![],
727 data_models: vec![],
728 };
729 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
730 renderer.project_describe(&detail, &meta).unwrap();
731
732 let stdout = out.string();
733 assert!(stdout.contains("0000\tminimal\t\t0\thttp://rdfh.ch/projects/0000"));
735 }
736
737 #[test]
738 fn project_describe_tsv_stderr_disclosure() {
739 let out = SharedBuf::new();
740 let err = SharedBuf::new();
741 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
742 let detail = ProjectDetail {
743 iri: "http://rdfh.ch/projects/0001".into(),
744 shortcode: "0001".into(),
745 shortname: "test".into(),
746 longname: None,
747 description: vec![],
748 keywords: vec![],
749 data_models: vec![],
750 };
751 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
752 renderer.project_describe(&detail, &meta).unwrap();
753
754 let err_str = err.string();
755 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
756 assert!(!out.string().contains("[anonymous"));
757 }
758
759 fn make_data_model_fixture() -> Vec<DataModel> {
760 vec![
761 DataModel {
762 name: "beol".into(),
763 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
764 label: Some("The BEOL data-model".into()),
765 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
766 is_builtin: false,
767 },
768 DataModel {
769 name: "biblio".into(),
770 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
771 label: None,
772 last_modified: None,
773 is_builtin: false,
774 },
775 DataModel {
776 name: "knora-api".into(),
777 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
778 label: None,
779 last_modified: None,
780 is_builtin: true,
781 },
782 ]
783 }
784
785 #[test]
786 fn data_models_tsv_output() {
787 let out = SharedBuf::new();
788 let err = SharedBuf::new();
789 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
790 let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
791 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
792 renderer.data_models(&view, &meta).unwrap();
793
794 let stdout = out.string();
795 assert!(stdout.starts_with("name\tiri\tlabel\tlast_modified\tis_builtin\n"));
797 assert!(stdout.contains(
799 "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\tfalse"
800 ));
801 assert!(stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\t\t\tfalse"));
803 assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\t\t\ttrue"));
805 }
806
807 #[test]
808 fn data_models_tsv_stderr_disclosure() {
809 let out = SharedBuf::new();
810 let err = SharedBuf::new();
811 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
812 let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
813 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
814 renderer.data_models(&view, &meta).unwrap();
815
816 let err_str = err.string();
817 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
818 assert!(!out.string().contains("[anonymous"));
819 }
820
821 fn make_beol_dm_detail() -> DataModelDetail {
824 DataModelDetail {
825 name: "beol".into(),
826 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
827 label: Some("The BEOL data-model".into()),
828 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
829 resource_types: vec![
830 ResourceTypeSummary {
831 name: "Archive".into(),
832 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
833 label: Some("Archive".into()),
834 },
835 ResourceTypeSummary {
836 name: "basicLetter".into(),
837 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
838 label: None,
839 },
840 ResourceTypeSummary {
841 name: "letter".into(),
842 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
843 label: Some("Letter".into()),
844 },
845 ],
846 }
847 }
848
849 #[test]
850 fn data_model_describe_tsv_output() {
851 let out = SharedBuf::new();
852 let err = SharedBuf::new();
853 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
854 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
855 renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
856
857 let stdout = out.string();
858 assert!(
860 stdout.starts_with("name\tiri\tlabel\tlast_modified\tresource_types\n"),
861 "header row missing; got:\n{stdout}"
862 );
863 assert!(
865 stdout.contains(
866 "beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\tThe BEOL data-model\t2024-05-27T13:43:26.233048Z\t3"
867 ),
868 "data row missing; got:\n{stdout}"
869 );
870 }
871
872 #[test]
873 fn data_model_describe_tsv_no_label_no_last_modified() {
874 let out = SharedBuf::new();
875 let mut renderer = TsvRenderer::with_writer(out.clone());
876 let detail = DataModelDetail {
877 name: "minimal".into(),
878 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
879 label: None,
880 last_modified: None,
881 resource_types: vec![],
882 };
883 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
884 renderer.data_model_describe(&detail, &meta).unwrap();
885
886 let stdout = out.string();
887 assert!(
889 stdout.contains("minimal\thttp://api.dasch.swiss/ontology/0000/minimal/v2\t\t\t0"),
890 "None → empty fields must be handled; got:\n{stdout}"
891 );
892 }
893
894 #[test]
895 fn data_model_describe_tsv_stderr_disclosure() {
896 let out = SharedBuf::new();
897 let err = SharedBuf::new();
898 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
899 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
900 renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
901
902 let err_str = err.string();
903 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
904 assert!(!out.string().contains("[anonymous"));
905 }
906
907 fn make_resource_type_fixture() -> Vec<ResourceType> {
910 vec![
911 ResourceType {
912 name: "Archive".into(),
913 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
914 label: Some("Archive".into()),
915 is_builtin: false,
916 count: None,
917 },
918 ResourceType {
919 name: "letter".into(),
920 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
921 label: None,
922 is_builtin: false,
923 count: None,
924 },
925 ]
926 }
927
928 #[test]
929 fn resource_types_tsv_output() {
930 let out = SharedBuf::new();
931 let err = SharedBuf::new();
932 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
933 let view = ResourceTypeListView {
934 items: make_resource_type_fixture(),
935 total: 2,
936 filter: None,
937 data_model: "beol".into(),
938 };
939 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
940 renderer.resource_types(&view, &meta).unwrap();
941
942 let stdout = out.string();
943 assert!(
945 stdout.starts_with("name\tiri\tlabel\tis_builtin\n"),
946 "header row missing; got:\n{stdout}"
947 );
948 assert!(
950 stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\tArchive\tfalse"),
951 "Archive row missing; got:\n{stdout}"
952 );
953 assert!(
955 stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\t\tfalse"),
956 "letter row missing; got:\n{stdout}"
957 );
958 }
959
960 #[test]
961 fn resource_types_tsv_with_builtins() {
962 let out = SharedBuf::new();
963 let mut renderer = TsvRenderer::with_writer(out.clone());
964 let view = ResourceTypeListView {
965 items: vec![ResourceType {
966 name: "Region".into(),
967 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
968 label: Some("Region".into()),
969 is_builtin: true,
970 count: None,
971 }],
972 total: 1,
973 filter: None,
974 data_model: "beol".into(),
975 };
976 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
977 renderer.resource_types(&view, &meta).unwrap();
978
979 let stdout = out.string();
980 assert!(
982 stdout.contains("Region\thttp://api.knora.org/ontology/knora-api/v2#Region\tRegion\ttrue"),
983 "built-in row missing; got:\n{stdout}"
984 );
985 }
986
987 #[test]
988 fn resource_types_tsv_with_filter() {
989 let out = SharedBuf::new();
991 let mut renderer = TsvRenderer::with_writer(out.clone());
992 let view = ResourceTypeListView {
993 items: vec![make_resource_type_fixture().remove(0)], total: 2,
995 filter: Some("arch".to_string()),
996 data_model: "beol".into(),
997 };
998 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
999 renderer.resource_types(&view, &meta).unwrap();
1000
1001 let stdout = out.string();
1002 assert!(stdout.starts_with("name\tiri\tlabel\tis_builtin\n"));
1003 assert!(stdout.contains("Archive"));
1004 }
1005
1006 #[test]
1007 fn resource_types_tsv_empty() {
1008 let out = SharedBuf::new();
1009 let mut renderer = TsvRenderer::with_writer(out.clone());
1010 let view = ResourceTypeListView {
1011 items: vec![],
1012 total: 0,
1013 filter: None,
1014 data_model: "beol".into(),
1015 };
1016 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1017 renderer.resource_types(&view, &meta).unwrap();
1018
1019 assert_eq!(out.string(), "name\tiri\tlabel\tis_builtin\n");
1021 }
1022
1023 #[test]
1024 fn resource_types_tsv_stderr_disclosure() {
1025 let out = SharedBuf::new();
1026 let err = SharedBuf::new();
1027 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1028 let view = ResourceTypeListView {
1029 items: make_resource_type_fixture(),
1030 total: 2,
1031 filter: None,
1032 data_model: "beol".into(),
1033 };
1034 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1035 renderer.resource_types(&view, &meta).unwrap();
1036
1037 let err_str = err.string();
1038 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
1039 assert!(!out.string().contains("[anonymous"));
1040 }
1041
1042 #[test]
1043 fn resource_types_tsv_with_count_auto_shows_column() {
1044 let out = SharedBuf::new();
1048 let err = SharedBuf::new();
1049 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1050 let mut items = make_resource_type_fixture();
1051 items[0].count = Some(10);
1052 let view = ResourceTypeListView { items, total: 2, filter: None, data_model: "beol".into() };
1053 let meta = crate::render::MetaContext {
1054 server_label: "https://api.test.dasch.swiss".into(),
1055 auth_state: "anonymous".into(),
1056 filter_warning: None,
1057 count_caveat: Some("counts are not permission-filtered".into()),
1058 count_cost: None,
1059 };
1060 renderer.resource_types(&view, &meta).unwrap();
1061
1062 let stdout = out.string();
1063 assert!(
1064 stdout.starts_with("name\tiri\tlabel\tis_builtin\tcount\n"),
1065 "header must auto-show count column; got:\n{stdout}"
1066 );
1067 assert!(
1068 stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\tArchive\tfalse\t10"),
1069 "Archive row must show count 10; got:\n{stdout}"
1070 );
1071 let err_str = err.string();
1072 assert!(
1073 err_str.contains("counts are not permission-filtered"),
1074 "stderr must carry count_caveat; got: {err_str:?}"
1075 );
1076 }
1077
1078 #[test]
1079 fn resource_type_describe_tsv_with_count_no_column_but_disclosure_carries_caveat() {
1080 let out = SharedBuf::new();
1086 let err = SharedBuf::new();
1087 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1088 let detail = crate::model::ResourceTypeDetail {
1089 name: "manuscript".into(),
1090 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1091 label: Some("Manuscript".into()),
1092 data_model: "beol".into(),
1093 representation: None,
1094 super_types: vec![],
1095 fields: vec![],
1096 count: Some(7),
1097 };
1098 let meta = crate::render::MetaContext {
1099 server_label: "https://api.dasch.swiss".into(),
1100 auth_state: "anonymous".into(),
1101 filter_warning: None,
1102 count_caveat: Some("counts exclude deleted resources".into()),
1103 count_cost: None,
1104 };
1105 renderer.resource_type_describe(&detail, &meta).unwrap();
1106
1107 assert!(
1109 out.string()
1110 .starts_with("name\tvalue_type\tlink_target\tcardinality\tlabel\tis_builtin\tdata_model\n")
1111 );
1112 assert!(
1114 err.string().contains("counts exclude deleted resources"),
1115 "stderr must carry count_caveat; got: {:?}",
1116 err.string()
1117 );
1118 }
1119
1120 use crate::model::{FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent};
1123
1124 fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1125 ResourceDetail {
1126 label: "Test Resource".into(),
1127 iri: "http://rdfh.ch/0803/abc123".into(),
1128 resource_type: "Page".into(),
1129 ark_url: None,
1130 creation_date: None,
1131 last_modified: None,
1132 attached_project: None,
1133 owner: None,
1134 visibility: Some(ResourceVisibility::Public),
1135 your_access: Some(ResourceAccess::View),
1136 values,
1137 }
1138 }
1139
1140 #[test]
1141 fn resource_describe_tsv_some_values_no_note_renders_value_rows() {
1142 let out = SharedBuf::new();
1146 let err = SharedBuf::new();
1147 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1148 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1149 renderer
1150 .resource_describe(
1151 &make_resource_detail(Some(vec![FieldValues {
1152 name: "hasTitle".into(),
1153 label: None,
1154 values: vec![ValueContent::Text("Hello".into()).into()],
1155 }])),
1156 &meta,
1157 )
1158 .unwrap();
1159
1160 let err_str = err.string();
1161 assert!(
1162 !err_str.contains("values not shown"),
1163 "no values note must be emitted when values is Some; got:\n{err_str}"
1164 );
1165 let stdout = out.string();
1166 assert!(
1167 stdout.starts_with("field\tfield_label\tvalue_type\tvalue\n"),
1168 "tsv values header must be the compact default set; got:\n{stdout}"
1169 );
1170 assert!(
1171 stdout.contains("hasTitle\t\ttext\tHello\n"),
1172 "expected value row for hasTitle; got:\n{stdout}"
1173 );
1174 assert!(
1176 !stdout.contains("Test Resource"),
1177 "metadata row must not appear in values mode; got:\n{stdout}"
1178 );
1179 }
1180
1181 #[test]
1182 fn resource_describe_tsv_no_values_no_note() {
1183 let out = SharedBuf::new();
1185 let err = SharedBuf::new();
1186 let mut renderer = TsvRenderer::with_writers(out.clone(), err.clone());
1187 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1188 renderer.resource_describe(&make_resource_detail(None), &meta).unwrap();
1189
1190 assert!(
1191 !err.string().contains("values not shown"),
1192 "no note expected when values is None; got:\n{}",
1193 err.string()
1194 );
1195 }
1196}