1use std::io::{self, Write};
10
11use chrono::{DateTime, Utc};
12
13use crate::diagnostic::Diagnostic;
14use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail, VocabularyDetail};
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::vocabulary::{flatten_vocabulary_detail, join_labels_prose};
22use crate::render::{
23 DataModelListView, MetaContext, ProjectListView, Renderer, ResourceListPagination,
24 ResourceListView, ResourceTypeListView, VocabularyListView,
25};
26use crate::util::text::{html_to_text, strip_control_chars};
27
28fn format_expiry_clause(expires_at: Option<DateTime<Utc>>, expired: bool) -> String {
39 match expires_at {
40 Some(exp) if expired => {
41 format!(" Token expired {}.", exp.format("%Y-%m-%d %H:%M UTC"))
42 }
43 Some(exp) => {
44 format!(" Token expires {}.", exp.format("%Y-%m-%d %H:%M UTC"))
45 }
46 None => String::new(),
47 }
48}
49
50pub struct ProseRenderer {
52 out: Box<dyn Write>,
53}
54
55impl ProseRenderer {
56 pub fn new() -> Self {
58 Self {
59 out: Box::new(io::stdout()),
60 }
61 }
62
63 pub fn with_writer(w: impl Write + 'static) -> Self {
65 Self { out: Box::new(w) }
66 }
67}
68
69impl Default for ProseRenderer {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75impl Renderer for ProseRenderer {
76 fn diagnostic(&mut self, diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
77 eprintln!("Error: {diag}"); 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 fn vocabularies(
884 &mut self,
885 view: &VocabularyListView,
886 meta: &MetaContext,
887 ) -> Result<(), Diagnostic> {
888 let n = view.items.len();
890 match &view.filter {
891 None => writeln!(self.out, "Vocabularies on {} ({n}):", meta.server_label)?,
892 Some(f) => writeln!(
893 self.out,
894 "Vocabularies on {} ({n} of {} matching \"{f}\"):",
895 meta.server_label, view.total
896 )?,
897 }
898
899 writeln!(self.out)?;
900
901 for item in &view.items {
902 let name = strip_control_chars(item.header.name.as_deref().unwrap_or(""));
903 let labels = strip_control_chars(&join_labels_prose(&item.header.labels));
904 write!(self.out, " {name} {labels}")?;
905 if view.counted
911 && let (Some(nodes), Some(depth)) = (item.node_count, item.depth)
912 {
913 let node_word = if nodes == 1 { "node" } else { "nodes" };
914 let level_word = if depth == 1 { "level" } else { "levels" };
915 write!(
916 self.out,
917 " \u{b7} {nodes} {node_word} \u{b7} {depth} {level_word}"
918 )?;
919 }
920 writeln!(self.out)?;
921 }
922
923 render_prose_footer(&mut *self.out, meta)?;
924 Ok(())
925 }
926
927 fn vocabulary_describe(
928 &mut self,
929 detail: &VocabularyDetail,
930 meta: &MetaContext,
931 ) -> Result<(), Diagnostic> {
932 let root = &detail.tree.root;
933 let root_name = strip_control_chars(root.name.as_deref().unwrap_or(""));
934 let root_labels = strip_control_chars(&join_labels_prose(&root.labels));
935 writeln!(self.out, "Vocabulary: {root_name} \u{2014} {root_labels}")?;
936 writeln!(self.out, "Root: {}", root.iri)?;
937
938 let rows = flatten_vocabulary_detail(detail);
942
943 let node_word = if detail.node_count == 1 {
944 "node"
945 } else {
946 "nodes"
947 };
948 let level_word = if detail.depth == 1 { "level" } else { "levels" };
949 write!(
950 self.out,
951 "{} {node_word} \u{b7} {} {level_word}",
952 detail.node_count, detail.depth
953 )?;
954 if let Some(target_iri) = &detail.subtree_of
957 && let Some(top) = rows.iter().find(|n| &n.header.iri == target_iri)
958 {
959 write!(self.out, " \u{b7} subtree of {}", top.number)?;
960 } else if let Some(requested) = &detail.tree.requested_node
961 && let Some(row) = rows.iter().find(|n| &n.header.iri == requested)
962 {
963 write!(self.out, " \u{2190} you asked about {}", row.number)?;
964 }
965 writeln!(self.out)?;
966 writeln!(self.out)?;
967
968 let base_depth = rows.first().map(|n| n.depth).unwrap_or(1);
972 for node in &rows {
973 let indent = " ".repeat(node.depth.saturating_sub(base_depth) + 1);
974 let labels = strip_control_chars(&join_labels_prose(&node.header.labels));
975 let marker = if detail.tree.requested_node.as_deref() == Some(node.header.iri.as_str())
976 {
977 " \u{2190}"
978 } else {
979 ""
980 };
981 writeln!(self.out, "{indent}{} {labels}{marker}", node.number)?;
982 }
983
984 render_prose_footer(&mut *self.out, meta)?;
985 Ok(())
986 }
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992 use crate::model::{
993 Cardinality, DataModel, DataModelDetail, DataModelSummary, Field, Project,
994 ProjectDescription, ProjectDetail, ProjectStatus, Representation, ResourceType,
995 ResourceTypeDetail, ResourceTypeSummary, ValueType,
996 };
997 use crate::render::test_support::{SharedBuf, make_meta};
998 use crate::render::{DataModelListView, ResourceTypeListView};
999
1000 fn make_fixture() -> Vec<Project> {
1001 vec![
1002 Project {
1003 iri: "http://rdfh.ch/projects/0001".into(),
1004 shortcode: "0001".into(),
1005 shortname: "anything".into(),
1006 longname: Some("Anything Project".into()),
1007 status: ProjectStatus::Active,
1008 data_models: 2,
1009 },
1010 Project {
1011 iri: "http://rdfh.ch/projects/0002".into(),
1012 shortcode: "0002".into(),
1013 shortname: "images".into(),
1014 longname: None,
1015 status: ProjectStatus::Inactive,
1016 data_models: 0,
1017 },
1018 Project {
1019 iri: "http://rdfh.ch/projects/0803".into(),
1020 shortcode: "0803".into(),
1021 shortname: "daschland".into(),
1022 longname: Some("=Formula Project".into()),
1023 status: ProjectStatus::Active,
1024 data_models: 1,
1025 },
1026 ]
1027 }
1028
1029 #[test]
1030 fn projects_prose_no_filter() {
1031 let out = SharedBuf::new();
1032 let mut renderer = ProseRenderer::with_writer(out.clone());
1033 let view = ProjectListView {
1034 items: make_fixture(),
1035 total: 3,
1036 filter: None,
1037 };
1038 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1039 renderer.projects(&view, &meta).unwrap();
1040
1041 let s = out.string();
1042 assert!(s.contains("Projects on https://api.test.dasch.swiss (3):"));
1043 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
1045 assert!(!s.contains("rdfh.ch"));
1047 assert!(s.contains("2 data-models"));
1049 assert!(s.contains("1 data-model"));
1051 let data_model_lines = s.lines().filter(|l| l.contains("data-model")).count();
1053 assert_eq!(data_model_lines, 2);
1054 assert!(!s.contains("None"));
1056 }
1057
1058 #[test]
1059 fn projects_prose_with_filter() {
1060 let out = SharedBuf::new();
1061 let mut renderer = ProseRenderer::with_writer(out.clone());
1062 let items = vec![make_fixture().remove(0)]; let view = ProjectListView {
1064 items,
1065 total: 3,
1066 filter: Some("any".to_string()),
1067 };
1068 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1069 renderer.projects(&view, &meta).unwrap();
1070
1071 let s = out.string();
1072 assert!(s.contains("(1 of 3 matching \"any\")"));
1073 }
1074
1075 #[test]
1076 fn projects_prose_footer_on_stdout() {
1077 let out = SharedBuf::new();
1079 let mut renderer = ProseRenderer::with_writer(out.clone());
1080 let view = ProjectListView {
1081 items: vec![],
1082 total: 0,
1083 filter: None,
1084 };
1085 let meta = make_meta("authenticated as alice", "https://api.dasch.swiss");
1086 renderer.projects(&view, &meta).unwrap();
1087
1088 let s = out.string();
1089 assert!(s.contains("[authenticated as alice on https://api.dasch.swiss]"));
1090 }
1091
1092 fn make_beol_detail() -> ProjectDetail {
1093 ProjectDetail {
1094 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
1095 shortcode: "0801".into(),
1096 shortname: "beol".into(),
1097 longname: Some("Bernoulli-Euler Online".into()),
1098 status: ProjectStatus::Active,
1099 description: vec![ProjectDescription {
1100 value: "<b>BEOL</b> — early modern mathematics.".into(),
1101 language: Some("en".into()),
1102 }],
1103 keywords: vec!["Bernoulli".into(), "Euler".into(), "Mathematics".into()],
1104 data_models: vec![
1105 DataModelSummary {
1106 name: "beol".into(),
1107 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1108 },
1109 DataModelSummary {
1110 name: "biblio".into(),
1111 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1112 },
1113 DataModelSummary {
1114 name: "leibniz".into(),
1115 iri: "http://api.dasch.swiss/ontology/0801/leibniz/v2".into(),
1116 },
1117 DataModelSummary {
1118 name: "newton".into(),
1119 iri: "http://api.dasch.swiss/ontology/0801/newton/v2".into(),
1120 },
1121 ],
1122 }
1123 }
1124
1125 #[test]
1126 fn project_describe_prose_full() {
1127 let out = SharedBuf::new();
1128 let mut renderer = ProseRenderer::with_writer(out.clone());
1129 let meta = make_meta("anonymous", "api.dasch.swiss");
1130 renderer
1131 .project_describe(&make_beol_detail(), &meta)
1132 .unwrap();
1133
1134 let s = out.string();
1135 assert!(s.contains("Project: beol (0801)"));
1137 assert!(s.contains("Name: Bernoulli-Euler Online"));
1139 assert!(s.contains("IRI: http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"));
1141 assert!(s.contains("Status: active"));
1142 assert!(s.contains("Keywords: Bernoulli, Euler, Mathematics"));
1143 assert!(s.contains("Data-models (4): beol, biblio, leibniz, newton"));
1145 assert!(s.contains("[en] BEOL"));
1147 assert!(
1148 !s.contains("<b>"),
1149 "description must not contain raw <b> tags"
1150 );
1151 assert!(
1152 !s.contains("</b>"),
1153 "description must not contain raw </b> tags"
1154 );
1155 assert!(s.contains("[anonymous on api.dasch.swiss]"));
1157 }
1158
1159 #[test]
1160 fn project_describe_prose_no_longname() {
1161 let out = SharedBuf::new();
1162 let mut renderer = ProseRenderer::with_writer(out.clone());
1163 let mut detail = make_beol_detail();
1164 detail.longname = None;
1165 let meta = make_meta("anonymous", "api.dasch.swiss");
1166 renderer.project_describe(&detail, &meta).unwrap();
1167
1168 let s = out.string();
1169 assert!(s.contains("Project: beol (0801)"));
1171 assert!(
1172 !s.contains("Name:"),
1173 "Name: line must be absent when longname is None"
1174 );
1175 }
1176
1177 #[test]
1178 fn project_describe_prose_empty_keywords_and_description() {
1179 let out = SharedBuf::new();
1180 let mut renderer = ProseRenderer::with_writer(out.clone());
1181 let detail = ProjectDetail {
1182 iri: "http://rdfh.ch/projects/0000".into(),
1183 shortcode: "0000".into(),
1184 shortname: "minimal".into(),
1185 longname: None,
1186 status: ProjectStatus::Inactive,
1187 description: vec![],
1188 keywords: vec![],
1189 data_models: vec![],
1190 };
1191 let meta = make_meta("anonymous", "api.dasch.swiss");
1192 renderer.project_describe(&detail, &meta).unwrap();
1193
1194 let s = out.string();
1195 assert!(!s.contains("Keywords"));
1197 assert!(!s.contains("Description"));
1199 assert!(s.contains("Data-models (0)"));
1201 assert!(!s.contains("Data-models (0):"));
1202 }
1203
1204 #[test]
1205 fn project_describe_prose_description_no_language() {
1206 let out = SharedBuf::new();
1207 let mut renderer = ProseRenderer::with_writer(out.clone());
1208 let detail = ProjectDetail {
1209 iri: "http://rdfh.ch/projects/0001".into(),
1210 shortcode: "0001".into(),
1211 shortname: "test".into(),
1212 longname: None,
1213 status: ProjectStatus::Active,
1214 description: vec![ProjectDescription {
1215 value: "Plain description without language tag.".into(),
1216 language: None,
1217 }],
1218 keywords: vec![],
1219 data_models: vec![],
1220 };
1221 let meta = make_meta("anonymous", "api.dasch.swiss");
1222 renderer.project_describe(&detail, &meta).unwrap();
1223
1224 let s = out.string();
1225 assert!(s.contains(" Plain description without language tag."));
1227 let desc_line = s.lines().find(|l| l.contains("Plain description")).unwrap();
1229 assert!(!desc_line.contains('['));
1230 }
1231
1232 #[test]
1236 fn project_describe_prose_two_descriptions_language_and_none() {
1237 let out = SharedBuf::new();
1238 let mut renderer = ProseRenderer::with_writer(out.clone());
1239 let detail = ProjectDetail {
1240 iri: "http://rdfh.ch/projects/0002".into(),
1241 shortcode: "0002".into(),
1242 shortname: "multilang".into(),
1243 longname: None,
1244 status: ProjectStatus::Active,
1245 description: vec![
1246 ProjectDescription {
1247 value: "English description of this project.".into(),
1248 language: Some("en".into()),
1249 },
1250 ProjectDescription {
1251 value: "Description without a language tag.".into(),
1252 language: None,
1253 },
1254 ],
1255 keywords: vec![],
1256 data_models: vec![],
1257 };
1258 let meta = make_meta("anonymous", "api.dasch.swiss");
1259 renderer.project_describe(&detail, &meta).unwrap();
1260
1261 let s = out.string();
1262
1263 assert!(
1265 s.contains("English description of this project."),
1266 "first description value must appear"
1267 );
1268 assert!(
1269 s.contains("Description without a language tag."),
1270 "second description value must appear"
1271 );
1272
1273 let en_line = s
1275 .lines()
1276 .find(|l| l.contains("English description"))
1277 .expect("expected a line containing 'English description'");
1278 assert!(
1279 en_line.contains("[en] "),
1280 "languaged entry must carry '[en] ' prefix; got: {en_line:?}"
1281 );
1282
1283 let plain_line = s
1285 .lines()
1286 .find(|l| l.contains("Description without a language tag."))
1287 .expect("expected a line containing 'Description without a language tag.'");
1288 assert!(
1289 !plain_line.contains('['),
1290 "no-language entry must not have a '[...]' prefix; got: {plain_line:?}"
1291 );
1292 }
1293
1294 #[test]
1297 fn project_describe_prose_blank_lines_are_not_indented() {
1298 let out = SharedBuf::new();
1299 let mut renderer = ProseRenderer::with_writer(out.clone());
1300 let detail = ProjectDetail {
1301 iri: "http://rdfh.ch/projects/0003".into(),
1302 shortcode: "0003".into(),
1303 shortname: "multipara".into(),
1304 longname: None,
1305 status: ProjectStatus::Active,
1306 description: vec![ProjectDescription {
1307 value: "First paragraph.\n\nSecond paragraph.".into(),
1308 language: Some("en".into()),
1309 }],
1310 keywords: vec![],
1311 data_models: vec![],
1312 };
1313 let meta = make_meta("anonymous", "api.dasch.swiss");
1314 renderer.project_describe(&detail, &meta).unwrap();
1315
1316 let s = out.string();
1317 assert!(s.contains("[en] First paragraph."));
1319 assert!(s.contains("Second paragraph."));
1320 let ws_only = s.lines().find(|l| !l.is_empty() && l.trim().is_empty());
1323 assert!(
1324 ws_only.is_none(),
1325 "blank separator lines must be truly empty, not whitespace-only; got:\n{s}"
1326 );
1327 }
1328
1329 #[test]
1332 fn project_describe_prose_empty_after_html_omits_description_label() {
1333 let out = SharedBuf::new();
1334 let mut renderer = ProseRenderer::with_writer(out.clone());
1335 let detail = ProjectDetail {
1336 iri: "http://rdfh.ch/projects/0004".into(),
1337 shortcode: "0004".into(),
1338 shortname: "tagsonly".into(),
1339 longname: None,
1340 status: ProjectStatus::Active,
1341 description: vec![ProjectDescription {
1342 value: "<br/>".into(), language: Some("en".into()),
1344 }],
1345 keywords: vec![],
1346 data_models: vec![],
1347 };
1348 let meta = make_meta("anonymous", "api.dasch.swiss");
1349 renderer.project_describe(&detail, &meta).unwrap();
1350
1351 let s = out.string();
1352 assert!(
1353 !s.contains("Description:"),
1354 "Description: label must be omitted when no entry has visible text; got:\n{s}"
1355 );
1356 }
1357
1358 fn make_data_model_fixture() -> Vec<DataModel> {
1359 vec![
1360 DataModel {
1361 name: "beol".into(),
1362 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1363 label: Some("The BEOL data-model".into()),
1364 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1365 is_builtin: false,
1366 },
1367 DataModel {
1368 name: "biblio".into(),
1369 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1370 label: None,
1371 last_modified: Some("2024-01-10T08:00:00.000000Z".into()),
1372 is_builtin: false,
1373 },
1374 DataModel {
1375 name: "knora-api".into(),
1376 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
1377 label: None,
1378 last_modified: None,
1379 is_builtin: true,
1380 },
1381 ]
1382 }
1383
1384 #[test]
1385 fn data_models_prose_no_filter() {
1386 let out = SharedBuf::new();
1387 let mut renderer = ProseRenderer::with_writer(out.clone());
1388 let view = DataModelListView {
1389 items: make_data_model_fixture(),
1390 total: 3,
1391 filter: None,
1392 };
1393 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1394 renderer.data_models(&view, &meta).unwrap();
1395
1396 let s = out.string();
1397 assert!(
1399 s.contains("data-models on https://api.test.dasch.swiss (3), incl. built-ins:"),
1400 "header must include count and built-ins suffix; got:\n{s}"
1401 );
1402 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
1404 assert!(
1406 s.contains("beol") && s.contains("The BEOL data-model") && s.contains("2024-05-27")
1407 );
1408 assert!(
1410 !s.contains("T13:43:26"),
1411 "prose must show only the date portion"
1412 );
1413 assert!(s.contains("biblio"));
1415 assert!(s.contains("(built-in)"));
1417 assert!(!s.contains("None"));
1419 }
1420
1421 #[test]
1422 fn data_models_prose_with_filter() {
1423 let out = SharedBuf::new();
1424 let mut renderer = ProseRenderer::with_writer(out.clone());
1425 let items = vec![make_data_model_fixture().remove(0)]; let view = DataModelListView {
1427 items,
1428 total: 3,
1429 filter: Some("beol".to_string()),
1430 };
1431 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1432 renderer.data_models(&view, &meta).unwrap();
1433
1434 let s = out.string();
1435 assert!(
1436 s.contains("(1 of 3 matching \"beol\")"),
1437 "filter header must show m of total; got:\n{s}"
1438 );
1439 assert!(!s.contains("incl. built-ins"));
1441 }
1442
1443 #[test]
1447 fn data_models_prose_no_builtins_header_has_no_builtin_suffix() {
1448 let out = SharedBuf::new();
1449 let mut renderer = ProseRenderer::with_writer(out.clone());
1450 let view = DataModelListView {
1451 items: vec![
1452 DataModel {
1453 name: "beol".into(),
1454 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1455 label: Some("The BEOL data-model".into()),
1456 last_modified: None,
1457 is_builtin: false,
1458 },
1459 DataModel {
1460 name: "biblio".into(),
1461 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1462 label: None,
1463 last_modified: None,
1464 is_builtin: false,
1465 },
1466 ],
1467 total: 2,
1468 filter: None,
1469 };
1470 let meta = make_meta("anonymous", "api.dasch.swiss");
1471 renderer.data_models(&view, &meta).unwrap();
1472
1473 let s = out.string();
1474 let header_line = s
1476 .lines()
1477 .next()
1478 .expect("output must have at least one line");
1479 assert!(
1480 header_line.ends_with("(2):"),
1481 "header must end with '(2):' when there are no builtins; got: {header_line:?}"
1482 );
1483 assert!(
1484 !s.contains("incl. built-ins"),
1485 "header must not contain 'incl. built-ins' when no builtin items are present; \
1486 got:\n{s}"
1487 );
1488 }
1489
1490 #[test]
1491 fn data_models_prose_last_modified_no_t_separator() {
1492 let out = SharedBuf::new();
1495 let mut renderer = ProseRenderer::with_writer(out.clone());
1496 let view = DataModelListView {
1497 items: vec![DataModel {
1498 name: "odd".into(),
1499 iri: "http://example.org/odd".into(),
1500 label: None,
1501 last_modified: Some("2024-05-27 no-separator".into()),
1502 is_builtin: false,
1503 }],
1504 total: 1,
1505 filter: None,
1506 };
1507 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1508 renderer.data_models(&view, &meta).unwrap();
1509
1510 let s = out.string();
1511 assert!(
1513 s.contains("2024-05-27 no-separator"),
1514 "expected full string when no 'T' separator; got:\n{s}"
1515 );
1516 }
1517
1518 fn make_beol_dm_detail() -> DataModelDetail {
1521 DataModelDetail {
1522 name: "beol".into(),
1523 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1524 label: Some("The BEOL data-model".into()),
1525 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1526 resource_types: vec![
1527 ResourceTypeSummary {
1528 name: "Archive".into(),
1529 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1530 label: Some("Archive".into()),
1531 },
1532 ResourceTypeSummary {
1533 name: "basicLetter".into(),
1534 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1535 label: None,
1536 },
1537 ResourceTypeSummary {
1538 name: "letter".into(),
1539 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1540 label: Some("Letter".into()),
1541 },
1542 ],
1543 }
1544 }
1545
1546 #[test]
1547 fn data_model_describe_prose_full() {
1548 let out = SharedBuf::new();
1549 let mut renderer = ProseRenderer::with_writer(out.clone());
1550 let meta = make_meta("anonymous", "api.dasch.swiss");
1551 renderer
1552 .data_model_describe(&make_beol_dm_detail(), &meta)
1553 .unwrap();
1554
1555 let s = out.string();
1556 assert!(s.contains("Data-model: beol"), "header missing; got:\n{s}");
1558 assert!(
1560 s.contains("Label: The BEOL data-model"),
1561 "label line missing; got:\n{s}"
1562 );
1563 assert!(
1565 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2"),
1566 "IRI line missing; got:\n{s}"
1567 );
1568 assert!(
1570 s.contains("Last-modified: 2024-05-27"),
1571 "last-modified line missing; got:\n{s}"
1572 );
1573 assert!(
1574 !s.contains("T13:43"),
1575 "prose must show only date portion, not full timestamp; got:\n{s}"
1576 );
1577 assert!(
1579 s.contains("Resource-types (3):"),
1580 "resource-types header missing; got:\n{s}"
1581 );
1582 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1584 assert!(
1585 s.contains("basicLetter"),
1586 "basicLetter row missing; got:\n{s}"
1587 );
1588 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1589 assert!(
1591 s.contains("[anonymous on api.dasch.swiss]"),
1592 "footer missing; got:\n{s}"
1593 );
1594 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1596 }
1597
1598 #[test]
1599 fn data_model_describe_prose_no_label_no_last_modified() {
1600 let out = SharedBuf::new();
1601 let mut renderer = ProseRenderer::with_writer(out.clone());
1602 let detail = DataModelDetail {
1603 name: "minimal".into(),
1604 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1605 label: None,
1606 last_modified: None,
1607 resource_types: vec![ResourceTypeSummary {
1608 name: "Thing".into(),
1609 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Thing".into(),
1610 label: None,
1611 }],
1612 };
1613 let meta = make_meta("anonymous", "api.dasch.swiss");
1614 renderer.data_model_describe(&detail, &meta).unwrap();
1615
1616 let s = out.string();
1617 assert!(
1619 !s.contains("Label:"),
1620 "Label: line must be absent when label is None; got:\n{s}"
1621 );
1622 assert!(
1624 !s.contains("Last-modified:"),
1625 "Last-modified: line must be absent when last_modified is None; got:\n{s}"
1626 );
1627 assert!(
1629 s.contains("IRI:"),
1630 "IRI line must always be present; got:\n{s}"
1631 );
1632 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1634 }
1635
1636 #[test]
1637 fn data_model_describe_prose_zero_resource_types() {
1638 let out = SharedBuf::new();
1639 let mut renderer = ProseRenderer::with_writer(out.clone());
1640 let detail = DataModelDetail {
1641 name: "empty".into(),
1642 iri: "http://api.dasch.swiss/ontology/9999/empty/v2".into(),
1643 label: None,
1644 last_modified: None,
1645 resource_types: vec![],
1646 };
1647 let meta = make_meta("anonymous", "api.dasch.swiss");
1648 renderer.data_model_describe(&detail, &meta).unwrap();
1649
1650 let s = out.string();
1651 assert!(
1653 s.contains(" Resource-types (0)"),
1654 "zero resource-types branch missing; got:\n{s}"
1655 );
1656 let rt_line = s
1658 .lines()
1659 .find(|l| l.contains("Resource-types (0)"))
1660 .expect("must have resource-types line");
1661 assert!(
1662 !rt_line.contains("Resource-types (0):"),
1663 "zero branch must NOT have a colon; got: {rt_line:?}"
1664 );
1665 }
1666
1667 #[test]
1668 fn data_model_describe_prose_last_modified_no_t_separator() {
1669 let out = SharedBuf::new();
1670 let mut renderer = ProseRenderer::with_writer(out.clone());
1671 let detail = DataModelDetail {
1672 name: "odd".into(),
1673 iri: "http://api.dasch.swiss/ontology/0000/odd/v2".into(),
1674 label: None,
1675 last_modified: Some("2024-05-27 no-separator".into()),
1676 resource_types: vec![],
1677 };
1678 let meta = make_meta("anonymous", "api.dasch.swiss");
1679 renderer.data_model_describe(&detail, &meta).unwrap();
1680
1681 let s = out.string();
1682 assert!(
1684 s.contains("2024-05-27 no-separator"),
1685 "expected full string when no 'T' separator; got:\n{s}"
1686 );
1687 }
1688
1689 fn make_resource_type_fixture() -> Vec<ResourceType> {
1692 vec![
1693 ResourceType {
1694 name: "Archive".into(),
1695 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1696 label: Some("Archive".into()),
1697 is_builtin: false,
1698 count: None,
1699 },
1700 ResourceType {
1701 name: "letter".into(),
1702 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1703 label: Some("Letter".into()),
1704 is_builtin: false,
1705 count: None,
1706 },
1707 ]
1708 }
1709
1710 #[test]
1711 fn resource_types_prose_project_only() {
1712 let out = SharedBuf::new();
1713 let mut renderer = ProseRenderer::with_writer(out.clone());
1714 let view = ResourceTypeListView {
1715 items: make_resource_type_fixture(),
1716 total: 2,
1717 filter: None,
1718 data_model: "beol".into(),
1719 };
1720 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1721 renderer.resource_types(&view, &meta).unwrap();
1722
1723 let s = out.string();
1724 assert!(
1726 s.contains("resource-types in beol on https://api.test.dasch.swiss (2):"),
1727 "header missing or incorrect; got:\n{s}"
1728 );
1729 assert!(
1730 !s.contains("incl. built-ins"),
1731 "must not have builtin suffix when no builtins; got:\n{s}"
1732 );
1733 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1735 assert!(s.contains("letter"), "letter row missing; got:\n{s}");
1736 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1737 assert!(
1739 !s.contains("(built-in)"),
1740 "must not have (built-in) marker on project-only items; got:\n{s}"
1741 );
1742 assert!(
1744 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1745 "footer missing; got:\n{s}"
1746 );
1747 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1749 }
1750
1751 #[test]
1752 fn resource_types_prose_with_builtins() {
1753 let out = SharedBuf::new();
1754 let mut renderer = ProseRenderer::with_writer(out.clone());
1755 let mut items = make_resource_type_fixture();
1756 items.push(ResourceType {
1757 name: "Region".into(),
1758 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1759 label: Some("Region".into()),
1760 is_builtin: true,
1761 count: None,
1762 });
1763 let view = ResourceTypeListView {
1764 items,
1765 total: 3,
1766 filter: None,
1767 data_model: "beol".into(),
1768 };
1769 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1770 renderer.resource_types(&view, &meta).unwrap();
1771
1772 let s = out.string();
1773 assert!(
1775 s.contains(
1776 "resource-types in beol on https://api.test.dasch.swiss (3), incl. built-ins:"
1777 ),
1778 "header with builtin suffix missing; got:\n{s}"
1779 );
1780 assert!(
1782 s.contains("(built-in)"),
1783 "(built-in) marker missing on built-in row; got:\n{s}"
1784 );
1785 let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1787 assert!(
1788 !archive_line.contains("(built-in)"),
1789 "project row must not have (built-in) marker; got: {archive_line:?}"
1790 );
1791 }
1792
1793 #[test]
1794 fn resource_types_prose_with_filter() {
1795 let out = SharedBuf::new();
1796 let mut renderer = ProseRenderer::with_writer(out.clone());
1797 let items = vec![make_resource_type_fixture().remove(0)]; let view = ResourceTypeListView {
1799 items,
1800 total: 3,
1801 filter: Some("arch".to_string()),
1802 data_model: "beol".into(),
1803 };
1804 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1805 renderer.resource_types(&view, &meta).unwrap();
1806
1807 let s = out.string();
1808 assert!(
1809 s.contains("(1 of 3 matching \"arch\")"),
1810 "filter header must show m of total; got:\n{s}"
1811 );
1812 }
1813
1814 #[test]
1815 fn resource_types_prose_empty() {
1816 let out = SharedBuf::new();
1817 let mut renderer = ProseRenderer::with_writer(out.clone());
1818 let view = ResourceTypeListView {
1819 items: vec![],
1820 total: 0,
1821 filter: None,
1822 data_model: "beol".into(),
1823 };
1824 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1825 renderer.resource_types(&view, &meta).unwrap();
1826
1827 let s = out.string();
1828 assert!(
1830 s.contains("resource-types in beol on https://api.test.dasch.swiss (0):"),
1831 "header must show data_model name even when empty; got:\n{s}"
1832 );
1833 assert!(
1835 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1836 "footer missing; got:\n{s}"
1837 );
1838 }
1839
1840 #[test]
1841 fn resource_types_prose_with_counts() {
1842 let out = SharedBuf::new();
1846 let mut renderer = ProseRenderer::with_writer(out.clone());
1847 let mut items = make_resource_type_fixture();
1848 items[0].count = Some(42);
1849 items[1].count = Some(7);
1850 items.push(ResourceType {
1851 name: "Region".into(),
1852 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1853 label: Some("Region".into()),
1854 is_builtin: true,
1855 count: None,
1856 });
1857 let view = ResourceTypeListView {
1858 items,
1859 total: 3,
1860 filter: None,
1861 data_model: "beol".into(),
1862 };
1863 let meta = MetaContext {
1864 server_label: "https://api.test.dasch.swiss".into(),
1865 auth_state: "anonymous".into(),
1866 filter_warning: None,
1867 count_caveat: Some("counts are not permission-filtered".into()),
1868 count_cost: None,
1869 };
1870 renderer.resource_types(&view, &meta).unwrap();
1871
1872 let s = out.string();
1873 let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1874 assert!(
1875 archive_line.contains("42"),
1876 "Archive row must show count 42; got: {archive_line:?}"
1877 );
1878 let letter_line = s.lines().find(|l| l.contains("letter")).unwrap();
1879 assert!(
1880 letter_line.contains('7'),
1881 "letter row must show count 7; got: {letter_line:?}"
1882 );
1883 let region_line = s.lines().find(|l| l.contains("Region")).unwrap();
1885 assert!(
1886 region_line.contains("(built-in)"),
1887 "built-in marker must still be present; got: {region_line:?}"
1888 );
1889 assert!(
1891 s.contains("counts are not permission-filtered"),
1892 "footer must carry count_caveat; got:\n{s}"
1893 );
1894 }
1895
1896 fn make_manuscript_detail() -> ResourceTypeDetail {
1902 ResourceTypeDetail {
1903 name: "manuscript".into(),
1904 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1905 label: Some("Manuscript".into()),
1906 data_model: "beol".into(),
1907 representation: Some(Representation::StillImage),
1908 super_types: vec!["writtenSource".into()],
1909 fields: vec![
1910 Field {
1911 name: "title".into(),
1912 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#title".into(),
1913 label: Some("Title".into()),
1914 value_type: ValueType::Text,
1915 link_target: None,
1916 cardinality: Cardinality::OneOrMore,
1917 is_builtin: false,
1918 data_model: Some("beol".into()),
1919 },
1920 Field {
1921 name: "hasAuthor".into(),
1922 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
1923 label: Some("Author".into()),
1924 value_type: ValueType::Link,
1925 link_target: Some("person".into()),
1926 cardinality: Cardinality::ZeroOrMore,
1927 is_builtin: false,
1928 data_model: Some("beol".into()),
1929 },
1930 Field {
1931 name: "isPartOfCollection".into(),
1932 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2#isPartOfCollection".into(),
1933 label: Some("is part of".into()),
1934 value_type: ValueType::Link,
1935 link_target: Some("Collection".into()),
1936 cardinality: Cardinality::ZeroOrMore,
1937 is_builtin: false,
1938 data_model: Some("biblio".into()),
1939 },
1940 ],
1941 count: None,
1942 }
1943 }
1944
1945 #[test]
1946 fn resource_type_describe_prose_full() {
1947 let out = SharedBuf::new();
1948 let mut renderer = ProseRenderer::with_writer(out.clone());
1949 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1950 renderer
1951 .resource_type_describe(&make_manuscript_detail(), &meta)
1952 .unwrap();
1953
1954 let s = out.string();
1955 assert!(
1957 s.contains("Resource-type: manuscript"),
1958 "header missing; got:\n{s}"
1959 );
1960 assert!(
1962 s.contains("Label: Manuscript"),
1963 "label line missing; got:\n{s}"
1964 );
1965 assert!(
1967 s.contains("Extends: writtenSource"),
1968 "Extends line missing; got:\n{s}"
1969 );
1970 assert!(
1972 s.contains("Representation: still-image"),
1973 "Representation line missing; got:\n{s}"
1974 );
1975 assert!(
1977 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2#manuscript"),
1978 "IRI line missing; got:\n{s}"
1979 );
1980 assert!(
1982 s.contains("Data-model: beol"),
1983 "Data-model line missing; got:\n{s}"
1984 );
1985 assert!(
1987 s.contains("Fields (3):"),
1988 "Fields header missing; got:\n{s}"
1989 );
1990 let title_line = s.lines().find(|l| l.contains("title")).unwrap();
1992 assert!(
1993 title_line.contains("text") && title_line.contains("1-n"),
1994 "title row incomplete; got: {title_line:?}"
1995 );
1996 assert!(
1997 !title_line.contains("[from"),
1998 "title row must not have [from ...] tag; got: {title_line:?}"
1999 );
2000 let author_line = s.lines().find(|l| l.contains("hasAuthor")).unwrap();
2002 assert!(
2003 author_line.contains("\u{2192} person"),
2004 "link field must show '→ person'; got: {author_line:?}"
2005 );
2006 let coll_line = s
2008 .lines()
2009 .find(|l| l.contains("isPartOfCollection"))
2010 .unwrap();
2011 assert!(
2012 coll_line.contains("[from biblio]"),
2013 "cross-DM field must show [from biblio]; got: {coll_line:?}"
2014 );
2015 assert!(
2017 s.contains("[anonymous on https://api.dasch.swiss]"),
2018 "footer missing; got:\n{s}"
2019 );
2020 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
2022 }
2023
2024 #[test]
2025 fn resource_type_describe_prose_with_count() {
2026 let out = SharedBuf::new();
2029 let mut renderer = ProseRenderer::with_writer(out.clone());
2030 let mut detail = make_manuscript_detail();
2031 detail.count = Some(123);
2032 let meta = MetaContext {
2033 server_label: "https://api.dasch.swiss".into(),
2034 auth_state: "anonymous".into(),
2035 filter_warning: None,
2036 count_caveat: Some("counts exclude deleted resources".into()),
2037 count_cost: None,
2038 };
2039 renderer.resource_type_describe(&detail, &meta).unwrap();
2040
2041 let s = out.string();
2042 assert!(
2043 s.contains("Instances: 123"),
2044 "Instances line missing or misaligned; got:\n{s}"
2045 );
2046 let dm_idx = s.find("Data-model:").expect("Data-model line present");
2048 let inst_idx = s.find("Instances:").expect("Instances line present");
2049 assert!(
2050 inst_idx > dm_idx,
2051 "Instances line must come after Data-model line"
2052 );
2053 assert!(
2054 s.contains("counts exclude deleted resources"),
2055 "footer must carry count_caveat; got:\n{s}"
2056 );
2057 }
2058
2059 #[test]
2060 fn resource_type_describe_prose_no_count_no_instances_line() {
2061 let out = SharedBuf::new();
2064 let mut renderer = ProseRenderer::with_writer(out.clone());
2065 let meta = make_meta("anonymous", "https://api.dasch.swiss");
2066 renderer
2067 .resource_type_describe(&make_manuscript_detail(), &meta)
2068 .unwrap();
2069
2070 let s = out.string();
2071 assert!(
2072 !s.contains("Instances:"),
2073 "Instances line must be absent when count is None; got:\n{s}"
2074 );
2075 }
2076
2077 #[test]
2078 fn resource_type_describe_prose_no_label() {
2079 let out = SharedBuf::new();
2080 let mut renderer = ProseRenderer::with_writer(out.clone());
2081 let mut detail = make_manuscript_detail();
2082 detail.label = None;
2083 let meta = make_meta("anonymous", "api.dasch.swiss");
2084 renderer.resource_type_describe(&detail, &meta).unwrap();
2085
2086 let s = out.string();
2087 assert!(
2089 !s.contains("Label:"),
2090 "Label: line must be absent when label is None; got:\n{s}"
2091 );
2092 assert!(
2094 s.contains("Resource-type: manuscript"),
2095 "header missing; got:\n{s}"
2096 );
2097 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
2099 }
2100
2101 #[test]
2102 fn resource_type_describe_prose_zero_fields() {
2103 let out = SharedBuf::new();
2104 let mut renderer = ProseRenderer::with_writer(out.clone());
2105 let detail = ResourceTypeDetail {
2106 name: "Empty".into(),
2107 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Empty".into(),
2108 label: None,
2109 data_model: "minimal".into(),
2110 representation: None,
2111 super_types: vec![],
2112 fields: vec![],
2113 count: None,
2114 };
2115 let meta = make_meta("anonymous", "api.dasch.swiss");
2116 renderer.resource_type_describe(&detail, &meta).unwrap();
2117
2118 let s = out.string();
2119 assert!(
2121 s.contains(" Fields (0)"),
2122 "zero fields branch missing; got:\n{s}"
2123 );
2124 let fields_line = s
2126 .lines()
2127 .find(|l| l.contains("Fields (0)"))
2128 .expect("must have Fields line");
2129 assert!(
2130 !fields_line.contains("Fields (0):"),
2131 "zero branch must NOT have a colon; got: {fields_line:?}"
2132 );
2133 assert!(
2135 !s.contains("Extends:"),
2136 "Extends line must be absent; got:\n{s}"
2137 );
2138 assert!(
2139 !s.contains("Representation:"),
2140 "Representation line must be absent; got:\n{s}"
2141 );
2142 }
2143
2144 #[test]
2145 fn resource_type_describe_prose_no_representation() {
2146 let out = SharedBuf::new();
2147 let mut renderer = ProseRenderer::with_writer(out.clone());
2148 let mut detail = make_manuscript_detail();
2149 detail.representation = None;
2150 let meta = make_meta("anonymous", "api.dasch.swiss");
2151 renderer.resource_type_describe(&detail, &meta).unwrap();
2152
2153 let s = out.string();
2154 assert!(
2156 !s.contains("Representation:"),
2157 "Representation: line must be absent when representation is None; got:\n{s}"
2158 );
2159 }
2160
2161 #[test]
2162 fn resource_type_describe_prose_include_builtins_marker() {
2163 let out = SharedBuf::new();
2165 let mut renderer = ProseRenderer::with_writer(out.clone());
2166 let detail = ResourceTypeDetail {
2167 name: "thing".into(),
2168 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#thing".into(),
2169 label: None,
2170 data_model: "minimal".into(),
2171 representation: None,
2172 super_types: vec![],
2173 fields: vec![
2174 Field {
2175 name: "hasText".into(),
2176 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#hasText".into(),
2177 label: Some("Text content".into()),
2178 value_type: ValueType::Text,
2179 link_target: None,
2180 cardinality: Cardinality::One,
2181 is_builtin: false,
2182 data_model: Some("minimal".into()),
2183 },
2184 Field {
2185 name: "arkUrl".into(),
2186 iri: "http://api.knora.org/ontology/knora-api/v2#arkUrl".into(),
2187 label: Some("ARK URL".into()),
2188 value_type: ValueType::Uri,
2189 link_target: None,
2190 cardinality: Cardinality::One,
2191 is_builtin: true,
2192 data_model: None,
2193 },
2194 ],
2195 count: None,
2196 };
2197 let meta = make_meta("anonymous", "api.dasch.swiss");
2198 renderer.resource_type_describe(&detail, &meta).unwrap();
2199
2200 let s = out.string();
2201 let text_line = s.lines().find(|l| l.contains("hasText")).unwrap();
2203 assert!(
2204 !text_line.contains("(built-in)"),
2205 "project field must not have (built-in) marker; got: {text_line:?}"
2206 );
2207 let ark_line = s.lines().find(|l| l.contains("arkUrl")).unwrap();
2209 assert!(
2210 ark_line.contains("(built-in)"),
2211 "built-in field must have (built-in) marker; got: {ark_line:?}"
2212 );
2213 }
2214
2215 #[test]
2216 fn resource_type_describe_prose_extends_multiple_supers() {
2217 let out = SharedBuf::new();
2219 let mut renderer = ProseRenderer::with_writer(out.clone());
2220 let detail = ResourceTypeDetail {
2221 name: "letter".into(),
2222 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
2223 label: Some("Letter".into()),
2224 data_model: "beol".into(),
2225 representation: None,
2226 super_types: vec!["basicLetter".into(), "writtenSource".into()],
2227 fields: vec![],
2228 count: None,
2229 };
2230 let meta = make_meta("anonymous", "api.dasch.swiss");
2231 renderer.resource_type_describe(&detail, &meta).unwrap();
2232
2233 let s = out.string();
2234 assert!(
2235 s.contains("Extends: basicLetter, writtenSource"),
2236 "multiple supers must be joined with ', '; got:\n{s}"
2237 );
2238 }
2239
2240 use crate::model::{
2243 DatePoint, DateValue, FieldValues, FileValue, ResourceAccess, ResourceDetail,
2244 ResourceVisibility, Value, ValueContent,
2245 };
2246
2247 fn make_resource_detail_no_values() -> ResourceDetail {
2248 ResourceDetail {
2249 label: "Test Resource".into(),
2250 iri: "http://rdfh.ch/0803/abc123".into(),
2251 resource_type: "Page".into(),
2252 ark_url: Some("ark:/72163/1/0803/abc123".into()),
2253 creation_date: Some("2021-01-01T00:00:00Z".into()),
2254 last_modified: None,
2255 attached_project: Some("http://rdfh.ch/projects/0803".into()),
2256 owner: Some("http://rdfh.ch/users/alice".into()),
2257 visibility: Some(ResourceVisibility::Public),
2258 your_access: Some(ResourceAccess::View),
2259 values: None,
2260 }
2261 }
2262
2263 #[test]
2264 fn resource_describe_prose_no_values_unchanged() {
2265 let out = SharedBuf::new();
2268 let mut renderer = ProseRenderer::with_writer(out.clone());
2269 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2270 renderer
2271 .resource_describe(&make_resource_detail_no_values(), &meta)
2272 .unwrap();
2273
2274 let s = out.string();
2275 assert!(s.contains("Resource: Test Resource"), "header missing");
2276 assert!(
2277 !s.contains("Values:"),
2278 "Values: section must be absent when values is None"
2279 );
2280 }
2281
2282 #[test]
2283 fn resource_describe_prose_some_empty_values() {
2284 let out = SharedBuf::new();
2286 let mut renderer = ProseRenderer::with_writer(out.clone());
2287 let mut detail = make_resource_detail_no_values();
2288 detail.values = Some(vec![]);
2289 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2290 renderer.resource_describe(&detail, &meta).unwrap();
2291
2292 let s = out.string();
2293 assert!(
2294 s.contains("Values: (none)"),
2295 "empty values must render as 'Values: (none)'; got:\n{s}"
2296 );
2297 }
2298
2299 #[test]
2300 fn resource_describe_prose_values_section() {
2301 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![
2306 FieldValues {
2307 name: "hasTitle".into(),
2308 label: Some("Title".into()),
2309 values: vec![ValueContent::Text("Incunabula Page".into()).into()],
2310 },
2311 FieldValues {
2312 name: "seqnum".into(),
2313 label: None, values: vec![ValueContent::Integer(42).into()],
2315 },
2316 ]);
2317 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2318 renderer.resource_describe(&detail, &meta).unwrap();
2319
2320 let s = out.string();
2321 assert!(s.contains("Values:"), "Values: header missing; got:\n{s}");
2322 assert!(
2324 s.contains("Title (hasTitle)"),
2325 "labelled field header format wrong; got:\n{s}"
2326 );
2327 assert!(
2328 s.contains("Incunabula Page"),
2329 "text value missing; got:\n{s}"
2330 );
2331 let seqnum_line = s.lines().find(|l| l.contains("seqnum")).unwrap();
2333 assert!(
2334 !seqnum_line.contains('('),
2335 "unlabelled field must not have parentheses; got: {seqnum_line:?}"
2336 );
2337 assert!(s.contains("42"), "integer value missing; got:\n{s}");
2338 }
2339
2340 #[test]
2341 fn resource_describe_prose_value_with_comment() {
2342 let out = SharedBuf::new();
2345 let mut renderer = ProseRenderer::with_writer(out.clone());
2346 let mut detail = make_resource_detail_no_values();
2347 detail.values = Some(vec![FieldValues {
2348 name: "hasTranscription".into(),
2349 label: Some("Transcription".into()),
2350 values: vec![Value {
2351 content: ValueContent::Text("some transcription".into()),
2352 comment: Some("reading uncertain".into()),
2353 }],
2354 }]);
2355 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2356 renderer.resource_describe(&detail, &meta).unwrap();
2357
2358 let s = out.string();
2359 assert!(
2360 s.contains("some transcription"),
2361 "value line missing; got:\n{s}"
2362 );
2363 assert!(
2364 s.contains(" comment: reading uncertain"),
2365 "comment line missing or mis-indented; got:\n{s}"
2366 );
2367 let lines: Vec<&str> = s.lines().collect();
2369 let value_idx = lines
2370 .iter()
2371 .position(|l| l.contains("some transcription"))
2372 .unwrap();
2373 assert_eq!(
2374 lines[value_idx + 1],
2375 " comment: reading uncertain",
2376 "comment line must directly follow the value line; got:\n{s}"
2377 );
2378 }
2379
2380 #[test]
2381 fn resource_describe_prose_value_without_comment_renders_no_comment_line() {
2382 let out = SharedBuf::new();
2386 let mut renderer = ProseRenderer::with_writer(out.clone());
2387 let mut detail = make_resource_detail_no_values();
2388 detail.values = Some(vec![FieldValues {
2389 name: "hasTranscription".into(),
2390 label: Some("Transcription".into()),
2391 values: vec![ValueContent::Text("some transcription".into()).into()],
2392 }]);
2393 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2394 renderer.resource_describe(&detail, &meta).unwrap();
2395
2396 let s = out.string();
2397 assert!(
2398 s.contains("some transcription"),
2399 "value line missing; got:\n{s}"
2400 );
2401 assert!(
2402 !s.contains("comment:"),
2403 "no comment line must be emitted when value.comment is None; got:\n{s}"
2404 );
2405 }
2406
2407 #[test]
2408 fn resource_describe_prose_link_with_label() {
2409 let out = SharedBuf::new();
2410 let mut renderer = ProseRenderer::with_writer(out.clone());
2411 let mut detail = make_resource_detail_no_values();
2412 detail.values = Some(vec![FieldValues {
2413 name: "isPartOf".into(),
2414 label: Some("Is part of".into()),
2415 values: vec![
2416 ValueContent::Link {
2417 target_iri: "http://rdfh.ch/0803/book1".into(),
2418 target_label: Some("Incunabula Book".into()),
2419 }
2420 .into(),
2421 ValueContent::Link {
2422 target_iri: "http://rdfh.ch/0803/book2".into(),
2423 target_label: None, }
2425 .into(),
2426 ],
2427 }]);
2428 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2429 renderer.resource_describe(&detail, &meta).unwrap();
2430
2431 let s = out.string();
2432 assert!(
2434 s.contains("\u{2192} Incunabula Book [http://rdfh.ch/0803/book1]"),
2435 "link with label rendered incorrectly; got:\n{s}"
2436 );
2437 assert!(
2439 s.contains("\u{2192} http://rdfh.ch/0803/book2"),
2440 "degraded link (no label) rendered incorrectly; got:\n{s}"
2441 );
2442 let degraded_line = s
2444 .lines()
2445 .find(|l| l.contains("http://rdfh.ch/0803/book2"))
2446 .unwrap();
2447 assert!(
2448 !degraded_line.contains('['),
2449 "degraded link must not have brackets; got: {degraded_line:?}"
2450 );
2451 }
2452
2453 #[test]
2454 fn resource_describe_prose_file_still_image_with_dims() {
2455 let out = SharedBuf::new();
2456 let mut renderer = ProseRenderer::with_writer(out.clone());
2457 let mut detail = make_resource_detail_no_values();
2458 detail.values = Some(vec![FieldValues {
2459 name: "hasStillImageFileValue".into(),
2460 label: None,
2461 values: vec![
2462 ValueContent::File(FileValue {
2463 value_type: ValueType::StillImage,
2464 filename: "image.jp2".into(),
2465 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
2466 width: Some(1200),
2467 height: Some(800),
2468 })
2469 .into(),
2470 ],
2471 }]);
2472 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2473 renderer.resource_describe(&detail, &meta).unwrap();
2474
2475 let s = out.string();
2476 assert!(
2478 s.contains("image.jp2 (1200\u{d7}800) https://iiif.example.com"),
2479 "still-image with dims rendered incorrectly; got:\n{s}"
2480 );
2481 }
2482
2483 #[test]
2484 fn resource_describe_prose_date_rendering() {
2485 let out = SharedBuf::new();
2486 let mut renderer = ProseRenderer::with_writer(out.clone());
2487 let mut detail = make_resource_detail_no_values();
2488 let pt = DatePoint {
2490 year: Some(1489),
2491 month: None,
2492 day: None,
2493 era: Some("CE".into()),
2494 };
2495 let single = DateValue {
2496 calendar: "GREGORIAN".into(),
2497 start: pt.clone(),
2498 end: pt.clone(),
2499 };
2500 let range = DateValue {
2502 calendar: "GREGORIAN".into(),
2503 start: DatePoint {
2504 year: Some(1489),
2505 month: None,
2506 day: None,
2507 era: Some("CE".into()),
2508 },
2509 end: DatePoint {
2510 year: Some(1490),
2511 month: None,
2512 day: None,
2513 era: Some("CE".into()),
2514 },
2515 };
2516 detail.values = Some(vec![FieldValues {
2517 name: "hasDate".into(),
2518 label: Some("Date".into()),
2519 values: vec![
2520 ValueContent::Date(single).into(),
2521 ValueContent::Date(range).into(),
2522 ],
2523 }]);
2524 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2525 renderer.resource_describe(&detail, &meta).unwrap();
2526
2527 let s = out.string();
2528 assert!(
2530 s.contains("1489 CE (GREGORIAN)"),
2531 "single-point date rendered incorrectly; got:\n{s}"
2532 );
2533 assert!(
2535 s.contains("1489 CE \u{2013} 1490 CE (GREGORIAN)"),
2536 "range date rendered incorrectly; got:\n{s}"
2537 );
2538 }
2539
2540 #[test]
2543 fn resource_describe_prose_strips_control_chars_in_label() {
2544 let out = SharedBuf::new();
2547 let mut renderer = ProseRenderer::with_writer(out.clone());
2548 let mut detail = make_resource_detail_no_values();
2549 detail.label = "Bad\u{1b}[31mLabel\u{7f}".into();
2550 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2551 renderer.resource_describe(&detail, &meta).unwrap();
2552
2553 let s = out.string();
2554 assert!(
2555 s.contains("Resource: Bad[31mLabel"),
2556 "label control chars not stripped; got:\n{s}"
2557 );
2558 assert!(
2559 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2560 "control characters leaked into prose output; got:\n{s:?}"
2561 );
2562 }
2563
2564 #[test]
2565 fn resources_prose_strips_control_chars_in_label() {
2566 use crate::model::ResourceSummary;
2567
2568 let out = SharedBuf::new();
2569 let mut renderer = ProseRenderer::with_writer(out.clone());
2570 let view = ResourceListView {
2571 items: vec![ResourceSummary {
2572 label: "Bad\u{1b}[31mLabel\u{7f}".into(),
2573 iri: "http://rdfh.ch/0803/abc123".into(),
2574 ark_url: None,
2575 creation_date: None,
2576 last_modified: None,
2577 resource_type: "Page".into(),
2578 }],
2579 total: 1,
2580 filter: None,
2581 resource_type: "Page".into(),
2582 pagination: ResourceListPagination::SinglePage {
2583 page: 0,
2584 may_have_more: false,
2585 },
2586 };
2587 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2588 renderer.resources(&view, &meta).unwrap();
2589
2590 let s = out.string();
2591 assert!(
2592 s.contains("Bad[31mLabel"),
2593 "label control chars not stripped; got:\n{s}"
2594 );
2595 assert!(
2596 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2597 "control characters leaked into prose output; got:\n{s:?}"
2598 );
2599 }
2600}