1use std::fmt::Write as _;
5
6use crate::model::{DiffLine, DiffModel};
7use crate::session::{Comment, CommentStatus, Session};
8
9pub struct FeedbackOptions<'a> {
10 pub title: &'a str,
11 pub file_filter: Option<&'a str>,
12 pub include_resolved: bool,
13}
14
15pub fn to_markdown(session: &Session, model: &DiffModel, opts: &FeedbackOptions<'_>) -> String {
16 let mut comments: Vec<&Comment> = session
17 .comments
18 .iter()
19 .filter(|c| opts.include_resolved || c.status != CommentStatus::Resolved)
20 .filter(|c| opts.file_filter.is_none_or(|f| c.anchor.file == f))
21 .collect();
22 comments.sort_by(|a, b| (&a.anchor.file, a.anchor.line).cmp(&(&b.anchor.file, b.anchor.line)));
23
24 let mut out = String::new();
25 let _ = writeln!(out, "## {}", opts.title);
26 for comment in comments {
27 out.push('\n');
28 render_comment(&mut out, comment, model, opts.include_resolved);
29 }
30 out
31}
32
33fn render_comment(out: &mut String, comment: &Comment, model: &DiffModel, include_resolved: bool) {
34 let anchor = &comment.anchor;
35 let _ = match (anchor.line, anchor.line_end) {
36 (Some(line), Some(end)) => writeln!(out, "### {}:{line}-{end}", anchor.file),
37 (Some(line), None) => writeln!(out, "### {}:{line}", anchor.file),
38 _ => writeln!(out, "### {}", anchor.file),
39 };
40
41 if let Some(line) = anchor.line {
42 match context_snippet(model, &anchor.file, line, anchor.on_old_side) {
43 Some(snippet) => {
44 let longest_run = snippet
46 .iter()
47 .map(|(_, text)| longest_backtick_run(text))
48 .max()
49 .unwrap_or(0);
50 let fence = "`".repeat((longest_run + 1).max(3));
51 let _ = writeln!(out, "{fence}");
52 for (origin, text) in snippet {
53 let _ = writeln!(out, "{origin}{text}");
54 }
55 let _ = writeln!(out, "{fence}");
56 }
57 None => out.push_str("_(outdated)_\n"),
58 }
59 } else if anchor.is_outdated(model) {
60 out.push_str("_(outdated)_\n");
61 }
62
63 for body_line in comment.body.lines() {
64 let _ = writeln!(out, "> {body_line}");
65 }
66 for reply in &comment.replies {
67 let mut lines = reply.body.lines();
68 if let Some(first) = lines.next() {
69 let _ = writeln!(out, "> > {}: {first}", reply.author);
70 }
71 for rest in lines {
72 let _ = writeln!(out, "> > {rest}");
73 }
74 }
75 if include_resolved && comment.status == CommentStatus::Resolved {
76 out.push_str("_(resolved)_\n");
77 }
78}
79
80pub fn context_snippet(
83 model: &DiffModel,
84 file: &str,
85 line: u32,
86 on_old_side: bool,
87) -> Option<Vec<(char, String)>> {
88 let file = model.files.iter().find(|f| f.path == file)?;
89 for hunk in &file.hunks {
90 let Some(idx) = hunk
91 .lines
92 .iter()
93 .position(|l| line_no(l, on_old_side) == Some(line))
94 else {
95 continue;
96 };
97 let start = idx.saturating_sub(1);
98 let end = (idx + 2).min(hunk.lines.len());
99 let snippet = hunk
100 .lines
101 .get(start..end)?
102 .iter()
103 .map(|l| (l.kind.origin(), l.text.clone()))
104 .collect();
105 return Some(snippet);
106 }
107 None
108}
109
110fn line_no(line: &DiffLine, on_old_side: bool) -> Option<u32> {
111 if on_old_side {
112 line.old_no
113 } else {
114 line.new_no
115 }
116}
117
118fn longest_backtick_run(text: &str) -> usize {
119 let mut longest = 0;
120 let mut current = 0;
121 for c in text.chars() {
122 if c == '`' {
123 current += 1;
124 longest = longest.max(current);
125 } else {
126 current = 0;
127 }
128 }
129 longest
130}
131
132#[cfg(test)]
133mod tests {
134 use crate::model::{FileDiff, FileStatus, Hunk, HunkId, LineKind};
135 use crate::test_support::anchor;
136
137 use super::*;
138
139 fn diff_line(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: &str) -> DiffLine {
140 DiffLine::new(kind, old_no, new_no, text.to_owned())
141 }
142
143 fn sample_model() -> DiffModel {
145 DiffModel {
146 files: vec![FileDiff {
147 path: "src/auth.py".into(),
148 old_path: None,
149 status: FileStatus::Modified,
150 binary: false,
151 old_text: None,
152 new_text: Some("one\nTWO\nthree\n".into()),
153 hunks: vec![Hunk {
154 id: HunkId("h1".into()),
155 old_start: 1,
156 old_lines: 3,
157 new_start: 1,
158 new_lines: 3,
159 context: String::new(),
160 lines: vec![
161 diff_line(LineKind::Context, Some(1), Some(1), "one"),
162 diff_line(LineKind::Deleted, Some(2), None, "two"),
163 diff_line(LineKind::Added, None, Some(2), "TWO"),
164 diff_line(LineKind::Context, Some(3), Some(3), "three"),
165 ],
166 }],
167 hashes: crate::model::HashCache::default(),
168 blobs: crate::model::BlobIds::default(),
169 }],
170 }
171 }
172
173 fn opts<'a>() -> FeedbackOptions<'a> {
174 FeedbackOptions {
175 title: "Review feedback",
176 file_filter: None,
177 include_resolved: false,
178 }
179 }
180
181 #[test]
182 fn single_line_comment_renders_heading_and_context_fence() {
183 let mut s = Session::default();
184 s.add_comment(anchor("src/auth.py", Some(2)), "reviewer", "why uppercase?");
185 let md = to_markdown(&s, &sample_model(), &opts());
186 assert!(md.starts_with("## Review feedback\n\n"));
187 assert!(md.contains("### src/auth.py:2\n"));
188 assert!(md.contains("```\n-two\n+TWO\n three\n```\n"));
189 assert!(md.contains("> why uppercase?\n"));
190 assert!(md.ends_with('\n'));
191 }
192
193 #[test]
194 fn old_side_anchor_finds_deleted_line() {
195 let mut s = Session::default();
196 let mut a = anchor("src/auth.py", Some(2));
197 a.on_old_side = true;
198 s.add_comment(a, "reviewer", "what was wrong with two?");
199 let md = to_markdown(&s, &sample_model(), &opts());
200 assert!(md.contains("```\n one\n-two\n+TWO\n```\n"));
201 }
202
203 #[test]
204 fn range_comment_renders_start_dash_end() {
205 let mut s = Session::default();
206 let mut a = anchor("src/auth.py", Some(3));
207 a.line_end = Some(5);
208 s.add_comment(a, "reviewer", "this whole block");
209 let md = to_markdown(&s, &sample_model(), &opts());
210 assert!(md.contains("### src/auth.py:3-5\n"));
211 }
212
213 #[test]
214 fn file_filter_excludes_other_files() {
215 let mut s = Session::default();
216 s.add_comment(anchor("src/auth.py", Some(2)), "reviewer", "keep");
217 s.add_comment(anchor("other.py", Some(1)), "reviewer", "drop");
218 let o = FeedbackOptions {
219 file_filter: Some("src/auth.py"),
220 ..opts()
221 };
222 let md = to_markdown(&s, &sample_model(), &o);
223 assert!(md.contains("> keep\n"));
224 assert!(!md.contains("drop"));
225 }
226
227 #[test]
228 fn resolved_skipped_by_default_included_and_marked_with_flag() {
229 let mut s = Session::default();
230 let id = s
231 .add_comment(anchor("src/auth.py", Some(2)), "reviewer", "done already")
232 .id
233 .clone();
234 assert!(s.resolve(&id));
235 let md = to_markdown(&s, &sample_model(), &opts());
236 assert!(!md.contains("done already"));
237
238 let o = FeedbackOptions {
239 include_resolved: true,
240 ..opts()
241 };
242 let md = to_markdown(&s, &sample_model(), &o);
243 assert!(md.contains("> done already\n"));
244 assert!(md.contains("_(resolved)_\n"));
245 }
246
247 #[test]
248 fn departed_file_renders_outdated_marker() {
249 let mut s = Session::default();
250 s.add_comment(anchor("gone.py", Some(7)), "reviewer", "still matters");
251 let md = to_markdown(&s, &sample_model(), &opts());
252 assert!(md.contains("### gone.py:7\n_(outdated)_\n"));
253 assert!(md.contains("> still matters\n"));
254 }
255
256 #[test]
257 fn departed_line_renders_outdated_marker() {
258 let mut s = Session::default();
259 s.add_comment(anchor("src/auth.py", Some(99)), "reviewer", "moved on");
260 let md = to_markdown(&s, &sample_model(), &opts());
261 assert!(md.contains("### src/auth.py:99\n_(outdated)_\n"));
262 }
263
264 #[test]
265 fn file_level_comment_has_no_fence_when_file_present() {
266 let mut s = Session::default();
267 s.add_comment(anchor("src/auth.py", None), "reviewer", "overall: nice");
268 let md = to_markdown(&s, &sample_model(), &opts());
269 assert!(md.contains("### src/auth.py\n> overall: nice\n"));
270 assert!(!md.contains("```"));
271 assert!(!md.contains("_(outdated)_"));
272 }
273
274 #[test]
275 fn replies_render_as_nested_quotes() {
276 let mut s = Session::default();
277 let id = s
278 .add_comment(anchor("src/auth.py", Some(2)), "reviewer", "why?")
279 .id
280 .clone();
281 assert!(s.reply(&id, "agent", "because tests\nand style"));
282 let md = to_markdown(&s, &sample_model(), &opts());
283 assert!(md.contains("> why?\n> > agent: because tests\n> > and style\n"));
284 }
285
286 #[test]
287 fn fenced_context_survives_backticks_in_diff_content() {
288 let mut model = sample_model();
289 if let Some(line) = model.files[0].hunks[0].lines.get_mut(2) {
290 line.text = "````md".into();
291 }
292 let mut s = Session::default();
293 s.add_comment(anchor("src/auth.py", Some(2)), "reviewer", "fence bomb");
294 let md = to_markdown(&s, &model, &opts());
295 let fence = "`````";
296 assert!(
297 md.contains(&format!("{fence}\n")),
298 "fence must outrun content runs: {md}"
299 );
300 let open = md.find(fence).expect("opening fence");
301 let close = md.rfind(fence).expect("closing fence");
302 assert!(close > open);
303 }
304
305 #[test]
306 fn comments_order_by_file_then_line() {
307 let mut s = Session::default();
308 s.add_comment(anchor("z.py", Some(1)), "reviewer", "third");
309 s.add_comment(anchor("src/auth.py", Some(3)), "reviewer", "second");
310 s.add_comment(anchor("src/auth.py", Some(1)), "reviewer", "first");
311 let md = to_markdown(&s, &sample_model(), &opts());
312 let first = md.find("> first").expect("first present");
313 let second = md.find("> second").expect("second present");
314 let third = md.find("> third").expect("third present");
315 assert!(first < second && second < third);
316 }
317}