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