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