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
4*/
5
6//! Org-mode output format.
7
8use super::format::OutputFormat;
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 content with emphasis (italics in org-mode: /text/).
31    fn emph(&self, content: Self::Output) -> Self::Output {
32        if content.is_empty() {
33            return content;
34        }
35        format!("/{content}/")
36    }
37
38    /// Render content with strong emphasis (bold in org-mode: *text*).
39    fn strong(&self, content: Self::Output) -> Self::Output {
40        if content.is_empty() {
41            return content;
42        }
43        format!("*{content}*")
44    }
45
46    /// Render content in small capitals (org-mode uses ~text~).
47    fn small_caps(&self, content: Self::Output) -> Self::Output {
48        if content.is_empty() {
49            return content;
50        }
51        format!("~{content}~")
52    }
53
54    fn superscript(&self, content: Self::Output) -> Self::Output {
55        if content.is_empty() {
56            return content;
57        }
58        format!("^{content}^")
59    }
60
61    fn quote(&self, content: Self::Output) -> Self::Output {
62        if content.is_empty() {
63            return content;
64        }
65        // Org-mode doesn't have native quotation marks, use as-is with Unicode
66        format!("\u{201C}{content}\u{201D}")
67    }
68
69    fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
70        format!("{prefix}{content}{suffix}")
71    }
72
73    fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
74        format!("{prefix}{content}{suffix}")
75    }
76
77    fn wrap_punctuation(&self, wrap: &WrapPunctuation, content: Self::Output) -> Self::Output {
78        match wrap {
79            WrapPunctuation::Parentheses => format!("({content})"),
80            WrapPunctuation::Brackets => format!("[{content}]"),
81            WrapPunctuation::Quotes => format!("\u{201C}{content}\u{201D}"),
82        }
83    }
84
85    fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
86        // Org-mode doesn't support semantic classes; just return the content
87        content
88    }
89
90    fn annotation(&self, content: Self::Output) -> Self::Output {
91        if content.is_empty() {
92            return content;
93        }
94        format!(
95            "\n\n#+begin_citum_annotation\n{}\n#+end_citum_annotation",
96            content
97        )
98    }
99
100    /// Render a hyperlink in org-mode format: `[[url][text]]`
101    fn link(&self, url: &str, content: Self::Output) -> Self::Output {
102        format!("[[{url}][{content}]]")
103    }
104
105    fn entry(
106        &self,
107        _id: &str,
108        content: Self::Output,
109        _url: Option<&str>,
110        _metadata: &super::format::ProcEntryMetadata,
111    ) -> Self::Output {
112        content
113    }
114}
115
116#[cfg(test)]
117#[allow(
118    clippy::unwrap_used,
119    clippy::expect_used,
120    clippy::panic,
121    clippy::indexing_slicing,
122    clippy::todo,
123    clippy::unimplemented,
124    clippy::unreachable,
125    clippy::get_unwrap,
126    reason = "Panicking is acceptable and often desired in tests."
127)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_org_emph() {
133        let fmt = OrgOutputFormat;
134        let result = fmt.emph(fmt.text("italic text"));
135        assert_eq!(result, "/italic text/");
136    }
137
138    #[test]
139    fn test_org_strong() {
140        let fmt = OrgOutputFormat;
141        let result = fmt.strong(fmt.text("bold text"));
142        assert_eq!(result, "*bold text*");
143    }
144
145    #[test]
146    fn test_org_small_caps() {
147        let fmt = OrgOutputFormat;
148        let result = fmt.small_caps(fmt.text("small caps"));
149        assert_eq!(result, "~small caps~");
150    }
151
152    #[test]
153    fn test_org_link() {
154        let fmt = OrgOutputFormat;
155        let result = fmt.link("https://example.com", fmt.text("Example"));
156        assert_eq!(result, "[[https://example.com][Example]]");
157    }
158
159    #[test]
160    fn test_org_empty_content() {
161        let fmt = OrgOutputFormat;
162        let result = fmt.emph(fmt.text(""));
163        assert_eq!(result, "");
164    }
165}