1use std::collections::HashMap;
7use std::fmt::Write;
8
9use crate::api::{AnnotationFormat, AnnotationStyle};
10use crate::render::component::{ProcEntry, ProcTemplateComponent, render_component_with_format};
11use crate::render::format::OutputFormat;
12use crate::render::plain::PlainText;
13use crate::render::rich_text::{render_djot_inline, render_org_inline};
14
15fn is_final_punctuation(c: char) -> bool {
17 matches!(c, '.' | ',' | ':' | ';' | '!' | '?')
18}
19
20fn is_sentence_ending_punctuation(c: char) -> bool {
22 matches!(c, '.' | '!' | '?')
23}
24
25fn visible_text(input: &str) -> String {
27 let mut output = String::with_capacity(input.len());
28 let mut in_tag = false;
29
30 for ch in input.chars() {
31 match ch {
32 '<' => in_tag = true,
33 '>' if in_tag => in_tag = false,
34 _ if !in_tag => output.push(ch),
35 _ => {}
36 }
37 }
38
39 output
40}
41
42fn first_visible_char(input: &str) -> Option<char> {
44 visible_text(input).chars().next()
45}
46
47fn last_visible_non_space_char(input: &str) -> Option<char> {
49 visible_text(input)
50 .chars()
51 .rev()
52 .find(|ch| !ch.is_whitespace())
53}
54
55fn ends_with_sentence_ending_visible_punctuation(input: &str) -> bool {
57 let visible = visible_text(input);
58 let mut chars = visible.chars().rev().filter(|ch| !ch.is_whitespace());
59 match chars.next() {
60 Some(ch) if is_sentence_ending_punctuation(ch) => true,
61 Some('"' | '\u{201D}') => chars.next().is_some_and(is_sentence_ending_punctuation),
62 _ => false,
63 }
64}
65
66#[must_use]
69pub(crate) fn component_starts_new_sentence(
70 entry_output: &str,
71 rendered: &str,
72 default_separator: &str,
73 punctuation_in_quote: bool,
74) -> bool {
75 if entry_output.is_empty() {
76 return true;
77 }
78
79 let first_char = first_visible_char(rendered).unwrap_or(' ');
80 let starts_with_separator = matches!(first_char, ',' | ';' | ':' | ' ' | '.' | '(');
81
82 if starts_with_separator {
83 return false;
84 }
85
86 if ends_with_sentence_ending_visible_punctuation(entry_output) {
87 return true;
88 }
89
90 let last_char = entry_output.chars().last().unwrap_or(' ');
91 let trimmed_last = last_visible_non_space_char(entry_output).unwrap_or(' ');
92 if !last_char.is_whitespace()
93 && !first_char.is_whitespace()
94 && !is_final_punctuation(trimmed_last)
95 && default_separator
96 .chars()
97 .next()
98 .is_some_and(is_sentence_ending_punctuation)
99 {
100 return true;
101 }
102
103 punctuation_in_quote
104 && default_separator.starts_with('.')
105 && (entry_output.ends_with('"') || entry_output.ends_with('\u{201D}'))
106}
107
108#[must_use]
110pub fn refs_to_string(proc_entries: Vec<ProcEntry>) -> String {
111 refs_to_string_with_format::<PlainText>(proc_entries, None, None)
112}
113
114#[must_use]
116pub fn render_entry_body_with_format<F: OutputFormat<Output = String>>(
117 entry: &ProcEntry,
118) -> String {
119 render_entry_body_components_with_format::<F>(&entry.template)
120}
121
122#[must_use]
124pub(crate) fn render_entry_body_components_with_format<F: OutputFormat<Output = String>>(
125 proc_template: &[ProcTemplateComponent],
126) -> String {
127 let mut entry_output = String::new();
128 let mut pending_component: Option<(
129 usize,
130 &crate::render::component::ProcTemplateComponent,
131 String,
132 )> = None;
133
134 let punctuation_in_quote = proc_template
136 .first()
137 .and_then(|c| c.config.as_ref())
138 .is_some_and(|cfg| cfg.punctuation_in_quote);
139
140 let default_separator = proc_template
142 .first()
143 .and_then(|c| c.bibliography_config.as_ref())
144 .and_then(|bib| bib.separator.as_deref())
145 .unwrap_or(". ");
146
147 for (index, component) in proc_template.iter().enumerate() {
148 let rendered = render_component_with_format::<F>(component);
149 if rendered.is_empty() {
150 continue;
151 }
152
153 if let Some((_, _, previous)) = pending_component.replace((index, component, rendered)) {
154 append_rendered_component(
155 &mut entry_output,
156 &previous,
157 default_separator,
158 punctuation_in_quote,
159 );
160 }
161 }
162
163 if let Some((last_index, last_component, rendered)) = pending_component {
164 let final_rendered = if last_index + 1 < proc_template.len() {
165 let mut trimmed_component = last_component.clone();
166 let rendering = trimmed_component.template_component.rendering_mut();
167 rendering.suffix = None;
168 if let Some(ref mut wrap_config) = rendering.wrap {
169 wrap_config.inner_suffix = None;
170 }
171 trimmed_component.suffix = None;
172 render_component_with_format::<F>(&trimmed_component)
173 } else {
174 rendered
175 };
176 append_rendered_component(
177 &mut entry_output,
178 &final_rendered,
179 default_separator,
180 punctuation_in_quote,
181 );
182 }
183
184 let bib_cfg = proc_template
185 .first()
186 .and_then(|c| c.bibliography_config.as_ref());
187 let entry_suffix = bib_cfg.and_then(|bib| bib.entry_suffix.as_deref());
188 match entry_suffix {
189 Some(suffix) if !suffix.is_empty() => {
190 let ends_with_url = ends_with_url_or_doi(&entry_output);
191 if !ends_with_url && !entry_output.ends_with(suffix.chars().next().unwrap_or('.')) {
192 if suffix == "."
193 && punctuation_in_quote
194 && (entry_output.ends_with('"') || entry_output.ends_with('\u{201D}'))
195 {
196 let is_curly = entry_output.ends_with('\u{201D}');
197 entry_output.pop();
198 entry_output.push_str(if is_curly { ".\u{201D}" } else { ".\"" });
199 } else {
200 entry_output.push_str(suffix);
201 }
202 }
203 }
204 _ => {}
205 }
206
207 cleanup_dangling_punctuation(&mut entry_output);
208 entry_output
209}
210
211pub(crate) fn append_rendered_component(
218 entry_output: &mut String,
219 rendered: &str,
220 default_separator: &str,
221 punctuation_in_quote: bool,
222) {
223 if !entry_output.is_empty() {
224 let last_char = entry_output.chars().last().unwrap_or(' ');
225 let first_char = first_visible_char(rendered).unwrap_or(' ');
226 let sep_first_char = default_separator.chars().next().unwrap_or('.');
227 let trimmed_last = last_visible_non_space_char(entry_output).unwrap_or(' ');
228 let ends_with_punctuation = is_final_punctuation(trimmed_last);
229 let starts_with_separator = matches!(first_char, ',' | ';' | ':' | ' ' | '.' | '(');
231
232 if starts_with_separator {
233 if first_char == '(' && !last_char.is_whitespace() && last_char != '[' {
236 entry_output.push(' ');
237 }
238 } else if ends_with_punctuation {
239 if !last_char.is_whitespace() {
241 entry_output.push(' ');
242 }
243 } else if punctuation_in_quote
244 && (last_char == '"' || last_char == '\u{201D}')
245 && sep_first_char == '.'
246 {
247 entry_output.pop();
249 let quote_str = if last_char == '\u{201D}' {
250 ".\u{201D} "
251 } else {
252 ".\" "
253 };
254 entry_output.push_str(quote_str);
255 } else if !last_char.is_whitespace() && !first_char.is_whitespace() {
256 entry_output.push_str(default_separator);
258 } else if !last_char.is_whitespace()
259 && first_char.is_whitespace()
260 && default_separator.starts_with('.')
261 && !ends_with_punctuation
262 {
263 entry_output.push('.');
266 }
267 }
268
269 let _ = write!(entry_output, "{rendered}");
270}
271
272#[must_use]
274pub fn refs_to_string_with_format<F: OutputFormat<Output = String>>(
275 proc_entries: Vec<ProcEntry>,
276 annotations: Option<&HashMap<String, String>>,
277 annotation_style: Option<&AnnotationStyle>,
278) -> String {
279 refs_to_string_slice_with_format::<F>(&proc_entries, annotations, annotation_style)
280}
281
282#[must_use]
284pub fn refs_to_string_slice_with_format<F: OutputFormat<Output = String>>(
285 proc_entries: &[ProcEntry],
286 annotations: Option<&HashMap<String, String>>,
287 annotation_style: Option<&AnnotationStyle>,
288) -> String {
289 let fmt = F::default();
290 let mut rendered_entries = Vec::with_capacity(proc_entries.len());
291
292 for entry in proc_entries {
293 let mut entry_output = render_entry_body_with_format::<F>(entry);
294 let proc_template = &entry.template;
295
296 if let Some(annotations) = annotations
298 && let Some(annotation_text) = annotations.get(&entry.id)
299 {
300 let style = annotation_style.cloned().unwrap_or_default();
301
302 let rendered = match style.format {
304 AnnotationFormat::Djot => render_djot_inline(annotation_text, &fmt),
305 AnnotationFormat::Plain => annotation_text.clone(),
306 AnnotationFormat::Org => render_org_inline(annotation_text, &fmt),
307 };
308
309 let rendered = rendered.trim();
310
311 if !rendered.is_empty() {
312 let annotation_output = fmt.text(rendered);
313 entry_output.push_str(&fmt.annotation(annotation_output));
314 }
315 }
316
317 if visible_text(&entry_output).trim().is_empty() {
318 continue;
319 }
320
321 let entry_url = proc_template
323 .first()
324 .and_then(|c| c.config.as_ref())
325 .and_then(|cfg| cfg.links.as_ref())
326 .and_then(|links| {
327 use citum_schema::options::LinkAnchor;
328 if matches!(links.anchor, Some(LinkAnchor::Entry)) {
329 proc_template.iter().find_map(|c| c.url.as_deref())
334 } else {
335 None
336 }
337 });
338
339 rendered_entries.push(fmt.entry(&entry.id, entry_output, entry_url, &entry.metadata));
340 }
341
342 fmt.finish(fmt.bibliography(rendered_entries))
343}
344
345fn ends_with_url_or_doi(output: &str) -> bool {
347 let visible = visible_text(output);
348 let trimmed = visible.trim_end_matches('.');
349 let trimmed = trimmed.trim_end();
350 if let Some(last_segment) = trimmed.rsplit_once(' ') {
352 let last = last_segment.1;
353 last.starts_with("https://") || last.starts_with("http://") || last.starts_with("doi.org/")
354 } else {
355 trimmed.starts_with("https://")
356 || trimmed.starts_with("http://")
357 || trimmed.starts_with("doi.org/")
358 }
359}
360
361fn cleanup_dangling_punctuation(output: &mut String) {
362 let patterns = [
363 (", .", "."),
364 (", ,", ","),
365 (": .", "."),
366 ("; .", "."),
367 (" ,", ","),
371 (" ;", ";"),
372 (" :", ":"),
373 (" .", "."),
374 (", ", ", "),
375 (". .", "."),
376 (".. ", ". "),
377 ("..", "."),
378 (" ", " "), ];
380
381 let mut changed = true;
382 while changed {
383 changed = false;
384 for (pattern, replacement) in &patterns {
385 if output.contains(pattern) {
386 *output = output.replace(pattern, replacement);
387 changed = true;
388 }
389 }
390 }
391}
392
393#[cfg(test)]
394#[allow(
395 clippy::unwrap_used,
396 clippy::expect_used,
397 clippy::panic,
398 clippy::indexing_slicing,
399 clippy::todo,
400 clippy::unimplemented,
401 clippy::unreachable,
402 clippy::get_unwrap,
403 reason = "Panicking is acceptable and often desired in tests."
404)]
405mod tests {
406 use super::*;
407 use crate::render::component::ProcTemplateComponent;
408 use citum_schema::template::{Rendering, TemplateComponent, WrapConfig, WrapPunctuation};
409
410 #[test]
411 fn test_component_starts_new_sentence_at_entry_start() {
412 assert!(component_starts_new_sentence(
413 "",
414 "Edited by Grimm, Jacob",
415 ". ",
416 false
417 ));
418 }
419
420 #[test]
421 fn test_component_starts_new_sentence_after_period() {
422 assert!(component_starts_new_sentence(
423 "Collected Essays.",
424 "edited by Grimm, Jacob",
425 ". ",
426 false
427 ));
428 }
429
430 #[test]
431 fn test_component_does_not_start_new_sentence_after_colon() {
432 assert!(!component_starts_new_sentence(
433 "Collected Essays:",
434 "edited by Grimm, Jacob",
435 ". ",
436 false
437 ));
438 }
439
440 #[test]
441 fn test_bibliography_separator_suppression() {
442 use citum_schema::options::{BibliographyConfig, Config};
443
444 let config = Config::default();
445 let bibliography_config = BibliographyConfig {
446 separator: Some(". ".to_string()),
447 entry_suffix: Some(String::new()),
448 ..Default::default()
449 };
450
451 let c1 = ProcTemplateComponent {
452 template_component: TemplateComponent::Variable(
453 citum_schema::template::TemplateVariable {
454 variable: citum_schema::template::SimpleVariable::Publisher,
455 rendering: Rendering::default(),
456 ..Default::default()
457 },
458 ),
459 template_index: None,
460 value: "Publisher1".to_string(),
461 prefix: None,
462 suffix: None,
463 ref_type: None,
464 config: Some(config.clone()),
465 bibliography_config: Some(bibliography_config.clone()),
466 url: None,
467 item_language: None,
468 sentence_initial: false,
469 pre_formatted: false,
470 };
471
472 let c2 = ProcTemplateComponent {
473 template_component: TemplateComponent::Variable(
474 citum_schema::template::TemplateVariable {
475 variable: citum_schema::template::SimpleVariable::PublisherPlace,
476 rendering: Rendering {
477 prefix: Some(". ".to_string()),
478 ..Default::default()
479 },
480 ..Default::default()
481 },
482 ),
483 template_index: None,
484 value: "Place".to_string(),
485 prefix: None,
486 suffix: None,
487 ref_type: None,
488 config: Some(config),
489 bibliography_config: Some(bibliography_config),
490 url: None,
491 item_language: None,
492 sentence_initial: false,
493 pre_formatted: false,
494 };
495
496 let entries = vec![ProcEntry {
497 id: "id1".to_string(),
498 template: vec![c1, c2],
499 metadata: crate::render::format::ProcEntryMetadata::default(),
500 }];
501 let result = refs_to_string(entries);
502 assert_eq!(result, "Publisher1. Place");
503 }
504
505 #[test]
506 fn test_no_suppression_after_parenthesis() {
507 use citum_schema::options::{BibliographyConfig, Config};
508
509 let config = Config::default();
510 let bibliography_config = BibliographyConfig {
511 separator: Some(", ".to_string()),
512 entry_suffix: Some(String::new()),
513 ..Default::default()
514 };
515
516 let c1 = ProcTemplateComponent {
517 template_component: TemplateComponent::Contributor(
518 citum_schema::template::TemplateContributor {
519 contributor: citum_schema::template::ContributorRole::Editor,
520 rendering: Rendering {
521 wrap: Some(WrapConfig {
522 punctuation: WrapPunctuation::Parentheses,
523 inner_prefix: None,
524 inner_suffix: None,
525 }),
526 ..Default::default()
527 },
528 ..Default::default()
529 },
530 ),
531 template_index: None,
532 value: "Eds.".to_string(),
533 prefix: None,
534 suffix: None,
535 ref_type: None,
536 config: Some(config.clone()),
537 bibliography_config: Some(bibliography_config.clone()),
538 url: None,
539 item_language: None,
540 sentence_initial: false,
541 pre_formatted: false,
542 };
543
544 let c2 = ProcTemplateComponent {
545 template_component: TemplateComponent::Title(citum_schema::template::TemplateTitle {
546 title: citum_schema::template::TitleType::Primary,
547 rendering: Rendering::default(),
548 ..Default::default()
549 }),
550 template_index: None,
551 value: "Title".to_string(),
552 prefix: None,
553 suffix: None,
554 ref_type: None,
555 config: Some(config),
556 bibliography_config: Some(bibliography_config),
557 url: None,
558 item_language: None,
559 sentence_initial: false,
560 pre_formatted: false,
561 };
562
563 let entries = vec![ProcEntry {
564 id: "id1".to_string(),
565 template: vec![c1, c2],
566 metadata: crate::render::format::ProcEntryMetadata::default(),
567 }];
568 let result = refs_to_string(entries);
569 assert_eq!(result, "(Eds.), Title");
570 }
571
572 #[test]
573 fn test_html_bibliography_structure() {
574 use crate::render::html::Html;
575 use citum_schema::template::TemplateTerm;
576
577 let c1 = ProcTemplateComponent {
578 template_component: TemplateComponent::Term(TemplateTerm::default()),
579 value: "Reference Content".to_string(),
580 ..Default::default()
581 };
582
583 let entries = vec![ProcEntry {
584 id: "ref-1".to_string(),
585 template: vec![c1],
586 metadata: crate::render::format::ProcEntryMetadata::default(),
587 }];
588
589 let result = refs_to_string_with_format::<Html>(entries, None, None);
590 assert_eq!(
591 result,
592 "<div class=\"citum-bibliography\">\n<div class=\"citum-entry\" id=\"ref-ref-1\">Reference Content</div>\n</div>"
593 );
594 }
595
596 #[test]
597 fn test_component_suffix_preserved_elsevier_harvard() {
598 use citum_schema::options::{BibliographyConfig, Config};
599
600 let config = Config::default();
603 let bibliography_config = BibliographyConfig {
604 separator: Some(". ".to_string()),
605 entry_suffix: Some(".".to_string()),
606 ..Default::default()
607 };
608
609 let c1 = ProcTemplateComponent {
610 template_component: TemplateComponent::Contributor(
611 citum_schema::template::TemplateContributor {
612 contributor: citum_schema::template::ContributorRole::Author,
613 rendering: Rendering {
614 suffix: Some(", ".to_string()),
615 ..Default::default()
616 },
617 ..Default::default()
618 },
619 ),
620 template_index: None,
621 value: "Hawking, S.".to_string(),
622 prefix: None,
623 suffix: None,
624 ref_type: None,
625 config: Some(config.clone()),
626 bibliography_config: Some(bibliography_config.clone()),
627 url: None,
628 item_language: None,
629 sentence_initial: false,
630 pre_formatted: false,
631 };
632
633 let c2 = ProcTemplateComponent {
634 template_component: TemplateComponent::Date(citum_schema::template::TemplateDate {
635 date: citum_schema::template::DateVariable::Issued,
636 rendering: Rendering {
637 suffix: Some(".".to_string()),
638 ..Default::default()
639 },
640 ..Default::default()
641 }),
642 template_index: None,
643 value: "1988".to_string(),
644 prefix: None,
645 suffix: None,
646 ref_type: None,
647 config: Some(config),
648 bibliography_config: Some(bibliography_config),
649 url: None,
650 item_language: None,
651 sentence_initial: false,
652 pre_formatted: false,
653 };
654
655 let entries = vec![ProcEntry {
656 id: "hawking1988".to_string(),
657 template: vec![c1, c2],
658 metadata: crate::render::format::ProcEntryMetadata::default(),
659 }];
660 let result = refs_to_string(entries);
661 assert_eq!(result, "Hawking, S., 1988.");
663 }
664
665 #[test]
666 fn test_terminal_component_suffix_suppressed_when_following_component_is_empty() {
667 use citum_schema::options::{BibliographyConfig, Config};
668
669 let config = Config::default();
670 let bibliography_config = BibliographyConfig {
671 separator: Some(". ".to_string()),
672 entry_suffix: Some(String::new()),
673 ..Default::default()
674 };
675
676 let date = ProcTemplateComponent {
677 template_component: TemplateComponent::Date(citum_schema::template::TemplateDate {
678 date: citum_schema::template::DateVariable::Issued,
679 rendering: Rendering {
680 suffix: Some(", ".to_string()),
681 ..Default::default()
682 },
683 ..Default::default()
684 }),
685 template_index: None,
686 value: "2024".to_string(),
687 prefix: None,
688 suffix: None,
689 ref_type: None,
690 config: Some(config.clone()),
691 bibliography_config: Some(bibliography_config.clone()),
692 url: None,
693 item_language: None,
694 sentence_initial: false,
695 pre_formatted: false,
696 };
697
698 let pages = ProcTemplateComponent {
699 template_component: TemplateComponent::Number(citum_schema::template::TemplateNumber {
700 number: citum_schema::template::NumberVariable::Pages,
701 rendering: Rendering::default(),
702 ..Default::default()
703 }),
704 template_index: None,
705 value: String::new(),
706 prefix: None,
707 suffix: None,
708 ref_type: None,
709 config: Some(config),
710 bibliography_config: Some(bibliography_config),
711 url: None,
712 item_language: None,
713 sentence_initial: false,
714 pre_formatted: false,
715 };
716
717 let result = refs_to_string(vec![ProcEntry {
718 id: "book-without-pages".to_string(),
719 template: vec![date, pages],
720 metadata: crate::render::format::ProcEntryMetadata::default(),
721 }]);
722
723 assert_eq!(result, "2024");
724 }
725
726 #[allow(
727 clippy::too_many_lines,
728 reason = "rendering fixture exercises a full punctuation case"
729 )]
730 #[test]
731 fn test_html_separator_logic_uses_visible_punctuation() {
732 use crate::render::html::Html;
733 use citum_schema::options::{BibliographyConfig, Config};
734 use citum_schema::template::{
735 NumberVariable, SimpleVariable, TemplateNumber, TemplateVariable,
736 };
737
738 let config = Config {
739 ..Default::default()
740 };
741 let bibliography_config = BibliographyConfig {
742 separator: Some(". ".to_string()),
743 entry_suffix: Some(String::new()),
744 ..Default::default()
745 };
746
747 let volume_issue = ProcTemplateComponent {
748 template_component: TemplateComponent::Number(TemplateNumber {
749 number: NumberVariable::Volume,
750 rendering: Rendering {
751 emph: Some(true),
752 ..Default::default()
753 },
754 ..Default::default()
755 }),
756 template_index: None,
757 value: "322(10)".to_string(),
758 prefix: None,
759 suffix: None,
760 ref_type: Some("article-journal".to_string()),
761 config: Some(config.clone()),
762 bibliography_config: Some(bibliography_config.clone()),
763 url: None,
764 item_language: None,
765 sentence_initial: false,
766 pre_formatted: false,
767 };
768
769 let pages = ProcTemplateComponent {
770 template_component: TemplateComponent::Number(TemplateNumber {
771 number: NumberVariable::Pages,
772 rendering: Rendering {
773 prefix: Some(", ".to_string()),
774 suffix: Some(".".to_string()),
775 ..Default::default()
776 },
777 ..Default::default()
778 }),
779 template_index: None,
780 value: "891–921".to_string(),
781 prefix: None,
782 suffix: None,
783 ref_type: Some("article-journal".to_string()),
784 config: Some(config.clone()),
785 bibliography_config: Some(bibliography_config.clone()),
786 url: None,
787 item_language: None,
788 sentence_initial: false,
789 pre_formatted: false,
790 };
791
792 let doi = ProcTemplateComponent {
793 template_component: TemplateComponent::Variable(TemplateVariable {
794 variable: SimpleVariable::Doi,
795 rendering: Rendering {
796 prefix: Some("https://doi.org/".to_string()),
797 ..Default::default()
798 },
799 ..Default::default()
800 }),
801 template_index: None,
802 value: "10.1002/andp.19053221004".to_string(),
803 prefix: None,
804 suffix: None,
805 ref_type: Some("article-journal".to_string()),
806 config: Some(config),
807 bibliography_config: Some(bibliography_config),
808 url: None,
809 item_language: None,
810 sentence_initial: false,
811 pre_formatted: false,
812 };
813
814 let result = refs_to_string_with_format::<Html>(
815 vec![ProcEntry {
816 id: "einstein1905".to_string(),
817 template: vec![volume_issue, pages, doi],
818 metadata: crate::render::format::ProcEntryMetadata::default(),
819 }],
820 None,
821 None,
822 );
823
824 assert!(
825 !result.contains("322(10)</i></span>. <span class=\"citum-pages\">, 891–921."),
826 "separator should not inject a period before pages: {result}"
827 );
828 assert!(
829 !result.contains("891–921.</span>. <span class=\"citum-doi\">"),
830 "separator should not inject a period before DOI: {result}"
831 );
832 assert!(
833 result.contains(
834 "<span class=\"citum-pages\">, 891–921.</span><span class=\"citum-doi\">"
835 ) || result.contains(
836 "<span class=\"citum-pages\">, 891–921.</span> <span class=\"citum-doi\">"
837 ),
838 "HTML output should preserve pages punctuation without duplicate separators: {result}"
839 );
840 }
841
842 fn make_entry(id: &str, value: &str) -> ProcEntry {
843 ProcEntry {
844 id: id.to_string(),
845 template: vec![ProcTemplateComponent {
846 template_component: TemplateComponent::Variable(
847 citum_schema::template::TemplateVariable {
848 variable: citum_schema::template::SimpleVariable::Publisher,
849 rendering: Rendering::default(),
850 ..Default::default()
851 },
852 ),
853 template_index: None,
854 value: value.to_string(),
855 prefix: None,
856 suffix: None,
857 ref_type: None,
858 config: None,
859 url: None,
860 bibliography_config: None,
861 item_language: None,
862 sentence_initial: false,
863 pre_formatted: false,
864 }],
865 metadata: crate::render::format::ProcEntryMetadata::default(),
866 }
867 }
868
869 #[test]
870 fn test_annotation_appended_after_entry() {
871 let mut annotations = HashMap::new();
872 annotations.insert(
873 "ref1".to_string(),
874 "A useful overview of the topic.".to_string(),
875 );
876
877 let style = AnnotationStyle::default();
878
879 let result = refs_to_string_with_format::<PlainText>(
880 vec![make_entry("ref1", "Some Publisher")],
881 Some(&annotations),
882 Some(&style),
883 );
884
885 assert!(
886 result.contains("Some Publisher"),
887 "entry text should appear: {result}"
888 );
889 assert!(
890 result.contains("A useful overview of the topic."),
891 "annotation should appear: {result}"
892 );
893 assert!(
895 result.contains("\n\nA useful overview"),
896 "annotation should be separated by blank line: {result}"
897 );
898 }
899
900 #[test]
901 fn test_no_annotation_when_id_absent() {
902 let mut annotations = HashMap::new();
903 annotations.insert(
904 "other-ref".to_string(),
905 "Annotation for someone else.".to_string(),
906 );
907
908 let style = AnnotationStyle::default();
909
910 let result = refs_to_string_with_format::<PlainText>(
911 vec![make_entry("ref1", "Some Publisher")],
912 Some(&annotations),
913 Some(&style),
914 );
915
916 assert!(
917 !result.contains("Annotation for someone else."),
918 "annotation for a different ref should not appear: {result}"
919 );
920 }
921
922 #[test]
923 fn test_no_annotations_when_none_supplied() {
924 let result = refs_to_string_with_format::<PlainText>(
925 vec![make_entry("ref1", "Some Publisher")],
926 None,
927 None,
928 );
929
930 assert!(
931 result.contains("Some Publisher"),
932 "entry should render normally: {result}"
933 );
934 let blank_line_count = result.matches("\n\n").count();
936 assert!(
937 blank_line_count <= 1,
938 "should not have spurious blank lines: {result}"
939 );
940 }
941}