Skip to main content

citum_engine/render/
org.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Org-mode output format.
7
8use super::format::{OutputFormat, QuoteMarks};
9use citum_schema::template::WrapPunctuation;
10
11/// Renders processed citations and bibliography entries as org-mode markup.
12#[derive(Default, Clone)]
13pub struct OrgOutputFormat;
14
15impl OutputFormat for OrgOutputFormat {
16    type Output = String;
17
18    fn text(&self, s: &str) -> Self::Output {
19        s.to_string()
20    }
21
22    fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
23        items.join(delimiter)
24    }
25
26    fn finish(&self, output: Self::Output) -> String {
27        output
28    }
29
30    /// Render a heading using org-mode stars (`*`, `**`, ...).
31    fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
32        let marks = "*".repeat(level.max(1) as usize);
33        format!("{marks} {content}\n\n")
34    }
35
36    /// Render content with emphasis (italics in org-mode: /text/).
37    fn emph(&self, content: Self::Output) -> Self::Output {
38        if content.is_empty() {
39            return content;
40        }
41        format!("/{content}/")
42    }
43
44    /// Render content with strong emphasis (bold in org-mode: *text*).
45    fn strong(&self, content: Self::Output) -> Self::Output {
46        if content.is_empty() {
47            return content;
48        }
49        format!("*{content}*")
50    }
51
52    /// Render content in small capitals (org-mode uses ~text~).
53    fn small_caps(&self, content: Self::Output) -> Self::Output {
54        if content.is_empty() {
55            return content;
56        }
57        format!("~{content}~")
58    }
59
60    fn superscript(&self, content: Self::Output) -> Self::Output {
61        if content.is_empty() {
62            return content;
63        }
64        format!("^{content}^")
65    }
66
67    fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
68        if content.is_empty() {
69            return content;
70        }
71        // Org-mode doesn't have native quotation marks; use the locale marks as-is.
72        let (open, close) = marks.for_depth(0);
73        format!("{open}{content}{close}")
74    }
75
76    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
77        format!("{prefix}{content}{suffix}")
78    }
79
80    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
81        format!("{prefix}{content}{suffix}")
82    }
83
84    fn wrap_punctuation(
85        &self,
86        wrap: &WrapPunctuation,
87        content: Self::Output,
88        marks: &QuoteMarks,
89    ) -> Self::Output {
90        match wrap {
91            WrapPunctuation::Parentheses => format!("({content})"),
92            WrapPunctuation::Brackets => format!("[{content}]"),
93            WrapPunctuation::Quotes => self.quote(content, marks),
94        }
95    }
96
97    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
98        // Org-mode doesn't support semantic classes; just return the content
99        content
100    }
101
102    fn annotation(&self, content: Self::Output) -> Self::Output {
103        if content.is_empty() {
104            return content;
105        }
106        format!(
107            "\n\n#+begin_citum_annotation\n{}\n#+end_citum_annotation",
108            content
109        )
110    }
111
112    /// Render a hyperlink in org-mode format: `[[url][text]]`
113    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
114        format!("[[{url}][{content}]]")
115    }
116
117    fn entry(
118        &self,
119        _id: &str,
120        content: Self::Output,
121        _url: Option<&str>,
122        _metadata: &super::format::ProcEntryMetadata,
123    ) -> Self::Output {
124        content
125    }
126}
127
128#[cfg(test)]
129#[allow(
130    clippy::unwrap_used,
131    clippy::expect_used,
132    clippy::panic,
133    clippy::indexing_slicing,
134    clippy::todo,
135    clippy::unimplemented,
136    clippy::unreachable,
137    clippy::get_unwrap,
138    reason = "Panicking is acceptable and often desired in tests."
139)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_org_emph() {
145        let fmt = OrgOutputFormat;
146        let result = fmt.emph(fmt.text("italic text"));
147        assert_eq!(result, "/italic text/");
148    }
149
150    #[test]
151    fn test_org_strong() {
152        let fmt = OrgOutputFormat;
153        let result = fmt.strong(fmt.text("bold text"));
154        assert_eq!(result, "*bold text*");
155    }
156
157    #[test]
158    fn test_org_small_caps() {
159        let fmt = OrgOutputFormat;
160        let result = fmt.small_caps(fmt.text("small caps"));
161        assert_eq!(result, "~small caps~");
162    }
163
164    #[test]
165    fn test_org_link() {
166        let fmt = OrgOutputFormat;
167        let result = fmt.link("https://example.com", fmt.text("Example"));
168        assert_eq!(result, "[[https://example.com][Example]]");
169    }
170
171    #[test]
172    fn test_org_empty_content() {
173        let fmt = OrgOutputFormat;
174        let result = fmt.emph(fmt.text(""));
175        assert_eq!(result, "");
176    }
177}