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 let mut row = format!(" {:<sc_w$} {:<sn_w$} {:<ln_w$}", item.shortcode, item.shortname, longname,);
201 if item.data_models > 0 {
202 let label = if item.data_models == 1 {
203 "data-model"
204 } else {
205 "data-models"
206 };
207 row.push_str(&format!(" \u{b7} {} {label}", item.data_models));
208 }
209 writeln!(self.out, "{}", row.trim_end())?;
210 }
211
212 render_prose_footer(&mut *self.out, meta)?;
213 Ok(())
214 }
215
216 fn project_describe(&mut self, project: &ProjectDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
217 writeln!(self.out, "Project: {} ({})", project.shortname, project.shortcode)?;
220
221 if let Some(ln) = &project.longname {
226 writeln!(self.out, " Name: {ln}")?;
227 }
228
229 writeln!(self.out, " IRI: {}", project.iri)?;
230
231 if !project.keywords.is_empty() {
233 writeln!(self.out, " Keywords: {}", project.keywords.join(", "))?;
234 }
235
236 let n = project.data_models.len();
238 if n == 0 {
239 writeln!(self.out, " Data-models (0)")?;
240 } else {
241 let names: Vec<&str> = project.data_models.iter().map(|dm| dm.name.as_str()).collect();
242 writeln!(self.out, " Data-models ({n}): {}", names.join(", "))?;
243 }
244
245 let descriptions: Vec<(Option<&str>, String)> = project
251 .description
252 .iter()
253 .map(|e| (e.language.as_deref(), html_to_text(&e.value)))
254 .filter(|(_, plain)| !plain.is_empty())
255 .collect();
256 if !descriptions.is_empty() {
257 writeln!(self.out, " Description:")?;
258 for (language, plain) in &descriptions {
259 let prefix = match language {
263 Some(lang) => format!(" [{lang}] "),
264 None => " ".to_string(),
265 };
266 let continuation_indent = " ".repeat(prefix.len());
267 let mut first_line = true;
268 for line in plain.lines() {
269 if first_line {
270 writeln!(self.out, "{prefix}{line}")?;
271 first_line = false;
272 } else if line.is_empty() {
273 writeln!(self.out)?;
276 } else {
277 writeln!(self.out, "{continuation_indent}{line}")?;
278 }
279 }
280 }
281 }
282
283 render_prose_footer(&mut *self.out, meta)?;
284 Ok(())
285 }
286
287 fn data_model_describe(&mut self, detail: &DataModelDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
288 writeln!(self.out, "Data-model: {}", detail.name)?;
290
291 if let Some(ref lbl) = detail.label {
295 writeln!(self.out, " Label: {lbl}")?;
296 }
297 writeln!(self.out, " IRI: {}", detail.iri)?;
298 if let Some(ref lm) = detail.last_modified {
299 let date = lm.split_once('T').map(|(d, _)| d).unwrap_or(lm.as_str());
300 writeln!(self.out, " Last-modified: {date}")?;
301 }
302
303 let n = detail.resource_types.len();
305 if n == 0 {
306 writeln!(self.out, " Resource-types (0)")?;
307 } else {
308 writeln!(self.out)?;
309 writeln!(self.out, " Resource-types ({n}):")?;
310 let name_w = detail.resource_types.iter().map(|rt| rt.name.len()).max().unwrap_or(0);
312 for rt in &detail.resource_types {
313 let label = rt.label.as_deref().unwrap_or("");
314 writeln!(self.out, " {:<name_w$} {label}", rt.name)?;
315 }
316 }
317
318 render_prose_footer(&mut *self.out, meta)?;
319 Ok(())
320 }
321
322 fn data_models(&mut self, view: &DataModelListView, meta: &MetaContext) -> Result<(), Diagnostic> {
323 let n = view.items.len();
324 let has_builtins = view.items.iter().any(|d| d.is_builtin);
325 let builtin_suffix = if has_builtins { ", incl. built-ins" } else { "" };
326
327 match &view.filter {
329 None => writeln!(self.out, "data-models on {} ({n}){builtin_suffix}:", meta.server_label,)?,
330 Some(f) => writeln!(
331 self.out,
332 "data-models on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
333 meta.server_label, view.total,
334 )?,
335 }
336
337 writeln!(self.out)?;
338
339 let name_w = view.items.iter().map(|d| d.name.len()).max().unwrap_or(0);
341 let label_w = view
342 .items
343 .iter()
344 .map(|d| {
345 if let Some(ref lbl) = d.label {
346 lbl.len()
347 } else if d.is_builtin {
348 "(built-in)".len()
349 } else {
350 0
351 }
352 })
353 .max()
354 .unwrap_or(0);
355
356 for item in &view.items {
357 let label_or_marker: &str = if let Some(ref lbl) = item.label {
359 lbl.as_str()
360 } else if item.is_builtin {
361 "(built-in)"
362 } else {
363 ""
364 };
365
366 let date_str: String = match &item.last_modified {
368 Some(s) => s.split_once('T').map(|(d, _)| d.to_string()).unwrap_or_else(|| s.clone()),
369 None => String::new(),
370 };
371
372 writeln!(
373 self.out,
374 " {:<name_w$} {:<label_w$} {}",
375 item.name, label_or_marker, date_str,
376 )?;
377 }
378
379 render_prose_footer(&mut *self.out, meta)?;
380 Ok(())
381 }
382
383 fn resource_types(&mut self, view: &ResourceTypeListView, meta: &MetaContext) -> Result<(), Diagnostic> {
384 let n = view.items.len();
385 let has_builtins = view.items.iter().any(|rt| rt.is_builtin);
386 let has_counts = view.items.iter().any(|rt| rt.count.is_some());
387 let builtin_suffix = if has_builtins { ", incl. built-ins" } else { "" };
388
389 match &view.filter {
391 None => writeln!(
392 self.out,
393 "resource-types in {} on {} ({n}){builtin_suffix}:",
394 view.data_model, meta.server_label,
395 )?,
396 Some(f) => writeln!(
397 self.out,
398 "resource-types in {} on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
399 view.data_model, meta.server_label, view.total,
400 )?,
401 }
402
403 writeln!(self.out)?;
404
405 let name_w = view.items.iter().map(|rt| rt.name.len()).max().unwrap_or(0);
407 let label_w = view
408 .items
409 .iter()
410 .map(|rt| rt.label.as_deref().map(|l| l.len()).unwrap_or(0))
411 .max()
412 .unwrap_or(0);
413
414 if has_counts {
415 let count_w = view
419 .items
420 .iter()
421 .map(|rt| rt.count.map(|c| c.to_string()).unwrap_or_default().len())
422 .max()
423 .unwrap_or(0);
424 for item in &view.items {
425 let label = item.label.as_deref().unwrap_or("");
426 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
427 if item.is_builtin {
428 writeln!(
429 self.out,
430 " {:<name_w$} {:<label_w$} {:>count_w$} (built-in)",
431 item.name, label, count_str,
432 )?;
433 } else {
434 writeln!(
435 self.out,
436 " {:<name_w$} {:<label_w$} {:>count_w$}",
437 item.name, label, count_str,
438 )?;
439 }
440 }
441 } else {
442 for item in &view.items {
443 let label = item.label.as_deref().unwrap_or("");
444 if item.is_builtin {
446 writeln!(self.out, " {:<name_w$} {:<label_w$} (built-in)", item.name, label,)?;
447 } else {
448 writeln!(self.out, " {:<name_w$} {label}", item.name)?;
449 }
450 }
451 }
452
453 render_prose_footer(&mut *self.out, meta)?;
454 Ok(())
455 }
456
457 fn resource_type_describe(
458 &mut self,
459 detail: &crate::model::ResourceTypeDetail,
460 meta: &MetaContext,
461 ) -> Result<(), Diagnostic> {
462 use crate::model::ValueType;
463
464 writeln!(self.out, "Resource-type: {}", detail.name)?;
466
467 if let Some(ref lbl) = detail.label {
471 writeln!(self.out, " Label: {lbl}")?;
472 }
473 if !detail.super_types.is_empty() {
474 writeln!(self.out, " Extends: {}", detail.super_types.join(", "))?;
475 }
476 if let Some(ref repr) = detail.representation {
477 writeln!(self.out, " Representation: {repr}")?;
478 }
479 writeln!(self.out, " IRI: {}", detail.iri)?;
480 writeln!(self.out, " Data-model: {}", detail.data_model)?;
481 if let Some(count) = detail.count {
482 writeln!(self.out, " Instances: {count}")?;
483 }
484
485 let n = detail.fields.len();
487 if n == 0 {
488 writeln!(self.out, "\n Fields (0)")?;
489 } else {
490 writeln!(self.out, "\n Fields ({n}):")?;
491
492 let vtype_strs: Vec<String> = detail
494 .fields
495 .iter()
496 .map(|f| match &f.value_type {
497 ValueType::Link => {
498 if let Some(ref tgt) = f.link_target {
499 format!("link \u{2192} {tgt}")
500 } else {
501 "link".to_string()
502 }
503 }
504 vt => vt.to_string(),
505 })
506 .collect();
507
508 let name_w = detail.fields.iter().map(|f| f.name.len()).max().unwrap_or(0);
510 let vtype_w = vtype_strs.iter().map(|s| s.len()).max().unwrap_or(0);
511 let card_w = detail.fields.iter().map(|f| f.cardinality.to_string().len()).max().unwrap_or(0);
512
513 for (field, vtype_str) in detail.fields.iter().zip(vtype_strs.iter()) {
514 let label = field.label.as_deref().unwrap_or("");
515 let card_str = field.cardinality.to_string();
516
517 let source_tag = if let Some(ref dm) = field.data_model {
520 if dm != &detail.data_model {
521 format!(" [from {dm}]")
522 } else {
523 String::new()
524 }
525 } else {
526 String::new()
527 };
528
529 let builtin_marker = if field.is_builtin { " (built-in)" } else { "" };
531
532 writeln!(
533 self.out,
534 " {:<name_w$} {:<vtype_w$} {:<card_w$} {label}{builtin_marker}{source_tag}",
535 field.name, vtype_str, card_str,
536 )?;
537 }
538 }
539
540 render_prose_footer(&mut *self.out, meta)?;
541 Ok(())
542 }
543
544 fn data_model_structure(&mut self, structure: &DataModelStructure, meta: &MetaContext) -> Result<(), Diagnostic> {
545 use crate::model::RelationKind;
546
547 let n = structure.relations.len();
549 let plural = if n == 1 { "relation" } else { "relations" };
550 writeln!(self.out, "Structure: {} ({n} {plural})", structure.data_model)?;
551
552 if n > 0 {
553 writeln!(self.out)?;
554
555 let source_w = structure.relations.iter().map(|r| r.source.len()).max().unwrap_or(0);
558
559 let target_w = structure
561 .relations
562 .iter()
563 .map(|r| {
564 let tag_len = match &r.target_data_model {
565 Some(dm) if dm != &structure.data_model => " [to ".len() + dm.len() + "]".len(),
566 _ => 0,
567 };
568 r.target.len() + tag_len
569 })
570 .max()
571 .unwrap_or(0);
572
573 let field_w = structure
575 .relations
576 .iter()
577 .map(|r| r.field.as_deref().unwrap_or("").len())
578 .max()
579 .unwrap_or(0);
580
581 for rel in &structure.relations {
582 let target_col = match &rel.target_data_model {
584 Some(dm) if dm != &structure.data_model => {
585 format!("{} [to {}]", rel.target, dm)
586 }
587 _ => rel.target.clone(),
588 };
589
590 let field_col = rel.field.as_deref().unwrap_or("");
592
593 let kind_marker = match rel.kind {
595 RelationKind::Link => "[link]",
596 RelationKind::Inherits => "[inherits]",
597 };
598
599 writeln!(
600 self.out,
601 " {:<source_w$} \u{2192} {:<target_w$} {:<field_w$} {kind_marker}",
602 rel.source, target_col, field_col,
603 )?;
604 }
605 }
606
607 render_prose_footer(&mut *self.out, meta)?;
608 Ok(())
609 }
610
611 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
612 let n = view.items.len();
613
614 match &view.filter {
616 None => writeln!(
617 self.out,
618 "resources of type {} on {} ({n}):",
619 view.resource_type, meta.server_label,
620 )?,
621 Some(f) => writeln!(
622 self.out,
623 "resources of type {} on {} ({n} of {} matching \"{f}\"):",
624 view.resource_type, meta.server_label, view.total,
625 )?,
626 }
627
628 if n > 0 {
629 writeln!(self.out)?;
630
631 let labels: Vec<String> = view.items.iter().map(|r| strip_control_chars(&r.label)).collect();
635 let label_w = labels.iter().map(|l| l.len()).max().unwrap_or(0);
636
637 for (item, label) in view.items.iter().zip(&labels) {
638 writeln!(self.out, " {:<label_w$} {}", label, item.iri,)?;
639 }
640 }
641
642 if let ResourceListPagination::SinglePage { may_have_more: true, .. } = view.pagination {
644 writeln!(self.out)?;
645 writeln!(self.out, " more results available (use --all to fetch all pages)")?;
646 }
647
648 render_prose_footer(&mut *self.out, meta)?;
649 Ok(())
650 }
651
652 fn resource_describe(
653 &mut self,
654 detail: &crate::model::ResourceDetail,
655 meta: &MetaContext,
656 ) -> Result<(), Diagnostic> {
657 writeln!(self.out, "Resource: {}", strip_control_chars(&detail.label))?;
659
660 writeln!(self.out, " Type: {}", detail.resource_type)?;
665 writeln!(self.out, " IRI: {}", detail.iri)?;
666 if let Some(ref ark) = detail.ark_url {
667 writeln!(self.out, " ARK: {ark}")?;
668 }
669 if let Some(ref created) = detail.creation_date {
670 writeln!(self.out, " Created: {created}")?;
671 }
672 if let Some(ref modified) = detail.last_modified {
673 writeln!(self.out, " Modified: {modified}")?;
674 }
675 if let Some(ref project) = detail.attached_project {
676 writeln!(self.out, " Project: {project}")?;
677 }
678 if let Some(ref owner) = detail.owner {
679 writeln!(self.out, " Owner: {owner}")?;
680 }
681 if let Some(ref vis) = detail.visibility {
682 writeln!(self.out, " Visibility: {}", vis.as_str())?;
683 }
684 if let Some(ref access) = detail.your_access {
685 writeln!(self.out, " Your access: {}", access.as_str())?;
686 }
687
688 if let Some(ref fields) = detail.values {
690 writeln!(self.out)?;
691 if fields.is_empty() {
692 writeln!(self.out, "Values: (none)")?;
693 } else {
694 writeln!(self.out, "Values:")?;
695 for fv in fields {
696 let header = match &fv.label {
698 Some(lbl) => {
699 format!("{} ({})", strip_control_chars(lbl), strip_control_chars(&fv.name))
700 }
701 None => strip_control_chars(&fv.name),
702 };
703 writeln!(self.out, " {header}")?;
704
705 for value in &fv.values {
706 let rendered = render_value_content(&value.content);
707 writeln!(self.out, " {}", strip_control_chars(&rendered))?;
708 if let Some(comment) = &value.comment {
709 writeln!(self.out, " comment: {}", strip_control_chars(comment))?;
710 }
711 }
712 }
713 }
714 }
715
716 render_prose_footer(&mut *self.out, meta)?;
717 Ok(())
718 }
719
720 fn vocabularies(&mut self, view: &VocabularyListView, meta: &MetaContext) -> Result<(), Diagnostic> {
721 let n = view.items.len();
723 match &view.filter {
724 None => writeln!(self.out, "Vocabularies on {} ({n}):", meta.server_label)?,
725 Some(f) => writeln!(
726 self.out,
727 "Vocabularies on {} ({n} of {} matching \"{f}\"):",
728 meta.server_label, view.total
729 )?,
730 }
731
732 writeln!(self.out)?;
733
734 for item in &view.items {
735 let name = strip_control_chars(item.header.name.as_deref().unwrap_or(""));
736 let labels = strip_control_chars(&join_labels_prose(&item.header.labels));
737 write!(self.out, " {name} {labels}")?;
738 if view.counted
744 && let (Some(nodes), Some(depth)) = (item.node_count, item.depth)
745 {
746 let node_word = if nodes == 1 { "node" } else { "nodes" };
747 let level_word = if depth == 1 { "level" } else { "levels" };
748 write!(self.out, " \u{b7} {nodes} {node_word} \u{b7} {depth} {level_word}")?;
749 }
750 writeln!(self.out)?;
751 }
752
753 render_prose_footer(&mut *self.out, meta)?;
754 Ok(())
755 }
756
757 fn vocabulary_describe(&mut self, detail: &VocabularyDetail, meta: &MetaContext) -> Result<(), Diagnostic> {
758 let root = &detail.tree.root;
759 let root_name = strip_control_chars(root.name.as_deref().unwrap_or(""));
760 let root_labels = strip_control_chars(&join_labels_prose(&root.labels));
761 writeln!(self.out, "Vocabulary: {root_name} \u{2014} {root_labels}")?;
762 writeln!(self.out, "Root: {}", root.iri)?;
763
764 let rows = flatten_vocabulary_detail(detail);
768
769 let node_word = if detail.node_count == 1 { "node" } else { "nodes" };
770 let level_word = if detail.depth == 1 { "level" } else { "levels" };
771 write!(
772 self.out,
773 "{} {node_word} \u{b7} {} {level_word}",
774 detail.node_count, detail.depth
775 )?;
776 if let Some(target_iri) = &detail.subtree_of
779 && let Some(top) = rows.iter().find(|n| &n.header.iri == target_iri)
780 {
781 write!(self.out, " \u{b7} subtree of {}", top.number)?;
782 } else if let Some(requested) = &detail.tree.requested_node
783 && let Some(row) = rows.iter().find(|n| &n.header.iri == requested)
784 {
785 write!(self.out, " \u{2190} you asked about {}", row.number)?;
786 }
787 writeln!(self.out)?;
788 writeln!(self.out)?;
789
790 let base_depth = rows.first().map(|n| n.depth).unwrap_or(1);
794 for node in &rows {
795 let indent = " ".repeat(node.depth.saturating_sub(base_depth) + 1);
796 let labels = strip_control_chars(&join_labels_prose(&node.header.labels));
797 let marker = if detail.tree.requested_node.as_deref() == Some(node.header.iri.as_str()) {
798 " \u{2190}"
799 } else {
800 ""
801 };
802 writeln!(self.out, "{indent}{} {labels}{marker}", node.number)?;
803 }
804
805 render_prose_footer(&mut *self.out, meta)?;
806 Ok(())
807 }
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use crate::model::{
814 Cardinality, DataModel, DataModelDetail, DataModelSummary, Field, Project, ProjectDescription, ProjectDetail,
815 Representation, ResourceType, ResourceTypeDetail, ResourceTypeSummary, ValueType,
816 };
817 use crate::render::test_support::{SharedBuf, make_meta};
818 use crate::render::{DataModelListView, ResourceTypeListView};
819
820 fn make_fixture() -> Vec<Project> {
821 vec![
822 Project {
823 iri: "http://rdfh.ch/projects/0001".into(),
824 shortcode: "0001".into(),
825 shortname: "anything".into(),
826 longname: Some("Anything Project".into()),
827 data_models: 2,
828 },
829 Project {
830 iri: "http://rdfh.ch/projects/0002".into(),
831 shortcode: "0002".into(),
832 shortname: "images".into(),
833 longname: None,
834 data_models: 0,
835 },
836 Project {
837 iri: "http://rdfh.ch/projects/0803".into(),
838 shortcode: "0803".into(),
839 shortname: "daschland".into(),
840 longname: Some("=Formula Project".into()),
841 data_models: 1,
842 },
843 ]
844 }
845
846 #[test]
847 fn projects_prose_no_filter() {
848 let out = SharedBuf::new();
849 let mut renderer = ProseRenderer::with_writer(out.clone());
850 let view = ProjectListView { items: make_fixture(), total: 3, filter: None };
851 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
852 renderer.projects(&view, &meta).unwrap();
853
854 let s = out.string();
855 assert!(s.contains("Projects on https://api.test.dasch.swiss (3):"));
856 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
858 assert!(!s.contains("rdfh.ch"));
860 assert!(s.contains("2 data-models"));
862 assert!(s.contains("1 data-model"));
864 let data_model_lines = s.lines().filter(|l| l.contains("data-model")).count();
866 assert_eq!(data_model_lines, 2);
867 assert!(!s.contains("None"));
869 }
870
871 #[test]
872 fn projects_prose_with_filter() {
873 let out = SharedBuf::new();
874 let mut renderer = ProseRenderer::with_writer(out.clone());
875 let items = vec![make_fixture().remove(0)]; let view = ProjectListView { items, total: 3, filter: Some("any".to_string()) };
877 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
878 renderer.projects(&view, &meta).unwrap();
879
880 let s = out.string();
881 assert!(s.contains("(1 of 3 matching \"any\")"));
882 }
883
884 #[test]
885 fn projects_prose_footer_on_stdout() {
886 let out = SharedBuf::new();
888 let mut renderer = ProseRenderer::with_writer(out.clone());
889 let view = ProjectListView { items: vec![], total: 0, filter: None };
890 let meta = make_meta("authenticated as alice", "https://api.dasch.swiss");
891 renderer.projects(&view, &meta).unwrap();
892
893 let s = out.string();
894 assert!(s.contains("[authenticated as alice on https://api.dasch.swiss]"));
895 }
896
897 fn make_beol_detail() -> ProjectDetail {
898 ProjectDetail {
899 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
900 shortcode: "0801".into(),
901 shortname: "beol".into(),
902 longname: Some("Bernoulli-Euler Online".into()),
903 description: vec![ProjectDescription {
904 value: "<b>BEOL</b> — early modern mathematics.".into(),
905 language: Some("en".into()),
906 }],
907 keywords: vec!["Bernoulli".into(), "Euler".into(), "Mathematics".into()],
908 data_models: vec![
909 DataModelSummary {
910 name: "beol".into(),
911 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
912 },
913 DataModelSummary {
914 name: "biblio".into(),
915 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
916 },
917 DataModelSummary {
918 name: "leibniz".into(),
919 iri: "http://api.dasch.swiss/ontology/0801/leibniz/v2".into(),
920 },
921 DataModelSummary {
922 name: "newton".into(),
923 iri: "http://api.dasch.swiss/ontology/0801/newton/v2".into(),
924 },
925 ],
926 }
927 }
928
929 #[test]
930 fn project_describe_prose_full() {
931 let out = SharedBuf::new();
932 let mut renderer = ProseRenderer::with_writer(out.clone());
933 let meta = make_meta("anonymous", "api.dasch.swiss");
934 renderer.project_describe(&make_beol_detail(), &meta).unwrap();
935
936 let s = out.string();
937 assert!(s.contains("Project: beol (0801)"));
939 assert!(s.contains("Name: Bernoulli-Euler Online"));
941 assert!(s.contains("IRI: http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"));
943 assert!(s.contains("Keywords: Bernoulli, Euler, Mathematics"));
944 assert!(s.contains("Data-models (4): beol, biblio, leibniz, newton"));
946 assert!(s.contains("[en] BEOL"));
948 assert!(!s.contains("<b>"), "description must not contain raw <b> tags");
949 assert!(!s.contains("</b>"), "description must not contain raw </b> tags");
950 assert!(s.contains("[anonymous on api.dasch.swiss]"));
952 }
953
954 #[test]
955 fn project_describe_prose_no_longname() {
956 let out = SharedBuf::new();
957 let mut renderer = ProseRenderer::with_writer(out.clone());
958 let mut detail = make_beol_detail();
959 detail.longname = None;
960 let meta = make_meta("anonymous", "api.dasch.swiss");
961 renderer.project_describe(&detail, &meta).unwrap();
962
963 let s = out.string();
964 assert!(s.contains("Project: beol (0801)"));
966 assert!(!s.contains("Name:"), "Name: line must be absent when longname is None");
967 }
968
969 #[test]
970 fn project_describe_prose_empty_keywords_and_description() {
971 let out = SharedBuf::new();
972 let mut renderer = ProseRenderer::with_writer(out.clone());
973 let detail = ProjectDetail {
974 iri: "http://rdfh.ch/projects/0000".into(),
975 shortcode: "0000".into(),
976 shortname: "minimal".into(),
977 longname: None,
978 description: vec![],
979 keywords: vec![],
980 data_models: vec![],
981 };
982 let meta = make_meta("anonymous", "api.dasch.swiss");
983 renderer.project_describe(&detail, &meta).unwrap();
984
985 let s = out.string();
986 assert!(!s.contains("Keywords"));
988 assert!(!s.contains("Description"));
990 assert!(s.contains("Data-models (0)"));
992 assert!(!s.contains("Data-models (0):"));
993 }
994
995 #[test]
996 fn project_describe_prose_description_no_language() {
997 let out = SharedBuf::new();
998 let mut renderer = ProseRenderer::with_writer(out.clone());
999 let detail = ProjectDetail {
1000 iri: "http://rdfh.ch/projects/0001".into(),
1001 shortcode: "0001".into(),
1002 shortname: "test".into(),
1003 longname: None,
1004 description: vec![ProjectDescription {
1005 value: "Plain description without language tag.".into(),
1006 language: None,
1007 }],
1008 keywords: vec![],
1009 data_models: vec![],
1010 };
1011 let meta = make_meta("anonymous", "api.dasch.swiss");
1012 renderer.project_describe(&detail, &meta).unwrap();
1013
1014 let s = out.string();
1015 assert!(s.contains(" Plain description without language tag."));
1017 let desc_line = s.lines().find(|l| l.contains("Plain description")).unwrap();
1019 assert!(!desc_line.contains('['));
1020 }
1021
1022 #[test]
1026 fn project_describe_prose_two_descriptions_language_and_none() {
1027 let out = SharedBuf::new();
1028 let mut renderer = ProseRenderer::with_writer(out.clone());
1029 let detail = ProjectDetail {
1030 iri: "http://rdfh.ch/projects/0002".into(),
1031 shortcode: "0002".into(),
1032 shortname: "multilang".into(),
1033 longname: None,
1034 description: vec![
1035 ProjectDescription {
1036 value: "English description of this project.".into(),
1037 language: Some("en".into()),
1038 },
1039 ProjectDescription {
1040 value: "Description without a language tag.".into(),
1041 language: None,
1042 },
1043 ],
1044 keywords: vec![],
1045 data_models: vec![],
1046 };
1047 let meta = make_meta("anonymous", "api.dasch.swiss");
1048 renderer.project_describe(&detail, &meta).unwrap();
1049
1050 let s = out.string();
1051
1052 assert!(
1054 s.contains("English description of this project."),
1055 "first description value must appear"
1056 );
1057 assert!(
1058 s.contains("Description without a language tag."),
1059 "second description value must appear"
1060 );
1061
1062 let en_line = s
1064 .lines()
1065 .find(|l| l.contains("English description"))
1066 .expect("expected a line containing 'English description'");
1067 assert!(
1068 en_line.contains("[en] "),
1069 "languaged entry must carry '[en] ' prefix; got: {en_line:?}"
1070 );
1071
1072 let plain_line = s
1074 .lines()
1075 .find(|l| l.contains("Description without a language tag."))
1076 .expect("expected a line containing 'Description without a language tag.'");
1077 assert!(
1078 !plain_line.contains('['),
1079 "no-language entry must not have a '[...]' prefix; got: {plain_line:?}"
1080 );
1081 }
1082
1083 #[test]
1086 fn project_describe_prose_blank_lines_are_not_indented() {
1087 let out = SharedBuf::new();
1088 let mut renderer = ProseRenderer::with_writer(out.clone());
1089 let detail = ProjectDetail {
1090 iri: "http://rdfh.ch/projects/0003".into(),
1091 shortcode: "0003".into(),
1092 shortname: "multipara".into(),
1093 longname: None,
1094 description: vec![ProjectDescription {
1095 value: "First paragraph.\n\nSecond paragraph.".into(),
1096 language: Some("en".into()),
1097 }],
1098 keywords: vec![],
1099 data_models: vec![],
1100 };
1101 let meta = make_meta("anonymous", "api.dasch.swiss");
1102 renderer.project_describe(&detail, &meta).unwrap();
1103
1104 let s = out.string();
1105 assert!(s.contains("[en] First paragraph."));
1107 assert!(s.contains("Second paragraph."));
1108 let ws_only = s.lines().find(|l| !l.is_empty() && l.trim().is_empty());
1111 assert!(
1112 ws_only.is_none(),
1113 "blank separator lines must be truly empty, not whitespace-only; got:\n{s}"
1114 );
1115 }
1116
1117 #[test]
1120 fn project_describe_prose_empty_after_html_omits_description_label() {
1121 let out = SharedBuf::new();
1122 let mut renderer = ProseRenderer::with_writer(out.clone());
1123 let detail = ProjectDetail {
1124 iri: "http://rdfh.ch/projects/0004".into(),
1125 shortcode: "0004".into(),
1126 shortname: "tagsonly".into(),
1127 longname: None,
1128 description: vec![ProjectDescription {
1129 value: "<br/>".into(), language: Some("en".into()),
1131 }],
1132 keywords: vec![],
1133 data_models: vec![],
1134 };
1135 let meta = make_meta("anonymous", "api.dasch.swiss");
1136 renderer.project_describe(&detail, &meta).unwrap();
1137
1138 let s = out.string();
1139 assert!(
1140 !s.contains("Description:"),
1141 "Description: label must be omitted when no entry has visible text; got:\n{s}"
1142 );
1143 }
1144
1145 fn make_data_model_fixture() -> Vec<DataModel> {
1146 vec![
1147 DataModel {
1148 name: "beol".into(),
1149 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1150 label: Some("The BEOL data-model".into()),
1151 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1152 is_builtin: false,
1153 },
1154 DataModel {
1155 name: "biblio".into(),
1156 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1157 label: None,
1158 last_modified: Some("2024-01-10T08:00:00.000000Z".into()),
1159 is_builtin: false,
1160 },
1161 DataModel {
1162 name: "knora-api".into(),
1163 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
1164 label: None,
1165 last_modified: None,
1166 is_builtin: true,
1167 },
1168 ]
1169 }
1170
1171 #[test]
1172 fn data_models_prose_no_filter() {
1173 let out = SharedBuf::new();
1174 let mut renderer = ProseRenderer::with_writer(out.clone());
1175 let view = DataModelListView { items: make_data_model_fixture(), total: 3, filter: None };
1176 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1177 renderer.data_models(&view, &meta).unwrap();
1178
1179 let s = out.string();
1180 assert!(
1182 s.contains("data-models on https://api.test.dasch.swiss (3), incl. built-ins:"),
1183 "header must include count and built-ins suffix; got:\n{s}"
1184 );
1185 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
1187 assert!(s.contains("beol") && s.contains("The BEOL data-model") && s.contains("2024-05-27"));
1189 assert!(!s.contains("T13:43:26"), "prose must show only the date portion");
1191 assert!(s.contains("biblio"));
1193 assert!(s.contains("(built-in)"));
1195 assert!(!s.contains("None"));
1197 }
1198
1199 #[test]
1200 fn data_models_prose_with_filter() {
1201 let out = SharedBuf::new();
1202 let mut renderer = ProseRenderer::with_writer(out.clone());
1203 let items = vec![make_data_model_fixture().remove(0)]; let view = DataModelListView { items, total: 3, filter: Some("beol".to_string()) };
1205 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1206 renderer.data_models(&view, &meta).unwrap();
1207
1208 let s = out.string();
1209 assert!(
1210 s.contains("(1 of 3 matching \"beol\")"),
1211 "filter header must show m of total; got:\n{s}"
1212 );
1213 assert!(!s.contains("incl. built-ins"));
1215 }
1216
1217 #[test]
1221 fn data_models_prose_no_builtins_header_has_no_builtin_suffix() {
1222 let out = SharedBuf::new();
1223 let mut renderer = ProseRenderer::with_writer(out.clone());
1224 let view = DataModelListView {
1225 items: vec![
1226 DataModel {
1227 name: "beol".into(),
1228 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1229 label: Some("The BEOL data-model".into()),
1230 last_modified: None,
1231 is_builtin: false,
1232 },
1233 DataModel {
1234 name: "biblio".into(),
1235 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1236 label: None,
1237 last_modified: None,
1238 is_builtin: false,
1239 },
1240 ],
1241 total: 2,
1242 filter: None,
1243 };
1244 let meta = make_meta("anonymous", "api.dasch.swiss");
1245 renderer.data_models(&view, &meta).unwrap();
1246
1247 let s = out.string();
1248 let header_line = s.lines().next().expect("output must have at least one line");
1250 assert!(
1251 header_line.ends_with("(2):"),
1252 "header must end with '(2):' when there are no builtins; got: {header_line:?}"
1253 );
1254 assert!(
1255 !s.contains("incl. built-ins"),
1256 "header must not contain 'incl. built-ins' when no builtin items are present; \
1257 got:\n{s}"
1258 );
1259 }
1260
1261 #[test]
1262 fn data_models_prose_last_modified_no_t_separator() {
1263 let out = SharedBuf::new();
1266 let mut renderer = ProseRenderer::with_writer(out.clone());
1267 let view = DataModelListView {
1268 items: vec![DataModel {
1269 name: "odd".into(),
1270 iri: "http://example.org/odd".into(),
1271 label: None,
1272 last_modified: Some("2024-05-27 no-separator".into()),
1273 is_builtin: false,
1274 }],
1275 total: 1,
1276 filter: None,
1277 };
1278 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1279 renderer.data_models(&view, &meta).unwrap();
1280
1281 let s = out.string();
1282 assert!(
1284 s.contains("2024-05-27 no-separator"),
1285 "expected full string when no 'T' separator; got:\n{s}"
1286 );
1287 }
1288
1289 fn make_beol_dm_detail() -> DataModelDetail {
1292 DataModelDetail {
1293 name: "beol".into(),
1294 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1295 label: Some("The BEOL data-model".into()),
1296 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1297 resource_types: vec![
1298 ResourceTypeSummary {
1299 name: "Archive".into(),
1300 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1301 label: Some("Archive".into()),
1302 },
1303 ResourceTypeSummary {
1304 name: "basicLetter".into(),
1305 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1306 label: None,
1307 },
1308 ResourceTypeSummary {
1309 name: "letter".into(),
1310 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1311 label: Some("Letter".into()),
1312 },
1313 ],
1314 }
1315 }
1316
1317 #[test]
1318 fn data_model_describe_prose_full() {
1319 let out = SharedBuf::new();
1320 let mut renderer = ProseRenderer::with_writer(out.clone());
1321 let meta = make_meta("anonymous", "api.dasch.swiss");
1322 renderer.data_model_describe(&make_beol_dm_detail(), &meta).unwrap();
1323
1324 let s = out.string();
1325 assert!(s.contains("Data-model: beol"), "header missing; got:\n{s}");
1327 assert!(
1329 s.contains("Label: The BEOL data-model"),
1330 "label line missing; got:\n{s}"
1331 );
1332 assert!(
1334 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2"),
1335 "IRI line missing; got:\n{s}"
1336 );
1337 assert!(
1339 s.contains("Last-modified: 2024-05-27"),
1340 "last-modified line missing; got:\n{s}"
1341 );
1342 assert!(
1343 !s.contains("T13:43"),
1344 "prose must show only date portion, not full timestamp; got:\n{s}"
1345 );
1346 assert!(s.contains("Resource-types (3):"), "resource-types header missing; got:\n{s}");
1348 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1350 assert!(s.contains("basicLetter"), "basicLetter row missing; got:\n{s}");
1351 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1352 assert!(s.contains("[anonymous on api.dasch.swiss]"), "footer missing; got:\n{s}");
1354 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1356 }
1357
1358 #[test]
1359 fn data_model_describe_prose_no_label_no_last_modified() {
1360 let out = SharedBuf::new();
1361 let mut renderer = ProseRenderer::with_writer(out.clone());
1362 let detail = DataModelDetail {
1363 name: "minimal".into(),
1364 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1365 label: None,
1366 last_modified: None,
1367 resource_types: vec![ResourceTypeSummary {
1368 name: "Thing".into(),
1369 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Thing".into(),
1370 label: None,
1371 }],
1372 };
1373 let meta = make_meta("anonymous", "api.dasch.swiss");
1374 renderer.data_model_describe(&detail, &meta).unwrap();
1375
1376 let s = out.string();
1377 assert!(
1379 !s.contains("Label:"),
1380 "Label: line must be absent when label is None; got:\n{s}"
1381 );
1382 assert!(
1384 !s.contains("Last-modified:"),
1385 "Last-modified: line must be absent when last_modified is None; got:\n{s}"
1386 );
1387 assert!(s.contains("IRI:"), "IRI line must always be present; got:\n{s}");
1389 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1391 }
1392
1393 #[test]
1394 fn data_model_describe_prose_zero_resource_types() {
1395 let out = SharedBuf::new();
1396 let mut renderer = ProseRenderer::with_writer(out.clone());
1397 let detail = DataModelDetail {
1398 name: "empty".into(),
1399 iri: "http://api.dasch.swiss/ontology/9999/empty/v2".into(),
1400 label: None,
1401 last_modified: None,
1402 resource_types: vec![],
1403 };
1404 let meta = make_meta("anonymous", "api.dasch.swiss");
1405 renderer.data_model_describe(&detail, &meta).unwrap();
1406
1407 let s = out.string();
1408 assert!(
1410 s.contains(" Resource-types (0)"),
1411 "zero resource-types branch missing; got:\n{s}"
1412 );
1413 let rt_line = s
1415 .lines()
1416 .find(|l| l.contains("Resource-types (0)"))
1417 .expect("must have resource-types line");
1418 assert!(
1419 !rt_line.contains("Resource-types (0):"),
1420 "zero branch must NOT have a colon; got: {rt_line:?}"
1421 );
1422 }
1423
1424 #[test]
1425 fn data_model_describe_prose_last_modified_no_t_separator() {
1426 let out = SharedBuf::new();
1427 let mut renderer = ProseRenderer::with_writer(out.clone());
1428 let detail = DataModelDetail {
1429 name: "odd".into(),
1430 iri: "http://api.dasch.swiss/ontology/0000/odd/v2".into(),
1431 label: None,
1432 last_modified: Some("2024-05-27 no-separator".into()),
1433 resource_types: vec![],
1434 };
1435 let meta = make_meta("anonymous", "api.dasch.swiss");
1436 renderer.data_model_describe(&detail, &meta).unwrap();
1437
1438 let s = out.string();
1439 assert!(
1441 s.contains("2024-05-27 no-separator"),
1442 "expected full string when no 'T' separator; got:\n{s}"
1443 );
1444 }
1445
1446 fn make_resource_type_fixture() -> Vec<ResourceType> {
1449 vec![
1450 ResourceType {
1451 name: "Archive".into(),
1452 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1453 label: Some("Archive".into()),
1454 is_builtin: false,
1455 count: None,
1456 },
1457 ResourceType {
1458 name: "letter".into(),
1459 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1460 label: Some("Letter".into()),
1461 is_builtin: false,
1462 count: None,
1463 },
1464 ]
1465 }
1466
1467 #[test]
1468 fn resource_types_prose_project_only() {
1469 let out = SharedBuf::new();
1470 let mut renderer = ProseRenderer::with_writer(out.clone());
1471 let view = ResourceTypeListView {
1472 items: make_resource_type_fixture(),
1473 total: 2,
1474 filter: None,
1475 data_model: "beol".into(),
1476 };
1477 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1478 renderer.resource_types(&view, &meta).unwrap();
1479
1480 let s = out.string();
1481 assert!(
1483 s.contains("resource-types in beol on https://api.test.dasch.swiss (2):"),
1484 "header missing or incorrect; got:\n{s}"
1485 );
1486 assert!(
1487 !s.contains("incl. built-ins"),
1488 "must not have builtin suffix when no builtins; got:\n{s}"
1489 );
1490 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1492 assert!(s.contains("letter"), "letter row missing; got:\n{s}");
1493 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1494 assert!(
1496 !s.contains("(built-in)"),
1497 "must not have (built-in) marker on project-only items; got:\n{s}"
1498 );
1499 assert!(
1501 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1502 "footer missing; got:\n{s}"
1503 );
1504 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1506 }
1507
1508 #[test]
1509 fn resource_types_prose_with_builtins() {
1510 let out = SharedBuf::new();
1511 let mut renderer = ProseRenderer::with_writer(out.clone());
1512 let mut items = make_resource_type_fixture();
1513 items.push(ResourceType {
1514 name: "Region".into(),
1515 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1516 label: Some("Region".into()),
1517 is_builtin: true,
1518 count: None,
1519 });
1520 let view = ResourceTypeListView { items, total: 3, filter: None, data_model: "beol".into() };
1521 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1522 renderer.resource_types(&view, &meta).unwrap();
1523
1524 let s = out.string();
1525 assert!(
1527 s.contains("resource-types in beol on https://api.test.dasch.swiss (3), incl. built-ins:"),
1528 "header with builtin suffix missing; got:\n{s}"
1529 );
1530 assert!(s.contains("(built-in)"), "(built-in) marker missing on built-in row; got:\n{s}");
1532 let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1534 assert!(
1535 !archive_line.contains("(built-in)"),
1536 "project row must not have (built-in) marker; got: {archive_line:?}"
1537 );
1538 }
1539
1540 #[test]
1541 fn resource_types_prose_with_filter() {
1542 let out = SharedBuf::new();
1543 let mut renderer = ProseRenderer::with_writer(out.clone());
1544 let items = vec![make_resource_type_fixture().remove(0)]; let view = ResourceTypeListView {
1546 items,
1547 total: 3,
1548 filter: Some("arch".to_string()),
1549 data_model: "beol".into(),
1550 };
1551 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1552 renderer.resource_types(&view, &meta).unwrap();
1553
1554 let s = out.string();
1555 assert!(
1556 s.contains("(1 of 3 matching \"arch\")"),
1557 "filter header must show m of total; got:\n{s}"
1558 );
1559 }
1560
1561 #[test]
1562 fn resource_types_prose_empty() {
1563 let out = SharedBuf::new();
1564 let mut renderer = ProseRenderer::with_writer(out.clone());
1565 let view = ResourceTypeListView {
1566 items: vec![],
1567 total: 0,
1568 filter: None,
1569 data_model: "beol".into(),
1570 };
1571 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1572 renderer.resource_types(&view, &meta).unwrap();
1573
1574 let s = out.string();
1575 assert!(
1577 s.contains("resource-types in beol on https://api.test.dasch.swiss (0):"),
1578 "header must show data_model name even when empty; got:\n{s}"
1579 );
1580 assert!(
1582 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1583 "footer missing; got:\n{s}"
1584 );
1585 }
1586
1587 #[test]
1588 fn resource_types_prose_with_counts() {
1589 let out = SharedBuf::new();
1593 let mut renderer = ProseRenderer::with_writer(out.clone());
1594 let mut items = make_resource_type_fixture();
1595 items[0].count = Some(42);
1596 items[1].count = Some(7);
1597 items.push(ResourceType {
1598 name: "Region".into(),
1599 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1600 label: Some("Region".into()),
1601 is_builtin: true,
1602 count: None,
1603 });
1604 let view = ResourceTypeListView { items, total: 3, filter: None, data_model: "beol".into() };
1605 let meta = MetaContext {
1606 server_label: "https://api.test.dasch.swiss".into(),
1607 auth_state: "anonymous".into(),
1608 filter_warning: None,
1609 count_caveat: Some("counts are not permission-filtered".into()),
1610 count_cost: None,
1611 };
1612 renderer.resource_types(&view, &meta).unwrap();
1613
1614 let s = out.string();
1615 let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1616 assert!(
1617 archive_line.contains("42"),
1618 "Archive row must show count 42; got: {archive_line:?}"
1619 );
1620 let letter_line = s.lines().find(|l| l.contains("letter")).unwrap();
1621 assert!(letter_line.contains('7'), "letter row must show count 7; got: {letter_line:?}");
1622 let region_line = s.lines().find(|l| l.contains("Region")).unwrap();
1624 assert!(
1625 region_line.contains("(built-in)"),
1626 "built-in marker must still be present; got: {region_line:?}"
1627 );
1628 assert!(
1630 s.contains("counts are not permission-filtered"),
1631 "footer must carry count_caveat; got:\n{s}"
1632 );
1633 }
1634
1635 fn make_manuscript_detail() -> ResourceTypeDetail {
1641 ResourceTypeDetail {
1642 name: "manuscript".into(),
1643 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1644 label: Some("Manuscript".into()),
1645 data_model: "beol".into(),
1646 representation: Some(Representation::StillImage),
1647 super_types: vec!["writtenSource".into()],
1648 fields: vec![
1649 Field {
1650 name: "title".into(),
1651 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#title".into(),
1652 label: Some("Title".into()),
1653 value_type: ValueType::Text,
1654 link_target: None,
1655 cardinality: Cardinality::OneOrMore,
1656 is_builtin: false,
1657 data_model: Some("beol".into()),
1658 },
1659 Field {
1660 name: "hasAuthor".into(),
1661 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
1662 label: Some("Author".into()),
1663 value_type: ValueType::Link,
1664 link_target: Some("person".into()),
1665 cardinality: Cardinality::ZeroOrMore,
1666 is_builtin: false,
1667 data_model: Some("beol".into()),
1668 },
1669 Field {
1670 name: "isPartOfCollection".into(),
1671 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2#isPartOfCollection".into(),
1672 label: Some("is part of".into()),
1673 value_type: ValueType::Link,
1674 link_target: Some("Collection".into()),
1675 cardinality: Cardinality::ZeroOrMore,
1676 is_builtin: false,
1677 data_model: Some("biblio".into()),
1678 },
1679 ],
1680 count: None,
1681 }
1682 }
1683
1684 #[test]
1685 fn resource_type_describe_prose_full() {
1686 let out = SharedBuf::new();
1687 let mut renderer = ProseRenderer::with_writer(out.clone());
1688 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1689 renderer.resource_type_describe(&make_manuscript_detail(), &meta).unwrap();
1690
1691 let s = out.string();
1692 assert!(s.contains("Resource-type: manuscript"), "header missing; got:\n{s}");
1694 assert!(s.contains("Label: Manuscript"), "label line missing; got:\n{s}");
1696 assert!(s.contains("Extends: writtenSource"), "Extends line missing; got:\n{s}");
1698 assert!(
1700 s.contains("Representation: still-image"),
1701 "Representation line missing; got:\n{s}"
1702 );
1703 assert!(
1705 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2#manuscript"),
1706 "IRI line missing; got:\n{s}"
1707 );
1708 assert!(s.contains("Data-model: beol"), "Data-model line missing; got:\n{s}");
1710 assert!(s.contains("Fields (3):"), "Fields header missing; got:\n{s}");
1712 let title_line = s.lines().find(|l| l.contains("title")).unwrap();
1714 assert!(
1715 title_line.contains("text") && title_line.contains("1-n"),
1716 "title row incomplete; got: {title_line:?}"
1717 );
1718 assert!(
1719 !title_line.contains("[from"),
1720 "title row must not have [from ...] tag; got: {title_line:?}"
1721 );
1722 let author_line = s.lines().find(|l| l.contains("hasAuthor")).unwrap();
1724 assert!(
1725 author_line.contains("\u{2192} person"),
1726 "link field must show '→ person'; got: {author_line:?}"
1727 );
1728 let coll_line = s.lines().find(|l| l.contains("isPartOfCollection")).unwrap();
1730 assert!(
1731 coll_line.contains("[from biblio]"),
1732 "cross-DM field must show [from biblio]; got: {coll_line:?}"
1733 );
1734 assert!(
1736 s.contains("[anonymous on https://api.dasch.swiss]"),
1737 "footer missing; got:\n{s}"
1738 );
1739 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1741 }
1742
1743 #[test]
1744 fn resource_type_describe_prose_with_count() {
1745 let out = SharedBuf::new();
1748 let mut renderer = ProseRenderer::with_writer(out.clone());
1749 let mut detail = make_manuscript_detail();
1750 detail.count = Some(123);
1751 let meta = MetaContext {
1752 server_label: "https://api.dasch.swiss".into(),
1753 auth_state: "anonymous".into(),
1754 filter_warning: None,
1755 count_caveat: Some("counts exclude deleted resources".into()),
1756 count_cost: None,
1757 };
1758 renderer.resource_type_describe(&detail, &meta).unwrap();
1759
1760 let s = out.string();
1761 assert!(
1762 s.contains("Instances: 123"),
1763 "Instances line missing or misaligned; got:\n{s}"
1764 );
1765 let dm_idx = s.find("Data-model:").expect("Data-model line present");
1767 let inst_idx = s.find("Instances:").expect("Instances line present");
1768 assert!(inst_idx > dm_idx, "Instances line must come after Data-model line");
1769 assert!(
1770 s.contains("counts exclude deleted resources"),
1771 "footer must carry count_caveat; got:\n{s}"
1772 );
1773 }
1774
1775 #[test]
1776 fn resource_type_describe_prose_no_count_no_instances_line() {
1777 let out = SharedBuf::new();
1780 let mut renderer = ProseRenderer::with_writer(out.clone());
1781 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1782 renderer.resource_type_describe(&make_manuscript_detail(), &meta).unwrap();
1783
1784 let s = out.string();
1785 assert!(
1786 !s.contains("Instances:"),
1787 "Instances line must be absent when count is None; got:\n{s}"
1788 );
1789 }
1790
1791 #[test]
1792 fn resource_type_describe_prose_no_label() {
1793 let out = SharedBuf::new();
1794 let mut renderer = ProseRenderer::with_writer(out.clone());
1795 let mut detail = make_manuscript_detail();
1796 detail.label = None;
1797 let meta = make_meta("anonymous", "api.dasch.swiss");
1798 renderer.resource_type_describe(&detail, &meta).unwrap();
1799
1800 let s = out.string();
1801 assert!(
1803 !s.contains("Label:"),
1804 "Label: line must be absent when label is None; got:\n{s}"
1805 );
1806 assert!(s.contains("Resource-type: manuscript"), "header missing; got:\n{s}");
1808 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1810 }
1811
1812 #[test]
1813 fn resource_type_describe_prose_zero_fields() {
1814 let out = SharedBuf::new();
1815 let mut renderer = ProseRenderer::with_writer(out.clone());
1816 let detail = ResourceTypeDetail {
1817 name: "Empty".into(),
1818 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Empty".into(),
1819 label: None,
1820 data_model: "minimal".into(),
1821 representation: None,
1822 super_types: vec![],
1823 fields: vec![],
1824 count: None,
1825 };
1826 let meta = make_meta("anonymous", "api.dasch.swiss");
1827 renderer.resource_type_describe(&detail, &meta).unwrap();
1828
1829 let s = out.string();
1830 assert!(s.contains(" Fields (0)"), "zero fields branch missing; got:\n{s}");
1832 let fields_line = s.lines().find(|l| l.contains("Fields (0)")).expect("must have Fields line");
1834 assert!(
1835 !fields_line.contains("Fields (0):"),
1836 "zero branch must NOT have a colon; got: {fields_line:?}"
1837 );
1838 assert!(!s.contains("Extends:"), "Extends line must be absent; got:\n{s}");
1840 assert!(!s.contains("Representation:"), "Representation line must be absent; got:\n{s}");
1841 }
1842
1843 #[test]
1844 fn resource_type_describe_prose_no_representation() {
1845 let out = SharedBuf::new();
1846 let mut renderer = ProseRenderer::with_writer(out.clone());
1847 let mut detail = make_manuscript_detail();
1848 detail.representation = None;
1849 let meta = make_meta("anonymous", "api.dasch.swiss");
1850 renderer.resource_type_describe(&detail, &meta).unwrap();
1851
1852 let s = out.string();
1853 assert!(
1855 !s.contains("Representation:"),
1856 "Representation: line must be absent when representation is None; got:\n{s}"
1857 );
1858 }
1859
1860 #[test]
1861 fn resource_type_describe_prose_include_builtins_marker() {
1862 let out = SharedBuf::new();
1864 let mut renderer = ProseRenderer::with_writer(out.clone());
1865 let detail = ResourceTypeDetail {
1866 name: "thing".into(),
1867 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#thing".into(),
1868 label: None,
1869 data_model: "minimal".into(),
1870 representation: None,
1871 super_types: vec![],
1872 fields: vec![
1873 Field {
1874 name: "hasText".into(),
1875 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#hasText".into(),
1876 label: Some("Text content".into()),
1877 value_type: ValueType::Text,
1878 link_target: None,
1879 cardinality: Cardinality::One,
1880 is_builtin: false,
1881 data_model: Some("minimal".into()),
1882 },
1883 Field {
1884 name: "arkUrl".into(),
1885 iri: "http://api.knora.org/ontology/knora-api/v2#arkUrl".into(),
1886 label: Some("ARK URL".into()),
1887 value_type: ValueType::Uri,
1888 link_target: None,
1889 cardinality: Cardinality::One,
1890 is_builtin: true,
1891 data_model: None,
1892 },
1893 ],
1894 count: None,
1895 };
1896 let meta = make_meta("anonymous", "api.dasch.swiss");
1897 renderer.resource_type_describe(&detail, &meta).unwrap();
1898
1899 let s = out.string();
1900 let text_line = s.lines().find(|l| l.contains("hasText")).unwrap();
1902 assert!(
1903 !text_line.contains("(built-in)"),
1904 "project field must not have (built-in) marker; got: {text_line:?}"
1905 );
1906 let ark_line = s.lines().find(|l| l.contains("arkUrl")).unwrap();
1908 assert!(
1909 ark_line.contains("(built-in)"),
1910 "built-in field must have (built-in) marker; got: {ark_line:?}"
1911 );
1912 }
1913
1914 #[test]
1915 fn resource_type_describe_prose_extends_multiple_supers() {
1916 let out = SharedBuf::new();
1918 let mut renderer = ProseRenderer::with_writer(out.clone());
1919 let detail = ResourceTypeDetail {
1920 name: "letter".into(),
1921 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1922 label: Some("Letter".into()),
1923 data_model: "beol".into(),
1924 representation: None,
1925 super_types: vec!["basicLetter".into(), "writtenSource".into()],
1926 fields: vec![],
1927 count: None,
1928 };
1929 let meta = make_meta("anonymous", "api.dasch.swiss");
1930 renderer.resource_type_describe(&detail, &meta).unwrap();
1931
1932 let s = out.string();
1933 assert!(
1934 s.contains("Extends: basicLetter, writtenSource"),
1935 "multiple supers must be joined with ', '; got:\n{s}"
1936 );
1937 }
1938
1939 use crate::model::{
1942 DatePoint, DateValue, FieldValues, FileValue, ResourceAccess, ResourceDetail, ResourceVisibility, Value,
1943 ValueContent,
1944 };
1945
1946 fn make_resource_detail_no_values() -> ResourceDetail {
1947 ResourceDetail {
1948 label: "Test Resource".into(),
1949 iri: "http://rdfh.ch/0803/abc123".into(),
1950 resource_type: "Page".into(),
1951 ark_url: Some("ark:/72163/1/0803/abc123".into()),
1952 creation_date: Some("2021-01-01T00:00:00Z".into()),
1953 last_modified: None,
1954 attached_project: Some("http://rdfh.ch/projects/0803".into()),
1955 owner: Some("http://rdfh.ch/users/alice".into()),
1956 visibility: Some(ResourceVisibility::Public),
1957 your_access: Some(ResourceAccess::View),
1958 values: None,
1959 }
1960 }
1961
1962 #[test]
1963 fn resource_describe_prose_no_values_unchanged() {
1964 let out = SharedBuf::new();
1967 let mut renderer = ProseRenderer::with_writer(out.clone());
1968 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1969 renderer.resource_describe(&make_resource_detail_no_values(), &meta).unwrap();
1970
1971 let s = out.string();
1972 assert!(s.contains("Resource: Test Resource"), "header missing");
1973 assert!(!s.contains("Values:"), "Values: section must be absent when values is None");
1974 }
1975
1976 #[test]
1977 fn resource_describe_prose_some_empty_values() {
1978 let out = SharedBuf::new();
1980 let mut renderer = ProseRenderer::with_writer(out.clone());
1981 let mut detail = make_resource_detail_no_values();
1982 detail.values = Some(vec![]);
1983 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1984 renderer.resource_describe(&detail, &meta).unwrap();
1985
1986 let s = out.string();
1987 assert!(
1988 s.contains("Values: (none)"),
1989 "empty values must render as 'Values: (none)'; got:\n{s}"
1990 );
1991 }
1992
1993 #[test]
1994 fn resource_describe_prose_values_section() {
1995 let out = SharedBuf::new();
1997 let mut renderer = ProseRenderer::with_writer(out.clone());
1998 let mut detail = make_resource_detail_no_values();
1999 detail.values = Some(vec![
2000 FieldValues {
2001 name: "hasTitle".into(),
2002 label: Some("Title".into()),
2003 values: vec![ValueContent::Text("Incunabula Page".into()).into()],
2004 },
2005 FieldValues {
2006 name: "seqnum".into(),
2007 label: None, values: vec![ValueContent::Integer(42).into()],
2009 },
2010 ]);
2011 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2012 renderer.resource_describe(&detail, &meta).unwrap();
2013
2014 let s = out.string();
2015 assert!(s.contains("Values:"), "Values: header missing; got:\n{s}");
2016 assert!(s.contains("Title (hasTitle)"), "labelled field header format wrong; got:\n{s}");
2018 assert!(s.contains("Incunabula Page"), "text value missing; got:\n{s}");
2019 let seqnum_line = s.lines().find(|l| l.contains("seqnum")).unwrap();
2021 assert!(
2022 !seqnum_line.contains('('),
2023 "unlabelled field must not have parentheses; got: {seqnum_line:?}"
2024 );
2025 assert!(s.contains("42"), "integer value missing; got:\n{s}");
2026 }
2027
2028 #[test]
2029 fn resource_describe_prose_value_with_comment() {
2030 let out = SharedBuf::new();
2033 let mut renderer = ProseRenderer::with_writer(out.clone());
2034 let mut detail = make_resource_detail_no_values();
2035 detail.values = Some(vec![FieldValues {
2036 name: "hasTranscription".into(),
2037 label: Some("Transcription".into()),
2038 values: vec![Value {
2039 content: ValueContent::Text("some transcription".into()),
2040 comment: Some("reading uncertain".into()),
2041 }],
2042 }]);
2043 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2044 renderer.resource_describe(&detail, &meta).unwrap();
2045
2046 let s = out.string();
2047 assert!(s.contains("some transcription"), "value line missing; got:\n{s}");
2048 assert!(
2049 s.contains(" comment: reading uncertain"),
2050 "comment line missing or mis-indented; got:\n{s}"
2051 );
2052 let lines: Vec<&str> = s.lines().collect();
2054 let value_idx = lines.iter().position(|l| l.contains("some transcription")).unwrap();
2055 assert_eq!(
2056 lines[value_idx + 1],
2057 " comment: reading uncertain",
2058 "comment line must directly follow the value line; got:\n{s}"
2059 );
2060 }
2061
2062 #[test]
2063 fn resource_describe_prose_value_without_comment_renders_no_comment_line() {
2064 let out = SharedBuf::new();
2068 let mut renderer = ProseRenderer::with_writer(out.clone());
2069 let mut detail = make_resource_detail_no_values();
2070 detail.values = Some(vec![FieldValues {
2071 name: "hasTranscription".into(),
2072 label: Some("Transcription".into()),
2073 values: vec![ValueContent::Text("some transcription".into()).into()],
2074 }]);
2075 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2076 renderer.resource_describe(&detail, &meta).unwrap();
2077
2078 let s = out.string();
2079 assert!(s.contains("some transcription"), "value line missing; got:\n{s}");
2080 assert!(
2081 !s.contains("comment:"),
2082 "no comment line must be emitted when value.comment is None; got:\n{s}"
2083 );
2084 }
2085
2086 #[test]
2087 fn resource_describe_prose_link_with_label() {
2088 let out = SharedBuf::new();
2089 let mut renderer = ProseRenderer::with_writer(out.clone());
2090 let mut detail = make_resource_detail_no_values();
2091 detail.values = Some(vec![FieldValues {
2092 name: "isPartOf".into(),
2093 label: Some("Is part of".into()),
2094 values: vec![
2095 ValueContent::Link {
2096 target_iri: "http://rdfh.ch/0803/book1".into(),
2097 target_label: Some("Incunabula Book".into()),
2098 }
2099 .into(),
2100 ValueContent::Link {
2101 target_iri: "http://rdfh.ch/0803/book2".into(),
2102 target_label: None, }
2104 .into(),
2105 ],
2106 }]);
2107 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2108 renderer.resource_describe(&detail, &meta).unwrap();
2109
2110 let s = out.string();
2111 assert!(
2113 s.contains("\u{2192} Incunabula Book [http://rdfh.ch/0803/book1]"),
2114 "link with label rendered incorrectly; got:\n{s}"
2115 );
2116 assert!(
2118 s.contains("\u{2192} http://rdfh.ch/0803/book2"),
2119 "degraded link (no label) rendered incorrectly; got:\n{s}"
2120 );
2121 let degraded_line = s.lines().find(|l| l.contains("http://rdfh.ch/0803/book2")).unwrap();
2123 assert!(
2124 !degraded_line.contains('['),
2125 "degraded link must not have brackets; got: {degraded_line:?}"
2126 );
2127 }
2128
2129 #[test]
2130 fn resource_describe_prose_file_still_image_with_dims() {
2131 let out = SharedBuf::new();
2132 let mut renderer = ProseRenderer::with_writer(out.clone());
2133 let mut detail = make_resource_detail_no_values();
2134 detail.values = Some(vec![FieldValues {
2135 name: "hasStillImageFileValue".into(),
2136 label: None,
2137 values: vec![
2138 ValueContent::File(FileValue {
2139 value_type: ValueType::StillImage,
2140 filename: "image.jp2".into(),
2141 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
2142 width: Some(1200),
2143 height: Some(800),
2144 })
2145 .into(),
2146 ],
2147 }]);
2148 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2149 renderer.resource_describe(&detail, &meta).unwrap();
2150
2151 let s = out.string();
2152 assert!(
2154 s.contains("image.jp2 (1200\u{d7}800) https://iiif.example.com"),
2155 "still-image with dims rendered incorrectly; got:\n{s}"
2156 );
2157 }
2158
2159 #[test]
2160 fn resource_describe_prose_date_rendering() {
2161 let out = SharedBuf::new();
2162 let mut renderer = ProseRenderer::with_writer(out.clone());
2163 let mut detail = make_resource_detail_no_values();
2164 let pt = DatePoint {
2166 year: Some(1489),
2167 month: None,
2168 day: None,
2169 era: Some("CE".into()),
2170 };
2171 let single = DateValue {
2172 calendar: "GREGORIAN".into(),
2173 start: pt.clone(),
2174 end: pt.clone(),
2175 };
2176 let range = DateValue {
2178 calendar: "GREGORIAN".into(),
2179 start: DatePoint {
2180 year: Some(1489),
2181 month: None,
2182 day: None,
2183 era: Some("CE".into()),
2184 },
2185 end: DatePoint {
2186 year: Some(1490),
2187 month: None,
2188 day: None,
2189 era: Some("CE".into()),
2190 },
2191 };
2192 detail.values = Some(vec![FieldValues {
2193 name: "hasDate".into(),
2194 label: Some("Date".into()),
2195 values: vec![ValueContent::Date(single).into(), ValueContent::Date(range).into()],
2196 }]);
2197 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2198 renderer.resource_describe(&detail, &meta).unwrap();
2199
2200 let s = out.string();
2201 assert!(
2203 s.contains("1489 CE (GREGORIAN)"),
2204 "single-point date rendered incorrectly; got:\n{s}"
2205 );
2206 assert!(
2208 s.contains("1489 CE \u{2013} 1490 CE (GREGORIAN)"),
2209 "range date rendered incorrectly; got:\n{s}"
2210 );
2211 }
2212
2213 #[test]
2216 fn resource_describe_prose_strips_control_chars_in_label() {
2217 let out = SharedBuf::new();
2220 let mut renderer = ProseRenderer::with_writer(out.clone());
2221 let mut detail = make_resource_detail_no_values();
2222 detail.label = "Bad\u{1b}[31mLabel\u{7f}".into();
2223 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2224 renderer.resource_describe(&detail, &meta).unwrap();
2225
2226 let s = out.string();
2227 assert!(
2228 s.contains("Resource: Bad[31mLabel"),
2229 "label control chars not stripped; got:\n{s}"
2230 );
2231 assert!(
2232 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2233 "control characters leaked into prose output; got:\n{s:?}"
2234 );
2235 }
2236
2237 #[test]
2238 fn resources_prose_strips_control_chars_in_label() {
2239 use crate::model::ResourceSummary;
2240
2241 let out = SharedBuf::new();
2242 let mut renderer = ProseRenderer::with_writer(out.clone());
2243 let view = ResourceListView {
2244 items: vec![ResourceSummary {
2245 label: "Bad\u{1b}[31mLabel\u{7f}".into(),
2246 iri: "http://rdfh.ch/0803/abc123".into(),
2247 ark_url: None,
2248 creation_date: None,
2249 last_modified: None,
2250 resource_type: "Page".into(),
2251 }],
2252 total: 1,
2253 filter: None,
2254 resource_type: "Page".into(),
2255 pagination: ResourceListPagination::SinglePage { page: 0, may_have_more: false },
2256 };
2257 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2258 renderer.resources(&view, &meta).unwrap();
2259
2260 let s = out.string();
2261 assert!(s.contains("Bad[31mLabel"), "label control chars not stripped; got:\n{s}");
2262 assert!(
2263 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2264 "control characters leaked into prose output; got:\n{s:?}"
2265 );
2266 }
2267}