1use serde::{Deserialize, Serialize};
6
7use crate::session::Comment;
8use crate::source::ReviewSource;
9use crate::syntax::registry::REGISTRY;
10
11pub 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 pub stops: Vec<String>,
24 #[serde(default)]
26 pub skipped: Option<String>,
27 #[serde(default)]
29 pub summary: Option<String>,
30 #[serde(default)]
34 pub rev: Option<String>,
35 #[serde(default)]
38 pub about: ReviewSource,
39}
40
41impl Walkthrough {
42 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
68pub(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 AnchorFileMissing,
94 NothingToAnchor,
96 NoteOutsideStop,
97 DuplicateId,
98}
99
100impl ReceiptCode {
101 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 Found {
128 line: u32,
129 end: u32,
130 },
131 Whole,
132 Lost,
134 FileMissing,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Target {
142 Symbol {
144 path: String,
145 symbol: String,
146 },
147 Line {
149 path: String,
150 line: u32,
151 end: u32,
152 },
153 File {
154 path: String,
155 },
156}
157
158impl Target {
159 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) -> 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 } => REGISTRY
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
219fn 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),
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),
430 Located::Found { line: 3, end: 5 }
431 );
432 }
433
434 #[test]
435 fn a_symbol_survives_an_edit_above_it() {
436 let before = "fn merge() {}\n";
437 let after = "use std::fmt;\n\nfn helper() {}\n\nfn merge() {}\n";
438 let target = Target::parse("lib.rs#merge");
439 assert_eq!(target.locate(before), Located::Found { line: 1, end: 1 });
440 assert_eq!(target.locate(after), Located::Found { line: 5, end: 5 });
441 }
442
443 #[test]
444 fn a_symbol_the_file_lost_stops_resolving() {
445 assert_eq!(
446 Target::parse("lib.rs#gone").locate("fn merge() {}\n"),
447 Located::Lost
448 );
449 }
450
451 #[test]
452 fn a_line_past_the_end_stops_resolving() {
453 assert_eq!(
454 Target::parse("lib.rs:400").locate("fn merge() {}\n"),
455 Located::Lost
456 );
457 assert_eq!(
458 Target::parse("lib.rs:1").locate("fn merge() {}\n"),
459 Located::Found { line: 1, end: 1 }
460 );
461 }
462
463 #[test]
464 fn a_whole_file_target_locates_the_file_and_no_line() {
465 assert_eq!(
466 Target::parse("lib.rs").locate("fn merge() {}\n"),
467 Located::Whole
468 );
469 }
470}