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