Skip to main content

packdiff_dto/
export.rs

1//! Export renderers for a [`ReviewDocument`]: Markdown, CSV, and the
2//! canonical JSON. Pure string builders — deterministic given the document
3//! (which [`ReviewDocument::sort`] keeps in canonical order).
4
5use 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
14/// Lossless export: the canonical pretty JSON of the whole document. This is
15/// the only format `merge`/import accepts back.
16pub fn to_json(doc: &ReviewDocument) -> String {
17  doc.to_json_pretty()
18}
19
20/// Human/agent-friendly export: comments grouped by file.
21///
22/// ```markdown
23/// # Review comments — repo main..feature
24///
25/// 2 comment(s) · base <sha12> · head <sha12>
26///
27/// ## src/app.rs
28///
29/// - **L42 (new)** — first line
30///   continuation lines indented
31/// ```
32pub 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
53/// Spreadsheet export: RFC 4180 (quoted fields, CRLF line endings).
54pub 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()); // trailing CRLF
74  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}