1use std::io::{self, Write};
13
14use crate::diagnostic::Diagnostic;
15use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail, VocabularyDetail};
16use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
17use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
18use crate::render::table::render_table_disclosure;
19use crate::render::value::{build_metadata_row, build_value_rows};
20use crate::render::vocabulary::{build_vocabulary_list_rows, build_vocabulary_rows};
21use crate::render::{
22 AUTH_LOGIN_COLUMNS, AUTH_LOGOUT_COLUMNS, DATA_MODEL_DESCRIBE_COLUMNS, DATA_MODEL_STRUCTURE_COLUMNS,
23 DATA_MODELS_COLUMNS, DataModelListView, HeaderMode, MetaContext, PROJECT_DUMP_COLUMNS,
24 PROJECT_DUMP_DELETED_COLUMNS, PROJECTS_COLUMNS, ProjectListView, QuoteMode, RESOURCE_DESCRIBE_COLUMNS,
25 RESOURCE_DESCRIBE_VALUES_COLUMNS, RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS, RESOURCE_LIST_COLUMNS,
26 RESOURCE_TYPE_DESCRIBE_COLUMNS, RESOURCE_TYPES_COLUMNS, Renderer, ResourceListView, ResourceTypeListView,
27 TableOptions, TableSpec, VOCABULARIES_COLUMNS, VOCABULARY_DESCRIBE_COLUMNS, VocabularyListView, render_table,
28};
29use crate::util::text::replace_control_chars;
30
31pub struct LinesRenderer {
33 out: Box<dyn Write>,
34 err: Box<dyn Write>,
39 options: TableOptions,
43}
44
45impl LinesRenderer {
46 pub fn new() -> Self {
51 Self {
52 out: Box::new(crate::util::BrokenPipeWriter::new(io::stdout())),
53 err: Box::new(io::stderr()),
54 options: TableOptions::default(),
55 }
56 }
57
58 pub fn with_writer(w: impl Write + 'static) -> Self {
62 Self {
63 out: Box::new(w),
64 err: Box::new(io::sink()),
65 options: TableOptions::default(),
66 }
67 }
68
69 pub fn with_writers(out: impl Write + 'static, err: impl Write + 'static) -> Self {
72 Self {
73 out: Box::new(out),
74 err: Box::new(err),
75 options: TableOptions::default(),
76 }
77 }
78
79 pub fn with_options(mut self, opts: TableOptions) -> Self {
82 self.options = opts;
83 self
84 }
85}
86
87impl Default for LinesRenderer {
88 fn default() -> Self {
89 Self::new()
90 }
91}
92
93impl Renderer for LinesRenderer {
94 fn diagnostic(&mut self, diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
95 eprintln!("Error: {diag}"); Ok(())
97 }
98
99 fn auth_login(&mut self, outcome: &AuthLoginOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
100 let expires = outcome.expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
101 let rows = vec![vec![
102 outcome.server.clone(),
103 outcome.user.clone(),
104 expires,
105 "login_success".to_string(),
106 ]];
107 let projected = self.options.projected();
108 let spec = TableSpec {
109 all_columns: AUTH_LOGIN_COLUMNS,
110 rows: &rows,
111 default_columns: Some(&["server"]),
112 projected,
113 quote: QuoteMode::Lines,
114 header: HeaderMode::Off,
115 };
116 render_table(&mut *self.out, &spec)
117 }
118
119 fn auth_status(&mut self, outcome: &AuthStatusOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
120 let row = match outcome {
121 AuthStatusOutcome::LoggedIn { server, user, expires_at, expired } => {
122 let user_str = user.as_deref().unwrap_or("").to_string();
123 let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
124 let state = if *expired { "expired" } else { "logged_in" };
125 vec![server.clone(), user_str, expires, state.to_string()]
126 }
127 AuthStatusOutcome::AuthenticatedViaEnv { server, expires_at, expired } => {
131 let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
132 let state = if *expired { "expired" } else { "logged_in" };
133 vec![server.clone(), String::new(), expires, state.to_string()]
134 }
135 AuthStatusOutcome::NotLoggedIn { server } => {
136 vec![
137 server.clone(),
138 String::new(),
139 String::new(),
140 "not_logged_in".to_string(),
141 ]
142 }
143 };
144 let rows = vec![row];
145 let projected = self.options.projected();
146 let spec = TableSpec {
147 all_columns: AUTH_LOGIN_COLUMNS,
148 rows: &rows,
149 default_columns: Some(&["server"]),
150 projected,
151 quote: QuoteMode::Lines,
152 header: HeaderMode::Off,
153 };
154 render_table(&mut *self.out, &spec)?;
155 render_table_disclosure(&mut *self.err, meta)?;
156 Ok(())
157 }
158
159 fn auth_logout(&mut self, outcome: &AuthLogoutOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
160 if self.options.columns.is_none() {
170 writeln!(
172 self.out,
173 "{}\twas_cached={}",
174 replace_control_chars(&outcome.server),
175 outcome.was_cached
176 )?;
177 } else {
178 let rows = vec![vec![outcome.server.clone(), outcome.was_cached.to_string()]];
180 let projected = self.options.projected();
181 let spec = TableSpec {
182 all_columns: AUTH_LOGOUT_COLUMNS,
183 rows: &rows,
184 default_columns: None,
185 projected,
186 quote: QuoteMode::Lines,
187 header: HeaderMode::Off,
188 };
189 render_table(&mut *self.out, &spec)?;
190 }
191 Ok(())
192 }
193
194 fn auth_set_token(&mut self, outcome: &AuthSetTokenOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
195 let user_str = outcome.user.as_deref().unwrap_or("").to_string();
196 let expires = outcome.expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
197 let rows = vec![vec![
198 outcome.server.clone(),
199 user_str,
200 expires,
201 "token_cached".to_string(),
202 ]];
203 let projected = self.options.projected();
204 let spec = TableSpec {
205 all_columns: AUTH_LOGIN_COLUMNS,
206 rows: &rows,
207 default_columns: Some(&["server"]),
208 projected,
209 quote: QuoteMode::Lines,
210 header: HeaderMode::Off,
211 };
212 render_table(&mut *self.out, &spec)
213 }
214
215 fn project_dump(&mut self, outcome: &DumpOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
216 let rows = vec![vec![outcome.path.display().to_string()]];
219 let projected = self.options.projected();
220 let spec = TableSpec {
221 all_columns: PROJECT_DUMP_COLUMNS,
222 rows: &rows,
223 default_columns: Some(&["path"]),
224 projected,
225 quote: QuoteMode::Lines,
226 header: HeaderMode::Off,
227 };
228 render_table(&mut *self.out, &spec)
229 }
230
231 fn project_dump_deleted(&mut self, outcome: &DumpDeleteOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
232 let token = if outcome.deleted {
238 "deleted".to_string()
239 } else {
240 "not-deleted".to_string()
241 };
242 let rows = vec![vec![token]];
243 let projected = self.options.projected();
244 let spec = TableSpec {
245 all_columns: PROJECT_DUMP_DELETED_COLUMNS,
246 rows: &rows,
247 default_columns: Some(&["deleted"]),
248 projected,
249 quote: QuoteMode::Lines,
250 header: HeaderMode::Off,
251 };
252 render_table(&mut *self.out, &spec)
253 }
254
255 fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
256 let rows: Vec<Vec<String>> = view
259 .items
260 .iter()
261 .map(|item| {
262 let longname = item.longname.as_deref().unwrap_or("").to_string();
263 let data_models_str = item.data_models.to_string();
264 vec![
265 item.shortcode.clone(),
266 item.shortname.clone(),
267 longname,
268 data_models_str,
269 item.iri.clone(),
270 ]
271 })
272 .collect();
273 let projected = self.options.projected();
274 let spec = TableSpec {
275 all_columns: PROJECTS_COLUMNS,
276 rows: &rows,
277 default_columns: Some(&["shortcode", "shortname", "longname"]),
278 projected,
279 quote: QuoteMode::Lines,
280 header: HeaderMode::Off,
281 };
282 render_table(&mut *self.out, &spec)?;
283 render_table_disclosure(&mut *self.err, meta)?;
284 Ok(())
285 }
286
287 fn project_describe(&mut self, project: &ProjectDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
288 let longname = project.longname.as_deref().unwrap_or("").to_string();
291 let data_models_str = project.data_models.len().to_string();
292 let rows = vec![vec![
293 project.shortcode.clone(),
294 project.shortname.clone(),
295 longname,
296 data_models_str,
297 project.iri.clone(),
298 ]];
299 let projected = self.options.projected();
300 let spec = TableSpec {
301 all_columns: PROJECTS_COLUMNS,
302 rows: &rows,
303 default_columns: Some(&["shortcode", "shortname", "longname"]),
304 projected,
305 quote: QuoteMode::Lines,
306 header: HeaderMode::Off,
307 };
308 render_table(&mut *self.out, &spec)?;
309 render_table_disclosure(&mut *self.err, meta)?;
310 Ok(())
311 }
312
313 fn data_models(&mut self, view: &DataModelListView, meta: &MetaContext) -> Result<(), Diagnostic> {
314 let rows: Vec<Vec<String>> = view
317 .items
318 .iter()
319 .map(|item| {
320 let label = item.label.as_deref().unwrap_or("").to_string();
321 let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
322 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
323 vec![
324 item.name.clone(),
325 item.iri.clone(),
326 label,
327 last_modified,
328 is_builtin_str.to_string(),
329 ]
330 })
331 .collect();
332 let projected = self.options.projected();
333 let spec = TableSpec {
334 all_columns: DATA_MODELS_COLUMNS,
335 rows: &rows,
336 default_columns: Some(&["name", "iri"]),
337 projected,
338 quote: QuoteMode::Lines,
339 header: HeaderMode::Off,
340 };
341 render_table(&mut *self.out, &spec)?;
342 render_table_disclosure(&mut *self.err, meta)?;
343 Ok(())
344 }
345
346 fn data_model_describe(&mut self, detail: &DataModelDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
347 let label = detail.label.as_deref().unwrap_or("").to_string();
350 let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
351 let resource_types_str = detail.resource_types.len().to_string();
352 let rows = vec![vec![
353 detail.name.clone(),
354 detail.iri.clone(),
355 label,
356 last_modified,
357 resource_types_str,
358 ]];
359 let projected = self.options.projected();
360 let spec = TableSpec {
361 all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
362 rows: &rows,
363 default_columns: Some(&["name", "iri"]),
364 projected,
365 quote: QuoteMode::Lines,
366 header: HeaderMode::Off,
367 };
368 render_table(&mut *self.out, &spec)?;
369 render_table_disclosure(&mut *self.err, meta)?;
370 Ok(())
371 }
372
373 fn resource_types(&mut self, view: &ResourceTypeListView, meta: &MetaContext) -> Result<(), Diagnostic> {
374 let rows: Vec<Vec<String>> = view
380 .items
381 .iter()
382 .map(|item| {
383 let label = item.label.as_deref().unwrap_or("").to_string();
384 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
385 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
386 vec![
387 item.name.clone(),
388 item.iri.clone(),
389 label,
390 is_builtin_str.to_string(),
391 count_str,
392 ]
393 })
394 .collect();
395 let projected = self.options.projected();
396 let spec = TableSpec {
397 all_columns: RESOURCE_TYPES_COLUMNS,
398 rows: &rows,
399 default_columns: Some(&["name", "iri"]),
400 projected,
401 quote: QuoteMode::Lines,
402 header: HeaderMode::Off,
403 };
404 render_table(&mut *self.out, &spec)?;
405 render_table_disclosure(&mut *self.err, meta)?;
406 Ok(())
407 }
408
409 fn resource_type_describe(
410 &mut self,
411 detail: &crate::model::ResourceTypeDetail,
412 meta: &MetaContext,
413 ) -> Result<(), Diagnostic> {
414 let rows: Vec<Vec<String>> = detail
422 .fields
423 .iter()
424 .map(|field| {
425 let value_type_str = field.value_type.to_string();
426 let link_target = field.link_target.as_deref().unwrap_or("").to_string();
427 let card_str = field.cardinality.to_string();
428 let label = field.label.as_deref().unwrap_or("").to_string();
429 let is_builtin_str = if field.is_builtin { "true" } else { "false" };
430 let data_model = field.data_model.as_deref().unwrap_or("").to_string();
431 vec![
432 field.name.clone(),
433 field.iri.clone(),
434 value_type_str,
435 link_target,
436 card_str,
437 label,
438 is_builtin_str.to_string(),
439 data_model,
440 ]
441 })
442 .collect();
443 let projected = self.options.projected();
444 let spec = TableSpec {
445 all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
446 rows: &rows,
447 default_columns: Some(&["name", "iri"]),
448 projected,
449 quote: QuoteMode::Lines,
450 header: HeaderMode::Off,
451 };
452 render_table(&mut *self.out, &spec)?;
453 render_table_disclosure(&mut *self.err, meta)?;
454 Ok(())
455 }
456
457 fn data_model_structure(&mut self, structure: &DataModelStructure, meta: &MetaContext) -> Result<(), Diagnostic> {
458 let rows: Vec<Vec<String>> = structure
462 .relations
463 .iter()
464 .map(|rel| {
465 let kind_str = rel.kind.to_string();
466 let field = rel.field.as_deref().unwrap_or("").to_string();
467 let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
468 vec![rel.source.clone(), rel.target.clone(), kind_str, field, target_dm]
469 })
470 .collect();
471 let projected = self.options.projected();
472 let spec = TableSpec {
473 all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
474 rows: &rows,
475 default_columns: Some(&["source", "target", "kind", "field"]),
476 projected,
477 quote: QuoteMode::Lines,
478 header: HeaderMode::Off,
479 };
480 render_table(&mut *self.out, &spec)?;
481 render_table_disclosure(&mut *self.err, meta)?;
482 Ok(())
483 }
484
485 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
486 let rows: Vec<Vec<String>> = view
487 .items
488 .iter()
489 .map(|item| {
490 vec![
491 item.label.clone(),
492 item.iri.clone(),
493 item.ark_url.as_deref().unwrap_or("").to_string(),
494 item.creation_date.as_deref().unwrap_or("").to_string(),
495 item.last_modified.as_deref().unwrap_or("").to_string(),
496 item.resource_type.clone(),
497 ]
498 })
499 .collect();
500 let projected = self.options.projected();
501 let spec = TableSpec {
502 all_columns: RESOURCE_LIST_COLUMNS,
503 rows: &rows,
504 default_columns: None,
505 projected,
506 quote: QuoteMode::Lines,
507 header: HeaderMode::Off,
508 };
509 render_table(&mut *self.out, &spec)?;
510 render_table_disclosure(&mut *self.err, meta)?;
511 Ok(())
512 }
513
514 fn resource_describe(
515 &mut self,
516 detail: &crate::model::ResourceDetail,
517 meta: &MetaContext,
518 ) -> Result<(), Diagnostic> {
519 match &detail.values {
523 None => {
524 let rows = vec![build_metadata_row(detail)];
525 let projected = self.options.projected();
526 let spec = TableSpec {
527 all_columns: RESOURCE_DESCRIBE_COLUMNS,
528 rows: &rows,
529 default_columns: Some(&["label", "iri"]),
530 projected,
531 quote: QuoteMode::Lines,
532 header: HeaderMode::Off,
533 };
534 render_table(&mut *self.out, &spec)?;
535 }
536 Some(fields) => {
537 let rows = build_value_rows(&detail.label, &detail.iri, fields);
538 let projected = self.options.projected();
539 let spec = TableSpec {
540 all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
541 rows: &rows,
542 default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
543 projected,
544 quote: QuoteMode::Lines,
545 header: HeaderMode::Off,
546 };
547 render_table(&mut *self.out, &spec)?;
548 }
549 }
550 render_table_disclosure(&mut *self.err, meta)?;
551 Ok(())
552 }
553
554 fn vocabularies(&mut self, view: &VocabularyListView, meta: &MetaContext) -> Result<(), Diagnostic> {
555 let rows = build_vocabulary_list_rows(&view.items);
559 let projected = self.options.projected();
560 let spec = TableSpec {
561 all_columns: VOCABULARIES_COLUMNS,
562 rows: &rows,
563 default_columns: Some(&["name", "iri"]),
564 projected,
565 quote: QuoteMode::Lines,
566 header: HeaderMode::Off,
567 };
568 render_table(&mut *self.out, &spec)?;
569 render_table_disclosure(&mut *self.err, meta)?;
570 Ok(())
571 }
572
573 fn vocabulary_describe(&mut self, detail: &VocabularyDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
574 let rows = build_vocabulary_rows(detail);
578 let projected = self.options.projected();
579 let spec = TableSpec {
580 all_columns: VOCABULARY_DESCRIBE_COLUMNS,
581 rows: &rows,
582 default_columns: Some(&["node_iri", "number"]),
583 projected,
584 quote: QuoteMode::Lines,
585 header: HeaderMode::Off,
586 };
587 render_table(&mut *self.out, &spec)?;
588 render_table_disclosure(&mut *self.err, meta)?;
589 Ok(())
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::model::{DataModel, DataModelDetail, Project, ProjectDetail, ResourceType, ResourceTypeSummary};
597 use crate::render::test_support::{SharedBuf, make_meta};
598 use crate::render::{DataModelListView, ResourceTypeListView};
599
600 fn make_fixture() -> Vec<Project> {
601 vec![
602 Project {
603 iri: "http://rdfh.ch/projects/0001".into(),
604 shortcode: "0001".into(),
605 shortname: "anything".into(),
606 longname: Some("Anything Project".into()),
607 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 data_models: 0,
615 },
616 Project {
617 iri: "http://rdfh.ch/projects/0803".into(),
618 shortcode: "0803".into(),
619 shortname: "daschland".into(),
620 longname: Some("DaSCHland Project".into()),
621 data_models: 1,
622 },
623 ]
624 }
625
626 #[test]
627 fn projects_lines_output() {
628 let out = SharedBuf::new();
629 let err = SharedBuf::new();
630 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
631 let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
632 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
633 renderer.projects(&view, &meta).unwrap();
634
635 let stdout = out.string();
636 assert!(!stdout.starts_with("shortcode"));
638 assert!(stdout.contains("0001\tanything\tAnything Project\n"));
640 assert!(stdout.contains("0002\timages\t\n"));
642 assert!(stdout.contains("0803\tdaschland\tDaSCHland Project\n"));
643 }
644
645 #[test]
646 fn projects_lines_stderr_disclosure() {
647 let out = SharedBuf::new();
648 let err = SharedBuf::new();
649 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
650 let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
651 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
652 renderer.projects(&view, &meta).unwrap();
653
654 let err_str = err.string();
655 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
656 assert!(!out.string().contains("[anonymous"));
657 }
658
659 #[test]
660 fn auth_status_lines_stderr_disclosure() {
661 let out = SharedBuf::new();
663 let err = SharedBuf::new();
664 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
665 let meta = make_meta("authenticated via DSP_TOKEN", "https://api.prod.dasch.swiss");
666 renderer
667 .auth_status(
668 &AuthStatusOutcome::AuthenticatedViaEnv {
669 server: "https://api.prod.dasch.swiss".to_string(),
670 expires_at: None,
671 expired: false,
672 },
673 &meta,
674 )
675 .unwrap();
676 let err_str = err.string();
677 assert_eq!(err_str.trim(), "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]");
678 assert!(!out.string().contains('['));
680
681 let out2 = SharedBuf::new();
683 let err2 = SharedBuf::new();
684 let mut renderer2 = LinesRenderer::with_writers(out2.clone(), err2.clone());
685 let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
686 renderer2
687 .auth_status(
688 &AuthStatusOutcome::NotLoggedIn { server: "https://api.prod.dasch.swiss".to_string() },
689 &meta2,
690 )
691 .unwrap();
692 let err2_str = err2.string();
693 assert_eq!(err2_str.trim(), "[anonymous on https://api.prod.dasch.swiss]");
694 assert!(!out2.string().contains('['));
696 }
697
698 #[test]
699 fn projects_lines_with_writer_no_stderr_leak() {
700 let out = SharedBuf::new();
701 let mut renderer = LinesRenderer::with_writer(out.clone());
702 let view = ProjectListView { items: vec![], total: 0, filter: None };
703 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
704 renderer.projects(&view, &meta).unwrap();
705 assert_eq!(out.string(), "");
707 }
708
709 #[test]
710 fn project_describe_lines_output() {
711 let out = SharedBuf::new();
712 let err = SharedBuf::new();
713 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
714 let detail = ProjectDetail {
715 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
716 shortcode: "0801".into(),
717 shortname: "beol".into(),
718 longname: Some("Bernoulli-Euler Online".into()),
719 description: vec![],
720 keywords: vec![],
721 data_models: vec![],
722 };
723 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
724 renderer.project_describe(&detail, &meta).unwrap();
725
726 let stdout = out.string();
727 assert!(stdout.contains("0801\tbeol\tBernoulli-Euler Online\n"));
729 assert!(!stdout.starts_with("shortcode"));
731 }
732
733 #[test]
734 fn project_describe_lines_no_longname_trailing_tab() {
735 let out = SharedBuf::new();
736 let err = SharedBuf::new();
737 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
738 let detail = ProjectDetail {
739 iri: "http://rdfh.ch/projects/0000".into(),
740 shortcode: "0000".into(),
741 shortname: "minimal".into(),
742 longname: None,
743 description: vec![],
744 keywords: vec![],
745 data_models: vec![],
746 };
747 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
748 renderer.project_describe(&detail, &meta).unwrap();
749
750 let stdout = out.string();
751 assert!(stdout.contains("0000\tminimal\t\n"));
753 }
754
755 #[test]
756 fn project_describe_lines_stderr_disclosure() {
757 let out = SharedBuf::new();
758 let err = SharedBuf::new();
759 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
760 let detail = ProjectDetail {
761 iri: "http://rdfh.ch/projects/0001".into(),
762 shortcode: "0001".into(),
763 shortname: "test".into(),
764 longname: None,
765 description: vec![],
766 keywords: vec![],
767 data_models: vec![],
768 };
769 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
770 renderer.project_describe(&detail, &meta).unwrap();
771
772 let err_str = err.string();
773 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
774 assert!(!out.string().contains("[anonymous"));
776 }
777
778 fn make_data_model_fixture() -> Vec<DataModel> {
779 vec![
780 DataModel {
781 name: "beol".into(),
782 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
783 label: Some("The BEOL data-model".into()),
784 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
785 is_builtin: false,
786 },
787 DataModel {
788 name: "biblio".into(),
789 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
790 label: None,
791 last_modified: None,
792 is_builtin: false,
793 },
794 DataModel {
795 name: "knora-api".into(),
796 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
797 label: None,
798 last_modified: None,
799 is_builtin: true,
800 },
801 ]
802 }
803
804 #[test]
805 fn data_models_lines_output() {
806 let out = SharedBuf::new();
807 let err = SharedBuf::new();
808 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
809 let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
810 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
811 renderer.data_models(&view, &meta).unwrap();
812
813 let stdout = out.string();
814 assert!(!stdout.starts_with("name"));
816 assert!(stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"));
818 assert!(stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\n"));
819 assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\n"));
820 assert!(!stdout.contains("The BEOL"));
822 assert!(!stdout.contains("2024-05-27"));
823 assert!(!stdout.contains("true"));
824 }
825
826 #[test]
827 fn data_models_lines_stderr_disclosure() {
828 let out = SharedBuf::new();
829 let err = SharedBuf::new();
830 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
831 let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
832 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
833 renderer.data_models(&view, &meta).unwrap();
834
835 let err_str = err.string();
836 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
837 assert!(!out.string().contains("[anonymous"));
839 }
840
841 fn make_beol_dm_detail() -> DataModelDetail {
844 DataModelDetail {
845 name: "beol".into(),
846 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
847 label: Some("The BEOL data-model".into()),
848 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
849 resource_types: vec![
850 ResourceTypeSummary {
851 name: "Archive".into(),
852 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
853 label: Some("Archive".into()),
854 },
855 ResourceTypeSummary {
856 name: "basicLetter".into(),
857 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
858 label: None,
859 },
860 ResourceTypeSummary {
861 name: "letter".into(),
862 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
863 label: Some("Letter".into()),
864 },
865 ],
866 }
867 }
868
869 #[test]
870 fn data_model_describe_lines_output() {
871 let out = SharedBuf::new();
872 let err = SharedBuf::new();
873 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
874 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
875 renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
876
877 let stdout = out.string();
878 assert!(
880 stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"),
881 "expected name TAB iri row; got:\n{stdout}"
882 );
883 assert!(!stdout.starts_with("name"));
885 assert!(!stdout.contains("Archive"), "resource-types must be omitted");
887 }
888
889 #[test]
890 fn data_model_describe_lines_stderr_disclosure() {
891 let out = SharedBuf::new();
892 let err = SharedBuf::new();
893 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
894 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
895 renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
896
897 let err_str = err.string();
898 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
899 assert!(!out.string().contains("[anonymous"));
901 }
902
903 fn make_resource_type_fixture() -> Vec<ResourceType> {
906 vec![
907 ResourceType {
908 name: "Archive".into(),
909 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
910 label: Some("Archive".into()),
911 is_builtin: false,
912 count: None,
913 },
914 ResourceType {
915 name: "letter".into(),
916 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
917 label: Some("Letter".into()),
918 is_builtin: false,
919 count: None,
920 },
921 ]
922 }
923
924 #[test]
925 fn resource_types_lines_output() {
926 let out = SharedBuf::new();
927 let err = SharedBuf::new();
928 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
929 let view = ResourceTypeListView {
930 items: make_resource_type_fixture(),
931 total: 2,
932 filter: None,
933 data_model: "beol".into(),
934 };
935 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
936 renderer.resource_types(&view, &meta).unwrap();
937
938 let stdout = out.string();
939 assert!(!stdout.starts_with("name"));
941 assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
943 assert!(stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\n"));
944 assert!(!stdout.contains("Letter"));
946 assert!(!stdout.contains("true"));
947 assert!(!stdout.contains("false"));
948 }
949
950 #[test]
951 fn resource_types_lines_with_builtins() {
952 let out = SharedBuf::new();
953 let err = SharedBuf::new();
954 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
955 let mut items = make_resource_type_fixture();
956 items.push(ResourceType {
957 name: "Region".into(),
958 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
959 label: Some("Region".into()),
960 is_builtin: true,
961 count: None,
962 });
963 let view = ResourceTypeListView { items, total: 3, filter: None, data_model: "beol".into() };
964 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
965 renderer.resource_types(&view, &meta).unwrap();
966
967 let stdout = out.string();
968 assert!(stdout.contains("Region\thttp://api.knora.org/ontology/knora-api/v2#Region\n"));
970 assert!(!stdout.contains("true"));
972 }
973
974 #[test]
975 fn resource_types_lines_stderr_disclosure() {
976 let out = SharedBuf::new();
977 let err = SharedBuf::new();
978 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
979 let view = ResourceTypeListView {
980 items: make_resource_type_fixture(),
981 total: 2,
982 filter: None,
983 data_model: "beol".into(),
984 };
985 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
986 renderer.resource_types(&view, &meta).unwrap();
987
988 let err_str = err.string();
989 assert_eq!(err_str.trim(), "[anonymous on https://api.test.dasch.swiss]");
990 assert!(!out.string().contains("[anonymous"));
991 }
992
993 #[test]
994 fn resource_types_lines_empty() {
995 let out = SharedBuf::new();
996 let err = SharedBuf::new();
997 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
998 let view = ResourceTypeListView {
999 items: vec![],
1000 total: 0,
1001 filter: None,
1002 data_model: "beol".into(),
1003 };
1004 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1005 renderer.resource_types(&view, &meta).unwrap();
1006
1007 assert_eq!(out.string(), "");
1009 assert!(err.string().contains("[anonymous on https://api.test.dasch.swiss]"));
1010 }
1011
1012 #[test]
1013 fn resource_types_lines_default_unchanged_count_only_via_columns() {
1014 let out = SharedBuf::new();
1018 let err = SharedBuf::new();
1019 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1020 let mut items = make_resource_type_fixture();
1021 items[0].count = Some(10);
1022 let view = ResourceTypeListView { items, total: 2, filter: None, data_model: "beol".into() };
1023 let meta = crate::render::MetaContext {
1024 server_label: "https://api.test.dasch.swiss".into(),
1025 auth_state: "anonymous".into(),
1026 filter_warning: None,
1027 count_caveat: Some("counts are not permission-filtered".into()),
1028 count_cost: None,
1029 };
1030 renderer.resource_types(&view, &meta).unwrap();
1031
1032 let stdout = out.string();
1033 assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
1035 assert!(
1036 !stdout.contains("10"),
1037 "count must not leak into default output; got:\n{stdout}"
1038 );
1039 assert!(
1040 err.string().contains("counts are not permission-filtered"),
1041 "stderr must carry count_caveat; got: {:?}",
1042 err.string()
1043 );
1044 }
1045
1046 #[test]
1047 fn resource_types_lines_columns_count_explicit_projection() {
1048 let out = SharedBuf::new();
1049 let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1050 columns: Some(vec!["name".to_string(), "count".to_string()]),
1051 header: HeaderMode::Off,
1052 });
1053 let mut items = make_resource_type_fixture();
1054 items[0].count = Some(10);
1055 let view = ResourceTypeListView { items, total: 2, filter: None, data_model: "beol".into() };
1056 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1057 renderer.resource_types(&view, &meta).unwrap();
1058
1059 let stdout = out.string();
1060 assert!(stdout.contains("Archive\t10\n"), "got:\n{stdout}");
1061 assert!(stdout.contains("letter\t\n"), "got:\n{stdout}");
1063 }
1064
1065 #[test]
1066 fn resource_type_describe_lines_columns_no_count_column_exists() {
1067 let out = SharedBuf::new();
1071 let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1072 columns: Some(vec!["count".to_string()]),
1073 header: HeaderMode::Off,
1074 });
1075 let detail = crate::model::ResourceTypeDetail {
1076 name: "manuscript".into(),
1077 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1078 label: Some("Manuscript".into()),
1079 data_model: "beol".into(),
1080 representation: None,
1081 super_types: vec![],
1082 fields: vec![],
1083 count: Some(7),
1084 };
1085 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1086 let result = renderer.resource_type_describe(&detail, &meta);
1087 assert!(result.is_err(), "count is not a valid column for resource_type_describe");
1088 }
1089
1090 #[test]
1093 fn auth_logout_lines_default_decorated_output() {
1094 let out = SharedBuf::new();
1096 let mut renderer = LinesRenderer::with_writer(out.clone());
1097 renderer
1098 .auth_logout(
1099 &AuthLogoutOutcome {
1100 server: "https://api.example.com".to_string(),
1101 was_cached: true,
1102 },
1103 &make_meta("anonymous", "https://api.example.com"),
1104 )
1105 .unwrap();
1106 assert_eq!(out.string(), "https://api.example.com\twas_cached=true\n");
1107 }
1108
1109 #[test]
1110 fn auth_logout_lines_projected_was_cached_plain_value() {
1111 let out = SharedBuf::new();
1114 let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1115 columns: Some(vec!["was_cached".to_string()]),
1116 header: HeaderMode::Off,
1117 });
1118 renderer
1119 .auth_logout(
1120 &AuthLogoutOutcome {
1121 server: "https://api.example.com".to_string(),
1122 was_cached: true,
1123 },
1124 &make_meta("anonymous", "https://api.example.com"),
1125 )
1126 .unwrap();
1127 assert_eq!(out.string(), "true\n");
1128 }
1129
1130 use crate::model::{FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent};
1133
1134 fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1135 ResourceDetail {
1136 label: "Test Resource".into(),
1137 iri: "http://rdfh.ch/0803/abc123".into(),
1138 resource_type: "Page".into(),
1139 ark_url: None,
1140 creation_date: None,
1141 last_modified: None,
1142 attached_project: None,
1143 owner: None,
1144 visibility: Some(ResourceVisibility::Public),
1145 your_access: Some(ResourceAccess::View),
1146 values,
1147 }
1148 }
1149
1150 #[test]
1151 fn resource_describe_lines_no_values_no_note() {
1152 let out = SharedBuf::new();
1155 let err = SharedBuf::new();
1156 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1157 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1158 renderer.resource_describe(&make_resource_detail(None), &meta).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 None; got stderr:\n{err_str}"
1164 );
1165 assert!(
1167 out.string().contains("Test Resource"),
1168 "metadata row missing; got:\n{}",
1169 out.string()
1170 );
1171 }
1172
1173 #[test]
1174 fn resource_describe_lines_some_values_no_note_renders_value_rows() {
1175 let out = SharedBuf::new();
1179 let err = SharedBuf::new();
1180 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1181 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1182 renderer
1183 .resource_describe(
1184 &make_resource_detail(Some(vec![FieldValues {
1185 name: "hasTitle".into(),
1186 label: None,
1187 values: vec![ValueContent::Text("Hello".into()).into()],
1188 }])),
1189 &meta,
1190 )
1191 .unwrap();
1192
1193 let err_str = err.string();
1194 assert!(
1195 !err_str.contains("values not shown"),
1196 "no values note must be emitted when values is Some; got stderr:\n{err_str}"
1197 );
1198 let stdout = out.string();
1199 assert!(
1200 stdout.contains("hasTitle\t\ttext\tHello\n"),
1201 "expected default-columns value row field\\tfield_label\\tvalue_type\\tvalue; got:\n{stdout}"
1202 );
1203 assert!(
1205 !stdout.contains("Test Resource"),
1206 "metadata row must not appear in values mode; got:\n{stdout}"
1207 );
1208 }
1209
1210 #[test]
1211 fn resource_describe_lines_columns_comment_renders_comment_column() {
1212 use crate::model::Value;
1216
1217 let out = SharedBuf::new();
1218 let err = SharedBuf::new();
1219 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone()).with_options(TableOptions {
1220 columns: Some(vec!["field".to_string(), "value".to_string(), "comment".to_string()]),
1221 header: HeaderMode::Off,
1222 });
1223 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1224 renderer
1225 .resource_describe(
1226 &make_resource_detail(Some(vec![FieldValues {
1227 name: "hasTranscription".into(),
1228 label: None,
1229 values: vec![Value {
1230 content: ValueContent::Text("teh book".into()),
1231 comment: Some("reading uncertain".into()),
1232 }],
1233 }])),
1234 &meta,
1235 )
1236 .unwrap();
1237
1238 let stdout = out.string();
1239 assert!(
1240 stdout.contains("hasTranscription\tteh book\treading uncertain\n"),
1241 "expected projected field/value/comment row; got:\n{stdout}"
1242 );
1243 }
1244
1245 #[test]
1246 fn resource_describe_lines_default_columns_omits_comment_even_when_present() {
1247 use crate::model::Value;
1252
1253 let out = SharedBuf::new();
1254 let err = SharedBuf::new();
1255 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1256 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1257 renderer
1258 .resource_describe(
1259 &make_resource_detail(Some(vec![FieldValues {
1260 name: "hasTranscription".into(),
1261 label: None,
1262 values: vec![Value {
1263 content: ValueContent::Text("teh book".into()),
1264 comment: Some("reading uncertain".into()),
1265 }],
1266 }])),
1267 &meta,
1268 )
1269 .unwrap();
1270
1271 let stdout = out.string();
1272 assert!(
1273 stdout.contains("hasTranscription\t\ttext\tteh book\n"),
1274 "expected default-columns row field\\tfield_label\\tvalue_type\\tvalue \
1275 (no comment column); got:\n{stdout}"
1276 );
1277 assert!(
1278 !stdout.contains("reading uncertain"),
1279 "comment must NOT appear under default columns even though the value \
1280 has one — comment is opt-in via --columns, not in the default set; \
1281 got:\n{stdout}"
1282 );
1283 }
1284}