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
437 .items
438 .iter()
439 .map(|item| {
440 let label = item.label.as_deref().unwrap_or("").to_string();
441 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
442 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
443 vec![
444 item.name.clone(),
445 item.iri.clone(),
446 label,
447 is_builtin_str.to_string(),
448 count_str,
449 ]
450 })
451 .collect();
452 let projected = self.options.projected();
453 let spec = TableSpec {
454 all_columns: RESOURCE_TYPES_COLUMNS,
455 rows: &rows,
456 default_columns: Some(&["name", "iri"]),
457 projected,
458 quote: QuoteMode::Lines,
459 header: HeaderMode::Off,
460 };
461 render_table(&mut *self.out, &spec)?;
462 render_table_disclosure(&mut *self.err, meta)?;
463 Ok(())
464 }
465
466 fn resource_type_describe(
467 &mut self,
468 detail: &crate::model::ResourceTypeDetail,
469 meta: &MetaContext,
470 ) -> Result<(), Diagnostic> {
471 let rows: Vec<Vec<String>> = detail
479 .fields
480 .iter()
481 .map(|field| {
482 let value_type_str = field.value_type.to_string();
483 let link_target = field.link_target.as_deref().unwrap_or("").to_string();
484 let card_str = field.cardinality.to_string();
485 let label = field.label.as_deref().unwrap_or("").to_string();
486 let is_builtin_str = if field.is_builtin { "true" } else { "false" };
487 let data_model = field.data_model.as_deref().unwrap_or("").to_string();
488 vec![
489 field.name.clone(),
490 field.iri.clone(),
491 value_type_str,
492 link_target,
493 card_str,
494 label,
495 is_builtin_str.to_string(),
496 data_model,
497 ]
498 })
499 .collect();
500 let projected = self.options.projected();
501 let spec = TableSpec {
502 all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
503 rows: &rows,
504 default_columns: Some(&["name", "iri"]),
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 data_model_structure(
515 &mut self,
516 structure: &DataModelStructure,
517 meta: &MetaContext,
518 ) -> Result<(), Diagnostic> {
519 let rows: Vec<Vec<String>> = structure
523 .relations
524 .iter()
525 .map(|rel| {
526 let kind_str = rel.kind.to_string();
527 let field = rel.field.as_deref().unwrap_or("").to_string();
528 let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
529 vec![
530 rel.source.clone(),
531 rel.target.clone(),
532 kind_str,
533 field,
534 target_dm,
535 ]
536 })
537 .collect();
538 let projected = self.options.projected();
539 let spec = TableSpec {
540 all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
541 rows: &rows,
542 default_columns: Some(&["source", "target", "kind", "field"]),
543 projected,
544 quote: QuoteMode::Lines,
545 header: HeaderMode::Off,
546 };
547 render_table(&mut *self.out, &spec)?;
548 render_table_disclosure(&mut *self.err, meta)?;
549 Ok(())
550 }
551
552 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
553 let rows: Vec<Vec<String>> = view
554 .items
555 .iter()
556 .map(|item| {
557 vec![
558 item.label.clone(),
559 item.iri.clone(),
560 item.ark_url.as_deref().unwrap_or("").to_string(),
561 item.creation_date.as_deref().unwrap_or("").to_string(),
562 item.last_modified.as_deref().unwrap_or("").to_string(),
563 item.resource_type.clone(),
564 ]
565 })
566 .collect();
567 let projected = self.options.projected();
568 let spec = TableSpec {
569 all_columns: RESOURCE_LIST_COLUMNS,
570 rows: &rows,
571 default_columns: None,
572 projected,
573 quote: QuoteMode::Lines,
574 header: HeaderMode::Off,
575 };
576 render_table(&mut *self.out, &spec)?;
577 render_table_disclosure(&mut *self.err, meta)?;
578 Ok(())
579 }
580
581 fn resource_describe(
582 &mut self,
583 detail: &crate::model::ResourceDetail,
584 meta: &MetaContext,
585 ) -> Result<(), Diagnostic> {
586 match &detail.values {
590 None => {
591 let rows = vec![build_metadata_row(detail)];
592 let projected = self.options.projected();
593 let spec = TableSpec {
594 all_columns: RESOURCE_DESCRIBE_COLUMNS,
595 rows: &rows,
596 default_columns: Some(&["label", "iri"]),
597 projected,
598 quote: QuoteMode::Lines,
599 header: HeaderMode::Off,
600 };
601 render_table(&mut *self.out, &spec)?;
602 }
603 Some(fields) => {
604 let rows = build_value_rows(&detail.label, &detail.iri, fields);
605 let projected = self.options.projected();
606 let spec = TableSpec {
607 all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
608 rows: &rows,
609 default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
610 projected,
611 quote: QuoteMode::Lines,
612 header: HeaderMode::Off,
613 };
614 render_table(&mut *self.out, &spec)?;
615 }
616 }
617 render_table_disclosure(&mut *self.err, meta)?;
618 Ok(())
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use crate::model::{
626 DataModel, DataModelDetail, Project, ProjectDetail, ProjectStatus, ResourceType,
627 ResourceTypeSummary,
628 };
629 use crate::render::test_support::{SharedBuf, make_meta};
630 use crate::render::{DataModelListView, ResourceTypeListView};
631
632 fn make_fixture() -> Vec<Project> {
633 vec![
634 Project {
635 iri: "http://rdfh.ch/projects/0001".into(),
636 shortcode: "0001".into(),
637 shortname: "anything".into(),
638 longname: Some("Anything Project".into()),
639 status: ProjectStatus::Active,
640 data_models: 2,
641 },
642 Project {
643 iri: "http://rdfh.ch/projects/0002".into(),
644 shortcode: "0002".into(),
645 shortname: "images".into(),
646 longname: None,
647 status: ProjectStatus::Inactive,
648 data_models: 0,
649 },
650 Project {
651 iri: "http://rdfh.ch/projects/0803".into(),
652 shortcode: "0803".into(),
653 shortname: "daschland".into(),
654 longname: Some("DaSCHland Project".into()),
655 status: ProjectStatus::Active,
656 data_models: 1,
657 },
658 ]
659 }
660
661 #[test]
662 fn projects_lines_output() {
663 let out = SharedBuf::new();
664 let err = SharedBuf::new();
665 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
666 let view = ProjectListView {
667 items: make_fixture(),
668 total: 3,
669 filter: None,
670 };
671 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
672 renderer.projects(&view, &meta).unwrap();
673
674 let stdout = out.string();
675 assert!(!stdout.starts_with("shortcode"));
677 assert!(stdout.contains("0001\tanything\tAnything Project\n"));
679 assert!(stdout.contains("0002\timages\t\n"));
681 assert!(stdout.contains("0803\tdaschland\tDaSCHland Project\n"));
682 }
683
684 #[test]
685 fn projects_lines_stderr_disclosure() {
686 let out = SharedBuf::new();
687 let err = SharedBuf::new();
688 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
689 let view = ProjectListView {
690 items: make_fixture(),
691 total: 3,
692 filter: None,
693 };
694 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
695 renderer.projects(&view, &meta).unwrap();
696
697 let err_str = err.string();
698 assert_eq!(
699 err_str.trim(),
700 "[anonymous on https://api.test.dasch.swiss]"
701 );
702 assert!(!out.string().contains("[anonymous"));
703 }
704
705 #[test]
706 fn auth_status_lines_stderr_disclosure() {
707 let out = SharedBuf::new();
709 let err = SharedBuf::new();
710 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
711 let meta = make_meta(
712 "authenticated via DSP_TOKEN",
713 "https://api.prod.dasch.swiss",
714 );
715 renderer
716 .auth_status(
717 &AuthStatusOutcome::AuthenticatedViaEnv {
718 server: "https://api.prod.dasch.swiss".to_string(),
719 expires_at: None,
720 expired: false,
721 },
722 &meta,
723 )
724 .unwrap();
725 let err_str = err.string();
726 assert_eq!(
727 err_str.trim(),
728 "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
729 );
730 assert!(!out.string().contains('['));
732
733 let out2 = SharedBuf::new();
735 let err2 = SharedBuf::new();
736 let mut renderer2 = LinesRenderer::with_writers(out2.clone(), err2.clone());
737 let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
738 renderer2
739 .auth_status(
740 &AuthStatusOutcome::NotLoggedIn {
741 server: "https://api.prod.dasch.swiss".to_string(),
742 },
743 &meta2,
744 )
745 .unwrap();
746 let err2_str = err2.string();
747 assert_eq!(
748 err2_str.trim(),
749 "[anonymous on https://api.prod.dasch.swiss]"
750 );
751 assert!(!out2.string().contains('['));
753 }
754
755 #[test]
756 fn projects_lines_with_writer_no_stderr_leak() {
757 let out = SharedBuf::new();
758 let mut renderer = LinesRenderer::with_writer(out.clone());
759 let view = ProjectListView {
760 items: vec![],
761 total: 0,
762 filter: None,
763 };
764 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
765 renderer.projects(&view, &meta).unwrap();
766 assert_eq!(out.string(), "");
768 }
769
770 #[test]
771 fn project_describe_lines_output() {
772 let out = SharedBuf::new();
773 let err = SharedBuf::new();
774 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
775 let detail = ProjectDetail {
776 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
777 shortcode: "0801".into(),
778 shortname: "beol".into(),
779 longname: Some("Bernoulli-Euler Online".into()),
780 status: ProjectStatus::Active,
781 description: vec![],
782 keywords: vec![],
783 data_models: vec![],
784 };
785 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
786 renderer.project_describe(&detail, &meta).unwrap();
787
788 let stdout = out.string();
789 assert!(stdout.contains("0801\tbeol\tBernoulli-Euler Online\n"));
791 assert!(!stdout.starts_with("shortcode"));
793 }
794
795 #[test]
796 fn project_describe_lines_no_longname_trailing_tab() {
797 let out = SharedBuf::new();
798 let err = SharedBuf::new();
799 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
800 let detail = ProjectDetail {
801 iri: "http://rdfh.ch/projects/0000".into(),
802 shortcode: "0000".into(),
803 shortname: "minimal".into(),
804 longname: None,
805 status: ProjectStatus::Inactive,
806 description: vec![],
807 keywords: vec![],
808 data_models: vec![],
809 };
810 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
811 renderer.project_describe(&detail, &meta).unwrap();
812
813 let stdout = out.string();
814 assert!(stdout.contains("0000\tminimal\t\n"));
816 }
817
818 #[test]
819 fn project_describe_lines_stderr_disclosure() {
820 let out = SharedBuf::new();
821 let err = SharedBuf::new();
822 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
823 let detail = ProjectDetail {
824 iri: "http://rdfh.ch/projects/0001".into(),
825 shortcode: "0001".into(),
826 shortname: "test".into(),
827 longname: None,
828 status: ProjectStatus::Active,
829 description: vec![],
830 keywords: vec![],
831 data_models: vec![],
832 };
833 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
834 renderer.project_describe(&detail, &meta).unwrap();
835
836 let err_str = err.string();
837 assert_eq!(
838 err_str.trim(),
839 "[anonymous on https://api.test.dasch.swiss]"
840 );
841 assert!(!out.string().contains("[anonymous"));
843 }
844
845 fn make_data_model_fixture() -> Vec<DataModel> {
846 vec![
847 DataModel {
848 name: "beol".into(),
849 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
850 label: Some("The BEOL data-model".into()),
851 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
852 is_builtin: false,
853 },
854 DataModel {
855 name: "biblio".into(),
856 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
857 label: None,
858 last_modified: None,
859 is_builtin: false,
860 },
861 DataModel {
862 name: "knora-api".into(),
863 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
864 label: None,
865 last_modified: None,
866 is_builtin: true,
867 },
868 ]
869 }
870
871 #[test]
872 fn data_models_lines_output() {
873 let out = SharedBuf::new();
874 let err = SharedBuf::new();
875 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
876 let view = DataModelListView {
877 items: make_data_model_fixture(),
878 total: 3,
879 filter: None,
880 };
881 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
882 renderer.data_models(&view, &meta).unwrap();
883
884 let stdout = out.string();
885 assert!(!stdout.starts_with("name"));
887 assert!(stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"));
889 assert!(stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\n"));
890 assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\n"));
891 assert!(!stdout.contains("The BEOL"));
893 assert!(!stdout.contains("2024-05-27"));
894 assert!(!stdout.contains("true"));
895 }
896
897 #[test]
898 fn data_models_lines_stderr_disclosure() {
899 let out = SharedBuf::new();
900 let err = SharedBuf::new();
901 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
902 let view = DataModelListView {
903 items: make_data_model_fixture(),
904 total: 3,
905 filter: None,
906 };
907 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
908 renderer.data_models(&view, &meta).unwrap();
909
910 let err_str = err.string();
911 assert_eq!(
912 err_str.trim(),
913 "[anonymous on https://api.test.dasch.swiss]"
914 );
915 assert!(!out.string().contains("[anonymous"));
917 }
918
919 fn make_beol_dm_detail() -> DataModelDetail {
922 DataModelDetail {
923 name: "beol".into(),
924 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
925 label: Some("The BEOL data-model".into()),
926 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
927 resource_types: vec![
928 ResourceTypeSummary {
929 name: "Archive".into(),
930 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
931 label: Some("Archive".into()),
932 },
933 ResourceTypeSummary {
934 name: "basicLetter".into(),
935 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
936 label: None,
937 },
938 ResourceTypeSummary {
939 name: "letter".into(),
940 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
941 label: Some("Letter".into()),
942 },
943 ],
944 }
945 }
946
947 #[test]
948 fn data_model_describe_lines_output() {
949 let out = SharedBuf::new();
950 let err = SharedBuf::new();
951 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
952 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
953 renderer
954 .data_model_describe(&make_beol_dm_detail(), &meta)
955 .unwrap();
956
957 let stdout = out.string();
958 assert!(
960 stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"),
961 "expected name TAB iri row; got:\n{stdout}"
962 );
963 assert!(!stdout.starts_with("name"));
965 assert!(
967 !stdout.contains("Archive"),
968 "resource-types must be omitted"
969 );
970 }
971
972 #[test]
973 fn data_model_describe_lines_stderr_disclosure() {
974 let out = SharedBuf::new();
975 let err = SharedBuf::new();
976 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
977 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
978 renderer
979 .data_model_describe(&make_beol_dm_detail(), &meta)
980 .unwrap();
981
982 let err_str = err.string();
983 assert_eq!(
984 err_str.trim(),
985 "[anonymous on https://api.test.dasch.swiss]"
986 );
987 assert!(!out.string().contains("[anonymous"));
989 }
990
991 fn make_resource_type_fixture() -> Vec<ResourceType> {
994 vec![
995 ResourceType {
996 name: "Archive".into(),
997 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
998 label: Some("Archive".into()),
999 is_builtin: false,
1000 count: None,
1001 },
1002 ResourceType {
1003 name: "letter".into(),
1004 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1005 label: Some("Letter".into()),
1006 is_builtin: false,
1007 count: None,
1008 },
1009 ]
1010 }
1011
1012 #[test]
1013 fn resource_types_lines_output() {
1014 let out = SharedBuf::new();
1015 let err = SharedBuf::new();
1016 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1017 let view = ResourceTypeListView {
1018 items: make_resource_type_fixture(),
1019 total: 2,
1020 filter: None,
1021 data_model: "beol".into(),
1022 };
1023 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1024 renderer.resource_types(&view, &meta).unwrap();
1025
1026 let stdout = out.string();
1027 assert!(!stdout.starts_with("name"));
1029 assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
1031 assert!(stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\n"));
1032 assert!(!stdout.contains("Letter"));
1034 assert!(!stdout.contains("true"));
1035 assert!(!stdout.contains("false"));
1036 }
1037
1038 #[test]
1039 fn resource_types_lines_with_builtins() {
1040 let out = SharedBuf::new();
1041 let err = SharedBuf::new();
1042 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1043 let mut items = make_resource_type_fixture();
1044 items.push(ResourceType {
1045 name: "Region".into(),
1046 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1047 label: Some("Region".into()),
1048 is_builtin: true,
1049 count: None,
1050 });
1051 let view = ResourceTypeListView {
1052 items,
1053 total: 3,
1054 filter: None,
1055 data_model: "beol".into(),
1056 };
1057 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1058 renderer.resource_types(&view, &meta).unwrap();
1059
1060 let stdout = out.string();
1061 assert!(stdout.contains("Region\thttp://api.knora.org/ontology/knora-api/v2#Region\n"));
1063 assert!(!stdout.contains("true"));
1065 }
1066
1067 #[test]
1068 fn resource_types_lines_stderr_disclosure() {
1069 let out = SharedBuf::new();
1070 let err = SharedBuf::new();
1071 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1072 let view = ResourceTypeListView {
1073 items: make_resource_type_fixture(),
1074 total: 2,
1075 filter: None,
1076 data_model: "beol".into(),
1077 };
1078 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1079 renderer.resource_types(&view, &meta).unwrap();
1080
1081 let err_str = err.string();
1082 assert_eq!(
1083 err_str.trim(),
1084 "[anonymous on https://api.test.dasch.swiss]"
1085 );
1086 assert!(!out.string().contains("[anonymous"));
1087 }
1088
1089 #[test]
1090 fn resource_types_lines_empty() {
1091 let out = SharedBuf::new();
1092 let err = SharedBuf::new();
1093 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1094 let view = ResourceTypeListView {
1095 items: vec![],
1096 total: 0,
1097 filter: None,
1098 data_model: "beol".into(),
1099 };
1100 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1101 renderer.resource_types(&view, &meta).unwrap();
1102
1103 assert_eq!(out.string(), "");
1105 assert!(
1106 err.string()
1107 .contains("[anonymous on https://api.test.dasch.swiss]")
1108 );
1109 }
1110
1111 #[test]
1112 fn resource_types_lines_default_unchanged_count_only_via_columns() {
1113 let out = SharedBuf::new();
1117 let err = SharedBuf::new();
1118 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1119 let mut items = make_resource_type_fixture();
1120 items[0].count = Some(10);
1121 let view = ResourceTypeListView {
1122 items,
1123 total: 2,
1124 filter: None,
1125 data_model: "beol".into(),
1126 };
1127 let meta = crate::render::MetaContext {
1128 server_label: "https://api.test.dasch.swiss".into(),
1129 auth_state: "anonymous".into(),
1130 filter_warning: None,
1131 count_caveat: Some("counts are not permission-filtered".into()),
1132 };
1133 renderer.resource_types(&view, &meta).unwrap();
1134
1135 let stdout = out.string();
1136 assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
1138 assert!(
1139 !stdout.contains("10"),
1140 "count must not leak into default output; got:\n{stdout}"
1141 );
1142 assert!(
1143 err.string().contains("counts are not permission-filtered"),
1144 "stderr must carry count_caveat; got: {:?}",
1145 err.string()
1146 );
1147 }
1148
1149 #[test]
1150 fn resource_types_lines_columns_count_explicit_projection() {
1151 let out = SharedBuf::new();
1152 let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1153 columns: Some(vec!["name".to_string(), "count".to_string()]),
1154 header: HeaderMode::Off,
1155 });
1156 let mut items = make_resource_type_fixture();
1157 items[0].count = Some(10);
1158 let view = ResourceTypeListView {
1159 items,
1160 total: 2,
1161 filter: None,
1162 data_model: "beol".into(),
1163 };
1164 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1165 renderer.resource_types(&view, &meta).unwrap();
1166
1167 let stdout = out.string();
1168 assert!(stdout.contains("Archive\t10\n"), "got:\n{stdout}");
1169 assert!(stdout.contains("letter\t\n"), "got:\n{stdout}");
1171 }
1172
1173 #[test]
1174 fn resource_type_describe_lines_columns_no_count_column_exists() {
1175 let out = SharedBuf::new();
1179 let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1180 columns: Some(vec!["count".to_string()]),
1181 header: HeaderMode::Off,
1182 });
1183 let detail = crate::model::ResourceTypeDetail {
1184 name: "manuscript".into(),
1185 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1186 label: Some("Manuscript".into()),
1187 data_model: "beol".into(),
1188 representation: None,
1189 super_types: vec![],
1190 fields: vec![],
1191 count: Some(7),
1192 };
1193 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1194 let result = renderer.resource_type_describe(&detail, &meta);
1195 assert!(
1196 result.is_err(),
1197 "count is not a valid column for resource_type_describe"
1198 );
1199 }
1200
1201 #[test]
1204 fn auth_logout_lines_default_decorated_output() {
1205 let out = SharedBuf::new();
1207 let mut renderer = LinesRenderer::with_writer(out.clone());
1208 renderer
1209 .auth_logout(
1210 &AuthLogoutOutcome {
1211 server: "https://api.example.com".to_string(),
1212 was_cached: true,
1213 },
1214 &make_meta("anonymous", "https://api.example.com"),
1215 )
1216 .unwrap();
1217 assert_eq!(out.string(), "https://api.example.com\twas_cached=true\n");
1218 }
1219
1220 #[test]
1221 fn auth_logout_lines_projected_was_cached_plain_value() {
1222 let out = SharedBuf::new();
1225 let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1226 columns: Some(vec!["was_cached".to_string()]),
1227 header: HeaderMode::Off,
1228 });
1229 renderer
1230 .auth_logout(
1231 &AuthLogoutOutcome {
1232 server: "https://api.example.com".to_string(),
1233 was_cached: true,
1234 },
1235 &make_meta("anonymous", "https://api.example.com"),
1236 )
1237 .unwrap();
1238 assert_eq!(out.string(), "true\n");
1239 }
1240
1241 use crate::model::{
1244 FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
1245 };
1246
1247 fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1248 ResourceDetail {
1249 label: "Test Resource".into(),
1250 iri: "http://rdfh.ch/0803/abc123".into(),
1251 resource_type: "Page".into(),
1252 ark_url: None,
1253 creation_date: None,
1254 last_modified: None,
1255 attached_project: None,
1256 owner: None,
1257 visibility: Some(ResourceVisibility::Public),
1258 your_access: Some(ResourceAccess::View),
1259 values,
1260 }
1261 }
1262
1263 #[test]
1264 fn resource_describe_lines_no_values_no_note() {
1265 let out = SharedBuf::new();
1268 let err = SharedBuf::new();
1269 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1270 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1271 renderer
1272 .resource_describe(&make_resource_detail(None), &meta)
1273 .unwrap();
1274
1275 let err_str = err.string();
1276 assert!(
1277 !err_str.contains("values not shown"),
1278 "no values note must be emitted when values is None; got stderr:\n{err_str}"
1279 );
1280 assert!(
1282 out.string().contains("Test Resource"),
1283 "metadata row missing; got:\n{}",
1284 out.string()
1285 );
1286 }
1287
1288 #[test]
1289 fn resource_describe_lines_some_values_no_note_renders_value_rows() {
1290 let out = SharedBuf::new();
1294 let err = SharedBuf::new();
1295 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1296 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1297 renderer
1298 .resource_describe(
1299 &make_resource_detail(Some(vec![FieldValues {
1300 name: "hasTitle".into(),
1301 label: None,
1302 values: vec![ValueContent::Text("Hello".into()).into()],
1303 }])),
1304 &meta,
1305 )
1306 .unwrap();
1307
1308 let err_str = err.string();
1309 assert!(
1310 !err_str.contains("values not shown"),
1311 "no values note must be emitted when values is Some; got stderr:\n{err_str}"
1312 );
1313 let stdout = out.string();
1314 assert!(
1315 stdout.contains("hasTitle\t\ttext\tHello\n"),
1316 "expected default-columns value row field\\tfield_label\\tvalue_type\\tvalue; got:\n{stdout}"
1317 );
1318 assert!(
1320 !stdout.contains("Test Resource"),
1321 "metadata row must not appear in values mode; got:\n{stdout}"
1322 );
1323 }
1324
1325 #[test]
1326 fn resource_describe_lines_columns_comment_renders_comment_column() {
1327 use crate::model::Value;
1331
1332 let out = SharedBuf::new();
1333 let err = SharedBuf::new();
1334 let mut renderer =
1335 LinesRenderer::with_writers(out.clone(), err.clone()).with_options(TableOptions {
1336 columns: Some(vec![
1337 "field".to_string(),
1338 "value".to_string(),
1339 "comment".to_string(),
1340 ]),
1341 header: HeaderMode::Off,
1342 });
1343 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1344 renderer
1345 .resource_describe(
1346 &make_resource_detail(Some(vec![FieldValues {
1347 name: "hasTranscription".into(),
1348 label: None,
1349 values: vec![Value {
1350 content: ValueContent::Text("teh book".into()),
1351 comment: Some("reading uncertain".into()),
1352 }],
1353 }])),
1354 &meta,
1355 )
1356 .unwrap();
1357
1358 let stdout = out.string();
1359 assert!(
1360 stdout.contains("hasTranscription\tteh book\treading uncertain\n"),
1361 "expected projected field/value/comment row; got:\n{stdout}"
1362 );
1363 }
1364
1365 #[test]
1366 fn resource_describe_lines_default_columns_omits_comment_even_when_present() {
1367 use crate::model::Value;
1372
1373 let out = SharedBuf::new();
1374 let err = SharedBuf::new();
1375 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1376 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1377 renderer
1378 .resource_describe(
1379 &make_resource_detail(Some(vec![FieldValues {
1380 name: "hasTranscription".into(),
1381 label: None,
1382 values: vec![Value {
1383 content: ValueContent::Text("teh book".into()),
1384 comment: Some("reading uncertain".into()),
1385 }],
1386 }])),
1387 &meta,
1388 )
1389 .unwrap();
1390
1391 let stdout = out.string();
1392 assert!(
1393 stdout.contains("hasTranscription\t\ttext\tteh book\n"),
1394 "expected default-columns row field\\tfield_label\\tvalue_type\\tvalue \
1395 (no comment column); got:\n{stdout}"
1396 );
1397 assert!(
1398 !stdout.contains("reading uncertain"),
1399 "comment must NOT appear under default columns even though the value \
1400 has one — comment is opt-in via --columns, not in the default set; \
1401 got:\n{stdout}"
1402 );
1403 }
1404}