1use std::io::{self, Write};
10
11use chrono::{DateTime, Utc};
12
13use crate::diagnostic::Diagnostic;
14use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail};
15use crate::render::auth::{
16 AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
17};
18use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
19use crate::render::table::render_prose_footer;
20use crate::render::value::render_value_content;
21use crate::render::{
22 DataModelListView, MetaContext, ProjectListView, Renderer, ResourceListPagination,
23 ResourceListView, ResourceTypeListView,
24};
25use crate::util::text::{html_to_text, strip_control_chars};
26
27fn format_expiry_clause(expires_at: Option<DateTime<Utc>>, expired: bool) -> String {
38 match expires_at {
39 Some(exp) if expired => {
40 format!(" Token expired {}.", exp.format("%Y-%m-%d %H:%M UTC"))
41 }
42 Some(exp) => {
43 format!(" Token expires {}.", exp.format("%Y-%m-%d %H:%M UTC"))
44 }
45 None => String::new(),
46 }
47}
48
49pub struct ProseRenderer {
51 out: Box<dyn Write>,
52}
53
54impl ProseRenderer {
55 pub fn new() -> Self {
57 Self {
58 out: Box::new(io::stdout()),
59 }
60 }
61
62 pub fn with_writer(w: impl Write + 'static) -> Self {
64 Self { out: Box::new(w) }
65 }
66}
67
68impl Default for ProseRenderer {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74impl Renderer for ProseRenderer {
75 fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
76 Ok(())
79 }
80
81 fn auth_login(
82 &mut self,
83 outcome: &AuthLoginOutcome,
84 _meta: &MetaContext,
85 ) -> Result<(), Diagnostic> {
86 match &outcome.expires_at {
87 Some(exp) => writeln!(
88 self.out,
89 "Logged in to {} as {}. Token expires {}.",
90 outcome.server,
91 outcome.user,
92 exp.format("%Y-%m-%d %H:%M UTC"),
93 )?,
94 None => writeln!(
95 self.out,
96 "Logged in to {} as {}.",
97 outcome.server, outcome.user,
98 )?,
99 }
100 Ok(())
101 }
102
103 fn auth_status(
104 &mut self,
105 outcome: &AuthStatusOutcome,
106 _meta: &MetaContext,
107 ) -> Result<(), Diagnostic> {
108 match outcome {
109 AuthStatusOutcome::LoggedIn {
110 server,
111 user,
112 expires_at,
113 expired,
114 } => {
115 let user_str = user
116 .as_deref()
117 .map(|u| format!(" as {u}"))
118 .unwrap_or_default();
119 let expiry_str = format_expiry_clause(*expires_at, *expired);
120 writeln!(self.out, "Logged in to {server}{user_str}.{expiry_str}")?;
121 }
122 AuthStatusOutcome::AuthenticatedViaEnv {
123 server,
124 expires_at,
125 expired,
126 } => {
127 let expiry_str = match expires_at {
128 None => " Token expiry unknown.".to_string(),
129 Some(_) => format_expiry_clause(*expires_at, *expired),
130 };
131 writeln!(
132 self.out,
133 "Authenticated to {server} via DSP_TOKEN.{expiry_str}"
134 )?;
135 }
136 AuthStatusOutcome::NotLoggedIn { server } => {
137 writeln!(self.out, "Not logged in to {server}.")?;
138 }
139 }
140 Ok(())
141 }
142
143 fn auth_logout(
144 &mut self,
145 outcome: &AuthLogoutOutcome,
146 _meta: &MetaContext,
147 ) -> Result<(), Diagnostic> {
148 if outcome.was_cached {
149 writeln!(self.out, "Logged out of {}.", outcome.server)?;
150 } else {
151 writeln!(
152 self.out,
153 "Not logged in to {} (nothing to remove).",
154 outcome.server
155 )?;
156 }
157 Ok(())
158 }
159
160 fn auth_set_token(
161 &mut self,
162 outcome: &AuthSetTokenOutcome,
163 _meta: &MetaContext,
164 ) -> Result<(), Diagnostic> {
165 let user_clause = outcome
166 .user
167 .as_deref()
168 .map(|u| format!(" as {u}"))
169 .unwrap_or_default();
170 let expiry_clause = match outcome.expires_at {
171 Some(exp) => format!(" Token expires {}.", exp.format("%Y-%m-%d %H:%M UTC")),
172 None => String::new(),
173 };
174 writeln!(
175 self.out,
176 "Cached token for {server}{user_clause}.{expiry_clause}",
177 server = outcome.server,
178 )?;
179 Ok(())
180 }
181
182 fn project_dump(
183 &mut self,
184 outcome: &DumpOutcome,
185 _meta: &MetaContext,
186 ) -> Result<(), Diagnostic> {
187 if outcome.reused {
188 match outcome.created_at {
191 Some(ts) => writeln!(
192 self.out,
193 "Downloaded existing dump (created {}).",
194 ts.format("%Y-%m-%d %H:%M UTC"),
195 )?,
196 None => writeln!(self.out, "Downloaded existing dump.")?,
197 }
198 }
199 writeln!(
200 self.out,
201 "Wrote {} ({} bytes).",
202 outcome.path.display(),
203 outcome.bytes,
204 )?;
205 if outcome.cleaned_up {
206 writeln!(self.out, "Cleaned up the server-side dump.")?;
207 }
208 Ok(())
209 }
210
211 fn project_dump_deleted(
212 &mut self,
213 outcome: &DumpDeleteOutcome,
214 _meta: &MetaContext,
215 ) -> Result<(), Diagnostic> {
216 if outcome.deleted {
217 writeln!(self.out, "Removed the project's dump.")?;
218 } else if let Some(ref note) = outcome.note {
219 writeln!(self.out, "{note}")?;
220 }
221 Ok(())
222 }
223
224 fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
225 let n = view.items.len();
227 match &view.filter {
228 None => writeln!(self.out, "Projects on {} ({n}):", meta.server_label)?,
229 Some(f) => writeln!(
230 self.out,
231 "Projects on {} ({n} of {} matching \"{f}\"):",
232 meta.server_label, view.total
233 )?,
234 }
235
236 writeln!(self.out)?;
237
238 let sc_w = view
240 .items
241 .iter()
242 .map(|p| p.shortcode.len())
243 .max()
244 .unwrap_or(0);
245 let sn_w = view
246 .items
247 .iter()
248 .map(|p| p.shortname.len())
249 .max()
250 .unwrap_or(0);
251 let ln_w = view
252 .items
253 .iter()
254 .map(|p| p.longname.as_deref().unwrap_or("").len())
255 .max()
256 .unwrap_or(0);
257
258 for item in &view.items {
259 let longname = item.longname.as_deref().unwrap_or("");
260 write!(
264 self.out,
265 " {:<sc_w$} {:<sn_w$} {:<ln_w$} {}",
266 item.shortcode,
267 item.shortname,
268 longname,
269 item.status.as_str(),
270 )?;
271 if item.data_models > 0 {
272 let label = if item.data_models == 1 {
273 "data-model"
274 } else {
275 "data-models"
276 };
277 write!(self.out, " \u{b7} {} {label}", item.data_models)?;
278 }
279 writeln!(self.out)?;
280 }
281
282 render_prose_footer(&mut *self.out, meta)?;
283 Ok(())
284 }
285
286 fn project_describe(
287 &mut self,
288 project: &ProjectDetail,
289 meta: &MetaContext,
290 ) -> Result<(), Diagnostic> {
291 writeln!(
294 self.out,
295 "Project: {} ({})",
296 project.shortname, project.shortcode
297 )?;
298
299 if let Some(ln) = &project.longname {
304 writeln!(self.out, " Name: {ln}")?;
305 }
306
307 writeln!(self.out, " IRI: {}", project.iri)?;
308 writeln!(self.out, " Status: {}", project.status.as_str())?;
309
310 if !project.keywords.is_empty() {
312 writeln!(self.out, " Keywords: {}", project.keywords.join(", "))?;
313 }
314
315 let n = project.data_models.len();
317 if n == 0 {
318 writeln!(self.out, " Data-models (0)")?;
319 } else {
320 let names: Vec<&str> = project
321 .data_models
322 .iter()
323 .map(|dm| dm.name.as_str())
324 .collect();
325 writeln!(self.out, " Data-models ({n}): {}", names.join(", "))?;
326 }
327
328 let descriptions: Vec<(Option<&str>, String)> = project
334 .description
335 .iter()
336 .map(|e| (e.language.as_deref(), html_to_text(&e.value)))
337 .filter(|(_, plain)| !plain.is_empty())
338 .collect();
339 if !descriptions.is_empty() {
340 writeln!(self.out, " Description:")?;
341 for (language, plain) in &descriptions {
342 let prefix = match language {
346 Some(lang) => format!(" [{lang}] "),
347 None => " ".to_string(),
348 };
349 let continuation_indent = " ".repeat(prefix.len());
350 let mut first_line = true;
351 for line in plain.lines() {
352 if first_line {
353 writeln!(self.out, "{prefix}{line}")?;
354 first_line = false;
355 } else if line.is_empty() {
356 writeln!(self.out)?;
359 } else {
360 writeln!(self.out, "{continuation_indent}{line}")?;
361 }
362 }
363 }
364 }
365
366 render_prose_footer(&mut *self.out, meta)?;
367 Ok(())
368 }
369
370 fn data_model_describe(
371 &mut self,
372 detail: &DataModelDetail,
373 meta: &MetaContext,
374 ) -> Result<(), Diagnostic> {
375 writeln!(self.out, "Data-model: {}", detail.name)?;
377
378 if let Some(ref lbl) = detail.label {
382 writeln!(self.out, " Label: {lbl}")?;
383 }
384 writeln!(self.out, " IRI: {}", detail.iri)?;
385 if let Some(ref lm) = detail.last_modified {
386 let date = lm.split_once('T').map(|(d, _)| d).unwrap_or(lm.as_str());
387 writeln!(self.out, " Last-modified: {date}")?;
388 }
389
390 let n = detail.resource_types.len();
392 if n == 0 {
393 writeln!(self.out, " Resource-types (0)")?;
394 } else {
395 writeln!(self.out)?;
396 writeln!(self.out, " Resource-types ({n}):")?;
397 let name_w = detail
399 .resource_types
400 .iter()
401 .map(|rt| rt.name.len())
402 .max()
403 .unwrap_or(0);
404 for rt in &detail.resource_types {
405 let label = rt.label.as_deref().unwrap_or("");
406 writeln!(self.out, " {:<name_w$} {label}", rt.name)?;
407 }
408 }
409
410 render_prose_footer(&mut *self.out, meta)?;
411 Ok(())
412 }
413
414 fn data_models(
415 &mut self,
416 view: &DataModelListView,
417 meta: &MetaContext,
418 ) -> Result<(), Diagnostic> {
419 let n = view.items.len();
420 let has_builtins = view.items.iter().any(|d| d.is_builtin);
421 let builtin_suffix = if has_builtins {
422 ", incl. built-ins"
423 } else {
424 ""
425 };
426
427 match &view.filter {
429 None => writeln!(
430 self.out,
431 "data-models on {} ({n}){builtin_suffix}:",
432 meta.server_label,
433 )?,
434 Some(f) => writeln!(
435 self.out,
436 "data-models on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
437 meta.server_label, view.total,
438 )?,
439 }
440
441 writeln!(self.out)?;
442
443 let name_w = view.items.iter().map(|d| d.name.len()).max().unwrap_or(0);
445 let label_w = view
446 .items
447 .iter()
448 .map(|d| {
449 if let Some(ref lbl) = d.label {
450 lbl.len()
451 } else if d.is_builtin {
452 "(built-in)".len()
453 } else {
454 0
455 }
456 })
457 .max()
458 .unwrap_or(0);
459
460 for item in &view.items {
461 let label_or_marker: &str = if let Some(ref lbl) = item.label {
463 lbl.as_str()
464 } else if item.is_builtin {
465 "(built-in)"
466 } else {
467 ""
468 };
469
470 let date_str: String = match &item.last_modified {
472 Some(s) => s
473 .split_once('T')
474 .map(|(d, _)| d.to_string())
475 .unwrap_or_else(|| s.clone()),
476 None => String::new(),
477 };
478
479 writeln!(
480 self.out,
481 " {:<name_w$} {:<label_w$} {}",
482 item.name, label_or_marker, date_str,
483 )?;
484 }
485
486 render_prose_footer(&mut *self.out, meta)?;
487 Ok(())
488 }
489
490 fn resource_types(
491 &mut self,
492 view: &ResourceTypeListView,
493 meta: &MetaContext,
494 ) -> Result<(), Diagnostic> {
495 let n = view.items.len();
496 let has_builtins = view.items.iter().any(|rt| rt.is_builtin);
497 let builtin_suffix = if has_builtins {
498 ", incl. built-ins"
499 } else {
500 ""
501 };
502
503 match &view.filter {
505 None => writeln!(
506 self.out,
507 "resource-types in {} on {} ({n}){builtin_suffix}:",
508 view.data_model, meta.server_label,
509 )?,
510 Some(f) => writeln!(
511 self.out,
512 "resource-types in {} on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
513 view.data_model, meta.server_label, view.total,
514 )?,
515 }
516
517 writeln!(self.out)?;
518
519 let name_w = view.items.iter().map(|rt| rt.name.len()).max().unwrap_or(0);
521 let label_w = view
522 .items
523 .iter()
524 .map(|rt| rt.label.as_deref().map(|l| l.len()).unwrap_or(0))
525 .max()
526 .unwrap_or(0);
527
528 for item in &view.items {
529 let label = item.label.as_deref().unwrap_or("");
530 if item.is_builtin {
532 writeln!(
533 self.out,
534 " {:<name_w$} {:<label_w$} (built-in)",
535 item.name, label,
536 )?;
537 } else {
538 writeln!(self.out, " {:<name_w$} {label}", item.name)?;
539 }
540 }
541
542 render_prose_footer(&mut *self.out, meta)?;
543 Ok(())
544 }
545
546 fn resource_type_describe(
547 &mut self,
548 detail: &crate::model::ResourceTypeDetail,
549 meta: &MetaContext,
550 ) -> Result<(), Diagnostic> {
551 use crate::model::ValueType;
552
553 writeln!(self.out, "Resource-type: {}", detail.name)?;
555
556 if let Some(ref lbl) = detail.label {
560 writeln!(self.out, " Label: {lbl}")?;
561 }
562 if !detail.super_types.is_empty() {
563 writeln!(
564 self.out,
565 " Extends: {}",
566 detail.super_types.join(", ")
567 )?;
568 }
569 if let Some(ref repr) = detail.representation {
570 writeln!(self.out, " Representation: {repr}")?;
571 }
572 writeln!(self.out, " IRI: {}", detail.iri)?;
573 writeln!(self.out, " Data-model: {}", detail.data_model)?;
574
575 let n = detail.fields.len();
577 if n == 0 {
578 writeln!(self.out, "\n Fields (0)")?;
579 } else {
580 writeln!(self.out, "\n Fields ({n}):")?;
581
582 let vtype_strs: Vec<String> = detail
584 .fields
585 .iter()
586 .map(|f| match &f.value_type {
587 ValueType::Link => {
588 if let Some(ref tgt) = f.link_target {
589 format!("link \u{2192} {tgt}")
590 } else {
591 "link".to_string()
592 }
593 }
594 vt => vt.to_string(),
595 })
596 .collect();
597
598 let name_w = detail
600 .fields
601 .iter()
602 .map(|f| f.name.len())
603 .max()
604 .unwrap_or(0);
605 let vtype_w = vtype_strs.iter().map(|s| s.len()).max().unwrap_or(0);
606 let card_w = detail
607 .fields
608 .iter()
609 .map(|f| f.cardinality.to_string().len())
610 .max()
611 .unwrap_or(0);
612
613 for (field, vtype_str) in detail.fields.iter().zip(vtype_strs.iter()) {
614 let label = field.label.as_deref().unwrap_or("");
615 let card_str = field.cardinality.to_string();
616
617 let source_tag = if let Some(ref dm) = field.data_model {
620 if dm != &detail.data_model {
621 format!(" [from {dm}]")
622 } else {
623 String::new()
624 }
625 } else {
626 String::new()
627 };
628
629 let builtin_marker = if field.is_builtin { " (built-in)" } else { "" };
631
632 writeln!(
633 self.out,
634 " {:<name_w$} {:<vtype_w$} {:<card_w$} {label}{builtin_marker}{source_tag}",
635 field.name, vtype_str, card_str,
636 )?;
637 }
638 }
639
640 render_prose_footer(&mut *self.out, meta)?;
641 Ok(())
642 }
643
644 fn data_model_structure(
645 &mut self,
646 structure: &DataModelStructure,
647 meta: &MetaContext,
648 ) -> Result<(), Diagnostic> {
649 use crate::model::RelationKind;
650
651 let n = structure.relations.len();
653 let plural = if n == 1 { "relation" } else { "relations" };
654 writeln!(
655 self.out,
656 "Structure: {} ({n} {plural})",
657 structure.data_model
658 )?;
659
660 if n > 0 {
661 writeln!(self.out)?;
662
663 let source_w = structure
666 .relations
667 .iter()
668 .map(|r| r.source.len())
669 .max()
670 .unwrap_or(0);
671
672 let target_w = structure
674 .relations
675 .iter()
676 .map(|r| {
677 let tag_len = match &r.target_data_model {
678 Some(dm) if dm != &structure.data_model => {
679 " [to ".len() + dm.len() + "]".len()
680 }
681 _ => 0,
682 };
683 r.target.len() + tag_len
684 })
685 .max()
686 .unwrap_or(0);
687
688 let field_w = structure
690 .relations
691 .iter()
692 .map(|r| r.field.as_deref().unwrap_or("").len())
693 .max()
694 .unwrap_or(0);
695
696 for rel in &structure.relations {
697 let target_col = match &rel.target_data_model {
699 Some(dm) if dm != &structure.data_model => {
700 format!("{} [to {}]", rel.target, dm)
701 }
702 _ => rel.target.clone(),
703 };
704
705 let field_col = rel.field.as_deref().unwrap_or("");
707
708 let kind_marker = match rel.kind {
710 RelationKind::Link => "[link]",
711 RelationKind::Inherits => "[inherits]",
712 };
713
714 writeln!(
715 self.out,
716 " {:<source_w$} \u{2192} {:<target_w$} {:<field_w$} {kind_marker}",
717 rel.source, target_col, field_col,
718 )?;
719 }
720 }
721
722 render_prose_footer(&mut *self.out, meta)?;
723 Ok(())
724 }
725
726 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
727 let n = view.items.len();
728
729 match &view.filter {
731 None => writeln!(
732 self.out,
733 "resources of type {} on {} ({n}):",
734 view.resource_type, meta.server_label,
735 )?,
736 Some(f) => writeln!(
737 self.out,
738 "resources of type {} on {} ({n} of {} matching \"{f}\"):",
739 view.resource_type, meta.server_label, view.total,
740 )?,
741 }
742
743 if n > 0 {
744 writeln!(self.out)?;
745
746 let labels: Vec<String> = view
750 .items
751 .iter()
752 .map(|r| strip_control_chars(&r.label))
753 .collect();
754 let label_w = labels.iter().map(|l| l.len()).max().unwrap_or(0);
755
756 for (item, label) in view.items.iter().zip(&labels) {
757 writeln!(self.out, " {:<label_w$} {}", label, item.iri,)?;
758 }
759 }
760
761 if let ResourceListPagination::SinglePage {
763 may_have_more: true,
764 ..
765 } = view.pagination
766 {
767 writeln!(self.out)?;
768 writeln!(
769 self.out,
770 " more results available (use --all to fetch all pages)"
771 )?;
772 }
773
774 render_prose_footer(&mut *self.out, meta)?;
775 Ok(())
776 }
777
778 fn resource_describe(
779 &mut self,
780 detail: &crate::model::ResourceDetail,
781 meta: &MetaContext,
782 ) -> Result<(), Diagnostic> {
783 writeln!(self.out, "Resource: {}", strip_control_chars(&detail.label))?;
785
786 writeln!(self.out, " Type: {}", detail.resource_type)?;
791 writeln!(self.out, " IRI: {}", detail.iri)?;
792 if let Some(ref ark) = detail.ark_url {
793 writeln!(self.out, " ARK: {ark}")?;
794 }
795 if let Some(ref created) = detail.creation_date {
796 writeln!(self.out, " Created: {created}")?;
797 }
798 if let Some(ref modified) = detail.last_modified {
799 writeln!(self.out, " Modified: {modified}")?;
800 }
801 if let Some(ref project) = detail.attached_project {
802 writeln!(self.out, " Project: {project}")?;
803 }
804 if let Some(ref owner) = detail.owner {
805 writeln!(self.out, " Owner: {owner}")?;
806 }
807 if let Some(ref vis) = detail.visibility {
808 writeln!(self.out, " Visibility: {}", vis.as_str())?;
809 }
810 if let Some(ref access) = detail.your_access {
811 writeln!(self.out, " Your access: {}", access.as_str())?;
812 }
813
814 if let Some(ref fields) = detail.values {
816 writeln!(self.out)?;
817 if fields.is_empty() {
818 writeln!(self.out, "Values: (none)")?;
819 } else {
820 writeln!(self.out, "Values:")?;
821 for fv in fields {
822 let header = match &fv.label {
824 Some(lbl) => {
825 format!(
826 "{} ({})",
827 strip_control_chars(lbl),
828 strip_control_chars(&fv.name)
829 )
830 }
831 None => strip_control_chars(&fv.name),
832 };
833 writeln!(self.out, " {header}")?;
834
835 for value in &fv.values {
836 let rendered = render_value_content(&value.content);
837 writeln!(self.out, " {}", strip_control_chars(&rendered))?;
838 if let Some(comment) = &value.comment {
839 writeln!(self.out, " comment: {}", strip_control_chars(comment))?;
840 }
841 }
842 }
843 }
844 }
845
846 render_prose_footer(&mut *self.out, meta)?;
847 Ok(())
848 }
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use crate::model::{
855 Cardinality, DataModel, DataModelDetail, DataModelSummary, Field, Project,
856 ProjectDescription, ProjectDetail, ProjectStatus, Representation, ResourceType,
857 ResourceTypeDetail, ResourceTypeSummary, ValueType,
858 };
859 use crate::render::test_support::{SharedBuf, make_meta};
860 use crate::render::{DataModelListView, ResourceTypeListView};
861
862 fn make_fixture() -> Vec<Project> {
863 vec![
864 Project {
865 iri: "http://rdfh.ch/projects/0001".into(),
866 shortcode: "0001".into(),
867 shortname: "anything".into(),
868 longname: Some("Anything Project".into()),
869 status: ProjectStatus::Active,
870 data_models: 2,
871 },
872 Project {
873 iri: "http://rdfh.ch/projects/0002".into(),
874 shortcode: "0002".into(),
875 shortname: "images".into(),
876 longname: None,
877 status: ProjectStatus::Inactive,
878 data_models: 0,
879 },
880 Project {
881 iri: "http://rdfh.ch/projects/0803".into(),
882 shortcode: "0803".into(),
883 shortname: "daschland".into(),
884 longname: Some("=Formula Project".into()),
885 status: ProjectStatus::Active,
886 data_models: 1,
887 },
888 ]
889 }
890
891 #[test]
892 fn projects_prose_no_filter() {
893 let out = SharedBuf::new();
894 let mut renderer = ProseRenderer::with_writer(out.clone());
895 let view = ProjectListView {
896 items: make_fixture(),
897 total: 3,
898 filter: None,
899 };
900 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
901 renderer.projects(&view, &meta).unwrap();
902
903 let s = out.string();
904 assert!(s.contains("Projects on https://api.test.dasch.swiss (3):"));
905 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
907 assert!(!s.contains("rdfh.ch"));
909 assert!(s.contains("2 data-models"));
911 assert!(s.contains("1 data-model"));
913 let data_model_lines = s.lines().filter(|l| l.contains("data-model")).count();
915 assert_eq!(data_model_lines, 2);
916 assert!(!s.contains("None"));
918 }
919
920 #[test]
921 fn projects_prose_with_filter() {
922 let out = SharedBuf::new();
923 let mut renderer = ProseRenderer::with_writer(out.clone());
924 let items = vec![make_fixture().remove(0)]; let view = ProjectListView {
926 items,
927 total: 3,
928 filter: Some("any".to_string()),
929 };
930 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
931 renderer.projects(&view, &meta).unwrap();
932
933 let s = out.string();
934 assert!(s.contains("(1 of 3 matching \"any\")"));
935 }
936
937 #[test]
938 fn projects_prose_footer_on_stdout() {
939 let out = SharedBuf::new();
941 let mut renderer = ProseRenderer::with_writer(out.clone());
942 let view = ProjectListView {
943 items: vec![],
944 total: 0,
945 filter: None,
946 };
947 let meta = make_meta("authenticated as alice", "https://api.dasch.swiss");
948 renderer.projects(&view, &meta).unwrap();
949
950 let s = out.string();
951 assert!(s.contains("[authenticated as alice on https://api.dasch.swiss]"));
952 }
953
954 fn make_beol_detail() -> ProjectDetail {
955 ProjectDetail {
956 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
957 shortcode: "0801".into(),
958 shortname: "beol".into(),
959 longname: Some("Bernoulli-Euler Online".into()),
960 status: ProjectStatus::Active,
961 description: vec![ProjectDescription {
962 value: "<b>BEOL</b> — early modern mathematics.".into(),
963 language: Some("en".into()),
964 }],
965 keywords: vec!["Bernoulli".into(), "Euler".into(), "Mathematics".into()],
966 data_models: vec![
967 DataModelSummary {
968 name: "beol".into(),
969 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
970 },
971 DataModelSummary {
972 name: "biblio".into(),
973 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
974 },
975 DataModelSummary {
976 name: "leibniz".into(),
977 iri: "http://api.dasch.swiss/ontology/0801/leibniz/v2".into(),
978 },
979 DataModelSummary {
980 name: "newton".into(),
981 iri: "http://api.dasch.swiss/ontology/0801/newton/v2".into(),
982 },
983 ],
984 }
985 }
986
987 #[test]
988 fn project_describe_prose_full() {
989 let out = SharedBuf::new();
990 let mut renderer = ProseRenderer::with_writer(out.clone());
991 let meta = make_meta("anonymous", "api.dasch.swiss");
992 renderer
993 .project_describe(&make_beol_detail(), &meta)
994 .unwrap();
995
996 let s = out.string();
997 assert!(s.contains("Project: beol (0801)"));
999 assert!(s.contains("Name: Bernoulli-Euler Online"));
1001 assert!(s.contains("IRI: http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"));
1003 assert!(s.contains("Status: active"));
1004 assert!(s.contains("Keywords: Bernoulli, Euler, Mathematics"));
1005 assert!(s.contains("Data-models (4): beol, biblio, leibniz, newton"));
1007 assert!(s.contains("[en] BEOL"));
1009 assert!(
1010 !s.contains("<b>"),
1011 "description must not contain raw <b> tags"
1012 );
1013 assert!(
1014 !s.contains("</b>"),
1015 "description must not contain raw </b> tags"
1016 );
1017 assert!(s.contains("[anonymous on api.dasch.swiss]"));
1019 }
1020
1021 #[test]
1022 fn project_describe_prose_no_longname() {
1023 let out = SharedBuf::new();
1024 let mut renderer = ProseRenderer::with_writer(out.clone());
1025 let mut detail = make_beol_detail();
1026 detail.longname = None;
1027 let meta = make_meta("anonymous", "api.dasch.swiss");
1028 renderer.project_describe(&detail, &meta).unwrap();
1029
1030 let s = out.string();
1031 assert!(s.contains("Project: beol (0801)"));
1033 assert!(
1034 !s.contains("Name:"),
1035 "Name: line must be absent when longname is None"
1036 );
1037 }
1038
1039 #[test]
1040 fn project_describe_prose_empty_keywords_and_description() {
1041 let out = SharedBuf::new();
1042 let mut renderer = ProseRenderer::with_writer(out.clone());
1043 let detail = ProjectDetail {
1044 iri: "http://rdfh.ch/projects/0000".into(),
1045 shortcode: "0000".into(),
1046 shortname: "minimal".into(),
1047 longname: None,
1048 status: ProjectStatus::Inactive,
1049 description: vec![],
1050 keywords: vec![],
1051 data_models: vec![],
1052 };
1053 let meta = make_meta("anonymous", "api.dasch.swiss");
1054 renderer.project_describe(&detail, &meta).unwrap();
1055
1056 let s = out.string();
1057 assert!(!s.contains("Keywords"));
1059 assert!(!s.contains("Description"));
1061 assert!(s.contains("Data-models (0)"));
1063 assert!(!s.contains("Data-models (0):"));
1064 }
1065
1066 #[test]
1067 fn project_describe_prose_description_no_language() {
1068 let out = SharedBuf::new();
1069 let mut renderer = ProseRenderer::with_writer(out.clone());
1070 let detail = ProjectDetail {
1071 iri: "http://rdfh.ch/projects/0001".into(),
1072 shortcode: "0001".into(),
1073 shortname: "test".into(),
1074 longname: None,
1075 status: ProjectStatus::Active,
1076 description: vec![ProjectDescription {
1077 value: "Plain description without language tag.".into(),
1078 language: None,
1079 }],
1080 keywords: vec![],
1081 data_models: vec![],
1082 };
1083 let meta = make_meta("anonymous", "api.dasch.swiss");
1084 renderer.project_describe(&detail, &meta).unwrap();
1085
1086 let s = out.string();
1087 assert!(s.contains(" Plain description without language tag."));
1089 let desc_line = s.lines().find(|l| l.contains("Plain description")).unwrap();
1091 assert!(!desc_line.contains('['));
1092 }
1093
1094 #[test]
1098 fn project_describe_prose_two_descriptions_language_and_none() {
1099 let out = SharedBuf::new();
1100 let mut renderer = ProseRenderer::with_writer(out.clone());
1101 let detail = ProjectDetail {
1102 iri: "http://rdfh.ch/projects/0002".into(),
1103 shortcode: "0002".into(),
1104 shortname: "multilang".into(),
1105 longname: None,
1106 status: ProjectStatus::Active,
1107 description: vec![
1108 ProjectDescription {
1109 value: "English description of this project.".into(),
1110 language: Some("en".into()),
1111 },
1112 ProjectDescription {
1113 value: "Description without a language tag.".into(),
1114 language: None,
1115 },
1116 ],
1117 keywords: vec![],
1118 data_models: vec![],
1119 };
1120 let meta = make_meta("anonymous", "api.dasch.swiss");
1121 renderer.project_describe(&detail, &meta).unwrap();
1122
1123 let s = out.string();
1124
1125 assert!(
1127 s.contains("English description of this project."),
1128 "first description value must appear"
1129 );
1130 assert!(
1131 s.contains("Description without a language tag."),
1132 "second description value must appear"
1133 );
1134
1135 let en_line = s
1137 .lines()
1138 .find(|l| l.contains("English description"))
1139 .expect("expected a line containing 'English description'");
1140 assert!(
1141 en_line.contains("[en] "),
1142 "languaged entry must carry '[en] ' prefix; got: {en_line:?}"
1143 );
1144
1145 let plain_line = s
1147 .lines()
1148 .find(|l| l.contains("Description without a language tag."))
1149 .expect("expected a line containing 'Description without a language tag.'");
1150 assert!(
1151 !plain_line.contains('['),
1152 "no-language entry must not have a '[...]' prefix; got: {plain_line:?}"
1153 );
1154 }
1155
1156 #[test]
1159 fn project_describe_prose_blank_lines_are_not_indented() {
1160 let out = SharedBuf::new();
1161 let mut renderer = ProseRenderer::with_writer(out.clone());
1162 let detail = ProjectDetail {
1163 iri: "http://rdfh.ch/projects/0003".into(),
1164 shortcode: "0003".into(),
1165 shortname: "multipara".into(),
1166 longname: None,
1167 status: ProjectStatus::Active,
1168 description: vec![ProjectDescription {
1169 value: "First paragraph.\n\nSecond paragraph.".into(),
1170 language: Some("en".into()),
1171 }],
1172 keywords: vec![],
1173 data_models: vec![],
1174 };
1175 let meta = make_meta("anonymous", "api.dasch.swiss");
1176 renderer.project_describe(&detail, &meta).unwrap();
1177
1178 let s = out.string();
1179 assert!(s.contains("[en] First paragraph."));
1181 assert!(s.contains("Second paragraph."));
1182 let ws_only = s.lines().find(|l| !l.is_empty() && l.trim().is_empty());
1185 assert!(
1186 ws_only.is_none(),
1187 "blank separator lines must be truly empty, not whitespace-only; got:\n{s}"
1188 );
1189 }
1190
1191 #[test]
1194 fn project_describe_prose_empty_after_html_omits_description_label() {
1195 let out = SharedBuf::new();
1196 let mut renderer = ProseRenderer::with_writer(out.clone());
1197 let detail = ProjectDetail {
1198 iri: "http://rdfh.ch/projects/0004".into(),
1199 shortcode: "0004".into(),
1200 shortname: "tagsonly".into(),
1201 longname: None,
1202 status: ProjectStatus::Active,
1203 description: vec![ProjectDescription {
1204 value: "<br/>".into(), language: Some("en".into()),
1206 }],
1207 keywords: vec![],
1208 data_models: vec![],
1209 };
1210 let meta = make_meta("anonymous", "api.dasch.swiss");
1211 renderer.project_describe(&detail, &meta).unwrap();
1212
1213 let s = out.string();
1214 assert!(
1215 !s.contains("Description:"),
1216 "Description: label must be omitted when no entry has visible text; got:\n{s}"
1217 );
1218 }
1219
1220 fn make_data_model_fixture() -> Vec<DataModel> {
1221 vec![
1222 DataModel {
1223 name: "beol".into(),
1224 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1225 label: Some("The BEOL data-model".into()),
1226 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1227 is_builtin: false,
1228 },
1229 DataModel {
1230 name: "biblio".into(),
1231 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1232 label: None,
1233 last_modified: Some("2024-01-10T08:00:00.000000Z".into()),
1234 is_builtin: false,
1235 },
1236 DataModel {
1237 name: "knora-api".into(),
1238 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
1239 label: None,
1240 last_modified: None,
1241 is_builtin: true,
1242 },
1243 ]
1244 }
1245
1246 #[test]
1247 fn data_models_prose_no_filter() {
1248 let out = SharedBuf::new();
1249 let mut renderer = ProseRenderer::with_writer(out.clone());
1250 let view = DataModelListView {
1251 items: make_data_model_fixture(),
1252 total: 3,
1253 filter: None,
1254 };
1255 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1256 renderer.data_models(&view, &meta).unwrap();
1257
1258 let s = out.string();
1259 assert!(
1261 s.contains("data-models on https://api.test.dasch.swiss (3), incl. built-ins:"),
1262 "header must include count and built-ins suffix; got:\n{s}"
1263 );
1264 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
1266 assert!(
1268 s.contains("beol") && s.contains("The BEOL data-model") && s.contains("2024-05-27")
1269 );
1270 assert!(
1272 !s.contains("T13:43:26"),
1273 "prose must show only the date portion"
1274 );
1275 assert!(s.contains("biblio"));
1277 assert!(s.contains("(built-in)"));
1279 assert!(!s.contains("None"));
1281 }
1282
1283 #[test]
1284 fn data_models_prose_with_filter() {
1285 let out = SharedBuf::new();
1286 let mut renderer = ProseRenderer::with_writer(out.clone());
1287 let items = vec![make_data_model_fixture().remove(0)]; let view = DataModelListView {
1289 items,
1290 total: 3,
1291 filter: Some("beol".to_string()),
1292 };
1293 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1294 renderer.data_models(&view, &meta).unwrap();
1295
1296 let s = out.string();
1297 assert!(
1298 s.contains("(1 of 3 matching \"beol\")"),
1299 "filter header must show m of total; got:\n{s}"
1300 );
1301 assert!(!s.contains("incl. built-ins"));
1303 }
1304
1305 #[test]
1309 fn data_models_prose_no_builtins_header_has_no_builtin_suffix() {
1310 let out = SharedBuf::new();
1311 let mut renderer = ProseRenderer::with_writer(out.clone());
1312 let view = DataModelListView {
1313 items: vec![
1314 DataModel {
1315 name: "beol".into(),
1316 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1317 label: Some("The BEOL data-model".into()),
1318 last_modified: None,
1319 is_builtin: false,
1320 },
1321 DataModel {
1322 name: "biblio".into(),
1323 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1324 label: None,
1325 last_modified: None,
1326 is_builtin: false,
1327 },
1328 ],
1329 total: 2,
1330 filter: None,
1331 };
1332 let meta = make_meta("anonymous", "api.dasch.swiss");
1333 renderer.data_models(&view, &meta).unwrap();
1334
1335 let s = out.string();
1336 let header_line = s
1338 .lines()
1339 .next()
1340 .expect("output must have at least one line");
1341 assert!(
1342 header_line.ends_with("(2):"),
1343 "header must end with '(2):' when there are no builtins; got: {header_line:?}"
1344 );
1345 assert!(
1346 !s.contains("incl. built-ins"),
1347 "header must not contain 'incl. built-ins' when no builtin items are present; \
1348 got:\n{s}"
1349 );
1350 }
1351
1352 #[test]
1353 fn data_models_prose_last_modified_no_t_separator() {
1354 let out = SharedBuf::new();
1357 let mut renderer = ProseRenderer::with_writer(out.clone());
1358 let view = DataModelListView {
1359 items: vec![DataModel {
1360 name: "odd".into(),
1361 iri: "http://example.org/odd".into(),
1362 label: None,
1363 last_modified: Some("2024-05-27 no-separator".into()),
1364 is_builtin: false,
1365 }],
1366 total: 1,
1367 filter: None,
1368 };
1369 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1370 renderer.data_models(&view, &meta).unwrap();
1371
1372 let s = out.string();
1373 assert!(
1375 s.contains("2024-05-27 no-separator"),
1376 "expected full string when no 'T' separator; got:\n{s}"
1377 );
1378 }
1379
1380 fn make_beol_dm_detail() -> DataModelDetail {
1383 DataModelDetail {
1384 name: "beol".into(),
1385 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1386 label: Some("The BEOL data-model".into()),
1387 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1388 resource_types: vec![
1389 ResourceTypeSummary {
1390 name: "Archive".into(),
1391 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1392 label: Some("Archive".into()),
1393 },
1394 ResourceTypeSummary {
1395 name: "basicLetter".into(),
1396 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1397 label: None,
1398 },
1399 ResourceTypeSummary {
1400 name: "letter".into(),
1401 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1402 label: Some("Letter".into()),
1403 },
1404 ],
1405 }
1406 }
1407
1408 #[test]
1409 fn data_model_describe_prose_full() {
1410 let out = SharedBuf::new();
1411 let mut renderer = ProseRenderer::with_writer(out.clone());
1412 let meta = make_meta("anonymous", "api.dasch.swiss");
1413 renderer
1414 .data_model_describe(&make_beol_dm_detail(), &meta)
1415 .unwrap();
1416
1417 let s = out.string();
1418 assert!(s.contains("Data-model: beol"), "header missing; got:\n{s}");
1420 assert!(
1422 s.contains("Label: The BEOL data-model"),
1423 "label line missing; got:\n{s}"
1424 );
1425 assert!(
1427 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2"),
1428 "IRI line missing; got:\n{s}"
1429 );
1430 assert!(
1432 s.contains("Last-modified: 2024-05-27"),
1433 "last-modified line missing; got:\n{s}"
1434 );
1435 assert!(
1436 !s.contains("T13:43"),
1437 "prose must show only date portion, not full timestamp; got:\n{s}"
1438 );
1439 assert!(
1441 s.contains("Resource-types (3):"),
1442 "resource-types header missing; got:\n{s}"
1443 );
1444 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1446 assert!(
1447 s.contains("basicLetter"),
1448 "basicLetter row missing; got:\n{s}"
1449 );
1450 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1451 assert!(
1453 s.contains("[anonymous on api.dasch.swiss]"),
1454 "footer missing; got:\n{s}"
1455 );
1456 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1458 }
1459
1460 #[test]
1461 fn data_model_describe_prose_no_label_no_last_modified() {
1462 let out = SharedBuf::new();
1463 let mut renderer = ProseRenderer::with_writer(out.clone());
1464 let detail = DataModelDetail {
1465 name: "minimal".into(),
1466 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1467 label: None,
1468 last_modified: None,
1469 resource_types: vec![ResourceTypeSummary {
1470 name: "Thing".into(),
1471 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Thing".into(),
1472 label: None,
1473 }],
1474 };
1475 let meta = make_meta("anonymous", "api.dasch.swiss");
1476 renderer.data_model_describe(&detail, &meta).unwrap();
1477
1478 let s = out.string();
1479 assert!(
1481 !s.contains("Label:"),
1482 "Label: line must be absent when label is None; got:\n{s}"
1483 );
1484 assert!(
1486 !s.contains("Last-modified:"),
1487 "Last-modified: line must be absent when last_modified is None; got:\n{s}"
1488 );
1489 assert!(
1491 s.contains("IRI:"),
1492 "IRI line must always be present; got:\n{s}"
1493 );
1494 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1496 }
1497
1498 #[test]
1499 fn data_model_describe_prose_zero_resource_types() {
1500 let out = SharedBuf::new();
1501 let mut renderer = ProseRenderer::with_writer(out.clone());
1502 let detail = DataModelDetail {
1503 name: "empty".into(),
1504 iri: "http://api.dasch.swiss/ontology/9999/empty/v2".into(),
1505 label: None,
1506 last_modified: None,
1507 resource_types: vec![],
1508 };
1509 let meta = make_meta("anonymous", "api.dasch.swiss");
1510 renderer.data_model_describe(&detail, &meta).unwrap();
1511
1512 let s = out.string();
1513 assert!(
1515 s.contains(" Resource-types (0)"),
1516 "zero resource-types branch missing; got:\n{s}"
1517 );
1518 let rt_line = s
1520 .lines()
1521 .find(|l| l.contains("Resource-types (0)"))
1522 .expect("must have resource-types line");
1523 assert!(
1524 !rt_line.contains("Resource-types (0):"),
1525 "zero branch must NOT have a colon; got: {rt_line:?}"
1526 );
1527 }
1528
1529 #[test]
1530 fn data_model_describe_prose_last_modified_no_t_separator() {
1531 let out = SharedBuf::new();
1532 let mut renderer = ProseRenderer::with_writer(out.clone());
1533 let detail = DataModelDetail {
1534 name: "odd".into(),
1535 iri: "http://api.dasch.swiss/ontology/0000/odd/v2".into(),
1536 label: None,
1537 last_modified: Some("2024-05-27 no-separator".into()),
1538 resource_types: vec![],
1539 };
1540 let meta = make_meta("anonymous", "api.dasch.swiss");
1541 renderer.data_model_describe(&detail, &meta).unwrap();
1542
1543 let s = out.string();
1544 assert!(
1546 s.contains("2024-05-27 no-separator"),
1547 "expected full string when no 'T' separator; got:\n{s}"
1548 );
1549 }
1550
1551 fn make_resource_type_fixture() -> Vec<ResourceType> {
1554 vec![
1555 ResourceType {
1556 name: "Archive".into(),
1557 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1558 label: Some("Archive".into()),
1559 is_builtin: false,
1560 },
1561 ResourceType {
1562 name: "letter".into(),
1563 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1564 label: Some("Letter".into()),
1565 is_builtin: false,
1566 },
1567 ]
1568 }
1569
1570 #[test]
1571 fn resource_types_prose_project_only() {
1572 let out = SharedBuf::new();
1573 let mut renderer = ProseRenderer::with_writer(out.clone());
1574 let view = ResourceTypeListView {
1575 items: make_resource_type_fixture(),
1576 total: 2,
1577 filter: None,
1578 data_model: "beol".into(),
1579 };
1580 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1581 renderer.resource_types(&view, &meta).unwrap();
1582
1583 let s = out.string();
1584 assert!(
1586 s.contains("resource-types in beol on https://api.test.dasch.swiss (2):"),
1587 "header missing or incorrect; got:\n{s}"
1588 );
1589 assert!(
1590 !s.contains("incl. built-ins"),
1591 "must not have builtin suffix when no builtins; got:\n{s}"
1592 );
1593 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1595 assert!(s.contains("letter"), "letter row missing; got:\n{s}");
1596 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1597 assert!(
1599 !s.contains("(built-in)"),
1600 "must not have (built-in) marker on project-only items; got:\n{s}"
1601 );
1602 assert!(
1604 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1605 "footer missing; got:\n{s}"
1606 );
1607 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1609 }
1610
1611 #[test]
1612 fn resource_types_prose_with_builtins() {
1613 let out = SharedBuf::new();
1614 let mut renderer = ProseRenderer::with_writer(out.clone());
1615 let mut items = make_resource_type_fixture();
1616 items.push(ResourceType {
1617 name: "Region".into(),
1618 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1619 label: Some("Region".into()),
1620 is_builtin: true,
1621 });
1622 let view = ResourceTypeListView {
1623 items,
1624 total: 3,
1625 filter: None,
1626 data_model: "beol".into(),
1627 };
1628 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1629 renderer.resource_types(&view, &meta).unwrap();
1630
1631 let s = out.string();
1632 assert!(
1634 s.contains(
1635 "resource-types in beol on https://api.test.dasch.swiss (3), incl. built-ins:"
1636 ),
1637 "header with builtin suffix missing; got:\n{s}"
1638 );
1639 assert!(
1641 s.contains("(built-in)"),
1642 "(built-in) marker missing on built-in row; got:\n{s}"
1643 );
1644 let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1646 assert!(
1647 !archive_line.contains("(built-in)"),
1648 "project row must not have (built-in) marker; got: {archive_line:?}"
1649 );
1650 }
1651
1652 #[test]
1653 fn resource_types_prose_with_filter() {
1654 let out = SharedBuf::new();
1655 let mut renderer = ProseRenderer::with_writer(out.clone());
1656 let items = vec![make_resource_type_fixture().remove(0)]; let view = ResourceTypeListView {
1658 items,
1659 total: 3,
1660 filter: Some("arch".to_string()),
1661 data_model: "beol".into(),
1662 };
1663 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1664 renderer.resource_types(&view, &meta).unwrap();
1665
1666 let s = out.string();
1667 assert!(
1668 s.contains("(1 of 3 matching \"arch\")"),
1669 "filter header must show m of total; got:\n{s}"
1670 );
1671 }
1672
1673 #[test]
1674 fn resource_types_prose_empty() {
1675 let out = SharedBuf::new();
1676 let mut renderer = ProseRenderer::with_writer(out.clone());
1677 let view = ResourceTypeListView {
1678 items: vec![],
1679 total: 0,
1680 filter: None,
1681 data_model: "beol".into(),
1682 };
1683 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1684 renderer.resource_types(&view, &meta).unwrap();
1685
1686 let s = out.string();
1687 assert!(
1689 s.contains("resource-types in beol on https://api.test.dasch.swiss (0):"),
1690 "header must show data_model name even when empty; got:\n{s}"
1691 );
1692 assert!(
1694 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1695 "footer missing; got:\n{s}"
1696 );
1697 }
1698
1699 fn make_manuscript_detail() -> ResourceTypeDetail {
1705 ResourceTypeDetail {
1706 name: "manuscript".into(),
1707 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1708 label: Some("Manuscript".into()),
1709 data_model: "beol".into(),
1710 representation: Some(Representation::StillImage),
1711 super_types: vec!["writtenSource".into()],
1712 fields: vec![
1713 Field {
1714 name: "title".into(),
1715 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#title".into(),
1716 label: Some("Title".into()),
1717 value_type: ValueType::Text,
1718 link_target: None,
1719 cardinality: Cardinality::OneOrMore,
1720 is_builtin: false,
1721 data_model: Some("beol".into()),
1722 },
1723 Field {
1724 name: "hasAuthor".into(),
1725 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
1726 label: Some("Author".into()),
1727 value_type: ValueType::Link,
1728 link_target: Some("person".into()),
1729 cardinality: Cardinality::ZeroOrMore,
1730 is_builtin: false,
1731 data_model: Some("beol".into()),
1732 },
1733 Field {
1734 name: "isPartOfCollection".into(),
1735 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2#isPartOfCollection".into(),
1736 label: Some("is part of".into()),
1737 value_type: ValueType::Link,
1738 link_target: Some("Collection".into()),
1739 cardinality: Cardinality::ZeroOrMore,
1740 is_builtin: false,
1741 data_model: Some("biblio".into()),
1742 },
1743 ],
1744 }
1745 }
1746
1747 #[test]
1748 fn resource_type_describe_prose_full() {
1749 let out = SharedBuf::new();
1750 let mut renderer = ProseRenderer::with_writer(out.clone());
1751 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1752 renderer
1753 .resource_type_describe(&make_manuscript_detail(), &meta)
1754 .unwrap();
1755
1756 let s = out.string();
1757 assert!(
1759 s.contains("Resource-type: manuscript"),
1760 "header missing; got:\n{s}"
1761 );
1762 assert!(
1764 s.contains("Label: Manuscript"),
1765 "label line missing; got:\n{s}"
1766 );
1767 assert!(
1769 s.contains("Extends: writtenSource"),
1770 "Extends line missing; got:\n{s}"
1771 );
1772 assert!(
1774 s.contains("Representation: still-image"),
1775 "Representation line missing; got:\n{s}"
1776 );
1777 assert!(
1779 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2#manuscript"),
1780 "IRI line missing; got:\n{s}"
1781 );
1782 assert!(
1784 s.contains("Data-model: beol"),
1785 "Data-model line missing; got:\n{s}"
1786 );
1787 assert!(
1789 s.contains("Fields (3):"),
1790 "Fields header missing; got:\n{s}"
1791 );
1792 let title_line = s.lines().find(|l| l.contains("title")).unwrap();
1794 assert!(
1795 title_line.contains("text") && title_line.contains("1-n"),
1796 "title row incomplete; got: {title_line:?}"
1797 );
1798 assert!(
1799 !title_line.contains("[from"),
1800 "title row must not have [from ...] tag; got: {title_line:?}"
1801 );
1802 let author_line = s.lines().find(|l| l.contains("hasAuthor")).unwrap();
1804 assert!(
1805 author_line.contains("\u{2192} person"),
1806 "link field must show '→ person'; got: {author_line:?}"
1807 );
1808 let coll_line = s
1810 .lines()
1811 .find(|l| l.contains("isPartOfCollection"))
1812 .unwrap();
1813 assert!(
1814 coll_line.contains("[from biblio]"),
1815 "cross-DM field must show [from biblio]; got: {coll_line:?}"
1816 );
1817 assert!(
1819 s.contains("[anonymous on https://api.dasch.swiss]"),
1820 "footer missing; got:\n{s}"
1821 );
1822 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1824 }
1825
1826 #[test]
1827 fn resource_type_describe_prose_no_label() {
1828 let out = SharedBuf::new();
1829 let mut renderer = ProseRenderer::with_writer(out.clone());
1830 let mut detail = make_manuscript_detail();
1831 detail.label = None;
1832 let meta = make_meta("anonymous", "api.dasch.swiss");
1833 renderer.resource_type_describe(&detail, &meta).unwrap();
1834
1835 let s = out.string();
1836 assert!(
1838 !s.contains("Label:"),
1839 "Label: line must be absent when label is None; got:\n{s}"
1840 );
1841 assert!(
1843 s.contains("Resource-type: manuscript"),
1844 "header missing; got:\n{s}"
1845 );
1846 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1848 }
1849
1850 #[test]
1851 fn resource_type_describe_prose_zero_fields() {
1852 let out = SharedBuf::new();
1853 let mut renderer = ProseRenderer::with_writer(out.clone());
1854 let detail = ResourceTypeDetail {
1855 name: "Empty".into(),
1856 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Empty".into(),
1857 label: None,
1858 data_model: "minimal".into(),
1859 representation: None,
1860 super_types: vec![],
1861 fields: vec![],
1862 };
1863 let meta = make_meta("anonymous", "api.dasch.swiss");
1864 renderer.resource_type_describe(&detail, &meta).unwrap();
1865
1866 let s = out.string();
1867 assert!(
1869 s.contains(" Fields (0)"),
1870 "zero fields branch missing; got:\n{s}"
1871 );
1872 let fields_line = s
1874 .lines()
1875 .find(|l| l.contains("Fields (0)"))
1876 .expect("must have Fields line");
1877 assert!(
1878 !fields_line.contains("Fields (0):"),
1879 "zero branch must NOT have a colon; got: {fields_line:?}"
1880 );
1881 assert!(
1883 !s.contains("Extends:"),
1884 "Extends line must be absent; got:\n{s}"
1885 );
1886 assert!(
1887 !s.contains("Representation:"),
1888 "Representation line must be absent; got:\n{s}"
1889 );
1890 }
1891
1892 #[test]
1893 fn resource_type_describe_prose_no_representation() {
1894 let out = SharedBuf::new();
1895 let mut renderer = ProseRenderer::with_writer(out.clone());
1896 let mut detail = make_manuscript_detail();
1897 detail.representation = None;
1898 let meta = make_meta("anonymous", "api.dasch.swiss");
1899 renderer.resource_type_describe(&detail, &meta).unwrap();
1900
1901 let s = out.string();
1902 assert!(
1904 !s.contains("Representation:"),
1905 "Representation: line must be absent when representation is None; got:\n{s}"
1906 );
1907 }
1908
1909 #[test]
1910 fn resource_type_describe_prose_include_builtins_marker() {
1911 let out = SharedBuf::new();
1913 let mut renderer = ProseRenderer::with_writer(out.clone());
1914 let detail = ResourceTypeDetail {
1915 name: "thing".into(),
1916 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#thing".into(),
1917 label: None,
1918 data_model: "minimal".into(),
1919 representation: None,
1920 super_types: vec![],
1921 fields: vec![
1922 Field {
1923 name: "hasText".into(),
1924 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#hasText".into(),
1925 label: Some("Text content".into()),
1926 value_type: ValueType::Text,
1927 link_target: None,
1928 cardinality: Cardinality::One,
1929 is_builtin: false,
1930 data_model: Some("minimal".into()),
1931 },
1932 Field {
1933 name: "arkUrl".into(),
1934 iri: "http://api.knora.org/ontology/knora-api/v2#arkUrl".into(),
1935 label: Some("ARK URL".into()),
1936 value_type: ValueType::Uri,
1937 link_target: None,
1938 cardinality: Cardinality::One,
1939 is_builtin: true,
1940 data_model: None,
1941 },
1942 ],
1943 };
1944 let meta = make_meta("anonymous", "api.dasch.swiss");
1945 renderer.resource_type_describe(&detail, &meta).unwrap();
1946
1947 let s = out.string();
1948 let text_line = s.lines().find(|l| l.contains("hasText")).unwrap();
1950 assert!(
1951 !text_line.contains("(built-in)"),
1952 "project field must not have (built-in) marker; got: {text_line:?}"
1953 );
1954 let ark_line = s.lines().find(|l| l.contains("arkUrl")).unwrap();
1956 assert!(
1957 ark_line.contains("(built-in)"),
1958 "built-in field must have (built-in) marker; got: {ark_line:?}"
1959 );
1960 }
1961
1962 #[test]
1963 fn resource_type_describe_prose_extends_multiple_supers() {
1964 let out = SharedBuf::new();
1966 let mut renderer = ProseRenderer::with_writer(out.clone());
1967 let detail = ResourceTypeDetail {
1968 name: "letter".into(),
1969 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1970 label: Some("Letter".into()),
1971 data_model: "beol".into(),
1972 representation: None,
1973 super_types: vec!["basicLetter".into(), "writtenSource".into()],
1974 fields: vec![],
1975 };
1976 let meta = make_meta("anonymous", "api.dasch.swiss");
1977 renderer.resource_type_describe(&detail, &meta).unwrap();
1978
1979 let s = out.string();
1980 assert!(
1981 s.contains("Extends: basicLetter, writtenSource"),
1982 "multiple supers must be joined with ', '; got:\n{s}"
1983 );
1984 }
1985
1986 use crate::model::{
1989 DatePoint, DateValue, FieldValues, FileValue, ResourceAccess, ResourceDetail,
1990 ResourceVisibility, Value, ValueContent,
1991 };
1992
1993 fn make_resource_detail_no_values() -> ResourceDetail {
1994 ResourceDetail {
1995 label: "Test Resource".into(),
1996 iri: "http://rdfh.ch/0803/abc123".into(),
1997 resource_type: "Page".into(),
1998 ark_url: Some("ark:/72163/1/0803/abc123".into()),
1999 creation_date: Some("2021-01-01T00:00:00Z".into()),
2000 last_modified: None,
2001 attached_project: Some("http://rdfh.ch/projects/0803".into()),
2002 owner: Some("http://rdfh.ch/users/alice".into()),
2003 visibility: Some(ResourceVisibility::Public),
2004 your_access: Some(ResourceAccess::View),
2005 values: None,
2006 }
2007 }
2008
2009 #[test]
2010 fn resource_describe_prose_no_values_unchanged() {
2011 let out = SharedBuf::new();
2014 let mut renderer = ProseRenderer::with_writer(out.clone());
2015 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2016 renderer
2017 .resource_describe(&make_resource_detail_no_values(), &meta)
2018 .unwrap();
2019
2020 let s = out.string();
2021 assert!(s.contains("Resource: Test Resource"), "header missing");
2022 assert!(
2023 !s.contains("Values:"),
2024 "Values: section must be absent when values is None"
2025 );
2026 }
2027
2028 #[test]
2029 fn resource_describe_prose_some_empty_values() {
2030 let out = SharedBuf::new();
2032 let mut renderer = ProseRenderer::with_writer(out.clone());
2033 let mut detail = make_resource_detail_no_values();
2034 detail.values = Some(vec![]);
2035 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2036 renderer.resource_describe(&detail, &meta).unwrap();
2037
2038 let s = out.string();
2039 assert!(
2040 s.contains("Values: (none)"),
2041 "empty values must render as 'Values: (none)'; got:\n{s}"
2042 );
2043 }
2044
2045 #[test]
2046 fn resource_describe_prose_values_section() {
2047 let out = SharedBuf::new();
2049 let mut renderer = ProseRenderer::with_writer(out.clone());
2050 let mut detail = make_resource_detail_no_values();
2051 detail.values = Some(vec![
2052 FieldValues {
2053 name: "hasTitle".into(),
2054 label: Some("Title".into()),
2055 values: vec![ValueContent::Text("Incunabula Page".into()).into()],
2056 },
2057 FieldValues {
2058 name: "seqnum".into(),
2059 label: None, values: vec![ValueContent::Integer(42).into()],
2061 },
2062 ]);
2063 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2064 renderer.resource_describe(&detail, &meta).unwrap();
2065
2066 let s = out.string();
2067 assert!(s.contains("Values:"), "Values: header missing; got:\n{s}");
2068 assert!(
2070 s.contains("Title (hasTitle)"),
2071 "labelled field header format wrong; got:\n{s}"
2072 );
2073 assert!(
2074 s.contains("Incunabula Page"),
2075 "text value missing; got:\n{s}"
2076 );
2077 let seqnum_line = s.lines().find(|l| l.contains("seqnum")).unwrap();
2079 assert!(
2080 !seqnum_line.contains('('),
2081 "unlabelled field must not have parentheses; got: {seqnum_line:?}"
2082 );
2083 assert!(s.contains("42"), "integer value missing; got:\n{s}");
2084 }
2085
2086 #[test]
2087 fn resource_describe_prose_value_with_comment() {
2088 let out = SharedBuf::new();
2091 let mut renderer = ProseRenderer::with_writer(out.clone());
2092 let mut detail = make_resource_detail_no_values();
2093 detail.values = Some(vec![FieldValues {
2094 name: "hasTranscription".into(),
2095 label: Some("Transcription".into()),
2096 values: vec![Value {
2097 content: ValueContent::Text("some transcription".into()),
2098 comment: Some("reading uncertain".into()),
2099 }],
2100 }]);
2101 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2102 renderer.resource_describe(&detail, &meta).unwrap();
2103
2104 let s = out.string();
2105 assert!(
2106 s.contains("some transcription"),
2107 "value line missing; got:\n{s}"
2108 );
2109 assert!(
2110 s.contains(" comment: reading uncertain"),
2111 "comment line missing or mis-indented; got:\n{s}"
2112 );
2113 let lines: Vec<&str> = s.lines().collect();
2115 let value_idx = lines
2116 .iter()
2117 .position(|l| l.contains("some transcription"))
2118 .unwrap();
2119 assert_eq!(
2120 lines[value_idx + 1],
2121 " comment: reading uncertain",
2122 "comment line must directly follow the value line; got:\n{s}"
2123 );
2124 }
2125
2126 #[test]
2127 fn resource_describe_prose_value_without_comment_renders_no_comment_line() {
2128 let out = SharedBuf::new();
2132 let mut renderer = ProseRenderer::with_writer(out.clone());
2133 let mut detail = make_resource_detail_no_values();
2134 detail.values = Some(vec![FieldValues {
2135 name: "hasTranscription".into(),
2136 label: Some("Transcription".into()),
2137 values: vec![ValueContent::Text("some transcription".into()).into()],
2138 }]);
2139 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2140 renderer.resource_describe(&detail, &meta).unwrap();
2141
2142 let s = out.string();
2143 assert!(
2144 s.contains("some transcription"),
2145 "value line missing; got:\n{s}"
2146 );
2147 assert!(
2148 !s.contains("comment:"),
2149 "no comment line must be emitted when value.comment is None; got:\n{s}"
2150 );
2151 }
2152
2153 #[test]
2154 fn resource_describe_prose_link_with_label() {
2155 let out = SharedBuf::new();
2156 let mut renderer = ProseRenderer::with_writer(out.clone());
2157 let mut detail = make_resource_detail_no_values();
2158 detail.values = Some(vec![FieldValues {
2159 name: "isPartOf".into(),
2160 label: Some("Is part of".into()),
2161 values: vec![
2162 ValueContent::Link {
2163 target_iri: "http://rdfh.ch/0803/book1".into(),
2164 target_label: Some("Incunabula Book".into()),
2165 }
2166 .into(),
2167 ValueContent::Link {
2168 target_iri: "http://rdfh.ch/0803/book2".into(),
2169 target_label: None, }
2171 .into(),
2172 ],
2173 }]);
2174 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2175 renderer.resource_describe(&detail, &meta).unwrap();
2176
2177 let s = out.string();
2178 assert!(
2180 s.contains("\u{2192} Incunabula Book [http://rdfh.ch/0803/book1]"),
2181 "link with label rendered incorrectly; got:\n{s}"
2182 );
2183 assert!(
2185 s.contains("\u{2192} http://rdfh.ch/0803/book2"),
2186 "degraded link (no label) rendered incorrectly; got:\n{s}"
2187 );
2188 let degraded_line = s
2190 .lines()
2191 .find(|l| l.contains("http://rdfh.ch/0803/book2"))
2192 .unwrap();
2193 assert!(
2194 !degraded_line.contains('['),
2195 "degraded link must not have brackets; got: {degraded_line:?}"
2196 );
2197 }
2198
2199 #[test]
2200 fn resource_describe_prose_file_still_image_with_dims() {
2201 let out = SharedBuf::new();
2202 let mut renderer = ProseRenderer::with_writer(out.clone());
2203 let mut detail = make_resource_detail_no_values();
2204 detail.values = Some(vec![FieldValues {
2205 name: "hasStillImageFileValue".into(),
2206 label: None,
2207 values: vec![
2208 ValueContent::File(FileValue {
2209 value_type: ValueType::StillImage,
2210 filename: "image.jp2".into(),
2211 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
2212 width: Some(1200),
2213 height: Some(800),
2214 })
2215 .into(),
2216 ],
2217 }]);
2218 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2219 renderer.resource_describe(&detail, &meta).unwrap();
2220
2221 let s = out.string();
2222 assert!(
2224 s.contains("image.jp2 (1200\u{d7}800) https://iiif.example.com"),
2225 "still-image with dims rendered incorrectly; got:\n{s}"
2226 );
2227 }
2228
2229 #[test]
2230 fn resource_describe_prose_date_rendering() {
2231 let out = SharedBuf::new();
2232 let mut renderer = ProseRenderer::with_writer(out.clone());
2233 let mut detail = make_resource_detail_no_values();
2234 let pt = DatePoint {
2236 year: Some(1489),
2237 month: None,
2238 day: None,
2239 era: Some("CE".into()),
2240 };
2241 let single = DateValue {
2242 calendar: "GREGORIAN".into(),
2243 start: pt.clone(),
2244 end: pt.clone(),
2245 };
2246 let range = DateValue {
2248 calendar: "GREGORIAN".into(),
2249 start: DatePoint {
2250 year: Some(1489),
2251 month: None,
2252 day: None,
2253 era: Some("CE".into()),
2254 },
2255 end: DatePoint {
2256 year: Some(1490),
2257 month: None,
2258 day: None,
2259 era: Some("CE".into()),
2260 },
2261 };
2262 detail.values = Some(vec![FieldValues {
2263 name: "hasDate".into(),
2264 label: Some("Date".into()),
2265 values: vec![
2266 ValueContent::Date(single).into(),
2267 ValueContent::Date(range).into(),
2268 ],
2269 }]);
2270 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2271 renderer.resource_describe(&detail, &meta).unwrap();
2272
2273 let s = out.string();
2274 assert!(
2276 s.contains("1489 CE (GREGORIAN)"),
2277 "single-point date rendered incorrectly; got:\n{s}"
2278 );
2279 assert!(
2281 s.contains("1489 CE \u{2013} 1490 CE (GREGORIAN)"),
2282 "range date rendered incorrectly; got:\n{s}"
2283 );
2284 }
2285
2286 #[test]
2289 fn resource_describe_prose_strips_control_chars_in_label() {
2290 let out = SharedBuf::new();
2293 let mut renderer = ProseRenderer::with_writer(out.clone());
2294 let mut detail = make_resource_detail_no_values();
2295 detail.label = "Bad\u{1b}[31mLabel\u{7f}".into();
2296 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2297 renderer.resource_describe(&detail, &meta).unwrap();
2298
2299 let s = out.string();
2300 assert!(
2301 s.contains("Resource: Bad[31mLabel"),
2302 "label control chars not stripped; got:\n{s}"
2303 );
2304 assert!(
2305 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2306 "control characters leaked into prose output; got:\n{s:?}"
2307 );
2308 }
2309
2310 #[test]
2311 fn resources_prose_strips_control_chars_in_label() {
2312 use crate::model::ResourceSummary;
2313
2314 let out = SharedBuf::new();
2315 let mut renderer = ProseRenderer::with_writer(out.clone());
2316 let view = ResourceListView {
2317 items: vec![ResourceSummary {
2318 label: "Bad\u{1b}[31mLabel\u{7f}".into(),
2319 iri: "http://rdfh.ch/0803/abc123".into(),
2320 ark_url: None,
2321 creation_date: None,
2322 last_modified: None,
2323 resource_type: "Page".into(),
2324 }],
2325 total: 1,
2326 filter: None,
2327 resource_type: "Page".into(),
2328 pagination: ResourceListPagination::SinglePage {
2329 page: 0,
2330 may_have_more: false,
2331 },
2332 };
2333 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2334 renderer.resources(&view, &meta).unwrap();
2335
2336 let s = out.string();
2337 assert!(
2338 s.contains("Bad[31mLabel"),
2339 "label control chars not stripped; got:\n{s}"
2340 );
2341 assert!(
2342 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2343 "control characters leaked into prose output; got:\n{s:?}"
2344 );
2345 }
2346}