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 has_counts = view.items.iter().any(|rt| rt.count.is_some());
498 let builtin_suffix = if has_builtins {
499 ", incl. built-ins"
500 } else {
501 ""
502 };
503
504 match &view.filter {
506 None => writeln!(
507 self.out,
508 "resource-types in {} on {} ({n}){builtin_suffix}:",
509 view.data_model, meta.server_label,
510 )?,
511 Some(f) => writeln!(
512 self.out,
513 "resource-types in {} on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
514 view.data_model, meta.server_label, view.total,
515 )?,
516 }
517
518 writeln!(self.out)?;
519
520 let name_w = view.items.iter().map(|rt| rt.name.len()).max().unwrap_or(0);
522 let label_w = view
523 .items
524 .iter()
525 .map(|rt| rt.label.as_deref().map(|l| l.len()).unwrap_or(0))
526 .max()
527 .unwrap_or(0);
528
529 if has_counts {
530 let count_w = view
534 .items
535 .iter()
536 .map(|rt| rt.count.map(|c| c.to_string()).unwrap_or_default().len())
537 .max()
538 .unwrap_or(0);
539 for item in &view.items {
540 let label = item.label.as_deref().unwrap_or("");
541 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
542 if item.is_builtin {
543 writeln!(
544 self.out,
545 " {:<name_w$} {:<label_w$} {:>count_w$} (built-in)",
546 item.name, label, count_str,
547 )?;
548 } else {
549 writeln!(
550 self.out,
551 " {:<name_w$} {:<label_w$} {:>count_w$}",
552 item.name, label, count_str,
553 )?;
554 }
555 }
556 } else {
557 for item in &view.items {
558 let label = item.label.as_deref().unwrap_or("");
559 if item.is_builtin {
561 writeln!(
562 self.out,
563 " {:<name_w$} {:<label_w$} (built-in)",
564 item.name, label,
565 )?;
566 } else {
567 writeln!(self.out, " {:<name_w$} {label}", item.name)?;
568 }
569 }
570 }
571
572 render_prose_footer(&mut *self.out, meta)?;
573 Ok(())
574 }
575
576 fn resource_type_describe(
577 &mut self,
578 detail: &crate::model::ResourceTypeDetail,
579 meta: &MetaContext,
580 ) -> Result<(), Diagnostic> {
581 use crate::model::ValueType;
582
583 writeln!(self.out, "Resource-type: {}", detail.name)?;
585
586 if let Some(ref lbl) = detail.label {
590 writeln!(self.out, " Label: {lbl}")?;
591 }
592 if !detail.super_types.is_empty() {
593 writeln!(
594 self.out,
595 " Extends: {}",
596 detail.super_types.join(", ")
597 )?;
598 }
599 if let Some(ref repr) = detail.representation {
600 writeln!(self.out, " Representation: {repr}")?;
601 }
602 writeln!(self.out, " IRI: {}", detail.iri)?;
603 writeln!(self.out, " Data-model: {}", detail.data_model)?;
604 if let Some(count) = detail.count {
605 writeln!(self.out, " Instances: {count}")?;
606 }
607
608 let n = detail.fields.len();
610 if n == 0 {
611 writeln!(self.out, "\n Fields (0)")?;
612 } else {
613 writeln!(self.out, "\n Fields ({n}):")?;
614
615 let vtype_strs: Vec<String> = detail
617 .fields
618 .iter()
619 .map(|f| match &f.value_type {
620 ValueType::Link => {
621 if let Some(ref tgt) = f.link_target {
622 format!("link \u{2192} {tgt}")
623 } else {
624 "link".to_string()
625 }
626 }
627 vt => vt.to_string(),
628 })
629 .collect();
630
631 let name_w = detail
633 .fields
634 .iter()
635 .map(|f| f.name.len())
636 .max()
637 .unwrap_or(0);
638 let vtype_w = vtype_strs.iter().map(|s| s.len()).max().unwrap_or(0);
639 let card_w = detail
640 .fields
641 .iter()
642 .map(|f| f.cardinality.to_string().len())
643 .max()
644 .unwrap_or(0);
645
646 for (field, vtype_str) in detail.fields.iter().zip(vtype_strs.iter()) {
647 let label = field.label.as_deref().unwrap_or("");
648 let card_str = field.cardinality.to_string();
649
650 let source_tag = if let Some(ref dm) = field.data_model {
653 if dm != &detail.data_model {
654 format!(" [from {dm}]")
655 } else {
656 String::new()
657 }
658 } else {
659 String::new()
660 };
661
662 let builtin_marker = if field.is_builtin { " (built-in)" } else { "" };
664
665 writeln!(
666 self.out,
667 " {:<name_w$} {:<vtype_w$} {:<card_w$} {label}{builtin_marker}{source_tag}",
668 field.name, vtype_str, card_str,
669 )?;
670 }
671 }
672
673 render_prose_footer(&mut *self.out, meta)?;
674 Ok(())
675 }
676
677 fn data_model_structure(
678 &mut self,
679 structure: &DataModelStructure,
680 meta: &MetaContext,
681 ) -> Result<(), Diagnostic> {
682 use crate::model::RelationKind;
683
684 let n = structure.relations.len();
686 let plural = if n == 1 { "relation" } else { "relations" };
687 writeln!(
688 self.out,
689 "Structure: {} ({n} {plural})",
690 structure.data_model
691 )?;
692
693 if n > 0 {
694 writeln!(self.out)?;
695
696 let source_w = structure
699 .relations
700 .iter()
701 .map(|r| r.source.len())
702 .max()
703 .unwrap_or(0);
704
705 let target_w = structure
707 .relations
708 .iter()
709 .map(|r| {
710 let tag_len = match &r.target_data_model {
711 Some(dm) if dm != &structure.data_model => {
712 " [to ".len() + dm.len() + "]".len()
713 }
714 _ => 0,
715 };
716 r.target.len() + tag_len
717 })
718 .max()
719 .unwrap_or(0);
720
721 let field_w = structure
723 .relations
724 .iter()
725 .map(|r| r.field.as_deref().unwrap_or("").len())
726 .max()
727 .unwrap_or(0);
728
729 for rel in &structure.relations {
730 let target_col = match &rel.target_data_model {
732 Some(dm) if dm != &structure.data_model => {
733 format!("{} [to {}]", rel.target, dm)
734 }
735 _ => rel.target.clone(),
736 };
737
738 let field_col = rel.field.as_deref().unwrap_or("");
740
741 let kind_marker = match rel.kind {
743 RelationKind::Link => "[link]",
744 RelationKind::Inherits => "[inherits]",
745 };
746
747 writeln!(
748 self.out,
749 " {:<source_w$} \u{2192} {:<target_w$} {:<field_w$} {kind_marker}",
750 rel.source, target_col, field_col,
751 )?;
752 }
753 }
754
755 render_prose_footer(&mut *self.out, meta)?;
756 Ok(())
757 }
758
759 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
760 let n = view.items.len();
761
762 match &view.filter {
764 None => writeln!(
765 self.out,
766 "resources of type {} on {} ({n}):",
767 view.resource_type, meta.server_label,
768 )?,
769 Some(f) => writeln!(
770 self.out,
771 "resources of type {} on {} ({n} of {} matching \"{f}\"):",
772 view.resource_type, meta.server_label, view.total,
773 )?,
774 }
775
776 if n > 0 {
777 writeln!(self.out)?;
778
779 let labels: Vec<String> = view
783 .items
784 .iter()
785 .map(|r| strip_control_chars(&r.label))
786 .collect();
787 let label_w = labels.iter().map(|l| l.len()).max().unwrap_or(0);
788
789 for (item, label) in view.items.iter().zip(&labels) {
790 writeln!(self.out, " {:<label_w$} {}", label, item.iri,)?;
791 }
792 }
793
794 if let ResourceListPagination::SinglePage {
796 may_have_more: true,
797 ..
798 } = view.pagination
799 {
800 writeln!(self.out)?;
801 writeln!(
802 self.out,
803 " more results available (use --all to fetch all pages)"
804 )?;
805 }
806
807 render_prose_footer(&mut *self.out, meta)?;
808 Ok(())
809 }
810
811 fn resource_describe(
812 &mut self,
813 detail: &crate::model::ResourceDetail,
814 meta: &MetaContext,
815 ) -> Result<(), Diagnostic> {
816 writeln!(self.out, "Resource: {}", strip_control_chars(&detail.label))?;
818
819 writeln!(self.out, " Type: {}", detail.resource_type)?;
824 writeln!(self.out, " IRI: {}", detail.iri)?;
825 if let Some(ref ark) = detail.ark_url {
826 writeln!(self.out, " ARK: {ark}")?;
827 }
828 if let Some(ref created) = detail.creation_date {
829 writeln!(self.out, " Created: {created}")?;
830 }
831 if let Some(ref modified) = detail.last_modified {
832 writeln!(self.out, " Modified: {modified}")?;
833 }
834 if let Some(ref project) = detail.attached_project {
835 writeln!(self.out, " Project: {project}")?;
836 }
837 if let Some(ref owner) = detail.owner {
838 writeln!(self.out, " Owner: {owner}")?;
839 }
840 if let Some(ref vis) = detail.visibility {
841 writeln!(self.out, " Visibility: {}", vis.as_str())?;
842 }
843 if let Some(ref access) = detail.your_access {
844 writeln!(self.out, " Your access: {}", access.as_str())?;
845 }
846
847 if let Some(ref fields) = detail.values {
849 writeln!(self.out)?;
850 if fields.is_empty() {
851 writeln!(self.out, "Values: (none)")?;
852 } else {
853 writeln!(self.out, "Values:")?;
854 for fv in fields {
855 let header = match &fv.label {
857 Some(lbl) => {
858 format!(
859 "{} ({})",
860 strip_control_chars(lbl),
861 strip_control_chars(&fv.name)
862 )
863 }
864 None => strip_control_chars(&fv.name),
865 };
866 writeln!(self.out, " {header}")?;
867
868 for value in &fv.values {
869 let rendered = render_value_content(&value.content);
870 writeln!(self.out, " {}", strip_control_chars(&rendered))?;
871 if let Some(comment) = &value.comment {
872 writeln!(self.out, " comment: {}", strip_control_chars(comment))?;
873 }
874 }
875 }
876 }
877 }
878
879 render_prose_footer(&mut *self.out, meta)?;
880 Ok(())
881 }
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887 use crate::model::{
888 Cardinality, DataModel, DataModelDetail, DataModelSummary, Field, Project,
889 ProjectDescription, ProjectDetail, ProjectStatus, Representation, ResourceType,
890 ResourceTypeDetail, ResourceTypeSummary, ValueType,
891 };
892 use crate::render::test_support::{SharedBuf, make_meta};
893 use crate::render::{DataModelListView, ResourceTypeListView};
894
895 fn make_fixture() -> Vec<Project> {
896 vec![
897 Project {
898 iri: "http://rdfh.ch/projects/0001".into(),
899 shortcode: "0001".into(),
900 shortname: "anything".into(),
901 longname: Some("Anything Project".into()),
902 status: ProjectStatus::Active,
903 data_models: 2,
904 },
905 Project {
906 iri: "http://rdfh.ch/projects/0002".into(),
907 shortcode: "0002".into(),
908 shortname: "images".into(),
909 longname: None,
910 status: ProjectStatus::Inactive,
911 data_models: 0,
912 },
913 Project {
914 iri: "http://rdfh.ch/projects/0803".into(),
915 shortcode: "0803".into(),
916 shortname: "daschland".into(),
917 longname: Some("=Formula Project".into()),
918 status: ProjectStatus::Active,
919 data_models: 1,
920 },
921 ]
922 }
923
924 #[test]
925 fn projects_prose_no_filter() {
926 let out = SharedBuf::new();
927 let mut renderer = ProseRenderer::with_writer(out.clone());
928 let view = ProjectListView {
929 items: make_fixture(),
930 total: 3,
931 filter: None,
932 };
933 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
934 renderer.projects(&view, &meta).unwrap();
935
936 let s = out.string();
937 assert!(s.contains("Projects on https://api.test.dasch.swiss (3):"));
938 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
940 assert!(!s.contains("rdfh.ch"));
942 assert!(s.contains("2 data-models"));
944 assert!(s.contains("1 data-model"));
946 let data_model_lines = s.lines().filter(|l| l.contains("data-model")).count();
948 assert_eq!(data_model_lines, 2);
949 assert!(!s.contains("None"));
951 }
952
953 #[test]
954 fn projects_prose_with_filter() {
955 let out = SharedBuf::new();
956 let mut renderer = ProseRenderer::with_writer(out.clone());
957 let items = vec![make_fixture().remove(0)]; let view = ProjectListView {
959 items,
960 total: 3,
961 filter: Some("any".to_string()),
962 };
963 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
964 renderer.projects(&view, &meta).unwrap();
965
966 let s = out.string();
967 assert!(s.contains("(1 of 3 matching \"any\")"));
968 }
969
970 #[test]
971 fn projects_prose_footer_on_stdout() {
972 let out = SharedBuf::new();
974 let mut renderer = ProseRenderer::with_writer(out.clone());
975 let view = ProjectListView {
976 items: vec![],
977 total: 0,
978 filter: None,
979 };
980 let meta = make_meta("authenticated as alice", "https://api.dasch.swiss");
981 renderer.projects(&view, &meta).unwrap();
982
983 let s = out.string();
984 assert!(s.contains("[authenticated as alice on https://api.dasch.swiss]"));
985 }
986
987 fn make_beol_detail() -> ProjectDetail {
988 ProjectDetail {
989 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
990 shortcode: "0801".into(),
991 shortname: "beol".into(),
992 longname: Some("Bernoulli-Euler Online".into()),
993 status: ProjectStatus::Active,
994 description: vec![ProjectDescription {
995 value: "<b>BEOL</b> — early modern mathematics.".into(),
996 language: Some("en".into()),
997 }],
998 keywords: vec!["Bernoulli".into(), "Euler".into(), "Mathematics".into()],
999 data_models: vec![
1000 DataModelSummary {
1001 name: "beol".into(),
1002 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1003 },
1004 DataModelSummary {
1005 name: "biblio".into(),
1006 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1007 },
1008 DataModelSummary {
1009 name: "leibniz".into(),
1010 iri: "http://api.dasch.swiss/ontology/0801/leibniz/v2".into(),
1011 },
1012 DataModelSummary {
1013 name: "newton".into(),
1014 iri: "http://api.dasch.swiss/ontology/0801/newton/v2".into(),
1015 },
1016 ],
1017 }
1018 }
1019
1020 #[test]
1021 fn project_describe_prose_full() {
1022 let out = SharedBuf::new();
1023 let mut renderer = ProseRenderer::with_writer(out.clone());
1024 let meta = make_meta("anonymous", "api.dasch.swiss");
1025 renderer
1026 .project_describe(&make_beol_detail(), &meta)
1027 .unwrap();
1028
1029 let s = out.string();
1030 assert!(s.contains("Project: beol (0801)"));
1032 assert!(s.contains("Name: Bernoulli-Euler Online"));
1034 assert!(s.contains("IRI: http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"));
1036 assert!(s.contains("Status: active"));
1037 assert!(s.contains("Keywords: Bernoulli, Euler, Mathematics"));
1038 assert!(s.contains("Data-models (4): beol, biblio, leibniz, newton"));
1040 assert!(s.contains("[en] BEOL"));
1042 assert!(
1043 !s.contains("<b>"),
1044 "description must not contain raw <b> tags"
1045 );
1046 assert!(
1047 !s.contains("</b>"),
1048 "description must not contain raw </b> tags"
1049 );
1050 assert!(s.contains("[anonymous on api.dasch.swiss]"));
1052 }
1053
1054 #[test]
1055 fn project_describe_prose_no_longname() {
1056 let out = SharedBuf::new();
1057 let mut renderer = ProseRenderer::with_writer(out.clone());
1058 let mut detail = make_beol_detail();
1059 detail.longname = None;
1060 let meta = make_meta("anonymous", "api.dasch.swiss");
1061 renderer.project_describe(&detail, &meta).unwrap();
1062
1063 let s = out.string();
1064 assert!(s.contains("Project: beol (0801)"));
1066 assert!(
1067 !s.contains("Name:"),
1068 "Name: line must be absent when longname is None"
1069 );
1070 }
1071
1072 #[test]
1073 fn project_describe_prose_empty_keywords_and_description() {
1074 let out = SharedBuf::new();
1075 let mut renderer = ProseRenderer::with_writer(out.clone());
1076 let detail = ProjectDetail {
1077 iri: "http://rdfh.ch/projects/0000".into(),
1078 shortcode: "0000".into(),
1079 shortname: "minimal".into(),
1080 longname: None,
1081 status: ProjectStatus::Inactive,
1082 description: vec![],
1083 keywords: vec![],
1084 data_models: vec![],
1085 };
1086 let meta = make_meta("anonymous", "api.dasch.swiss");
1087 renderer.project_describe(&detail, &meta).unwrap();
1088
1089 let s = out.string();
1090 assert!(!s.contains("Keywords"));
1092 assert!(!s.contains("Description"));
1094 assert!(s.contains("Data-models (0)"));
1096 assert!(!s.contains("Data-models (0):"));
1097 }
1098
1099 #[test]
1100 fn project_describe_prose_description_no_language() {
1101 let out = SharedBuf::new();
1102 let mut renderer = ProseRenderer::with_writer(out.clone());
1103 let detail = ProjectDetail {
1104 iri: "http://rdfh.ch/projects/0001".into(),
1105 shortcode: "0001".into(),
1106 shortname: "test".into(),
1107 longname: None,
1108 status: ProjectStatus::Active,
1109 description: vec![ProjectDescription {
1110 value: "Plain description without language tag.".into(),
1111 language: None,
1112 }],
1113 keywords: vec![],
1114 data_models: vec![],
1115 };
1116 let meta = make_meta("anonymous", "api.dasch.swiss");
1117 renderer.project_describe(&detail, &meta).unwrap();
1118
1119 let s = out.string();
1120 assert!(s.contains(" Plain description without language tag."));
1122 let desc_line = s.lines().find(|l| l.contains("Plain description")).unwrap();
1124 assert!(!desc_line.contains('['));
1125 }
1126
1127 #[test]
1131 fn project_describe_prose_two_descriptions_language_and_none() {
1132 let out = SharedBuf::new();
1133 let mut renderer = ProseRenderer::with_writer(out.clone());
1134 let detail = ProjectDetail {
1135 iri: "http://rdfh.ch/projects/0002".into(),
1136 shortcode: "0002".into(),
1137 shortname: "multilang".into(),
1138 longname: None,
1139 status: ProjectStatus::Active,
1140 description: vec![
1141 ProjectDescription {
1142 value: "English description of this project.".into(),
1143 language: Some("en".into()),
1144 },
1145 ProjectDescription {
1146 value: "Description without a language tag.".into(),
1147 language: None,
1148 },
1149 ],
1150 keywords: vec![],
1151 data_models: vec![],
1152 };
1153 let meta = make_meta("anonymous", "api.dasch.swiss");
1154 renderer.project_describe(&detail, &meta).unwrap();
1155
1156 let s = out.string();
1157
1158 assert!(
1160 s.contains("English description of this project."),
1161 "first description value must appear"
1162 );
1163 assert!(
1164 s.contains("Description without a language tag."),
1165 "second description value must appear"
1166 );
1167
1168 let en_line = s
1170 .lines()
1171 .find(|l| l.contains("English description"))
1172 .expect("expected a line containing 'English description'");
1173 assert!(
1174 en_line.contains("[en] "),
1175 "languaged entry must carry '[en] ' prefix; got: {en_line:?}"
1176 );
1177
1178 let plain_line = s
1180 .lines()
1181 .find(|l| l.contains("Description without a language tag."))
1182 .expect("expected a line containing 'Description without a language tag.'");
1183 assert!(
1184 !plain_line.contains('['),
1185 "no-language entry must not have a '[...]' prefix; got: {plain_line:?}"
1186 );
1187 }
1188
1189 #[test]
1192 fn project_describe_prose_blank_lines_are_not_indented() {
1193 let out = SharedBuf::new();
1194 let mut renderer = ProseRenderer::with_writer(out.clone());
1195 let detail = ProjectDetail {
1196 iri: "http://rdfh.ch/projects/0003".into(),
1197 shortcode: "0003".into(),
1198 shortname: "multipara".into(),
1199 longname: None,
1200 status: ProjectStatus::Active,
1201 description: vec![ProjectDescription {
1202 value: "First paragraph.\n\nSecond paragraph.".into(),
1203 language: Some("en".into()),
1204 }],
1205 keywords: vec![],
1206 data_models: vec![],
1207 };
1208 let meta = make_meta("anonymous", "api.dasch.swiss");
1209 renderer.project_describe(&detail, &meta).unwrap();
1210
1211 let s = out.string();
1212 assert!(s.contains("[en] First paragraph."));
1214 assert!(s.contains("Second paragraph."));
1215 let ws_only = s.lines().find(|l| !l.is_empty() && l.trim().is_empty());
1218 assert!(
1219 ws_only.is_none(),
1220 "blank separator lines must be truly empty, not whitespace-only; got:\n{s}"
1221 );
1222 }
1223
1224 #[test]
1227 fn project_describe_prose_empty_after_html_omits_description_label() {
1228 let out = SharedBuf::new();
1229 let mut renderer = ProseRenderer::with_writer(out.clone());
1230 let detail = ProjectDetail {
1231 iri: "http://rdfh.ch/projects/0004".into(),
1232 shortcode: "0004".into(),
1233 shortname: "tagsonly".into(),
1234 longname: None,
1235 status: ProjectStatus::Active,
1236 description: vec![ProjectDescription {
1237 value: "<br/>".into(), language: Some("en".into()),
1239 }],
1240 keywords: vec![],
1241 data_models: vec![],
1242 };
1243 let meta = make_meta("anonymous", "api.dasch.swiss");
1244 renderer.project_describe(&detail, &meta).unwrap();
1245
1246 let s = out.string();
1247 assert!(
1248 !s.contains("Description:"),
1249 "Description: label must be omitted when no entry has visible text; got:\n{s}"
1250 );
1251 }
1252
1253 fn make_data_model_fixture() -> Vec<DataModel> {
1254 vec![
1255 DataModel {
1256 name: "beol".into(),
1257 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1258 label: Some("The BEOL data-model".into()),
1259 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1260 is_builtin: false,
1261 },
1262 DataModel {
1263 name: "biblio".into(),
1264 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1265 label: None,
1266 last_modified: Some("2024-01-10T08:00:00.000000Z".into()),
1267 is_builtin: false,
1268 },
1269 DataModel {
1270 name: "knora-api".into(),
1271 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
1272 label: None,
1273 last_modified: None,
1274 is_builtin: true,
1275 },
1276 ]
1277 }
1278
1279 #[test]
1280 fn data_models_prose_no_filter() {
1281 let out = SharedBuf::new();
1282 let mut renderer = ProseRenderer::with_writer(out.clone());
1283 let view = DataModelListView {
1284 items: make_data_model_fixture(),
1285 total: 3,
1286 filter: None,
1287 };
1288 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1289 renderer.data_models(&view, &meta).unwrap();
1290
1291 let s = out.string();
1292 assert!(
1294 s.contains("data-models on https://api.test.dasch.swiss (3), incl. built-ins:"),
1295 "header must include count and built-ins suffix; got:\n{s}"
1296 );
1297 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
1299 assert!(
1301 s.contains("beol") && s.contains("The BEOL data-model") && s.contains("2024-05-27")
1302 );
1303 assert!(
1305 !s.contains("T13:43:26"),
1306 "prose must show only the date portion"
1307 );
1308 assert!(s.contains("biblio"));
1310 assert!(s.contains("(built-in)"));
1312 assert!(!s.contains("None"));
1314 }
1315
1316 #[test]
1317 fn data_models_prose_with_filter() {
1318 let out = SharedBuf::new();
1319 let mut renderer = ProseRenderer::with_writer(out.clone());
1320 let items = vec![make_data_model_fixture().remove(0)]; let view = DataModelListView {
1322 items,
1323 total: 3,
1324 filter: Some("beol".to_string()),
1325 };
1326 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1327 renderer.data_models(&view, &meta).unwrap();
1328
1329 let s = out.string();
1330 assert!(
1331 s.contains("(1 of 3 matching \"beol\")"),
1332 "filter header must show m of total; got:\n{s}"
1333 );
1334 assert!(!s.contains("incl. built-ins"));
1336 }
1337
1338 #[test]
1342 fn data_models_prose_no_builtins_header_has_no_builtin_suffix() {
1343 let out = SharedBuf::new();
1344 let mut renderer = ProseRenderer::with_writer(out.clone());
1345 let view = DataModelListView {
1346 items: vec![
1347 DataModel {
1348 name: "beol".into(),
1349 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1350 label: Some("The BEOL data-model".into()),
1351 last_modified: None,
1352 is_builtin: false,
1353 },
1354 DataModel {
1355 name: "biblio".into(),
1356 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1357 label: None,
1358 last_modified: None,
1359 is_builtin: false,
1360 },
1361 ],
1362 total: 2,
1363 filter: None,
1364 };
1365 let meta = make_meta("anonymous", "api.dasch.swiss");
1366 renderer.data_models(&view, &meta).unwrap();
1367
1368 let s = out.string();
1369 let header_line = s
1371 .lines()
1372 .next()
1373 .expect("output must have at least one line");
1374 assert!(
1375 header_line.ends_with("(2):"),
1376 "header must end with '(2):' when there are no builtins; got: {header_line:?}"
1377 );
1378 assert!(
1379 !s.contains("incl. built-ins"),
1380 "header must not contain 'incl. built-ins' when no builtin items are present; \
1381 got:\n{s}"
1382 );
1383 }
1384
1385 #[test]
1386 fn data_models_prose_last_modified_no_t_separator() {
1387 let out = SharedBuf::new();
1390 let mut renderer = ProseRenderer::with_writer(out.clone());
1391 let view = DataModelListView {
1392 items: vec![DataModel {
1393 name: "odd".into(),
1394 iri: "http://example.org/odd".into(),
1395 label: None,
1396 last_modified: Some("2024-05-27 no-separator".into()),
1397 is_builtin: false,
1398 }],
1399 total: 1,
1400 filter: None,
1401 };
1402 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1403 renderer.data_models(&view, &meta).unwrap();
1404
1405 let s = out.string();
1406 assert!(
1408 s.contains("2024-05-27 no-separator"),
1409 "expected full string when no 'T' separator; got:\n{s}"
1410 );
1411 }
1412
1413 fn make_beol_dm_detail() -> DataModelDetail {
1416 DataModelDetail {
1417 name: "beol".into(),
1418 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1419 label: Some("The BEOL data-model".into()),
1420 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1421 resource_types: vec![
1422 ResourceTypeSummary {
1423 name: "Archive".into(),
1424 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1425 label: Some("Archive".into()),
1426 },
1427 ResourceTypeSummary {
1428 name: "basicLetter".into(),
1429 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1430 label: None,
1431 },
1432 ResourceTypeSummary {
1433 name: "letter".into(),
1434 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1435 label: Some("Letter".into()),
1436 },
1437 ],
1438 }
1439 }
1440
1441 #[test]
1442 fn data_model_describe_prose_full() {
1443 let out = SharedBuf::new();
1444 let mut renderer = ProseRenderer::with_writer(out.clone());
1445 let meta = make_meta("anonymous", "api.dasch.swiss");
1446 renderer
1447 .data_model_describe(&make_beol_dm_detail(), &meta)
1448 .unwrap();
1449
1450 let s = out.string();
1451 assert!(s.contains("Data-model: beol"), "header missing; got:\n{s}");
1453 assert!(
1455 s.contains("Label: The BEOL data-model"),
1456 "label line missing; got:\n{s}"
1457 );
1458 assert!(
1460 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2"),
1461 "IRI line missing; got:\n{s}"
1462 );
1463 assert!(
1465 s.contains("Last-modified: 2024-05-27"),
1466 "last-modified line missing; got:\n{s}"
1467 );
1468 assert!(
1469 !s.contains("T13:43"),
1470 "prose must show only date portion, not full timestamp; got:\n{s}"
1471 );
1472 assert!(
1474 s.contains("Resource-types (3):"),
1475 "resource-types header missing; got:\n{s}"
1476 );
1477 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1479 assert!(
1480 s.contains("basicLetter"),
1481 "basicLetter row missing; got:\n{s}"
1482 );
1483 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1484 assert!(
1486 s.contains("[anonymous on api.dasch.swiss]"),
1487 "footer missing; got:\n{s}"
1488 );
1489 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1491 }
1492
1493 #[test]
1494 fn data_model_describe_prose_no_label_no_last_modified() {
1495 let out = SharedBuf::new();
1496 let mut renderer = ProseRenderer::with_writer(out.clone());
1497 let detail = DataModelDetail {
1498 name: "minimal".into(),
1499 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1500 label: None,
1501 last_modified: None,
1502 resource_types: vec![ResourceTypeSummary {
1503 name: "Thing".into(),
1504 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Thing".into(),
1505 label: None,
1506 }],
1507 };
1508 let meta = make_meta("anonymous", "api.dasch.swiss");
1509 renderer.data_model_describe(&detail, &meta).unwrap();
1510
1511 let s = out.string();
1512 assert!(
1514 !s.contains("Label:"),
1515 "Label: line must be absent when label is None; got:\n{s}"
1516 );
1517 assert!(
1519 !s.contains("Last-modified:"),
1520 "Last-modified: line must be absent when last_modified is None; got:\n{s}"
1521 );
1522 assert!(
1524 s.contains("IRI:"),
1525 "IRI line must always be present; got:\n{s}"
1526 );
1527 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1529 }
1530
1531 #[test]
1532 fn data_model_describe_prose_zero_resource_types() {
1533 let out = SharedBuf::new();
1534 let mut renderer = ProseRenderer::with_writer(out.clone());
1535 let detail = DataModelDetail {
1536 name: "empty".into(),
1537 iri: "http://api.dasch.swiss/ontology/9999/empty/v2".into(),
1538 label: None,
1539 last_modified: None,
1540 resource_types: vec![],
1541 };
1542 let meta = make_meta("anonymous", "api.dasch.swiss");
1543 renderer.data_model_describe(&detail, &meta).unwrap();
1544
1545 let s = out.string();
1546 assert!(
1548 s.contains(" Resource-types (0)"),
1549 "zero resource-types branch missing; got:\n{s}"
1550 );
1551 let rt_line = s
1553 .lines()
1554 .find(|l| l.contains("Resource-types (0)"))
1555 .expect("must have resource-types line");
1556 assert!(
1557 !rt_line.contains("Resource-types (0):"),
1558 "zero branch must NOT have a colon; got: {rt_line:?}"
1559 );
1560 }
1561
1562 #[test]
1563 fn data_model_describe_prose_last_modified_no_t_separator() {
1564 let out = SharedBuf::new();
1565 let mut renderer = ProseRenderer::with_writer(out.clone());
1566 let detail = DataModelDetail {
1567 name: "odd".into(),
1568 iri: "http://api.dasch.swiss/ontology/0000/odd/v2".into(),
1569 label: None,
1570 last_modified: Some("2024-05-27 no-separator".into()),
1571 resource_types: vec![],
1572 };
1573 let meta = make_meta("anonymous", "api.dasch.swiss");
1574 renderer.data_model_describe(&detail, &meta).unwrap();
1575
1576 let s = out.string();
1577 assert!(
1579 s.contains("2024-05-27 no-separator"),
1580 "expected full string when no 'T' separator; got:\n{s}"
1581 );
1582 }
1583
1584 fn make_resource_type_fixture() -> Vec<ResourceType> {
1587 vec![
1588 ResourceType {
1589 name: "Archive".into(),
1590 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1591 label: Some("Archive".into()),
1592 is_builtin: false,
1593 count: None,
1594 },
1595 ResourceType {
1596 name: "letter".into(),
1597 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1598 label: Some("Letter".into()),
1599 is_builtin: false,
1600 count: None,
1601 },
1602 ]
1603 }
1604
1605 #[test]
1606 fn resource_types_prose_project_only() {
1607 let out = SharedBuf::new();
1608 let mut renderer = ProseRenderer::with_writer(out.clone());
1609 let view = ResourceTypeListView {
1610 items: make_resource_type_fixture(),
1611 total: 2,
1612 filter: None,
1613 data_model: "beol".into(),
1614 };
1615 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1616 renderer.resource_types(&view, &meta).unwrap();
1617
1618 let s = out.string();
1619 assert!(
1621 s.contains("resource-types in beol on https://api.test.dasch.swiss (2):"),
1622 "header missing or incorrect; got:\n{s}"
1623 );
1624 assert!(
1625 !s.contains("incl. built-ins"),
1626 "must not have builtin suffix when no builtins; got:\n{s}"
1627 );
1628 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1630 assert!(s.contains("letter"), "letter row missing; got:\n{s}");
1631 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1632 assert!(
1634 !s.contains("(built-in)"),
1635 "must not have (built-in) marker on project-only items; got:\n{s}"
1636 );
1637 assert!(
1639 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1640 "footer missing; got:\n{s}"
1641 );
1642 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1644 }
1645
1646 #[test]
1647 fn resource_types_prose_with_builtins() {
1648 let out = SharedBuf::new();
1649 let mut renderer = ProseRenderer::with_writer(out.clone());
1650 let mut items = make_resource_type_fixture();
1651 items.push(ResourceType {
1652 name: "Region".into(),
1653 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1654 label: Some("Region".into()),
1655 is_builtin: true,
1656 count: None,
1657 });
1658 let view = ResourceTypeListView {
1659 items,
1660 total: 3,
1661 filter: None,
1662 data_model: "beol".into(),
1663 };
1664 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1665 renderer.resource_types(&view, &meta).unwrap();
1666
1667 let s = out.string();
1668 assert!(
1670 s.contains(
1671 "resource-types in beol on https://api.test.dasch.swiss (3), incl. built-ins:"
1672 ),
1673 "header with builtin suffix missing; got:\n{s}"
1674 );
1675 assert!(
1677 s.contains("(built-in)"),
1678 "(built-in) marker missing on built-in row; got:\n{s}"
1679 );
1680 let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1682 assert!(
1683 !archive_line.contains("(built-in)"),
1684 "project row must not have (built-in) marker; got: {archive_line:?}"
1685 );
1686 }
1687
1688 #[test]
1689 fn resource_types_prose_with_filter() {
1690 let out = SharedBuf::new();
1691 let mut renderer = ProseRenderer::with_writer(out.clone());
1692 let items = vec![make_resource_type_fixture().remove(0)]; let view = ResourceTypeListView {
1694 items,
1695 total: 3,
1696 filter: Some("arch".to_string()),
1697 data_model: "beol".into(),
1698 };
1699 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1700 renderer.resource_types(&view, &meta).unwrap();
1701
1702 let s = out.string();
1703 assert!(
1704 s.contains("(1 of 3 matching \"arch\")"),
1705 "filter header must show m of total; got:\n{s}"
1706 );
1707 }
1708
1709 #[test]
1710 fn resource_types_prose_empty() {
1711 let out = SharedBuf::new();
1712 let mut renderer = ProseRenderer::with_writer(out.clone());
1713 let view = ResourceTypeListView {
1714 items: vec![],
1715 total: 0,
1716 filter: None,
1717 data_model: "beol".into(),
1718 };
1719 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1720 renderer.resource_types(&view, &meta).unwrap();
1721
1722 let s = out.string();
1723 assert!(
1725 s.contains("resource-types in beol on https://api.test.dasch.swiss (0):"),
1726 "header must show data_model name even when empty; got:\n{s}"
1727 );
1728 assert!(
1730 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1731 "footer missing; got:\n{s}"
1732 );
1733 }
1734
1735 #[test]
1736 fn resource_types_prose_with_counts() {
1737 let out = SharedBuf::new();
1741 let mut renderer = ProseRenderer::with_writer(out.clone());
1742 let mut items = make_resource_type_fixture();
1743 items[0].count = Some(42);
1744 items[1].count = Some(7);
1745 items.push(ResourceType {
1746 name: "Region".into(),
1747 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1748 label: Some("Region".into()),
1749 is_builtin: true,
1750 count: None,
1751 });
1752 let view = ResourceTypeListView {
1753 items,
1754 total: 3,
1755 filter: None,
1756 data_model: "beol".into(),
1757 };
1758 let meta = MetaContext {
1759 server_label: "https://api.test.dasch.swiss".into(),
1760 auth_state: "anonymous".into(),
1761 filter_warning: None,
1762 count_caveat: Some("counts are not permission-filtered".into()),
1763 };
1764 renderer.resource_types(&view, &meta).unwrap();
1765
1766 let s = out.string();
1767 let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1768 assert!(
1769 archive_line.contains("42"),
1770 "Archive row must show count 42; got: {archive_line:?}"
1771 );
1772 let letter_line = s.lines().find(|l| l.contains("letter")).unwrap();
1773 assert!(
1774 letter_line.contains('7'),
1775 "letter row must show count 7; got: {letter_line:?}"
1776 );
1777 let region_line = s.lines().find(|l| l.contains("Region")).unwrap();
1779 assert!(
1780 region_line.contains("(built-in)"),
1781 "built-in marker must still be present; got: {region_line:?}"
1782 );
1783 assert!(
1785 s.contains("counts are not permission-filtered"),
1786 "footer must carry count_caveat; got:\n{s}"
1787 );
1788 }
1789
1790 fn make_manuscript_detail() -> ResourceTypeDetail {
1796 ResourceTypeDetail {
1797 name: "manuscript".into(),
1798 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1799 label: Some("Manuscript".into()),
1800 data_model: "beol".into(),
1801 representation: Some(Representation::StillImage),
1802 super_types: vec!["writtenSource".into()],
1803 fields: vec![
1804 Field {
1805 name: "title".into(),
1806 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#title".into(),
1807 label: Some("Title".into()),
1808 value_type: ValueType::Text,
1809 link_target: None,
1810 cardinality: Cardinality::OneOrMore,
1811 is_builtin: false,
1812 data_model: Some("beol".into()),
1813 },
1814 Field {
1815 name: "hasAuthor".into(),
1816 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
1817 label: Some("Author".into()),
1818 value_type: ValueType::Link,
1819 link_target: Some("person".into()),
1820 cardinality: Cardinality::ZeroOrMore,
1821 is_builtin: false,
1822 data_model: Some("beol".into()),
1823 },
1824 Field {
1825 name: "isPartOfCollection".into(),
1826 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2#isPartOfCollection".into(),
1827 label: Some("is part of".into()),
1828 value_type: ValueType::Link,
1829 link_target: Some("Collection".into()),
1830 cardinality: Cardinality::ZeroOrMore,
1831 is_builtin: false,
1832 data_model: Some("biblio".into()),
1833 },
1834 ],
1835 count: None,
1836 }
1837 }
1838
1839 #[test]
1840 fn resource_type_describe_prose_full() {
1841 let out = SharedBuf::new();
1842 let mut renderer = ProseRenderer::with_writer(out.clone());
1843 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1844 renderer
1845 .resource_type_describe(&make_manuscript_detail(), &meta)
1846 .unwrap();
1847
1848 let s = out.string();
1849 assert!(
1851 s.contains("Resource-type: manuscript"),
1852 "header missing; got:\n{s}"
1853 );
1854 assert!(
1856 s.contains("Label: Manuscript"),
1857 "label line missing; got:\n{s}"
1858 );
1859 assert!(
1861 s.contains("Extends: writtenSource"),
1862 "Extends line missing; got:\n{s}"
1863 );
1864 assert!(
1866 s.contains("Representation: still-image"),
1867 "Representation line missing; got:\n{s}"
1868 );
1869 assert!(
1871 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2#manuscript"),
1872 "IRI line missing; got:\n{s}"
1873 );
1874 assert!(
1876 s.contains("Data-model: beol"),
1877 "Data-model line missing; got:\n{s}"
1878 );
1879 assert!(
1881 s.contains("Fields (3):"),
1882 "Fields header missing; got:\n{s}"
1883 );
1884 let title_line = s.lines().find(|l| l.contains("title")).unwrap();
1886 assert!(
1887 title_line.contains("text") && title_line.contains("1-n"),
1888 "title row incomplete; got: {title_line:?}"
1889 );
1890 assert!(
1891 !title_line.contains("[from"),
1892 "title row must not have [from ...] tag; got: {title_line:?}"
1893 );
1894 let author_line = s.lines().find(|l| l.contains("hasAuthor")).unwrap();
1896 assert!(
1897 author_line.contains("\u{2192} person"),
1898 "link field must show '→ person'; got: {author_line:?}"
1899 );
1900 let coll_line = s
1902 .lines()
1903 .find(|l| l.contains("isPartOfCollection"))
1904 .unwrap();
1905 assert!(
1906 coll_line.contains("[from biblio]"),
1907 "cross-DM field must show [from biblio]; got: {coll_line:?}"
1908 );
1909 assert!(
1911 s.contains("[anonymous on https://api.dasch.swiss]"),
1912 "footer missing; got:\n{s}"
1913 );
1914 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1916 }
1917
1918 #[test]
1919 fn resource_type_describe_prose_with_count() {
1920 let out = SharedBuf::new();
1923 let mut renderer = ProseRenderer::with_writer(out.clone());
1924 let mut detail = make_manuscript_detail();
1925 detail.count = Some(123);
1926 let meta = MetaContext {
1927 server_label: "https://api.dasch.swiss".into(),
1928 auth_state: "anonymous".into(),
1929 filter_warning: None,
1930 count_caveat: Some("counts exclude deleted resources".into()),
1931 };
1932 renderer.resource_type_describe(&detail, &meta).unwrap();
1933
1934 let s = out.string();
1935 assert!(
1936 s.contains("Instances: 123"),
1937 "Instances line missing or misaligned; got:\n{s}"
1938 );
1939 let dm_idx = s.find("Data-model:").expect("Data-model line present");
1941 let inst_idx = s.find("Instances:").expect("Instances line present");
1942 assert!(
1943 inst_idx > dm_idx,
1944 "Instances line must come after Data-model line"
1945 );
1946 assert!(
1947 s.contains("counts exclude deleted resources"),
1948 "footer must carry count_caveat; got:\n{s}"
1949 );
1950 }
1951
1952 #[test]
1953 fn resource_type_describe_prose_no_count_no_instances_line() {
1954 let out = SharedBuf::new();
1957 let mut renderer = ProseRenderer::with_writer(out.clone());
1958 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1959 renderer
1960 .resource_type_describe(&make_manuscript_detail(), &meta)
1961 .unwrap();
1962
1963 let s = out.string();
1964 assert!(
1965 !s.contains("Instances:"),
1966 "Instances line must be absent when count is None; got:\n{s}"
1967 );
1968 }
1969
1970 #[test]
1971 fn resource_type_describe_prose_no_label() {
1972 let out = SharedBuf::new();
1973 let mut renderer = ProseRenderer::with_writer(out.clone());
1974 let mut detail = make_manuscript_detail();
1975 detail.label = None;
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!(
1982 !s.contains("Label:"),
1983 "Label: line must be absent when label is None; got:\n{s}"
1984 );
1985 assert!(
1987 s.contains("Resource-type: manuscript"),
1988 "header missing; got:\n{s}"
1989 );
1990 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1992 }
1993
1994 #[test]
1995 fn resource_type_describe_prose_zero_fields() {
1996 let out = SharedBuf::new();
1997 let mut renderer = ProseRenderer::with_writer(out.clone());
1998 let detail = ResourceTypeDetail {
1999 name: "Empty".into(),
2000 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Empty".into(),
2001 label: None,
2002 data_model: "minimal".into(),
2003 representation: None,
2004 super_types: vec![],
2005 fields: vec![],
2006 count: None,
2007 };
2008 let meta = make_meta("anonymous", "api.dasch.swiss");
2009 renderer.resource_type_describe(&detail, &meta).unwrap();
2010
2011 let s = out.string();
2012 assert!(
2014 s.contains(" Fields (0)"),
2015 "zero fields branch missing; got:\n{s}"
2016 );
2017 let fields_line = s
2019 .lines()
2020 .find(|l| l.contains("Fields (0)"))
2021 .expect("must have Fields line");
2022 assert!(
2023 !fields_line.contains("Fields (0):"),
2024 "zero branch must NOT have a colon; got: {fields_line:?}"
2025 );
2026 assert!(
2028 !s.contains("Extends:"),
2029 "Extends line must be absent; got:\n{s}"
2030 );
2031 assert!(
2032 !s.contains("Representation:"),
2033 "Representation line must be absent; got:\n{s}"
2034 );
2035 }
2036
2037 #[test]
2038 fn resource_type_describe_prose_no_representation() {
2039 let out = SharedBuf::new();
2040 let mut renderer = ProseRenderer::with_writer(out.clone());
2041 let mut detail = make_manuscript_detail();
2042 detail.representation = None;
2043 let meta = make_meta("anonymous", "api.dasch.swiss");
2044 renderer.resource_type_describe(&detail, &meta).unwrap();
2045
2046 let s = out.string();
2047 assert!(
2049 !s.contains("Representation:"),
2050 "Representation: line must be absent when representation is None; got:\n{s}"
2051 );
2052 }
2053
2054 #[test]
2055 fn resource_type_describe_prose_include_builtins_marker() {
2056 let out = SharedBuf::new();
2058 let mut renderer = ProseRenderer::with_writer(out.clone());
2059 let detail = ResourceTypeDetail {
2060 name: "thing".into(),
2061 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#thing".into(),
2062 label: None,
2063 data_model: "minimal".into(),
2064 representation: None,
2065 super_types: vec![],
2066 fields: vec![
2067 Field {
2068 name: "hasText".into(),
2069 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#hasText".into(),
2070 label: Some("Text content".into()),
2071 value_type: ValueType::Text,
2072 link_target: None,
2073 cardinality: Cardinality::One,
2074 is_builtin: false,
2075 data_model: Some("minimal".into()),
2076 },
2077 Field {
2078 name: "arkUrl".into(),
2079 iri: "http://api.knora.org/ontology/knora-api/v2#arkUrl".into(),
2080 label: Some("ARK URL".into()),
2081 value_type: ValueType::Uri,
2082 link_target: None,
2083 cardinality: Cardinality::One,
2084 is_builtin: true,
2085 data_model: None,
2086 },
2087 ],
2088 count: None,
2089 };
2090 let meta = make_meta("anonymous", "api.dasch.swiss");
2091 renderer.resource_type_describe(&detail, &meta).unwrap();
2092
2093 let s = out.string();
2094 let text_line = s.lines().find(|l| l.contains("hasText")).unwrap();
2096 assert!(
2097 !text_line.contains("(built-in)"),
2098 "project field must not have (built-in) marker; got: {text_line:?}"
2099 );
2100 let ark_line = s.lines().find(|l| l.contains("arkUrl")).unwrap();
2102 assert!(
2103 ark_line.contains("(built-in)"),
2104 "built-in field must have (built-in) marker; got: {ark_line:?}"
2105 );
2106 }
2107
2108 #[test]
2109 fn resource_type_describe_prose_extends_multiple_supers() {
2110 let out = SharedBuf::new();
2112 let mut renderer = ProseRenderer::with_writer(out.clone());
2113 let detail = ResourceTypeDetail {
2114 name: "letter".into(),
2115 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
2116 label: Some("Letter".into()),
2117 data_model: "beol".into(),
2118 representation: None,
2119 super_types: vec!["basicLetter".into(), "writtenSource".into()],
2120 fields: vec![],
2121 count: None,
2122 };
2123 let meta = make_meta("anonymous", "api.dasch.swiss");
2124 renderer.resource_type_describe(&detail, &meta).unwrap();
2125
2126 let s = out.string();
2127 assert!(
2128 s.contains("Extends: basicLetter, writtenSource"),
2129 "multiple supers must be joined with ', '; got:\n{s}"
2130 );
2131 }
2132
2133 use crate::model::{
2136 DatePoint, DateValue, FieldValues, FileValue, ResourceAccess, ResourceDetail,
2137 ResourceVisibility, Value, ValueContent,
2138 };
2139
2140 fn make_resource_detail_no_values() -> ResourceDetail {
2141 ResourceDetail {
2142 label: "Test Resource".into(),
2143 iri: "http://rdfh.ch/0803/abc123".into(),
2144 resource_type: "Page".into(),
2145 ark_url: Some("ark:/72163/1/0803/abc123".into()),
2146 creation_date: Some("2021-01-01T00:00:00Z".into()),
2147 last_modified: None,
2148 attached_project: Some("http://rdfh.ch/projects/0803".into()),
2149 owner: Some("http://rdfh.ch/users/alice".into()),
2150 visibility: Some(ResourceVisibility::Public),
2151 your_access: Some(ResourceAccess::View),
2152 values: None,
2153 }
2154 }
2155
2156 #[test]
2157 fn resource_describe_prose_no_values_unchanged() {
2158 let out = SharedBuf::new();
2161 let mut renderer = ProseRenderer::with_writer(out.clone());
2162 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2163 renderer
2164 .resource_describe(&make_resource_detail_no_values(), &meta)
2165 .unwrap();
2166
2167 let s = out.string();
2168 assert!(s.contains("Resource: Test Resource"), "header missing");
2169 assert!(
2170 !s.contains("Values:"),
2171 "Values: section must be absent when values is None"
2172 );
2173 }
2174
2175 #[test]
2176 fn resource_describe_prose_some_empty_values() {
2177 let out = SharedBuf::new();
2179 let mut renderer = ProseRenderer::with_writer(out.clone());
2180 let mut detail = make_resource_detail_no_values();
2181 detail.values = Some(vec![]);
2182 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2183 renderer.resource_describe(&detail, &meta).unwrap();
2184
2185 let s = out.string();
2186 assert!(
2187 s.contains("Values: (none)"),
2188 "empty values must render as 'Values: (none)'; got:\n{s}"
2189 );
2190 }
2191
2192 #[test]
2193 fn resource_describe_prose_values_section() {
2194 let out = SharedBuf::new();
2196 let mut renderer = ProseRenderer::with_writer(out.clone());
2197 let mut detail = make_resource_detail_no_values();
2198 detail.values = Some(vec![
2199 FieldValues {
2200 name: "hasTitle".into(),
2201 label: Some("Title".into()),
2202 values: vec![ValueContent::Text("Incunabula Page".into()).into()],
2203 },
2204 FieldValues {
2205 name: "seqnum".into(),
2206 label: None, values: vec![ValueContent::Integer(42).into()],
2208 },
2209 ]);
2210 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2211 renderer.resource_describe(&detail, &meta).unwrap();
2212
2213 let s = out.string();
2214 assert!(s.contains("Values:"), "Values: header missing; got:\n{s}");
2215 assert!(
2217 s.contains("Title (hasTitle)"),
2218 "labelled field header format wrong; got:\n{s}"
2219 );
2220 assert!(
2221 s.contains("Incunabula Page"),
2222 "text value missing; got:\n{s}"
2223 );
2224 let seqnum_line = s.lines().find(|l| l.contains("seqnum")).unwrap();
2226 assert!(
2227 !seqnum_line.contains('('),
2228 "unlabelled field must not have parentheses; got: {seqnum_line:?}"
2229 );
2230 assert!(s.contains("42"), "integer value missing; got:\n{s}");
2231 }
2232
2233 #[test]
2234 fn resource_describe_prose_value_with_comment() {
2235 let out = SharedBuf::new();
2238 let mut renderer = ProseRenderer::with_writer(out.clone());
2239 let mut detail = make_resource_detail_no_values();
2240 detail.values = Some(vec![FieldValues {
2241 name: "hasTranscription".into(),
2242 label: Some("Transcription".into()),
2243 values: vec![Value {
2244 content: ValueContent::Text("some transcription".into()),
2245 comment: Some("reading uncertain".into()),
2246 }],
2247 }]);
2248 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2249 renderer.resource_describe(&detail, &meta).unwrap();
2250
2251 let s = out.string();
2252 assert!(
2253 s.contains("some transcription"),
2254 "value line missing; got:\n{s}"
2255 );
2256 assert!(
2257 s.contains(" comment: reading uncertain"),
2258 "comment line missing or mis-indented; got:\n{s}"
2259 );
2260 let lines: Vec<&str> = s.lines().collect();
2262 let value_idx = lines
2263 .iter()
2264 .position(|l| l.contains("some transcription"))
2265 .unwrap();
2266 assert_eq!(
2267 lines[value_idx + 1],
2268 " comment: reading uncertain",
2269 "comment line must directly follow the value line; got:\n{s}"
2270 );
2271 }
2272
2273 #[test]
2274 fn resource_describe_prose_value_without_comment_renders_no_comment_line() {
2275 let out = SharedBuf::new();
2279 let mut renderer = ProseRenderer::with_writer(out.clone());
2280 let mut detail = make_resource_detail_no_values();
2281 detail.values = Some(vec![FieldValues {
2282 name: "hasTranscription".into(),
2283 label: Some("Transcription".into()),
2284 values: vec![ValueContent::Text("some transcription".into()).into()],
2285 }]);
2286 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2287 renderer.resource_describe(&detail, &meta).unwrap();
2288
2289 let s = out.string();
2290 assert!(
2291 s.contains("some transcription"),
2292 "value line missing; got:\n{s}"
2293 );
2294 assert!(
2295 !s.contains("comment:"),
2296 "no comment line must be emitted when value.comment is None; got:\n{s}"
2297 );
2298 }
2299
2300 #[test]
2301 fn resource_describe_prose_link_with_label() {
2302 let out = SharedBuf::new();
2303 let mut renderer = ProseRenderer::with_writer(out.clone());
2304 let mut detail = make_resource_detail_no_values();
2305 detail.values = Some(vec![FieldValues {
2306 name: "isPartOf".into(),
2307 label: Some("Is part of".into()),
2308 values: vec![
2309 ValueContent::Link {
2310 target_iri: "http://rdfh.ch/0803/book1".into(),
2311 target_label: Some("Incunabula Book".into()),
2312 }
2313 .into(),
2314 ValueContent::Link {
2315 target_iri: "http://rdfh.ch/0803/book2".into(),
2316 target_label: None, }
2318 .into(),
2319 ],
2320 }]);
2321 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2322 renderer.resource_describe(&detail, &meta).unwrap();
2323
2324 let s = out.string();
2325 assert!(
2327 s.contains("\u{2192} Incunabula Book [http://rdfh.ch/0803/book1]"),
2328 "link with label rendered incorrectly; got:\n{s}"
2329 );
2330 assert!(
2332 s.contains("\u{2192} http://rdfh.ch/0803/book2"),
2333 "degraded link (no label) rendered incorrectly; got:\n{s}"
2334 );
2335 let degraded_line = s
2337 .lines()
2338 .find(|l| l.contains("http://rdfh.ch/0803/book2"))
2339 .unwrap();
2340 assert!(
2341 !degraded_line.contains('['),
2342 "degraded link must not have brackets; got: {degraded_line:?}"
2343 );
2344 }
2345
2346 #[test]
2347 fn resource_describe_prose_file_still_image_with_dims() {
2348 let out = SharedBuf::new();
2349 let mut renderer = ProseRenderer::with_writer(out.clone());
2350 let mut detail = make_resource_detail_no_values();
2351 detail.values = Some(vec![FieldValues {
2352 name: "hasStillImageFileValue".into(),
2353 label: None,
2354 values: vec![
2355 ValueContent::File(FileValue {
2356 value_type: ValueType::StillImage,
2357 filename: "image.jp2".into(),
2358 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
2359 width: Some(1200),
2360 height: Some(800),
2361 })
2362 .into(),
2363 ],
2364 }]);
2365 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2366 renderer.resource_describe(&detail, &meta).unwrap();
2367
2368 let s = out.string();
2369 assert!(
2371 s.contains("image.jp2 (1200\u{d7}800) https://iiif.example.com"),
2372 "still-image with dims rendered incorrectly; got:\n{s}"
2373 );
2374 }
2375
2376 #[test]
2377 fn resource_describe_prose_date_rendering() {
2378 let out = SharedBuf::new();
2379 let mut renderer = ProseRenderer::with_writer(out.clone());
2380 let mut detail = make_resource_detail_no_values();
2381 let pt = DatePoint {
2383 year: Some(1489),
2384 month: None,
2385 day: None,
2386 era: Some("CE".into()),
2387 };
2388 let single = DateValue {
2389 calendar: "GREGORIAN".into(),
2390 start: pt.clone(),
2391 end: pt.clone(),
2392 };
2393 let range = DateValue {
2395 calendar: "GREGORIAN".into(),
2396 start: DatePoint {
2397 year: Some(1489),
2398 month: None,
2399 day: None,
2400 era: Some("CE".into()),
2401 },
2402 end: DatePoint {
2403 year: Some(1490),
2404 month: None,
2405 day: None,
2406 era: Some("CE".into()),
2407 },
2408 };
2409 detail.values = Some(vec![FieldValues {
2410 name: "hasDate".into(),
2411 label: Some("Date".into()),
2412 values: vec![
2413 ValueContent::Date(single).into(),
2414 ValueContent::Date(range).into(),
2415 ],
2416 }]);
2417 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2418 renderer.resource_describe(&detail, &meta).unwrap();
2419
2420 let s = out.string();
2421 assert!(
2423 s.contains("1489 CE (GREGORIAN)"),
2424 "single-point date rendered incorrectly; got:\n{s}"
2425 );
2426 assert!(
2428 s.contains("1489 CE \u{2013} 1490 CE (GREGORIAN)"),
2429 "range date rendered incorrectly; got:\n{s}"
2430 );
2431 }
2432
2433 #[test]
2436 fn resource_describe_prose_strips_control_chars_in_label() {
2437 let out = SharedBuf::new();
2440 let mut renderer = ProseRenderer::with_writer(out.clone());
2441 let mut detail = make_resource_detail_no_values();
2442 detail.label = "Bad\u{1b}[31mLabel\u{7f}".into();
2443 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2444 renderer.resource_describe(&detail, &meta).unwrap();
2445
2446 let s = out.string();
2447 assert!(
2448 s.contains("Resource: Bad[31mLabel"),
2449 "label control chars not stripped; got:\n{s}"
2450 );
2451 assert!(
2452 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2453 "control characters leaked into prose output; got:\n{s:?}"
2454 );
2455 }
2456
2457 #[test]
2458 fn resources_prose_strips_control_chars_in_label() {
2459 use crate::model::ResourceSummary;
2460
2461 let out = SharedBuf::new();
2462 let mut renderer = ProseRenderer::with_writer(out.clone());
2463 let view = ResourceListView {
2464 items: vec![ResourceSummary {
2465 label: "Bad\u{1b}[31mLabel\u{7f}".into(),
2466 iri: "http://rdfh.ch/0803/abc123".into(),
2467 ark_url: None,
2468 creation_date: None,
2469 last_modified: None,
2470 resource_type: "Page".into(),
2471 }],
2472 total: 1,
2473 filter: None,
2474 resource_type: "Page".into(),
2475 pagination: ResourceListPagination::SinglePage {
2476 page: 0,
2477 may_have_more: false,
2478 },
2479 };
2480 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2481 renderer.resources(&view, &meta).unwrap();
2482
2483 let s = out.string();
2484 assert!(
2485 s.contains("Bad[31mLabel"),
2486 "label control chars not stripped; got:\n{s}"
2487 );
2488 assert!(
2489 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2490 "control characters leaked into prose output; got:\n{s:?}"
2491 );
2492 }
2493}