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