Skip to main content

diffler_core/
feedback.rs

1//! Markdown export of review comments with their diff context.
2
3// writing into a String is infallible, so we discard the `write!` results
4use 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                // we outrun every backtick run so diff content can't close the fence
45                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
80/// The anchored line and its neighbours in the same hunk, each with its diff
81/// origin char. `None` when the line has left the model.
82pub 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    /// One file, one hunk: context(1/1), deleted(2), added(2), context(3/3).
144    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}