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