sampo_core/
markdown.rs

1//! Utilities to render Markdown consistently across crates.
2
3/// Format a Markdown list item for a changeset message.
4///
5/// - Ensures multi-line messages are indented so that subsequent lines are
6///   rendered as part of the same list item.
7/// - If the message itself contains list items (e.g. lines starting with "- "),
8///   they become properly nested under the changeset item.
9/// - Always ends with a trailing newline.
10pub fn format_markdown_list_item(message: &str) -> String {
11    let mut out = String::new();
12    let mut lines = message.lines();
13    if let Some(first) = lines.next() {
14        out.push_str("- ");
15        out.push_str(first);
16        out.push('\n');
17    } else {
18        // TODO: should empty messages be allowed? If so, how should they be rendered?
19        // For now, render as an empty list item. At some point, this subject will be
20        // brought up for discussion.
21        out.push_str("- \n");
22        return out;
23    }
24
25    // Indent continuation lines by two spaces so they remain part of the same
26    // list item in Markdown. Nested list markers will be correctly nested.
27    for line in lines {
28        out.push_str("  ");
29        out.push_str(line);
30        out.push('\n');
31    }
32
33    out
34}
35
36/// Compose a Markdown message with an optional prefix and suffix.
37///
38/// Ensures the suffix does not break closing code fences: when the message ends
39/// with a triple backtick fence (```), the suffix is put on a new line.
40pub fn compose_markdown_with_affixes(message: &str, prefix: &str, suffix: &str) -> String {
41    if suffix.is_empty() {
42        return format!("{}{}", prefix, message);
43    }
44
45    let ends_with_fence = message.trim_end().ends_with("```");
46    if ends_with_fence {
47        format!("{}{}\n{}", prefix, message, suffix)
48    } else {
49        format!("{}{}{}", prefix, message, suffix)
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn list_item_single_line() {
59        let out = format_markdown_list_item("feat: add new feature");
60        assert_eq!(out, "- feat: add new feature\n");
61    }
62
63    #[test]
64    fn list_item_multiline_with_nested_list() {
65        let msg = "feat: big change\n- add A\n- add B";
66        let out = format_markdown_list_item(msg);
67        let expected = "- feat: big change\n  - add A\n  - add B\n";
68        assert_eq!(out, expected);
69    }
70
71    #[test]
72    fn list_item_with_empty_message() {
73        let out = format_markdown_list_item("");
74        assert_eq!(out, "- \n");
75    }
76
77    #[test]
78    fn compose_affixes_simple() {
79        let msg = compose_markdown_with_affixes(
80            "feat: add new feature",
81            "[abcd](link) ",
82            " — Thanks @user!",
83        );
84        assert_eq!(msg, "[abcd](link) feat: add new feature — Thanks @user!");
85    }
86
87    #[test]
88    fn compose_affixes_preserves_code_fence() {
89        let message = "Here is code:\n```rust\nfn main() {}\n```";
90        let result = compose_markdown_with_affixes(message, "[abcd](link) ", " — Thanks @user!");
91        let expected = "[abcd](link) Here is code:\n```rust\nfn main() {}\n```\n — Thanks @user!";
92        assert_eq!(result, expected);
93    }
94}