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/// Verdict: changes required · 2026-07-13T10:00:00Z
26///
27/// 2 comment(s), 1 open · base <sha12> · head <sha12>
28///
29/// ## src/app.rs
30///
31/// - **L42 (new)** — first line
32///   continuation lines indented
33/// - **L50 (new, resolved)** — a settled thread
34/// ```
35///
36/// The verdict line appears only when a verdict is set; the `, N open`
37/// count only when some comments are resolved.
38pub fn to_markdown(doc: &ReviewDocument) -> String {
39  let mut out = String::new();
40  out.push_str(&format!("# Review comments — {} {}..{}\n\n", doc.repo, doc.base.name, doc.head.name));
41  if let Some(v) = &doc.verdict {
42    out.push_str(&format!("Verdict: {} · {}\n\n", v.label(), v.at()));
43  }
44  let open = doc.comments.iter().filter(|c| c.resolved_at.is_none()).count();
45  let openness = if open == doc.comments.len() { String::new() } else { format!(", {open} open") };
46  out.push_str(&format!(
47    "{} comment(s){openness} · base {} · head {}\n",
48    doc.comments.len(),
49    &doc.base.sha[..doc.base.sha.len().min(12)],
50    &doc.head.sha[..doc.head.sha.len().min(12)],
51  ));
52  let mut current_file: Option<&str> = None;
53  for c in &doc.comments {
54    if current_file != Some(c.file.as_str()) {
55      current_file = Some(c.file.as_str());
56      out.push_str(&format!("\n## {}\n\n", c.file));
57    }
58    let body = c.text.replace('\n', "\n  ");
59    let state = if c.resolved_at.is_some() { ", resolved" } else { "" };
60    out.push_str(&format!("- **L{} ({}{state})** — {}\n", c.line, side_label(c.side), body));
61  }
62  out
63}
64
65/// Spreadsheet export: RFC 4180 (quoted fields, CRLF line endings). One row
66/// per comment — the document-level verdict lives in the JSON and Markdown
67/// exports, not here. `resolved_at` is empty for open comments.
68pub fn to_csv(doc: &ReviewDocument) -> String {
69  fn field(v: &str) -> String {
70    format!("\"{}\"", v.replace('"', "\"\""))
71  }
72  let mut rows = vec![["file", "side", "line", "created_at", "updated_at", "resolved_at", "text"].map(field).join(",")];
73  for c in &doc.comments {
74    rows.push(
75      [
76        c.file.as_str(),
77        side_label(c.side),
78        &c.line.to_string(),
79        c.created_at.as_str(),
80        c.updated_at.as_str(),
81        c.resolved_at.as_deref().unwrap_or(""),
82        c.text.as_str(),
83      ]
84      .map(field)
85      .join(","),
86    );
87  }
88  rows.push(String::new()); // trailing CRLF
89  rows.join("\r\n")
90}
91
92#[cfg(test)]
93mod tests {
94  use super::*;
95  use crate::review::Comment;
96  use crate::RefInfo;
97
98  fn doc_with_comments() -> ReviewDocument {
99    let mut d = ReviewDocument::new(
100      "myrepo".into(),
101      RefInfo { name: "main".into(), sha: "a".repeat(40) },
102      RefInfo { name: "feat".into(), sha: "b".repeat(40) },
103    );
104    d.upsert(Comment {
105      id: "c2".into(),
106      file: "src/lib.rs".into(),
107      side: Side::Old,
108      line: 7,
109      text: "why removed?".into(),
110      created_at: "2026-07-03T10:05:00Z".into(),
111      updated_at: "2026-07-03T10:05:00Z".into(),
112      resolved_at: None,
113    })
114    .unwrap();
115    d.upsert(Comment {
116      id: "c1".into(),
117      file: "src/app.rs".into(),
118      side: Side::New,
119      line: 42,
120      text: "multi\nline \"note\"".into(),
121      created_at: "2026-07-03T10:00:00Z".into(),
122      updated_at: "2026-07-03T10:00:00Z".into(),
123      resolved_at: None,
124    })
125    .unwrap();
126    d
127  }
128
129  #[test]
130  fn markdown_groups_by_file_and_indents_continuations() {
131    let md = to_markdown(&doc_with_comments());
132    assert!(md.starts_with("# Review comments — myrepo main..feat\n"));
133    assert!(md.contains("2 comment(s) · base aaaaaaaaaaaa · head bbbbbbbbbbbb"));
134    assert!(!md.contains("Verdict:"), "no verdict line while the review is in progress");
135    assert!(!md.contains("open"), "no open count while every comment is open");
136    let app = md.find("## src/app.rs").unwrap();
137    let lib = md.find("## src/lib.rs").unwrap();
138    assert!(app < lib, "files sorted");
139    assert!(md.contains("- **L42 (new)** — multi\n  line \"note\"\n"));
140    assert!(md.contains("- **L7 (old)** — why removed?\n"));
141  }
142
143  #[test]
144  fn markdown_carries_verdict_and_resolution_state() {
145    let mut d = doc_with_comments();
146    d.set_verdict(Some(crate::review::Verdict::ChangesRequired { at: "2026-07-13T10:00:00Z".into() })).unwrap();
147    let mut resolved = d.comments[1].clone();
148    resolved.resolved_at = Some("2026-07-13T11:00:00Z".into());
149    d.upsert(resolved).unwrap();
150    let md = to_markdown(&d);
151    assert!(md.contains("Verdict: changes required · 2026-07-13T10:00:00Z\n"));
152    assert!(md.contains("2 comment(s), 1 open · base"));
153    assert!(md.contains("- **L7 (old, resolved)** — why removed?\n"));
154    assert!(md.contains("- **L42 (new)** — multi"), "open comments carry no state marker");
155  }
156
157  #[test]
158  fn csv_quotes_and_escapes() {
159    let csv = to_csv(&doc_with_comments());
160    let lines: Vec<&str> = csv.split("\r\n").collect();
161    assert_eq!(lines[0], "\"file\",\"side\",\"line\",\"created_at\",\"updated_at\",\"resolved_at\",\"text\"");
162    assert!(lines[1].starts_with("\"src/app.rs\",\"new\",\"42\""));
163    assert!(lines[1].contains(",\"\",\"multi\nline \"\"note\"\"\""), "open comment: empty resolved_at");
164    assert_eq!(lines.last(), Some(&""), "ends with CRLF");
165    let mut d = doc_with_comments();
166    let mut resolved = d.comments[0].clone();
167    resolved.resolved_at = Some("2026-07-13T11:00:00Z".into());
168    d.upsert(resolved).unwrap();
169    assert!(to_csv(&d).contains("\"2026-07-13T11:00:00Z\""));
170  }
171
172  #[test]
173  fn json_export_is_canonical_and_reimportable() {
174    let d = doc_with_comments();
175    let json = to_json(&d);
176    let back = ReviewDocument::parse(&json).unwrap();
177    assert_eq!(back, d);
178  }
179
180  #[test]
181  fn empty_document_exports() {
182    let d = ReviewDocument::new(
183      "r".into(),
184      RefInfo { name: "a".into(), sha: "a".repeat(40) },
185      RefInfo { name: "b".into(), sha: "b".repeat(40) },
186    );
187    assert!(to_markdown(&d).contains("0 comment(s)"));
188    let csv = to_csv(&d);
189    assert!(csv.ends_with("\"text\"\r\n"), "header row only, CRLF-terminated: {csv:?}");
190  }
191}