#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Comment {
pub(crate) author: String,
pub(crate) body: String,
pub(crate) source: String,
pub(crate) is_reply: bool,
}
pub(crate) const SOURCE_CAP: usize = 200;
pub(crate) fn collapse_ws(s: &str) -> String {
s.split(|c: char| c.is_ascii_whitespace())
.filter(|seg| !seg.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn cap_text(s: &str, max_chars: usize) -> String {
match s.char_indices().nth(max_chars) {
Some((idx, _)) => {
let mut out = String::with_capacity(idx + '…'.len_utf8());
out.push_str(&s[..idx]);
out.push('…');
out
}
None => s.to_string(),
}
}
pub(crate) fn format_author(author: &str, date: &str) -> String {
let author = author.trim();
let author = if author.is_empty() { "Unknown" } else { author };
let date = date.trim();
if date.is_empty() {
author.to_string()
} else {
format!("{author} ({date})")
}
}
fn body_display(c: &Comment) -> String {
if c.is_reply {
if c.body.is_empty() {
"(reply)".to_string()
} else {
format!("(reply) {}", c.body)
}
} else {
c.body.clone()
}
}
fn render_comments_md(comments: &[Comment]) -> String {
let mut out = String::from("# Comments\n");
for (i, c) in comments.iter().enumerate() {
out.push_str(&format!("\n## {}\n", i + 1));
out.push_str(&format!("- **author**: {}\n", c.author));
out.push_str(&format!("- **comment**: {}\n", body_display(c)));
out.push_str(&format!("- **source**: {}\n", c.source));
}
out
}
fn render_comments_plain(comments: &[Comment]) -> String {
let mut out = String::from("Comments\n");
for (i, c) in comments.iter().enumerate() {
out.push_str(&format!("\n{}\n", i + 1));
out.push_str(&format!("author: {}\n", c.author));
out.push_str(&format!("comment: {}\n", body_display(c)));
out.push_str(&format!("source: {}\n", c.source));
}
out
}
fn append_section(buf: &mut String, section: &str) {
let keep = buf.trim_end().len();
if keep == 0 {
*buf = section.to_string();
return;
}
buf.truncate(keep);
buf.push_str("\n\n");
buf.push_str(section);
}
pub(crate) fn append_comments(
markdown: &mut String,
plain_text: &mut String,
comments: &[Comment],
) {
if comments.is_empty() {
return;
}
append_section(markdown, &render_comments_md(comments));
append_section(plain_text, &render_comments_plain(comments));
}
#[cfg(test)]
mod tests {
use super::*;
fn c(author: &str, body: &str, source: &str, is_reply: bool) -> Comment {
Comment {
author: author.to_string(),
body: body.to_string(),
source: source.to_string(),
is_reply,
}
}
#[test]
fn test_collapse_ws_newlines_and_tabs_to_space() {
assert_eq!(collapse_ws("a\nb\tc"), "a b c");
}
#[test]
fn test_collapse_ws_runs_collapsed_and_trimmed() {
assert_eq!(collapse_ws(" a \n\n b "), "a b");
}
#[test]
fn test_collapse_ws_empty() {
assert_eq!(collapse_ws(" \n\t "), "");
}
#[test]
fn test_collapse_ws_cjk_preserved() {
assert_eq!(collapse_ws("한국어\n中文"), "한국어 中文");
}
#[test]
fn test_collapse_ws_preserves_non_ascii_spaces() {
assert_eq!(collapse_ws("a\u{00A0}b"), "a\u{00A0}b");
assert_eq!(collapse_ws("中\u{3000}文"), "中\u{3000}文");
assert_eq!(collapse_ws(" a\u{00A0}b \n c "), "a\u{00A0}b c");
}
#[test]
fn test_cap_text_under_limit_unchanged() {
assert_eq!(cap_text("hello", 200), "hello");
}
#[test]
fn test_cap_text_exact_limit_unchanged() {
let s = "a".repeat(200);
assert_eq!(cap_text(&s, 200), s);
}
#[test]
fn test_cap_text_over_limit_truncated_with_ellipsis() {
let s = "a".repeat(201);
let out = cap_text(&s, 200);
assert_eq!(out.chars().count(), 201); assert!(out.ends_with('…'));
assert_eq!(&out[..200], &"a".repeat(200));
}
#[test]
fn test_cap_text_cjk_not_split_mid_char() {
let s = "한".repeat(250);
let out = cap_text(&s, 200);
assert_eq!(out.chars().count(), 201);
assert!(out.ends_with('…'));
assert_eq!(out.chars().filter(|&ch| ch == '한').count(), 200);
}
#[test]
fn test_format_author_name_and_date() {
assert_eq!(
format_author("Jane Smith", "2024-01-15T09:30:00Z"),
"Jane Smith (2024-01-15T09:30:00Z)"
);
}
#[test]
fn test_format_author_empty_date_name_only() {
assert_eq!(format_author("Jane Smith", ""), "Jane Smith");
assert_eq!(format_author("Jane Smith", " "), "Jane Smith");
}
#[test]
fn test_format_author_empty_author_unknown() {
assert_eq!(format_author("", ""), "Unknown");
assert_eq!(format_author(" ", ""), "Unknown");
}
#[test]
fn test_format_author_unknown_with_date() {
assert_eq!(format_author("", "2024-01-15"), "Unknown (2024-01-15)");
}
#[test]
fn test_body_display_non_reply() {
assert_eq!(body_display(&c("A", "Hello", "src", false)), "Hello");
}
#[test]
fn test_body_display_reply_prefixed() {
assert_eq!(
body_display(&c("A", "Agreed.", "src", true)),
"(reply) Agreed."
);
}
#[test]
fn test_body_display_reply_empty_body() {
assert_eq!(body_display(&c("A", "", "src", true)), "(reply)");
}
#[test]
fn test_render_comments_md_matches_spec() {
let comments = vec![
c(
"Jane Smith (2024-01-15T09:30:00Z)",
"Please revise this.",
"the quick brown fox",
false,
),
c("Unknown", "Agreed.", "jumped over", true),
];
let expected = "# Comments\n\
\n## 1\n\
- **author**: Jane Smith (2024-01-15T09:30:00Z)\n\
- **comment**: Please revise this.\n\
- **source**: the quick brown fox\n\
\n## 2\n\
- **author**: Unknown\n\
- **comment**: (reply) Agreed.\n\
- **source**: jumped over\n";
assert_eq!(render_comments_md(&comments), expected);
}
#[test]
fn test_render_comments_plain_matches_layout() {
let comments = vec![
c(
"Jane Smith (2024-01-15T09:30:00Z)",
"Please revise this.",
"the quick brown fox",
false,
),
c("Unknown", "Agreed.", "jumped over", true),
];
let expected = "Comments\n\
\n1\n\
author: Jane Smith (2024-01-15T09:30:00Z)\n\
comment: Please revise this.\n\
source: the quick brown fox\n\
\n2\n\
author: Unknown\n\
comment: (reply) Agreed.\n\
source: jumped over\n";
assert_eq!(render_comments_plain(&comments), expected);
assert!(!render_comments_plain(&comments).contains('#'));
assert!(!render_comments_plain(&comments).contains('*'));
}
#[test]
fn test_append_comments_empty_is_noop() {
let mut md = "body\n".to_string();
let mut plain = "body\n".to_string();
append_comments(&mut md, &mut plain, &[]);
assert_eq!(md, "body\n");
assert_eq!(plain, "body\n");
}
#[test]
fn test_append_comments_separated_by_blank_line() {
let mut md = "# Title\n\nSome body.\n".to_string();
let mut plain = "Some body.\n".to_string();
append_comments(&mut md, &mut plain, &[c("A", "B", "C", false)]);
assert!(md.contains("Some body.\n\n# Comments\n"));
assert!(plain.contains("Some body.\n\nComments\n"));
assert!(md.ends_with("- **source**: C\n"));
}
#[test]
fn test_append_comments_empty_body_no_leading_blank() {
let mut md = String::new();
let mut plain = String::new();
append_comments(&mut md, &mut plain, &[c("A", "B", "C", false)]);
assert!(md.starts_with("# Comments\n"));
assert!(plain.starts_with("Comments\n"));
}
#[test]
fn test_append_comments_whitespace_only_body_treated_as_empty() {
let mut md = "\n\n".to_string();
let mut plain = " \n".to_string();
append_comments(&mut md, &mut plain, &[c("A", "B", "C", false)]);
assert!(md.starts_with("# Comments\n"));
assert!(plain.starts_with("Comments\n"));
}
}