Skip to main content

diffler_core/
walkthrough.rs

1//! The agent's reading order for a review. A stop is an agent comment with a
2//! title and an anchor. A walkthrough is its own review source, so every
3//! comment in that source's session belongs to it.
4
5use serde::{Deserialize, Serialize};
6
7use crate::highlight::Highlighter;
8use crate::session::Comment;
9use crate::source::ReviewSource;
10
11/// A cap against dumping the whole diff; real walkthroughs stay well under.
12pub const MAX_STOPS: usize = 20;
13pub const BODY_MAX_BYTES: usize = 8 * 1024;
14pub const TOTAL_MAX_BYTES: usize = 64 * 1024;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct Walkthrough {
18    pub id: String,
19    pub title: String,
20    pub author: String,
21    pub at: u64,
22    /// The comment ids of the stops, in reading order.
23    pub stops: Vec<String>,
24    /// One line on what the agent left out and why.
25    #[serde(default)]
26    pub skipped: Option<String>,
27    /// Markdown overview, shown as the sidebar's leading slide.
28    #[serde(default)]
29    pub summary: Option<String>,
30    /// Full oid of `HEAD` at publish time, so anchors resolve against the code
31    /// they describe after the checkout moves. `None` resolves against the
32    /// worktree.
33    #[serde(default)]
34    pub rev: Option<String>,
35    /// The review this walkthrough describes; its diff is what stops resolve
36    /// against.
37    #[serde(default)]
38    pub about: ReviewSource,
39}
40
41impl Walkthrough {
42    /// The note ids of each stop, in stop order. A note is a titleless comment
43    /// with an `anchor_ref` inside a stop's region; it goes to the first stop
44    /// that holds it.
45    pub fn notes_by_stop(&self, comments: &[Comment]) -> Vec<Vec<String>> {
46        let mut groups: Vec<Vec<String>> = self.stops.iter().map(|_| Vec::new()).collect();
47        for comment in comments {
48            if comment.title.is_some()
49                || comment.anchor_ref.is_none()
50                || self.stops.contains(&comment.id)
51            {
52                continue;
53            }
54            let region = self.stops.iter().position(|stop_id| {
55                comments
56                    .iter()
57                    .find(|c| c.id == *stop_id)
58                    .is_some_and(|stop| region_contains(&stop.anchor, &comment.anchor))
59            });
60            if let Some(group) = region.and_then(|index| groups.get_mut(index)) {
61                group.push(comment.id.clone());
62            }
63        }
64        groups
65    }
66}
67
68/// Whether `other`'s line (or line end) falls inside `region`'s span, on the
69/// same file and side. Two file-level anchors match.
70pub(crate) fn region_contains(
71    region: &crate::session::Anchor,
72    other: &crate::session::Anchor,
73) -> bool {
74    if region.file != other.file || region.on_old_side != other.on_old_side {
75        return false;
76    }
77    match (region.span(), other.line_end.or(other.line)) {
78        (Some((start, end)), Some(at)) => start <= at && at <= end,
79        (None, None) => true,
80        (Some(_), None) | (None, Some(_)) => false,
81    }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum ReceiptCode {
87    TooManyStops,
88    EmptyStops,
89    BodyTooLong,
90    TotalTooLong,
91    AnchorUnparsed,
92    /// A stop's anchor names a file neither in the diff nor on disk.
93    AnchorFileMissing,
94    /// No stop names a file and the diff is empty.
95    NothingToAnchor,
96    NoteOutsideStop,
97    DuplicateId,
98}
99
100impl ReceiptCode {
101    /// The serde wire name.
102    pub fn name(self) -> &'static str {
103        match self {
104            Self::TooManyStops => "too_many_stops",
105            Self::EmptyStops => "empty_stops",
106            Self::BodyTooLong => "body_too_long",
107            Self::TotalTooLong => "total_too_long",
108            Self::AnchorUnparsed => "anchor_unparsed",
109            Self::AnchorFileMissing => "anchor_file_missing",
110            Self::NothingToAnchor => "nothing_to_anchor",
111            Self::NoteOutsideStop => "note_outside_stop",
112            Self::DuplicateId => "duplicate_id",
113        }
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct Receipt {
119    pub stop: Option<usize>,
120    pub code: ReceiptCode,
121    pub detail: String,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum Located {
126    /// 1-based inclusive rows; a single line is a span of one.
127    Found {
128        line: u32,
129        end: u32,
130    },
131    Whole,
132    /// The file was read, but the symbol or line is gone from it.
133    Lost,
134    /// The file could not be read at all.
135    FileMissing,
136}
137
138/// Where a figure's node or a stop's anchor points. We resolve it at draw
139/// time so it follows the code after an edit.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Target {
142    /// `path#symbol`, resolved through the file's definition spans.
143    Symbol {
144        path: String,
145        symbol: String,
146    },
147    /// `path:line` or `path:start-end`, 1-based and inclusive.
148    Line {
149        path: String,
150        line: u32,
151        end: u32,
152    },
153    File {
154        path: String,
155    },
156}
157
158impl Target {
159    /// `#` wins over `:`, and both win over a bare path, so a file whose name
160    /// contains either is unaddressable.
161    pub fn parse(raw: &str) -> Self {
162        let raw = raw.trim();
163        if let Some((path, symbol)) = raw.split_once('#')
164            && !symbol.is_empty()
165        {
166            return Self::Symbol {
167                path: path.to_owned(),
168                symbol: symbol.to_owned(),
169            };
170        }
171        if let Some((path, rows)) = raw.rsplit_once(':')
172            && let Some((line, end)) = parse_rows(rows)
173        {
174            return Self::Line {
175                path: path.to_owned(),
176                line,
177                end,
178            };
179        }
180        Self::File {
181            path: raw.to_owned(),
182        }
183    }
184
185    pub fn path(&self) -> &str {
186        match self {
187            Self::Symbol { path, .. } | Self::Line { path, .. } | Self::File { path } => path,
188        }
189    }
190
191    pub fn locate(&self, content: &str, highlighter: &Highlighter) -> Located {
192        let rows = content.lines().count();
193        match self {
194            Self::File { .. } => Located::Whole,
195            Self::Line { line, end, .. } => {
196                if (*line as usize) <= rows {
197                    Located::Found {
198                        line: *line,
199                        end: (*end).min(u32::try_from(rows).unwrap_or(*end)).max(*line),
200                    }
201                } else {
202                    Located::Lost
203                }
204            }
205            Self::Symbol { path, symbol } => highlighter
206                .scope_index(path, content)
207                .def_span(symbol)
208                .and_then(|(start, end)| {
209                    Some(Located::Found {
210                        line: u32::try_from(start + 1).ok()?,
211                        end: u32::try_from(end + 1).ok()?,
212                    })
213                })
214                .unwrap_or(Located::Lost),
215        }
216    }
217}
218
219/// `12` or `12-40`, 1-based and inclusive. A reversed end collapses to the
220/// start.
221fn parse_rows(raw: &str) -> Option<(u32, u32)> {
222    let (start, end) = raw.split_once('-').unwrap_or((raw, raw));
223    let start = start.parse::<u32>().ok().filter(|line| *line > 0)?;
224    let end = end.parse::<u32>().ok().filter(|line| *line > 0)?;
225    Some((start, end.max(start)))
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn receipt_code_name_matches_its_wire_name() {
234        for code in [
235            ReceiptCode::TooManyStops,
236            ReceiptCode::EmptyStops,
237            ReceiptCode::BodyTooLong,
238            ReceiptCode::TotalTooLong,
239            ReceiptCode::AnchorUnparsed,
240            ReceiptCode::AnchorFileMissing,
241            ReceiptCode::NothingToAnchor,
242            ReceiptCode::NoteOutsideStop,
243            ReceiptCode::DuplicateId,
244        ] {
245            let wire = serde_json::to_value(code).expect("a unit enum always serializes");
246            assert_eq!(wire, serde_json::json!(code.name()));
247        }
248    }
249
250    #[test]
251    fn walkthrough_serializes_round_trip() {
252        let w = Walkthrough {
253            id: "w1".to_owned(),
254            title: "tour".to_owned(),
255            author: "agent".to_owned(),
256            at: 1,
257            stops: vec!["c1".to_owned(), "c2".to_owned()],
258            skipped: Some("the tests".to_owned()),
259            summary: Some("what changed".to_owned()),
260            rev: Some("deadbeef".to_owned()),
261            about: ReviewSource::pr(7),
262        };
263        let json = serde_json::to_string(&w).expect("serialize");
264        let back: Walkthrough = serde_json::from_str(&json).expect("deserialize");
265        assert_eq!(w, back);
266    }
267
268    #[test]
269    fn a_walkthrough_with_no_rev_field_deserializes_to_none() {
270        let json = r#"{"id":"w1","title":"tour","author":"agent","at":1,"stops":["c1"]}"#;
271        let w: Walkthrough = serde_json::from_str(json).expect("deserialize");
272        assert_eq!(w.rev, None);
273    }
274
275    #[test]
276    fn a_walkthrough_with_no_about_field_deserializes_to_the_working_tree() {
277        let json = r#"{"id":"w1","title":"tour","author":"agent","at":1,"stops":["c1"]}"#;
278        let w: Walkthrough = serde_json::from_str(json).expect("deserialize");
279        assert_eq!(w.about, ReviewSource::WorkingTree);
280    }
281
282    fn stop(id: &str, file: &str, line: u32) -> Comment {
283        use crate::session::{Anchor, CommentStatus};
284        Comment {
285            id: id.to_owned(),
286            author: "agent".to_owned(),
287            remote_id: None,
288            thread_id: None,
289            anchor: Anchor {
290                file: file.to_owned(),
291                line: Some(line),
292                line_end: None,
293                on_old_side: false,
294                line_text: None,
295            },
296            title: Some(id.to_owned()),
297            anchor_ref: Some(format!("{file}:{line}")),
298            body: "why".to_owned(),
299            status: CommentStatus::Open,
300            replies: Vec::new(),
301            at: 1,
302        }
303    }
304
305    fn note(id: &str, file: &str, line: u32) -> Comment {
306        Comment {
307            title: None,
308            ..stop(id, file, line)
309        }
310    }
311
312    #[test]
313    fn notes_group_under_the_stop_whose_region_holds_them() {
314        let w = Walkthrough {
315            id: "w1".to_owned(),
316            title: "tour".to_owned(),
317            author: "agent".to_owned(),
318            at: 1,
319            stops: vec!["c1".to_owned(), "c2".to_owned()],
320            skipped: None,
321            summary: None,
322            rev: None,
323            about: ReviewSource::WorkingTree,
324        };
325        let comments = vec![
326            stop("c1", "a.txt", 1),
327            note("n1", "a.txt", 1),
328            note("n2", "a.txt", 1),
329            stop("c2", "b.txt", 1),
330        ];
331        assert_eq!(
332            w.notes_by_stop(&comments),
333            vec![vec!["n1".to_owned(), "n2".to_owned()], Vec::new()]
334        );
335    }
336
337    #[test]
338    fn a_human_comment_in_a_stops_region_is_never_counted_as_a_note() {
339        let w = Walkthrough {
340            id: "w1".to_owned(),
341            title: "tour".to_owned(),
342            author: "agent".to_owned(),
343            at: 1,
344            stops: vec!["c1".to_owned()],
345            skipped: None,
346            summary: None,
347            rev: None,
348            about: ReviewSource::WorkingTree,
349        };
350        let human = Comment {
351            anchor_ref: None,
352            ..note("human-1", "a.txt", 1)
353        };
354        let comments = vec![stop("c1", "a.txt", 1), human];
355        assert_eq!(w.notes_by_stop(&comments), vec![Vec::<String>::new()]);
356    }
357
358    #[test]
359    fn every_target_form_parses() {
360        assert_eq!(
361            Target::parse("src/config.rs#merge"),
362            Target::Symbol {
363                path: "src/config.rs".to_owned(),
364                symbol: "merge".to_owned()
365            }
366        );
367        assert_eq!(
368            Target::parse("src/config.rs:88"),
369            Target::Line {
370                path: "src/config.rs".to_owned(),
371                line: 88,
372                end: 88
373            }
374        );
375        assert_eq!(
376            Target::parse("src/config.rs"),
377            Target::File {
378                path: "src/config.rs".to_owned()
379            }
380        );
381    }
382
383    #[test]
384    fn a_colon_that_is_not_a_line_number_stays_in_the_path() {
385        assert_eq!(
386            Target::parse("src/config.rs:"),
387            Target::File {
388                path: "src/config.rs:".to_owned()
389            }
390        );
391        assert_eq!(
392            Target::parse("src/config.rs:0"),
393            Target::File {
394                path: "src/config.rs:0".to_owned()
395            }
396        );
397    }
398
399    #[test]
400    fn a_line_range_parses_and_clamps_to_the_file() {
401        assert_eq!(
402            Target::parse("src/config.rs:10-20"),
403            Target::Line {
404                path: "src/config.rs".to_owned(),
405                line: 10,
406                end: 20
407            }
408        );
409        assert_eq!(
410            Target::parse("src/config.rs:20-10"),
411            Target::Line {
412                path: "src/config.rs".to_owned(),
413                line: 20,
414                end: 20
415            }
416        );
417        let content = "a\nb\nc\n";
418        assert_eq!(
419            Target::parse("lib.rs:2-99").locate(content, &Highlighter::default()),
420            Located::Found { line: 2, end: 3 },
421            "a range past the end clamps instead of going Lost"
422        );
423    }
424
425    #[test]
426    fn a_symbol_resolves_to_the_line_that_defines_it() {
427        let content = "fn first() {}\n\nfn merge(a: u8) -> u8 {\n    a\n}\n";
428        assert_eq!(
429            Target::parse("lib.rs#merge").locate(content, &Highlighter::default()),
430            Located::Found { line: 3, end: 5 }
431        );
432    }
433
434    #[test]
435    fn a_symbol_resolves_through_the_readers_language_rule() {
436        let content = "fn first() {}\n\nfn merge(a: u8) -> u8 {\n    a\n}\n";
437        let target = Target::parse("lib.inc#merge");
438        assert_eq!(
439            target.locate(content, &Highlighter::default()),
440            Located::Lost
441        );
442        let rules = vec![("*.inc".to_owned(), "rust".to_owned())];
443        assert_eq!(
444            target.locate(content, &Highlighter::default().with_rules(rules)),
445            Located::Found { line: 3, end: 5 }
446        );
447    }
448
449    #[test]
450    fn a_symbol_survives_an_edit_above_it() {
451        let before = "fn merge() {}\n";
452        let after = "use std::fmt;\n\nfn helper() {}\n\nfn merge() {}\n";
453        let target = Target::parse("lib.rs#merge");
454        assert_eq!(
455            target.locate(before, &Highlighter::default()),
456            Located::Found { line: 1, end: 1 }
457        );
458        assert_eq!(
459            target.locate(after, &Highlighter::default()),
460            Located::Found { line: 5, end: 5 }
461        );
462    }
463
464    #[test]
465    fn a_symbol_the_file_lost_stops_resolving() {
466        assert_eq!(
467            Target::parse("lib.rs#gone").locate("fn merge() {}\n", &Highlighter::default()),
468            Located::Lost
469        );
470    }
471
472    #[test]
473    fn a_line_past_the_end_stops_resolving() {
474        assert_eq!(
475            Target::parse("lib.rs:400").locate("fn merge() {}\n", &Highlighter::default()),
476            Located::Lost
477        );
478        assert_eq!(
479            Target::parse("lib.rs:1").locate("fn merge() {}\n", &Highlighter::default()),
480            Located::Found { line: 1, end: 1 }
481        );
482    }
483
484    #[test]
485    fn a_whole_file_target_locates_the_file_and_no_line() {
486        assert_eq!(
487            Target::parse("lib.rs").locate("fn merge() {}\n", &Highlighter::default()),
488            Located::Whole
489        );
490    }
491}