1use serde::{Deserialize, Serialize};
10
11use crate::session::Comment;
12use crate::syntax::registry::REGISTRY;
13
14pub 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 pub stops: Vec<String>,
28 #[serde(default)]
30 pub skipped: Option<String>,
31 #[serde(default)]
35 pub summary: Option<String>,
36 #[serde(default)]
41 pub rev: Option<String>,
42}
43
44impl Walkthrough {
45 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
75pub(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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Located {
132 Found { line: u32, end: u32 },
135 Whole,
137 Lost,
140 FileMissing,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum Target {
152 Symbol {
155 path: String,
156 symbol: String,
157 },
158 Line {
160 path: String,
161 line: u32,
162 end: u32,
163 },
164 File {
165 path: String,
166 },
167}
168
169impl Target {
170 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 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 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
237fn 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 #[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 #[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 #[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 #[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 #[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 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 assert_eq!(
447 Target::parse("lib.rs#merge").locate(content),
448 Located::Found { line: 3, end: 5 }
449 );
450 }
451
452 #[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}