1use crate::review::{ReviewDocument, Side};
6
7fn side_label(side: Side) -> &'static str {
8 match side {
9 Side::Old => "old",
10 Side::New => "new",
11 }
12}
13
14pub fn to_json(doc: &ReviewDocument) -> String {
17 doc.to_json_pretty()
18}
19
20pub fn to_markdown(doc: &ReviewDocument) -> String {
33 let mut out = String::new();
34 out.push_str(&format!("# Review comments — {} {}..{}\n\n", doc.repo, doc.base.name, doc.head.name));
35 out.push_str(&format!(
36 "{} comment(s) · base {} · head {}\n",
37 doc.comments.len(),
38 &doc.base.sha[..doc.base.sha.len().min(12)],
39 &doc.head.sha[..doc.head.sha.len().min(12)],
40 ));
41 let mut current_file: Option<&str> = None;
42 for c in &doc.comments {
43 if current_file != Some(c.file.as_str()) {
44 current_file = Some(c.file.as_str());
45 out.push_str(&format!("\n## {}\n\n", c.file));
46 }
47 let body = c.text.replace('\n', "\n ");
48 out.push_str(&format!("- **L{} ({})** — {}\n", c.line, side_label(c.side), body));
49 }
50 out
51}
52
53pub fn to_csv(doc: &ReviewDocument) -> String {
55 fn field(v: &str) -> String {
56 format!("\"{}\"", v.replace('"', "\"\""))
57 }
58 let mut rows = vec![["file", "side", "line", "created_at", "updated_at", "text"].map(field).join(",")];
59 for c in &doc.comments {
60 rows.push(
61 [
62 c.file.as_str(),
63 side_label(c.side),
64 &c.line.to_string(),
65 c.created_at.as_str(),
66 c.updated_at.as_str(),
67 c.text.as_str(),
68 ]
69 .map(field)
70 .join(","),
71 );
72 }
73 rows.push(String::new()); rows.join("\r\n")
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80 use crate::review::Comment;
81 use crate::RefInfo;
82
83 fn doc_with_comments() -> ReviewDocument {
84 let mut d = ReviewDocument::new(
85 "myrepo".into(),
86 RefInfo { name: "main".into(), sha: "a".repeat(40) },
87 RefInfo { name: "feat".into(), sha: "b".repeat(40) },
88 );
89 d.upsert(Comment {
90 id: "c2".into(),
91 file: "src/lib.rs".into(),
92 side: Side::Old,
93 line: 7,
94 text: "why removed?".into(),
95 created_at: "2026-07-03T10:05:00Z".into(),
96 updated_at: "2026-07-03T10:05:00Z".into(),
97 })
98 .unwrap();
99 d.upsert(Comment {
100 id: "c1".into(),
101 file: "src/app.rs".into(),
102 side: Side::New,
103 line: 42,
104 text: "multi\nline \"note\"".into(),
105 created_at: "2026-07-03T10:00:00Z".into(),
106 updated_at: "2026-07-03T10:00:00Z".into(),
107 })
108 .unwrap();
109 d
110 }
111
112 #[test]
113 fn markdown_groups_by_file_and_indents_continuations() {
114 let md = to_markdown(&doc_with_comments());
115 assert!(md.starts_with("# Review comments — myrepo main..feat\n"));
116 assert!(md.contains("2 comment(s) · base aaaaaaaaaaaa · head bbbbbbbbbbbb"));
117 let app = md.find("## src/app.rs").unwrap();
118 let lib = md.find("## src/lib.rs").unwrap();
119 assert!(app < lib, "files sorted");
120 assert!(md.contains("- **L42 (new)** — multi\n line \"note\"\n"));
121 assert!(md.contains("- **L7 (old)** — why removed?\n"));
122 }
123
124 #[test]
125 fn csv_quotes_and_escapes() {
126 let csv = to_csv(&doc_with_comments());
127 let lines: Vec<&str> = csv.split("\r\n").collect();
128 assert_eq!(lines[0], "\"file\",\"side\",\"line\",\"created_at\",\"updated_at\",\"text\"");
129 assert!(lines[1].starts_with("\"src/app.rs\",\"new\",\"42\""));
130 assert!(lines[1].contains("\"multi\nline \"\"note\"\"\""));
131 assert_eq!(lines.last(), Some(&""), "ends with CRLF");
132 }
133
134 #[test]
135 fn json_export_is_canonical_and_reimportable() {
136 let d = doc_with_comments();
137 let json = to_json(&d);
138 let back = ReviewDocument::parse(&json).unwrap();
139 assert_eq!(back, d);
140 }
141
142 #[test]
143 fn empty_document_exports() {
144 let d = ReviewDocument::new(
145 "r".into(),
146 RefInfo { name: "a".into(), sha: "a".repeat(40) },
147 RefInfo { name: "b".into(), sha: "b".repeat(40) },
148 );
149 assert!(to_markdown(&d).contains("0 comment(s)"));
150 let csv = to_csv(&d);
151 assert!(csv.ends_with("\"text\"\r\n"), "header row only, CRLF-terminated: {csv:?}");
152 }
153}