Skip to main content

citum_engine/render/
bibliography.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6use 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
15/// Returns true if the character is a sentence-ending or clause-ending punctuation mark.
16fn is_final_punctuation(c: char) -> bool {
17    matches!(c, '.' | ',' | ':' | ';' | '!' | '?')
18}
19
20/// Returns true if the character ends a sentence (period, question mark, exclamation).
21fn is_sentence_ending_punctuation(c: char) -> bool {
22    matches!(c, '.' | '!' | '?')
23}
24
25/// Extracts the visible (non-markup) text content from a rendered fragment.
26fn 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
42/// Returns the first character of the visible (tag-stripped) text, which may be whitespace.
43fn first_visible_char(input: &str) -> Option<char> {
44    visible_text(input).chars().next()
45}
46
47/// Returns the last non-whitespace visible character, used for punctuation deduplication.
48fn 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
55/// Returns true if the rendered output ends with sentence-ending punctuation, used to suppress trailing period addition.
56fn 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/// Returns true when the next rendered component should be treated as sentence-initial
67/// under the same join semantics used by bibliography rendering.
68#[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/// Render processed templates into a final bibliography string using `PlainText` format.
109#[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/// Render one processed bibliography entry body without outer entry/bibliography wrappers.
115#[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/// Render processed bibliography components without outer entry/bibliography wrappers.
123#[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    // Check locale option for punctuation placement in quotes.
135    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    // Get the bibliography separator from the config, defaulting to ". "
141    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
211/// Append a rendered component to `entry_output`, inserting spacing or the
212/// `default_separator` according to bibliography house-style punctuation rules.
213///
214/// The separator logic inspects the boundary between the accumulated output
215/// and the incoming `rendered` string; `punctuation_in_quote` controls whether
216/// a period should be pulled inside a preceding closing quotation mark.
217pub(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        // The incoming component already carries its own leading separator (e.g. ", " or "; ").
230        let starts_with_separator = matches!(first_char, ',' | ';' | ':' | ' ' | '.' | '(');
231
232        if starts_with_separator {
233            // The rendered component is self-delimiting — don't add a separator.
234            // Exception: an opening parenthesis needs a leading space unless already spaced.
235            if first_char == '(' && !last_char.is_whitespace() && last_char != '[' {
236                entry_output.push(' ');
237            }
238        } else if ends_with_punctuation {
239            // Output already ends in terminal punctuation — just ensure a separating space.
240            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            // Punctuation-in-quote: pull the period inside the closing quotation mark.
248            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            // Both sides are non-space — insert the configured separator between them.
257            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            // The next component leads with whitespace and the separator is period-prefixed:
264            // supply the missing period so the gap doesn't swallow the sentence boundary.
265            entry_output.push('.');
266        }
267    }
268
269    let _ = write!(entry_output, "{rendered}");
270}
271
272/// Render processed templates into a final bibliography string using a specific format.
273#[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/// Render borrowed processed templates into a final bibliography string using a specific format.
283#[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        // Apply annotation if present
297        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            // Render annotation text through markup format if enabled
303            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        // Resolve entry URL if whole-entry linking is enabled
322        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                    // We need the reference to resolve the URL.
330                    // This is a bit tricky as ProcEntry doesn't have the reference.
331                    // But we can look it up from the bibliography if we had access to it.
332                    // For now, let's see if any component in the template has a URL resolved.
333                    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
345/// Check if the output ends with a URL or DOI (to suppress trailing period).
346fn 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    // Check if the last "word" looks like a URL or DOI
351    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        // NOTE: Removed (".,", ".") pattern - it was too aggressive and removed legitimate
368        // component suffixes like "S.," from author initials. In Citum, component suffixes are
369        // explicit and well-defined, so we don't have the CSL 1.0 dual-punctuation issue.
370        (" ,", ","),
371        (" ;", ";"),
372        (" :", ":"),
373        (" .", "."),
374        (",  ", ", "),
375        (". .", "."),
376        (".. ", ". "),
377        ("..", "."),
378        ("  ", " "), // Double space to single
379    ];
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        // Elsevier Harvard: author component has suffix `, ` and date has suffix `.`
601        // Expected: "Hawking, S., 1988." (comma from author suffix preserved)
602        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        // The comma from author's suffix should be preserved
662        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        // Blank line separator: entry text followed by \n\n
894        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        // No extra blank lines beyond entry separator
935        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}