1use serde::{Deserialize, Serialize};
10
11use crate::session::Comment;
12use crate::source::ReviewSource;
13use crate::syntax::registry::REGISTRY;
14
15pub 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 pub stops: Vec<String>,
29 #[serde(default)]
31 pub skipped: Option<String>,
32 #[serde(default)]
36 pub summary: Option<String>,
37 #[serde(default)]
42 pub rev: Option<String>,
43 #[serde(default)]
49 pub about: ReviewSource,
50}
51
52impl Walkthrough {
53 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
83pub(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 AnchorFileMissing,
113 NothingToAnchor,
116 NoteOutsideStop,
117 DuplicateId,
118}
119
120impl ReceiptCode {
121 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Located {
148 Found { line: u32, end: u32 },
151 Whole,
153 Lost,
156 FileMissing,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum Target {
168 Symbol {
171 path: String,
172 symbol: String,
173 },
174 Line {
176 path: String,
177 line: u32,
178 end: u32,
179 },
180 File {
181 path: String,
182 },
183}
184
185impl Target {
186 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 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 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
253fn 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 #[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 #[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 #[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 #[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 #[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 #[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 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 assert_eq!(
478 Target::parse("lib.rs#merge").locate(content),
479 Located::Found { line: 3, end: 5 }
480 );
481 }
482
483 #[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}