1use std::io::{self, Write};
13
14use crate::diagnostic::Diagnostic;
15use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail, VocabularyDetail};
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::vocabulary::{build_vocabulary_list_rows, build_vocabulary_rows};
23use crate::render::{
24 AUTH_LOGIN_COLUMNS, AUTH_LOGOUT_COLUMNS, DATA_MODEL_DESCRIBE_COLUMNS,
25 DATA_MODEL_STRUCTURE_COLUMNS, DATA_MODELS_COLUMNS, DataModelListView, HeaderMode, MetaContext,
26 PROJECT_DUMP_COLUMNS, PROJECT_DUMP_DELETED_COLUMNS, PROJECTS_COLUMNS, ProjectListView,
27 QuoteMode, RESOURCE_DESCRIBE_COLUMNS, RESOURCE_DESCRIBE_VALUES_COLUMNS,
28 RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS, RESOURCE_LIST_COLUMNS,
29 RESOURCE_TYPE_DESCRIBE_COLUMNS, RESOURCE_TYPES_COLUMNS, Renderer, ResourceListView,
30 ResourceTypeListView, TableOptions, TableSpec, VOCABULARIES_COLUMNS,
31 VOCABULARY_DESCRIBE_COLUMNS, VocabularyListView, render_table,
32};
33use crate::util::text::replace_control_chars;
34
35pub struct LinesRenderer {
37 out: Box<dyn Write>,
38 err: Box<dyn Write>,
43 options: TableOptions,
47}
48
49impl LinesRenderer {
50 pub fn new() -> Self {
52 Self {
53 out: Box::new(io::stdout()),
54 err: Box::new(io::stderr()),
55 options: TableOptions::default(),
56 }
57 }
58
59 pub fn with_writer(w: impl Write + 'static) -> Self {
63 Self {
64 out: Box::new(w),
65 err: Box::new(io::sink()),
66 options: TableOptions::default(),
67 }
68 }
69
70 pub fn with_writers(out: impl Write + 'static, err: impl Write + 'static) -> Self {
73 Self {
74 out: Box::new(out),
75 err: Box::new(err),
76 options: TableOptions::default(),
77 }
78 }
79
80 pub fn with_options(mut self, opts: TableOptions) -> Self {
83 self.options = opts;
84 self
85 }
86}
87
88impl Default for LinesRenderer {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl Renderer for LinesRenderer {
95 fn diagnostic(&mut self, diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
96 eprintln!("Error: {diag}"); Ok(())
98 }
99
100 fn auth_login(
101 &mut self,
102 outcome: &AuthLoginOutcome,
103 _meta: &MetaContext,
104 ) -> Result<(), Diagnostic> {
105 let expires = outcome
106 .expires_at
107 .map(|dt| dt.to_rfc3339())
108 .unwrap_or_default();
109 let rows = vec![vec![
110 outcome.server.clone(),
111 outcome.user.clone(),
112 expires,
113 "login_success".to_string(),
114 ]];
115 let projected = self.options.projected();
116 let spec = TableSpec {
117 all_columns: AUTH_LOGIN_COLUMNS,
118 rows: &rows,
119 default_columns: Some(&["server"]),
120 projected,
121 quote: QuoteMode::Lines,
122 header: HeaderMode::Off,
123 };
124 render_table(&mut *self.out, &spec)
125 }
126
127 fn auth_status(
128 &mut self,
129 outcome: &AuthStatusOutcome,
130 meta: &MetaContext,
131 ) -> Result<(), Diagnostic> {
132 let row = match outcome {
133 AuthStatusOutcome::LoggedIn {
134 server,
135 user,
136 expires_at,
137 expired,
138 } => {
139 let user_str = user.as_deref().unwrap_or("").to_string();
140 let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
141 let state = if *expired { "expired" } else { "logged_in" };
142 vec![server.clone(), user_str, expires, state.to_string()]
143 }
144 AuthStatusOutcome::AuthenticatedViaEnv {
148 server,
149 expires_at,
150 expired,
151 } => {
152 let expires = expires_at.map(|dt| dt.to_rfc3339()).unwrap_or_default();
153 let state = if *expired { "expired" } else { "logged_in" };
154 vec![server.clone(), String::new(), expires, state.to_string()]
155 }
156 AuthStatusOutcome::NotLoggedIn { server } => {
157 vec![
158 server.clone(),
159 String::new(),
160 String::new(),
161 "not_logged_in".to_string(),
162 ]
163 }
164 };
165 let rows = vec![row];
166 let projected = self.options.projected();
167 let spec = TableSpec {
168 all_columns: AUTH_LOGIN_COLUMNS,
169 rows: &rows,
170 default_columns: Some(&["server"]),
171 projected,
172 quote: QuoteMode::Lines,
173 header: HeaderMode::Off,
174 };
175 render_table(&mut *self.out, &spec)?;
176 render_table_disclosure(&mut *self.err, meta)?;
177 Ok(())
178 }
179
180 fn auth_logout(
181 &mut self,
182 outcome: &AuthLogoutOutcome,
183 _meta: &MetaContext,
184 ) -> Result<(), Diagnostic> {
185 if self.options.columns.is_none() {
195 writeln!(
197 self.out,
198 "{}\twas_cached={}",
199 replace_control_chars(&outcome.server),
200 outcome.was_cached
201 )?;
202 } else {
203 let rows = vec![vec![outcome.server.clone(), outcome.was_cached.to_string()]];
205 let projected = self.options.projected();
206 let spec = TableSpec {
207 all_columns: AUTH_LOGOUT_COLUMNS,
208 rows: &rows,
209 default_columns: None,
210 projected,
211 quote: QuoteMode::Lines,
212 header: HeaderMode::Off,
213 };
214 render_table(&mut *self.out, &spec)?;
215 }
216 Ok(())
217 }
218
219 fn auth_set_token(
220 &mut self,
221 outcome: &AuthSetTokenOutcome,
222 _meta: &MetaContext,
223 ) -> Result<(), Diagnostic> {
224 let user_str = outcome.user.as_deref().unwrap_or("").to_string();
225 let expires = outcome
226 .expires_at
227 .map(|dt| dt.to_rfc3339())
228 .unwrap_or_default();
229 let rows = vec![vec![
230 outcome.server.clone(),
231 user_str,
232 expires,
233 "token_cached".to_string(),
234 ]];
235 let projected = self.options.projected();
236 let spec = TableSpec {
237 all_columns: AUTH_LOGIN_COLUMNS,
238 rows: &rows,
239 default_columns: Some(&["server"]),
240 projected,
241 quote: QuoteMode::Lines,
242 header: HeaderMode::Off,
243 };
244 render_table(&mut *self.out, &spec)
245 }
246
247 fn project_dump(
248 &mut self,
249 outcome: &DumpOutcome,
250 _meta: &MetaContext,
251 ) -> Result<(), Diagnostic> {
252 let rows = vec![vec![outcome.path.display().to_string()]];
255 let projected = self.options.projected();
256 let spec = TableSpec {
257 all_columns: PROJECT_DUMP_COLUMNS,
258 rows: &rows,
259 default_columns: Some(&["path"]),
260 projected,
261 quote: QuoteMode::Lines,
262 header: HeaderMode::Off,
263 };
264 render_table(&mut *self.out, &spec)
265 }
266
267 fn project_dump_deleted(
268 &mut self,
269 outcome: &DumpDeleteOutcome,
270 _meta: &MetaContext,
271 ) -> Result<(), Diagnostic> {
272 let token = if outcome.deleted {
278 "deleted".to_string()
279 } else {
280 "not-deleted".to_string()
281 };
282 let rows = vec![vec![token]];
283 let projected = self.options.projected();
284 let spec = TableSpec {
285 all_columns: PROJECT_DUMP_DELETED_COLUMNS,
286 rows: &rows,
287 default_columns: Some(&["deleted"]),
288 projected,
289 quote: QuoteMode::Lines,
290 header: HeaderMode::Off,
291 };
292 render_table(&mut *self.out, &spec)
293 }
294
295 fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
296 let rows: Vec<Vec<String>> = view
299 .items
300 .iter()
301 .map(|item| {
302 let longname = item.longname.as_deref().unwrap_or("").to_string();
303 let data_models_str = item.data_models.to_string();
304 vec![
305 item.shortcode.clone(),
306 item.shortname.clone(),
307 longname,
308 item.status.as_str().to_string(),
309 data_models_str,
310 item.iri.clone(),
311 ]
312 })
313 .collect();
314 let projected = self.options.projected();
315 let spec = TableSpec {
316 all_columns: PROJECTS_COLUMNS,
317 rows: &rows,
318 default_columns: Some(&["shortcode", "shortname", "longname"]),
319 projected,
320 quote: QuoteMode::Lines,
321 header: HeaderMode::Off,
322 };
323 render_table(&mut *self.out, &spec)?;
324 render_table_disclosure(&mut *self.err, meta)?;
325 Ok(())
326 }
327
328 fn project_describe(
329 &mut self,
330 project: &ProjectDetail,
331 meta: &MetaContext,
332 ) -> Result<(), Diagnostic> {
333 let longname = project.longname.as_deref().unwrap_or("").to_string();
336 let data_models_str = project.data_models.len().to_string();
337 let rows = vec![vec![
338 project.shortcode.clone(),
339 project.shortname.clone(),
340 longname,
341 project.status.as_str().to_string(),
342 data_models_str,
343 project.iri.clone(),
344 ]];
345 let projected = self.options.projected();
346 let spec = TableSpec {
347 all_columns: PROJECTS_COLUMNS,
348 rows: &rows,
349 default_columns: Some(&["shortcode", "shortname", "longname"]),
350 projected,
351 quote: QuoteMode::Lines,
352 header: HeaderMode::Off,
353 };
354 render_table(&mut *self.out, &spec)?;
355 render_table_disclosure(&mut *self.err, meta)?;
356 Ok(())
357 }
358
359 fn data_models(
360 &mut self,
361 view: &DataModelListView,
362 meta: &MetaContext,
363 ) -> Result<(), Diagnostic> {
364 let rows: Vec<Vec<String>> = view
367 .items
368 .iter()
369 .map(|item| {
370 let label = item.label.as_deref().unwrap_or("").to_string();
371 let last_modified = item.last_modified.as_deref().unwrap_or("").to_string();
372 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
373 vec![
374 item.name.clone(),
375 item.iri.clone(),
376 label,
377 last_modified,
378 is_builtin_str.to_string(),
379 ]
380 })
381 .collect();
382 let projected = self.options.projected();
383 let spec = TableSpec {
384 all_columns: DATA_MODELS_COLUMNS,
385 rows: &rows,
386 default_columns: Some(&["name", "iri"]),
387 projected,
388 quote: QuoteMode::Lines,
389 header: HeaderMode::Off,
390 };
391 render_table(&mut *self.out, &spec)?;
392 render_table_disclosure(&mut *self.err, meta)?;
393 Ok(())
394 }
395
396 fn data_model_describe(
397 &mut self,
398 detail: &DataModelDetail,
399 meta: &MetaContext,
400 ) -> Result<(), Diagnostic> {
401 let label = detail.label.as_deref().unwrap_or("").to_string();
404 let last_modified = detail.last_modified.as_deref().unwrap_or("").to_string();
405 let resource_types_str = detail.resource_types.len().to_string();
406 let rows = vec![vec![
407 detail.name.clone(),
408 detail.iri.clone(),
409 label,
410 last_modified,
411 resource_types_str,
412 ]];
413 let projected = self.options.projected();
414 let spec = TableSpec {
415 all_columns: DATA_MODEL_DESCRIBE_COLUMNS,
416 rows: &rows,
417 default_columns: Some(&["name", "iri"]),
418 projected,
419 quote: QuoteMode::Lines,
420 header: HeaderMode::Off,
421 };
422 render_table(&mut *self.out, &spec)?;
423 render_table_disclosure(&mut *self.err, meta)?;
424 Ok(())
425 }
426
427 fn resource_types(
428 &mut self,
429 view: &ResourceTypeListView,
430 meta: &MetaContext,
431 ) -> Result<(), Diagnostic> {
432 let rows: Vec<Vec<String>> = view
438 .items
439 .iter()
440 .map(|item| {
441 let label = item.label.as_deref().unwrap_or("").to_string();
442 let is_builtin_str = if item.is_builtin { "true" } else { "false" };
443 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
444 vec![
445 item.name.clone(),
446 item.iri.clone(),
447 label,
448 is_builtin_str.to_string(),
449 count_str,
450 ]
451 })
452 .collect();
453 let projected = self.options.projected();
454 let spec = TableSpec {
455 all_columns: RESOURCE_TYPES_COLUMNS,
456 rows: &rows,
457 default_columns: Some(&["name", "iri"]),
458 projected,
459 quote: QuoteMode::Lines,
460 header: HeaderMode::Off,
461 };
462 render_table(&mut *self.out, &spec)?;
463 render_table_disclosure(&mut *self.err, meta)?;
464 Ok(())
465 }
466
467 fn resource_type_describe(
468 &mut self,
469 detail: &crate::model::ResourceTypeDetail,
470 meta: &MetaContext,
471 ) -> Result<(), Diagnostic> {
472 let rows: Vec<Vec<String>> = detail
480 .fields
481 .iter()
482 .map(|field| {
483 let value_type_str = field.value_type.to_string();
484 let link_target = field.link_target.as_deref().unwrap_or("").to_string();
485 let card_str = field.cardinality.to_string();
486 let label = field.label.as_deref().unwrap_or("").to_string();
487 let is_builtin_str = if field.is_builtin { "true" } else { "false" };
488 let data_model = field.data_model.as_deref().unwrap_or("").to_string();
489 vec![
490 field.name.clone(),
491 field.iri.clone(),
492 value_type_str,
493 link_target,
494 card_str,
495 label,
496 is_builtin_str.to_string(),
497 data_model,
498 ]
499 })
500 .collect();
501 let projected = self.options.projected();
502 let spec = TableSpec {
503 all_columns: RESOURCE_TYPE_DESCRIBE_COLUMNS,
504 rows: &rows,
505 default_columns: Some(&["name", "iri"]),
506 projected,
507 quote: QuoteMode::Lines,
508 header: HeaderMode::Off,
509 };
510 render_table(&mut *self.out, &spec)?;
511 render_table_disclosure(&mut *self.err, meta)?;
512 Ok(())
513 }
514
515 fn data_model_structure(
516 &mut self,
517 structure: &DataModelStructure,
518 meta: &MetaContext,
519 ) -> Result<(), Diagnostic> {
520 let rows: Vec<Vec<String>> = structure
524 .relations
525 .iter()
526 .map(|rel| {
527 let kind_str = rel.kind.to_string();
528 let field = rel.field.as_deref().unwrap_or("").to_string();
529 let target_dm = rel.target_data_model.as_deref().unwrap_or("").to_string();
530 vec![
531 rel.source.clone(),
532 rel.target.clone(),
533 kind_str,
534 field,
535 target_dm,
536 ]
537 })
538 .collect();
539 let projected = self.options.projected();
540 let spec = TableSpec {
541 all_columns: DATA_MODEL_STRUCTURE_COLUMNS,
542 rows: &rows,
543 default_columns: Some(&["source", "target", "kind", "field"]),
544 projected,
545 quote: QuoteMode::Lines,
546 header: HeaderMode::Off,
547 };
548 render_table(&mut *self.out, &spec)?;
549 render_table_disclosure(&mut *self.err, meta)?;
550 Ok(())
551 }
552
553 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
554 let rows: Vec<Vec<String>> = view
555 .items
556 .iter()
557 .map(|item| {
558 vec![
559 item.label.clone(),
560 item.iri.clone(),
561 item.ark_url.as_deref().unwrap_or("").to_string(),
562 item.creation_date.as_deref().unwrap_or("").to_string(),
563 item.last_modified.as_deref().unwrap_or("").to_string(),
564 item.resource_type.clone(),
565 ]
566 })
567 .collect();
568 let projected = self.options.projected();
569 let spec = TableSpec {
570 all_columns: RESOURCE_LIST_COLUMNS,
571 rows: &rows,
572 default_columns: None,
573 projected,
574 quote: QuoteMode::Lines,
575 header: HeaderMode::Off,
576 };
577 render_table(&mut *self.out, &spec)?;
578 render_table_disclosure(&mut *self.err, meta)?;
579 Ok(())
580 }
581
582 fn resource_describe(
583 &mut self,
584 detail: &crate::model::ResourceDetail,
585 meta: &MetaContext,
586 ) -> Result<(), Diagnostic> {
587 match &detail.values {
591 None => {
592 let rows = vec![build_metadata_row(detail)];
593 let projected = self.options.projected();
594 let spec = TableSpec {
595 all_columns: RESOURCE_DESCRIBE_COLUMNS,
596 rows: &rows,
597 default_columns: Some(&["label", "iri"]),
598 projected,
599 quote: QuoteMode::Lines,
600 header: HeaderMode::Off,
601 };
602 render_table(&mut *self.out, &spec)?;
603 }
604 Some(fields) => {
605 let rows = build_value_rows(&detail.label, &detail.iri, fields);
606 let projected = self.options.projected();
607 let spec = TableSpec {
608 all_columns: RESOURCE_DESCRIBE_VALUES_COLUMNS,
609 rows: &rows,
610 default_columns: Some(RESOURCE_DESCRIBE_VALUES_DEFAULT_COLUMNS),
611 projected,
612 quote: QuoteMode::Lines,
613 header: HeaderMode::Off,
614 };
615 render_table(&mut *self.out, &spec)?;
616 }
617 }
618 render_table_disclosure(&mut *self.err, meta)?;
619 Ok(())
620 }
621
622 fn vocabularies(
623 &mut self,
624 view: &VocabularyListView,
625 meta: &MetaContext,
626 ) -> Result<(), Diagnostic> {
627 let rows = build_vocabulary_list_rows(&view.items);
631 let projected = self.options.projected();
632 let spec = TableSpec {
633 all_columns: VOCABULARIES_COLUMNS,
634 rows: &rows,
635 default_columns: Some(&["name", "iri"]),
636 projected,
637 quote: QuoteMode::Lines,
638 header: HeaderMode::Off,
639 };
640 render_table(&mut *self.out, &spec)?;
641 render_table_disclosure(&mut *self.err, meta)?;
642 Ok(())
643 }
644
645 fn vocabulary_describe(
646 &mut self,
647 detail: &VocabularyDetail,
648 meta: &MetaContext,
649 ) -> Result<(), Diagnostic> {
650 let rows = build_vocabulary_rows(detail);
654 let projected = self.options.projected();
655 let spec = TableSpec {
656 all_columns: VOCABULARY_DESCRIBE_COLUMNS,
657 rows: &rows,
658 default_columns: Some(&["node_iri", "number"]),
659 projected,
660 quote: QuoteMode::Lines,
661 header: HeaderMode::Off,
662 };
663 render_table(&mut *self.out, &spec)?;
664 render_table_disclosure(&mut *self.err, meta)?;
665 Ok(())
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::model::{
673 DataModel, DataModelDetail, Project, ProjectDetail, ProjectStatus, ResourceType,
674 ResourceTypeSummary,
675 };
676 use crate::render::test_support::{SharedBuf, make_meta};
677 use crate::render::{DataModelListView, ResourceTypeListView};
678
679 fn make_fixture() -> Vec<Project> {
680 vec![
681 Project {
682 iri: "http://rdfh.ch/projects/0001".into(),
683 shortcode: "0001".into(),
684 shortname: "anything".into(),
685 longname: Some("Anything Project".into()),
686 status: ProjectStatus::Active,
687 data_models: 2,
688 },
689 Project {
690 iri: "http://rdfh.ch/projects/0002".into(),
691 shortcode: "0002".into(),
692 shortname: "images".into(),
693 longname: None,
694 status: ProjectStatus::Inactive,
695 data_models: 0,
696 },
697 Project {
698 iri: "http://rdfh.ch/projects/0803".into(),
699 shortcode: "0803".into(),
700 shortname: "daschland".into(),
701 longname: Some("DaSCHland Project".into()),
702 status: ProjectStatus::Active,
703 data_models: 1,
704 },
705 ]
706 }
707
708 #[test]
709 fn projects_lines_output() {
710 let out = SharedBuf::new();
711 let err = SharedBuf::new();
712 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
713 let view = ProjectListView {
714 items: make_fixture(),
715 total: 3,
716 filter: None,
717 };
718 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
719 renderer.projects(&view, &meta).unwrap();
720
721 let stdout = out.string();
722 assert!(!stdout.starts_with("shortcode"));
724 assert!(stdout.contains("0001\tanything\tAnything Project\n"));
726 assert!(stdout.contains("0002\timages\t\n"));
728 assert!(stdout.contains("0803\tdaschland\tDaSCHland Project\n"));
729 }
730
731 #[test]
732 fn projects_lines_stderr_disclosure() {
733 let out = SharedBuf::new();
734 let err = SharedBuf::new();
735 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
736 let view = ProjectListView {
737 items: make_fixture(),
738 total: 3,
739 filter: None,
740 };
741 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
742 renderer.projects(&view, &meta).unwrap();
743
744 let err_str = err.string();
745 assert_eq!(
746 err_str.trim(),
747 "[anonymous on https://api.test.dasch.swiss]"
748 );
749 assert!(!out.string().contains("[anonymous"));
750 }
751
752 #[test]
753 fn auth_status_lines_stderr_disclosure() {
754 let out = SharedBuf::new();
756 let err = SharedBuf::new();
757 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
758 let meta = make_meta(
759 "authenticated via DSP_TOKEN",
760 "https://api.prod.dasch.swiss",
761 );
762 renderer
763 .auth_status(
764 &AuthStatusOutcome::AuthenticatedViaEnv {
765 server: "https://api.prod.dasch.swiss".to_string(),
766 expires_at: None,
767 expired: false,
768 },
769 &meta,
770 )
771 .unwrap();
772 let err_str = err.string();
773 assert_eq!(
774 err_str.trim(),
775 "[authenticated via DSP_TOKEN on https://api.prod.dasch.swiss]"
776 );
777 assert!(!out.string().contains('['));
779
780 let out2 = SharedBuf::new();
782 let err2 = SharedBuf::new();
783 let mut renderer2 = LinesRenderer::with_writers(out2.clone(), err2.clone());
784 let meta2 = make_meta("anonymous", "https://api.prod.dasch.swiss");
785 renderer2
786 .auth_status(
787 &AuthStatusOutcome::NotLoggedIn {
788 server: "https://api.prod.dasch.swiss".to_string(),
789 },
790 &meta2,
791 )
792 .unwrap();
793 let err2_str = err2.string();
794 assert_eq!(
795 err2_str.trim(),
796 "[anonymous on https://api.prod.dasch.swiss]"
797 );
798 assert!(!out2.string().contains('['));
800 }
801
802 #[test]
803 fn projects_lines_with_writer_no_stderr_leak() {
804 let out = SharedBuf::new();
805 let mut renderer = LinesRenderer::with_writer(out.clone());
806 let view = ProjectListView {
807 items: vec![],
808 total: 0,
809 filter: None,
810 };
811 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
812 renderer.projects(&view, &meta).unwrap();
813 assert_eq!(out.string(), "");
815 }
816
817 #[test]
818 fn project_describe_lines_output() {
819 let out = SharedBuf::new();
820 let err = SharedBuf::new();
821 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
822 let detail = ProjectDetail {
823 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
824 shortcode: "0801".into(),
825 shortname: "beol".into(),
826 longname: Some("Bernoulli-Euler Online".into()),
827 status: ProjectStatus::Active,
828 description: vec![],
829 keywords: vec![],
830 data_models: vec![],
831 };
832 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
833 renderer.project_describe(&detail, &meta).unwrap();
834
835 let stdout = out.string();
836 assert!(stdout.contains("0801\tbeol\tBernoulli-Euler Online\n"));
838 assert!(!stdout.starts_with("shortcode"));
840 }
841
842 #[test]
843 fn project_describe_lines_no_longname_trailing_tab() {
844 let out = SharedBuf::new();
845 let err = SharedBuf::new();
846 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
847 let detail = ProjectDetail {
848 iri: "http://rdfh.ch/projects/0000".into(),
849 shortcode: "0000".into(),
850 shortname: "minimal".into(),
851 longname: None,
852 status: ProjectStatus::Inactive,
853 description: vec![],
854 keywords: vec![],
855 data_models: vec![],
856 };
857 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
858 renderer.project_describe(&detail, &meta).unwrap();
859
860 let stdout = out.string();
861 assert!(stdout.contains("0000\tminimal\t\n"));
863 }
864
865 #[test]
866 fn project_describe_lines_stderr_disclosure() {
867 let out = SharedBuf::new();
868 let err = SharedBuf::new();
869 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
870 let detail = ProjectDetail {
871 iri: "http://rdfh.ch/projects/0001".into(),
872 shortcode: "0001".into(),
873 shortname: "test".into(),
874 longname: None,
875 status: ProjectStatus::Active,
876 description: vec![],
877 keywords: vec![],
878 data_models: vec![],
879 };
880 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
881 renderer.project_describe(&detail, &meta).unwrap();
882
883 let err_str = err.string();
884 assert_eq!(
885 err_str.trim(),
886 "[anonymous on https://api.test.dasch.swiss]"
887 );
888 assert!(!out.string().contains("[anonymous"));
890 }
891
892 fn make_data_model_fixture() -> Vec<DataModel> {
893 vec![
894 DataModel {
895 name: "beol".into(),
896 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
897 label: Some("The BEOL data-model".into()),
898 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
899 is_builtin: false,
900 },
901 DataModel {
902 name: "biblio".into(),
903 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
904 label: None,
905 last_modified: None,
906 is_builtin: false,
907 },
908 DataModel {
909 name: "knora-api".into(),
910 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
911 label: None,
912 last_modified: None,
913 is_builtin: true,
914 },
915 ]
916 }
917
918 #[test]
919 fn data_models_lines_output() {
920 let out = SharedBuf::new();
921 let err = SharedBuf::new();
922 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
923 let view = DataModelListView {
924 items: make_data_model_fixture(),
925 total: 3,
926 filter: None,
927 };
928 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
929 renderer.data_models(&view, &meta).unwrap();
930
931 let stdout = out.string();
932 assert!(!stdout.starts_with("name"));
934 assert!(stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"));
936 assert!(stdout.contains("biblio\thttp://api.dasch.swiss/ontology/0801/biblio/v2\n"));
937 assert!(stdout.contains("knora-api\thttp://api.knora.org/ontology/knora-api/v2\n"));
938 assert!(!stdout.contains("The BEOL"));
940 assert!(!stdout.contains("2024-05-27"));
941 assert!(!stdout.contains("true"));
942 }
943
944 #[test]
945 fn data_models_lines_stderr_disclosure() {
946 let out = SharedBuf::new();
947 let err = SharedBuf::new();
948 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
949 let view = DataModelListView {
950 items: make_data_model_fixture(),
951 total: 3,
952 filter: None,
953 };
954 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
955 renderer.data_models(&view, &meta).unwrap();
956
957 let err_str = err.string();
958 assert_eq!(
959 err_str.trim(),
960 "[anonymous on https://api.test.dasch.swiss]"
961 );
962 assert!(!out.string().contains("[anonymous"));
964 }
965
966 fn make_beol_dm_detail() -> DataModelDetail {
969 DataModelDetail {
970 name: "beol".into(),
971 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
972 label: Some("The BEOL data-model".into()),
973 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
974 resource_types: vec![
975 ResourceTypeSummary {
976 name: "Archive".into(),
977 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
978 label: Some("Archive".into()),
979 },
980 ResourceTypeSummary {
981 name: "basicLetter".into(),
982 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
983 label: None,
984 },
985 ResourceTypeSummary {
986 name: "letter".into(),
987 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
988 label: Some("Letter".into()),
989 },
990 ],
991 }
992 }
993
994 #[test]
995 fn data_model_describe_lines_output() {
996 let out = SharedBuf::new();
997 let err = SharedBuf::new();
998 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
999 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1000 renderer
1001 .data_model_describe(&make_beol_dm_detail(), &meta)
1002 .unwrap();
1003
1004 let stdout = out.string();
1005 assert!(
1007 stdout.contains("beol\thttp://api.dasch.swiss/ontology/0801/beol/v2\n"),
1008 "expected name TAB iri row; got:\n{stdout}"
1009 );
1010 assert!(!stdout.starts_with("name"));
1012 assert!(
1014 !stdout.contains("Archive"),
1015 "resource-types must be omitted"
1016 );
1017 }
1018
1019 #[test]
1020 fn data_model_describe_lines_stderr_disclosure() {
1021 let out = SharedBuf::new();
1022 let err = SharedBuf::new();
1023 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1024 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1025 renderer
1026 .data_model_describe(&make_beol_dm_detail(), &meta)
1027 .unwrap();
1028
1029 let err_str = err.string();
1030 assert_eq!(
1031 err_str.trim(),
1032 "[anonymous on https://api.test.dasch.swiss]"
1033 );
1034 assert!(!out.string().contains("[anonymous"));
1036 }
1037
1038 fn make_resource_type_fixture() -> Vec<ResourceType> {
1041 vec![
1042 ResourceType {
1043 name: "Archive".into(),
1044 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1045 label: Some("Archive".into()),
1046 is_builtin: false,
1047 count: None,
1048 },
1049 ResourceType {
1050 name: "letter".into(),
1051 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1052 label: Some("Letter".into()),
1053 is_builtin: false,
1054 count: None,
1055 },
1056 ]
1057 }
1058
1059 #[test]
1060 fn resource_types_lines_output() {
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 stdout = out.string();
1074 assert!(!stdout.starts_with("name"));
1076 assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
1078 assert!(stdout.contains("letter\thttp://api.dasch.swiss/ontology/0801/beol/v2#letter\n"));
1079 assert!(!stdout.contains("Letter"));
1081 assert!(!stdout.contains("true"));
1082 assert!(!stdout.contains("false"));
1083 }
1084
1085 #[test]
1086 fn resource_types_lines_with_builtins() {
1087 let out = SharedBuf::new();
1088 let err = SharedBuf::new();
1089 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1090 let mut items = make_resource_type_fixture();
1091 items.push(ResourceType {
1092 name: "Region".into(),
1093 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1094 label: Some("Region".into()),
1095 is_builtin: true,
1096 count: None,
1097 });
1098 let view = ResourceTypeListView {
1099 items,
1100 total: 3,
1101 filter: None,
1102 data_model: "beol".into(),
1103 };
1104 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1105 renderer.resource_types(&view, &meta).unwrap();
1106
1107 let stdout = out.string();
1108 assert!(stdout.contains("Region\thttp://api.knora.org/ontology/knora-api/v2#Region\n"));
1110 assert!(!stdout.contains("true"));
1112 }
1113
1114 #[test]
1115 fn resource_types_lines_stderr_disclosure() {
1116 let out = SharedBuf::new();
1117 let err = SharedBuf::new();
1118 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1119 let view = ResourceTypeListView {
1120 items: make_resource_type_fixture(),
1121 total: 2,
1122 filter: None,
1123 data_model: "beol".into(),
1124 };
1125 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1126 renderer.resource_types(&view, &meta).unwrap();
1127
1128 let err_str = err.string();
1129 assert_eq!(
1130 err_str.trim(),
1131 "[anonymous on https://api.test.dasch.swiss]"
1132 );
1133 assert!(!out.string().contains("[anonymous"));
1134 }
1135
1136 #[test]
1137 fn resource_types_lines_empty() {
1138 let out = SharedBuf::new();
1139 let err = SharedBuf::new();
1140 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1141 let view = ResourceTypeListView {
1142 items: vec![],
1143 total: 0,
1144 filter: None,
1145 data_model: "beol".into(),
1146 };
1147 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1148 renderer.resource_types(&view, &meta).unwrap();
1149
1150 assert_eq!(out.string(), "");
1152 assert!(
1153 err.string()
1154 .contains("[anonymous on https://api.test.dasch.swiss]")
1155 );
1156 }
1157
1158 #[test]
1159 fn resource_types_lines_default_unchanged_count_only_via_columns() {
1160 let out = SharedBuf::new();
1164 let err = SharedBuf::new();
1165 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1166 let mut items = make_resource_type_fixture();
1167 items[0].count = Some(10);
1168 let view = ResourceTypeListView {
1169 items,
1170 total: 2,
1171 filter: None,
1172 data_model: "beol".into(),
1173 };
1174 let meta = crate::render::MetaContext {
1175 server_label: "https://api.test.dasch.swiss".into(),
1176 auth_state: "anonymous".into(),
1177 filter_warning: None,
1178 count_caveat: Some("counts are not permission-filtered".into()),
1179 count_cost: None,
1180 };
1181 renderer.resource_types(&view, &meta).unwrap();
1182
1183 let stdout = out.string();
1184 assert!(stdout.contains("Archive\thttp://api.dasch.swiss/ontology/0801/beol/v2#Archive\n"));
1186 assert!(
1187 !stdout.contains("10"),
1188 "count must not leak into default output; got:\n{stdout}"
1189 );
1190 assert!(
1191 err.string().contains("counts are not permission-filtered"),
1192 "stderr must carry count_caveat; got: {:?}",
1193 err.string()
1194 );
1195 }
1196
1197 #[test]
1198 fn resource_types_lines_columns_count_explicit_projection() {
1199 let out = SharedBuf::new();
1200 let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1201 columns: Some(vec!["name".to_string(), "count".to_string()]),
1202 header: HeaderMode::Off,
1203 });
1204 let mut items = make_resource_type_fixture();
1205 items[0].count = Some(10);
1206 let view = ResourceTypeListView {
1207 items,
1208 total: 2,
1209 filter: None,
1210 data_model: "beol".into(),
1211 };
1212 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1213 renderer.resource_types(&view, &meta).unwrap();
1214
1215 let stdout = out.string();
1216 assert!(stdout.contains("Archive\t10\n"), "got:\n{stdout}");
1217 assert!(stdout.contains("letter\t\n"), "got:\n{stdout}");
1219 }
1220
1221 #[test]
1222 fn resource_type_describe_lines_columns_no_count_column_exists() {
1223 let out = SharedBuf::new();
1227 let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1228 columns: Some(vec!["count".to_string()]),
1229 header: HeaderMode::Off,
1230 });
1231 let detail = crate::model::ResourceTypeDetail {
1232 name: "manuscript".into(),
1233 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1234 label: Some("Manuscript".into()),
1235 data_model: "beol".into(),
1236 representation: None,
1237 super_types: vec![],
1238 fields: vec![],
1239 count: Some(7),
1240 };
1241 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1242 let result = renderer.resource_type_describe(&detail, &meta);
1243 assert!(
1244 result.is_err(),
1245 "count is not a valid column for resource_type_describe"
1246 );
1247 }
1248
1249 #[test]
1252 fn auth_logout_lines_default_decorated_output() {
1253 let out = SharedBuf::new();
1255 let mut renderer = LinesRenderer::with_writer(out.clone());
1256 renderer
1257 .auth_logout(
1258 &AuthLogoutOutcome {
1259 server: "https://api.example.com".to_string(),
1260 was_cached: true,
1261 },
1262 &make_meta("anonymous", "https://api.example.com"),
1263 )
1264 .unwrap();
1265 assert_eq!(out.string(), "https://api.example.com\twas_cached=true\n");
1266 }
1267
1268 #[test]
1269 fn auth_logout_lines_projected_was_cached_plain_value() {
1270 let out = SharedBuf::new();
1273 let mut renderer = LinesRenderer::with_writer(out.clone()).with_options(TableOptions {
1274 columns: Some(vec!["was_cached".to_string()]),
1275 header: HeaderMode::Off,
1276 });
1277 renderer
1278 .auth_logout(
1279 &AuthLogoutOutcome {
1280 server: "https://api.example.com".to_string(),
1281 was_cached: true,
1282 },
1283 &make_meta("anonymous", "https://api.example.com"),
1284 )
1285 .unwrap();
1286 assert_eq!(out.string(), "true\n");
1287 }
1288
1289 use crate::model::{
1292 FieldValues, ResourceAccess, ResourceDetail, ResourceVisibility, ValueContent,
1293 };
1294
1295 fn make_resource_detail(values: Option<Vec<FieldValues>>) -> ResourceDetail {
1296 ResourceDetail {
1297 label: "Test Resource".into(),
1298 iri: "http://rdfh.ch/0803/abc123".into(),
1299 resource_type: "Page".into(),
1300 ark_url: None,
1301 creation_date: None,
1302 last_modified: None,
1303 attached_project: None,
1304 owner: None,
1305 visibility: Some(ResourceVisibility::Public),
1306 your_access: Some(ResourceAccess::View),
1307 values,
1308 }
1309 }
1310
1311 #[test]
1312 fn resource_describe_lines_no_values_no_note() {
1313 let out = SharedBuf::new();
1316 let err = SharedBuf::new();
1317 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1318 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1319 renderer
1320 .resource_describe(&make_resource_detail(None), &meta)
1321 .unwrap();
1322
1323 let err_str = err.string();
1324 assert!(
1325 !err_str.contains("values not shown"),
1326 "no values note must be emitted when values is None; got stderr:\n{err_str}"
1327 );
1328 assert!(
1330 out.string().contains("Test Resource"),
1331 "metadata row missing; got:\n{}",
1332 out.string()
1333 );
1334 }
1335
1336 #[test]
1337 fn resource_describe_lines_some_values_no_note_renders_value_rows() {
1338 let out = SharedBuf::new();
1342 let err = SharedBuf::new();
1343 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1344 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1345 renderer
1346 .resource_describe(
1347 &make_resource_detail(Some(vec![FieldValues {
1348 name: "hasTitle".into(),
1349 label: None,
1350 values: vec![ValueContent::Text("Hello".into()).into()],
1351 }])),
1352 &meta,
1353 )
1354 .unwrap();
1355
1356 let err_str = err.string();
1357 assert!(
1358 !err_str.contains("values not shown"),
1359 "no values note must be emitted when values is Some; got stderr:\n{err_str}"
1360 );
1361 let stdout = out.string();
1362 assert!(
1363 stdout.contains("hasTitle\t\ttext\tHello\n"),
1364 "expected default-columns value row field\\tfield_label\\tvalue_type\\tvalue; got:\n{stdout}"
1365 );
1366 assert!(
1368 !stdout.contains("Test Resource"),
1369 "metadata row must not appear in values mode; got:\n{stdout}"
1370 );
1371 }
1372
1373 #[test]
1374 fn resource_describe_lines_columns_comment_renders_comment_column() {
1375 use crate::model::Value;
1379
1380 let out = SharedBuf::new();
1381 let err = SharedBuf::new();
1382 let mut renderer =
1383 LinesRenderer::with_writers(out.clone(), err.clone()).with_options(TableOptions {
1384 columns: Some(vec![
1385 "field".to_string(),
1386 "value".to_string(),
1387 "comment".to_string(),
1388 ]),
1389 header: HeaderMode::Off,
1390 });
1391 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1392 renderer
1393 .resource_describe(
1394 &make_resource_detail(Some(vec![FieldValues {
1395 name: "hasTranscription".into(),
1396 label: None,
1397 values: vec![Value {
1398 content: ValueContent::Text("teh book".into()),
1399 comment: Some("reading uncertain".into()),
1400 }],
1401 }])),
1402 &meta,
1403 )
1404 .unwrap();
1405
1406 let stdout = out.string();
1407 assert!(
1408 stdout.contains("hasTranscription\tteh book\treading uncertain\n"),
1409 "expected projected field/value/comment row; got:\n{stdout}"
1410 );
1411 }
1412
1413 #[test]
1414 fn resource_describe_lines_default_columns_omits_comment_even_when_present() {
1415 use crate::model::Value;
1420
1421 let out = SharedBuf::new();
1422 let err = SharedBuf::new();
1423 let mut renderer = LinesRenderer::with_writers(out.clone(), err.clone());
1424 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1425 renderer
1426 .resource_describe(
1427 &make_resource_detail(Some(vec![FieldValues {
1428 name: "hasTranscription".into(),
1429 label: None,
1430 values: vec![Value {
1431 content: ValueContent::Text("teh book".into()),
1432 comment: Some("reading uncertain".into()),
1433 }],
1434 }])),
1435 &meta,
1436 )
1437 .unwrap();
1438
1439 let stdout = out.string();
1440 assert!(
1441 stdout.contains("hasTranscription\t\ttext\tteh book\n"),
1442 "expected default-columns row field\\tfield_label\\tvalue_type\\tvalue \
1443 (no comment column); got:\n{stdout}"
1444 );
1445 assert!(
1446 !stdout.contains("reading uncertain"),
1447 "comment must NOT appear under default columns even though the value \
1448 has one — comment is opt-in via --columns, not in the default set; \
1449 got:\n{stdout}"
1450 );
1451 }
1452}