Skip to main content

diffler_core/
walkthrough.rs

1//! The walkthrough: the agent's own reading order for a review, one stop per
2//! real decision, each a span of code and a short reason. A stop is an agent
3//! comment carrying a title and an anchor, so the reader replies to it, the
4//! comments pane lists it and the card renderer draws it with no second
5//! system beside the first. A walkthrough is a review source of its own
6//! (`ReviewSource::Walkthrough`): every comment in that source's session is
7//! this walkthrough's, so nothing tracks ownership beyond `stops` itself.
8
9use serde::{Deserialize, Serialize};
10
11use crate::session::Comment;
12use crate::source::ReviewSource;
13use crate::syntax::registry::REGISTRY;
14
15/// A rail against dumping the diff, not a target: the skill asks for one stop
16/// per real decision, which lands well under this.
17pub const MAX_STOPS: usize = 20;
18pub const BODY_MAX_BYTES: usize = 8 * 1024;
19pub const TOTAL_MAX_BYTES: usize = 64 * 1024;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct Walkthrough {
23    pub id: String,
24    pub title: String,
25    pub author: String,
26    pub at: u64,
27    /// The comment ids of the stops, in reading order.
28    pub stops: Vec<String>,
29    /// One line on what the agent left out and why. Surfaced, never hidden.
30    #[serde(default)]
31    pub skipped: Option<String>,
32    /// The walkthrough's own overview: a markdown body exactly like a stop's,
33    /// shown as the sidebar's leading slide. `None` for a walkthrough with no
34    /// summary, which leaves the sidebar starting at the first stop.
35    #[serde(default)]
36    pub summary: Option<String>,
37    /// The full oid of `HEAD` when this walkthrough was published, so its
38    /// anchors can be resolved against the code they actually describe once
39    /// the checkout moves on. `None` for a walkthrough saved before this
40    /// existed; its anchors resolve against the live worktree.
41    #[serde(default)]
42    pub rev: Option<String>,
43    /// The review this walkthrough describes: the working tree, or the
44    /// commit/range/PR the human had open when it was published. Its diff is
45    /// what a stop's anchor resolves against and what opening the walkthrough
46    /// renders. Defaults to the working tree for a walkthrough saved before
47    /// this existed, and for a publish with no other review open.
48    #[serde(default)]
49    pub about: ReviewSource,
50}
51
52impl Walkthrough {
53    /// The notes of each stop, in stop order: every comment of the walkthrough's
54    /// own session that names no title (so it is a note, not a stop), carries
55    /// an anchor of its own (a human comment never does), and whose own anchor
56    /// falls inside that stop's region. A note whose region matches more than
57    /// one stop goes to the first, the way the agent wrote it; one matching
58    /// none (a stop and its own anchor both absent) is not grouped, though it
59    /// still exists as a comment.
60    pub fn notes_by_stop(&self, comments: &[Comment]) -> Vec<Vec<String>> {
61        let mut groups: Vec<Vec<String>> = self.stops.iter().map(|_| Vec::new()).collect();
62        for comment in comments {
63            if comment.title.is_some()
64                || comment.anchor_ref.is_none()
65                || self.stops.contains(&comment.id)
66            {
67                continue;
68            }
69            let region = self.stops.iter().position(|stop_id| {
70                comments
71                    .iter()
72                    .find(|c| c.id == *stop_id)
73                    .is_some_and(|stop| region_contains(&stop.anchor, &comment.anchor))
74            });
75            if let Some(group) = region.and_then(|index| groups.get_mut(index)) {
76                group.push(comment.id.clone());
77            }
78        }
79        groups
80    }
81}
82
83/// Whether `other`'s own anchored line (or line end) falls inside `region`'s
84/// span, on the same file and side. Two file-level anchors (no line at all)
85/// count as matching, the way a stop with no line holds every other file-level
86/// note of the same file. Shared with `store`'s legacy-walkthrough split,
87/// which uses the same containment to decide what moves with a stop.
88pub(crate) fn region_contains(
89    region: &crate::session::Anchor,
90    other: &crate::session::Anchor,
91) -> bool {
92    if region.file != other.file || region.on_old_side != other.on_old_side {
93        return false;
94    }
95    match (region.span(), other.line_end.or(other.line)) {
96        (Some((start, end)), Some(at)) => start <= at && at <= end,
97        (None, None) => true,
98        (Some(_), None) | (None, Some(_)) => false,
99    }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum ReceiptCode {
105    TooManyStops,
106    EmptyStops,
107    BodyTooLong,
108    TotalTooLong,
109    AnchorUnparsed,
110    /// A stop's anchor names a file this review has no honest way to reach:
111    /// not in the diff, and not readable on disk either.
112    AnchorFileMissing,
113    /// No stop names a file and the diff itself is empty, so an anchorless
114    /// stop has nothing real to fall back on.
115    NothingToAnchor,
116    NoteOutsideStop,
117    DuplicateId,
118}
119
120impl ReceiptCode {
121    /// The name serde gives this code on the wire, so a diagnostic printed
122    /// for a human matches what an agent reads back from the tool call.
123    pub fn name(self) -> &'static str {
124        match self {
125            Self::TooManyStops => "too_many_stops",
126            Self::EmptyStops => "empty_stops",
127            Self::BodyTooLong => "body_too_long",
128            Self::TotalTooLong => "total_too_long",
129            Self::AnchorUnparsed => "anchor_unparsed",
130            Self::AnchorFileMissing => "anchor_file_missing",
131            Self::NothingToAnchor => "nothing_to_anchor",
132            Self::NoteOutsideStop => "note_outside_stop",
133            Self::DuplicateId => "duplicate_id",
134        }
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct Receipt {
140    pub stop: Option<usize>,
141    pub code: ReceiptCode,
142    pub detail: String,
143}
144
145/// The outcome of pointing a target at a file's current contents.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Located {
148    /// The rows the reference covers, 1-based and inclusive. A reference to a
149    /// single line is a span of one, so the reader always gets a segment.
150    Found { line: u32, end: u32 },
151    /// A whole-file target: the file, at no particular line.
152    Whole,
153    /// The file was read, but the symbol or line the target names is gone
154    /// from it.
155    Lost,
156    /// The file itself could not be read at all: absent from the revision
157    /// the target was resolved against (or, with none pinned, from the
158    /// worktree). Distinguished from `Lost` so the reader is told which one
159    /// happened.
160    FileMissing,
161}
162
163/// Where a figure's node, or a stop's anchor, points. Resolution happens
164/// when it is drawn, never when it is written, so it keeps pointing at the
165/// right code after an edit moves it.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum Target {
168    /// `path#symbol`, resolved through the file's definition spans. The form
169    /// to prefer: it survives the symbol moving.
170    Symbol {
171        path: String,
172        symbol: String,
173    },
174    /// `path:line` or `path:start-end`, 1-based and inclusive.
175    Line {
176        path: String,
177        line: u32,
178        end: u32,
179    },
180    File {
181        path: String,
182    },
183}
184
185impl Target {
186    /// `#` wins over `:`, and both win over a bare path, so a file whose name
187    /// contains either is unaddressable. Naming a symbol is worth more than
188    /// serving a path POSIX allows and no repo uses.
189    pub fn parse(raw: &str) -> Self {
190        let raw = raw.trim();
191        if let Some((path, symbol)) = raw.split_once('#')
192            && !symbol.is_empty()
193        {
194            return Self::Symbol {
195                path: path.to_owned(),
196                symbol: symbol.to_owned(),
197            };
198        }
199        if let Some((path, rows)) = raw.rsplit_once(':')
200            && let Some((line, end)) = parse_rows(rows)
201        {
202            return Self::Line {
203                path: path.to_owned(),
204                line,
205                end,
206            };
207        }
208        Self::File {
209            path: raw.to_owned(),
210        }
211    }
212
213    pub fn path(&self) -> &str {
214        match self {
215            Self::Symbol { path, .. } | Self::Line { path, .. } | Self::File { path } => path,
216        }
217    }
218
219    /// Where in `content` the target lands, in one pass: `Found(line)` for a
220    /// target that names a line, `Whole` for a file, `Lost` for a symbol the
221    /// file no longer defines. One call, since resolving a symbol parses the
222    /// file and asking twice parses it twice.
223    pub fn locate(&self, content: &str) -> Located {
224        let rows = content.lines().count();
225        match self {
226            Self::File { .. } => Located::Whole,
227            Self::Line { line, end, .. } => {
228                if (*line as usize) <= rows {
229                    // a range running off the end still opens, clamped: the
230                    // file shrank under the reference, it did not move
231                    Located::Found {
232                        line: *line,
233                        end: (*end).min(u32::try_from(rows).unwrap_or(*end)).max(*line),
234                    }
235                } else {
236                    Located::Lost
237                }
238            }
239            Self::Symbol { path, symbol } => REGISTRY
240                .scope_index(path, content)
241                .def_span(symbol)
242                .and_then(|(start, end)| {
243                    Some(Located::Found {
244                        line: u32::try_from(start + 1).ok()?,
245                        end: u32::try_from(end + 1).ok()?,
246                    })
247                })
248                .unwrap_or(Located::Lost),
249        }
250    }
251}
252
253/// `12` or `12-40`, both 1-based and inclusive. A reversed or absent end
254/// collapses to the start, so every accepted form yields a usable span.
255fn parse_rows(raw: &str) -> Option<(u32, u32)> {
256    let (start, end) = raw.split_once('-').unwrap_or((raw, raw));
257    let start = start.parse::<u32>().ok().filter(|line| *line > 0)?;
258    let end = end.parse::<u32>().ok().filter(|line| *line > 0)?;
259    Some((start, end.max(start)))
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn receipt_code_name_matches_its_wire_name() {
268        for code in [
269            ReceiptCode::TooManyStops,
270            ReceiptCode::EmptyStops,
271            ReceiptCode::BodyTooLong,
272            ReceiptCode::TotalTooLong,
273            ReceiptCode::AnchorUnparsed,
274            ReceiptCode::AnchorFileMissing,
275            ReceiptCode::NothingToAnchor,
276            ReceiptCode::NoteOutsideStop,
277            ReceiptCode::DuplicateId,
278        ] {
279            let wire = serde_json::to_value(code).expect("a unit enum always serializes");
280            assert_eq!(wire, serde_json::json!(code.name()));
281        }
282    }
283
284    #[test]
285    fn walkthrough_serializes_round_trip() {
286        let w = Walkthrough {
287            id: "w1".to_owned(),
288            title: "tour".to_owned(),
289            author: "agent".to_owned(),
290            at: 1,
291            stops: vec!["c1".to_owned(), "c2".to_owned()],
292            skipped: Some("the tests".to_owned()),
293            summary: Some("what changed".to_owned()),
294            rev: Some("deadbeef".to_owned()),
295            about: ReviewSource::pr(7),
296        };
297        let json = serde_json::to_string(&w).expect("serialize");
298        let back: Walkthrough = serde_json::from_str(&json).expect("deserialize");
299        assert_eq!(w, back);
300    }
301
302    /// A walkthrough saved before `rev` existed has no such key at all; it
303    /// still loads, with `rev` defaulting to `None`.
304    #[test]
305    fn a_walkthrough_with_no_rev_field_deserializes_to_none() {
306        let json = r#"{"id":"w1","title":"tour","author":"agent","at":1,"stops":["c1"]}"#;
307        let w: Walkthrough = serde_json::from_str(json).expect("deserialize");
308        assert_eq!(w.rev, None);
309    }
310
311    /// A walkthrough saved before `about` existed has no such key at all; it
312    /// still loads, describing the working tree, exactly what every
313    /// walkthrough described before this field existed.
314    #[test]
315    fn a_walkthrough_with_no_about_field_deserializes_to_the_working_tree() {
316        let json = r#"{"id":"w1","title":"tour","author":"agent","at":1,"stops":["c1"]}"#;
317        let w: Walkthrough = serde_json::from_str(json).expect("deserialize");
318        assert_eq!(w.about, ReviewSource::WorkingTree);
319    }
320
321    fn stop(id: &str, file: &str, line: u32) -> Comment {
322        use crate::session::{Anchor, CommentStatus};
323        Comment {
324            id: id.to_owned(),
325            author: "agent".to_owned(),
326            remote_id: None,
327            thread_id: None,
328            anchor: Anchor {
329                file: file.to_owned(),
330                line: Some(line),
331                line_end: None,
332                on_old_side: false,
333                line_text: None,
334            },
335            title: Some(id.to_owned()),
336            anchor_ref: Some(format!("{file}:{line}")),
337            body: "why".to_owned(),
338            status: CommentStatus::Open,
339            replies: Vec::new(),
340            at: 1,
341        }
342    }
343
344    fn note(id: &str, file: &str, line: u32) -> Comment {
345        Comment {
346            title: None,
347            ..stop(id, file, line)
348        }
349    }
350
351    /// A note belongs to the stop whose region its own anchor falls inside.
352    #[test]
353    fn notes_group_under_the_stop_whose_region_holds_them() {
354        let w = Walkthrough {
355            id: "w1".to_owned(),
356            title: "tour".to_owned(),
357            author: "agent".to_owned(),
358            at: 1,
359            stops: vec!["c1".to_owned(), "c2".to_owned()],
360            skipped: None,
361            summary: None,
362            rev: None,
363            about: ReviewSource::WorkingTree,
364        };
365        let comments = vec![
366            stop("c1", "a.txt", 1),
367            note("n1", "a.txt", 1),
368            note("n2", "a.txt", 1),
369            stop("c2", "b.txt", 1),
370        ];
371        assert_eq!(
372            w.notes_by_stop(&comments),
373            vec![vec!["n1".to_owned(), "n2".to_owned()], Vec::new()]
374        );
375    }
376
377    /// A human comment carries no `anchor_ref`, so it is never mistaken for a
378    /// note even when it sits inside a stop's own region.
379    #[test]
380    fn a_human_comment_in_a_stops_region_is_never_counted_as_a_note() {
381        let w = Walkthrough {
382            id: "w1".to_owned(),
383            title: "tour".to_owned(),
384            author: "agent".to_owned(),
385            at: 1,
386            stops: vec!["c1".to_owned()],
387            skipped: None,
388            summary: None,
389            rev: None,
390            about: ReviewSource::WorkingTree,
391        };
392        let human = Comment {
393            anchor_ref: None,
394            ..note("human-1", "a.txt", 1)
395        };
396        let comments = vec![stop("c1", "a.txt", 1), human];
397        assert_eq!(w.notes_by_stop(&comments), vec![Vec::<String>::new()]);
398    }
399
400    #[test]
401    fn every_target_form_parses() {
402        assert_eq!(
403            Target::parse("src/config.rs#merge"),
404            Target::Symbol {
405                path: "src/config.rs".to_owned(),
406                symbol: "merge".to_owned()
407            }
408        );
409        assert_eq!(
410            Target::parse("src/config.rs:88"),
411            Target::Line {
412                path: "src/config.rs".to_owned(),
413                line: 88,
414                end: 88
415            }
416        );
417        assert_eq!(
418            Target::parse("src/config.rs"),
419            Target::File {
420                path: "src/config.rs".to_owned()
421            }
422        );
423    }
424
425    /// A windows path, and a trailing colon with no number, are files: a bad
426    /// split would point the node at a path that does not exist.
427    #[test]
428    fn a_colon_that_is_not_a_line_number_stays_in_the_path() {
429        assert_eq!(
430            Target::parse("src/config.rs:"),
431            Target::File {
432                path: "src/config.rs:".to_owned()
433            }
434        );
435        assert_eq!(
436            Target::parse("src/config.rs:0"),
437            Target::File {
438                path: "src/config.rs:0".to_owned()
439            }
440        );
441    }
442
443    /// `path:start-end` is how an agent points at a segment it can see but
444    /// cannot name: a block inside a function, a stanza of config.
445    #[test]
446    fn a_line_range_parses_and_clamps_to_the_file() {
447        assert_eq!(
448            Target::parse("src/config.rs:10-20"),
449            Target::Line {
450                path: "src/config.rs".to_owned(),
451                line: 10,
452                end: 20
453            }
454        );
455        // a reversed range is the reader's typo, not a reason to refuse
456        assert_eq!(
457            Target::parse("src/config.rs:20-10"),
458            Target::Line {
459                path: "src/config.rs".to_owned(),
460                line: 20,
461                end: 20
462            }
463        );
464        let content = "a\nb\nc\n";
465        assert_eq!(
466            Target::parse("lib.rs:2-99").locate(content),
467            Located::Found { line: 2, end: 3 },
468            "a range past the end clamps instead of going Lost"
469        );
470    }
471
472    #[test]
473    fn a_symbol_resolves_to_the_line_that_defines_it() {
474        let content = "fn first() {}\n\nfn merge(a: u8) -> u8 {\n    a\n}\n";
475        // the span runs to the end of the definition, so opening it shows the
476        // whole function rather than seating a cursor on its signature
477        assert_eq!(
478            Target::parse("lib.rs#merge").locate(content),
479            Located::Found { line: 3, end: 5 }
480        );
481    }
482
483    /// The point of anchoring to a symbol: an edit above it moves the line and
484    /// the target still finds it.
485    #[test]
486    fn a_symbol_survives_an_edit_above_it() {
487        let before = "fn merge() {}\n";
488        let after = "use std::fmt;\n\nfn helper() {}\n\nfn merge() {}\n";
489        let target = Target::parse("lib.rs#merge");
490        assert_eq!(target.locate(before), Located::Found { line: 1, end: 1 });
491        assert_eq!(target.locate(after), Located::Found { line: 5, end: 5 });
492    }
493
494    #[test]
495    fn a_symbol_the_file_lost_stops_resolving() {
496        assert_eq!(
497            Target::parse("lib.rs#gone").locate("fn merge() {}\n"),
498            Located::Lost
499        );
500    }
501
502    #[test]
503    fn a_line_past_the_end_stops_resolving() {
504        assert_eq!(
505            Target::parse("lib.rs:400").locate("fn merge() {}\n"),
506            Located::Lost
507        );
508        assert_eq!(
509            Target::parse("lib.rs:1").locate("fn merge() {}\n"),
510            Located::Found { line: 1, end: 1 }
511        );
512    }
513
514    #[test]
515    fn a_whole_file_target_locates_the_file_and_no_line() {
516        assert_eq!(
517            Target::parse("lib.rs").locate("fn merge() {}\n"),
518            Located::Whole
519        );
520    }
521}