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