1use std::collections::BTreeSet;
2use std::fmt;
3use std::ops::RangeInclusive;
4
5use url::Url;
6
7#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
9pub enum GithubResourceKind {
10 Issue,
11 PullRequest,
12}
13
14impl GithubResourceKind {
15 pub const fn scheme(self) -> &'static str {
16 match self {
17 Self::Issue => "issue",
18 Self::PullRequest => "pr",
19 }
20 }
21
22 pub const fn command(self) -> &'static str {
23 match self {
24 Self::Issue => "issue",
25 Self::PullRequest => "pr",
26 }
27 }
28
29 pub const fn label(self) -> &'static str {
30 match self {
31 Self::Issue => "Issue",
32 Self::PullRequest => "Pull request",
33 }
34 }
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
39pub enum SelectorItem {
40 Positive(RangeInclusive<usize>),
42 Negative { start: usize, end: usize },
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct ResolvedCommentSelector {
51 ranges: Vec<RangeInclusive<usize>>,
52}
53
54impl ResolvedCommentSelector {
55 pub fn contains(&self, ordinal: usize) -> bool {
56 self.ranges.iter().any(|range| range.contains(&ordinal))
57 }
58
59 pub fn ranges(&self) -> &[RangeInclusive<usize>] {
60 &self.ranges
61 }
62
63 pub fn ordinals(&self) -> BTreeSet<usize> {
64 let mut ordinals = BTreeSet::new();
65 for range in &self.ranges {
66 for ordinal in range.clone() {
67 ordinals.insert(ordinal);
68 }
69 }
70 ordinals
71 }
72}
73
74#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct GithubCommentSelector {
77 items: Vec<SelectorItem>,
78}
79
80impl GithubCommentSelector {
81 pub fn parse(selector: &str) -> Result<Self, InvalidGithubResource> {
83 parse_comment_selector("/comments/<sel>", selector)
84 }
85
86 pub fn contains(&self, ordinal: usize) -> bool {
87 self.items.iter().any(|item| match item {
88 SelectorItem::Positive(range) => range.contains(&ordinal),
89 SelectorItem::Negative { .. } => false,
90 })
91 }
92
93 pub fn resolve(&self, total_count: usize) -> Result<ResolvedCommentSelector, isize> {
98 let mut ranges = Vec::new();
99 for item in &self.items {
100 match item {
101 SelectorItem::Positive(range) => {
102 if total_count == 0 {
103 return Err(*range.start() as isize);
104 }
105 if *range.start() > total_count {
106 return Err(*range.start() as isize);
107 }
108 if *range.end() > total_count {
109 return Err(*range.end() as isize);
110 }
111 ranges.push(range.clone());
112 }
113 SelectorItem::Negative { start, end } => {
114 if total_count == 0 || *start > total_count {
115 return Err(-(*start as isize));
116 }
117 let resolved_start = total_count - *start + 1;
118 let resolved_end = total_count - *end + 1;
119 ranges.push(resolved_start..=resolved_end);
120 }
121 }
122 }
123 Ok(ResolvedCommentSelector { ranges })
124 }
125
126 pub fn first_out_of_range(&self, valid_end: usize) -> Option<isize> {
127 self.resolve(valid_end).err()
128 }
129
130 pub fn single_positive_ordinal(&self) -> Option<usize> {
132 match self.items.as_slice() {
133 [SelectorItem::Positive(range)] if range.start() == range.end() => Some(*range.start()),
134 _ => None,
135 }
136 }
137}
138
139#[derive(Clone, Debug, Eq, PartialEq)]
145pub struct GithubResource {
146 pub kind: GithubResourceKind,
147 pub number: u64,
148 pub repository: Option<String>,
149 pub comment_selector: Option<GithubCommentSelector>,
150}
151
152impl GithubResource {
153 pub fn is_explicit(&self) -> bool {
154 self.repository.is_some()
155 }
156
157 pub fn base_spelling(&self) -> String {
158 match &self.repository {
159 Some(repository) => format!("{}://{repository}/{}", self.kind.scheme(), self.number),
160 None => format!("{}://{}", self.kind.scheme(), self.number),
161 }
162 }
163
164 pub fn without_comment_selector(&self) -> Self {
165 let mut resource = self.clone();
166 resource.comment_selector = None;
167 resource
168 }
169}
170
171#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct InvalidGithubResource {
174 resource: String,
175 reason: String,
176}
177
178impl InvalidGithubResource {
179 fn new(resource: &str, reason: impl Into<String>) -> Self {
180 Self {
181 resource: resource.to_string(),
182 reason: reason.into(),
183 }
184 }
185
186 pub fn code(&self) -> &'static str {
187 "invalid_resource"
188 }
189}
190
191impl fmt::Display for InvalidGithubResource {
192 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193 write!(
194 formatter,
195 "invalid GitHub resource '{}': {}. Use issue://NUMBER, pr://NUMBER, issue://OWNER/REPO/NUMBER, or pr://OWNER/REPO/NUMBER; append /comments/<sel> for discussion ordinals",
196 self.resource, self.reason
197 )
198 }
199}
200
201impl std::error::Error for InvalidGithubResource {}
202
203pub fn parse_resource(resource: &str) -> Result<GithubResource, InvalidGithubResource> {
209 let parsed = Url::parse(resource)
210 .map_err(|_| InvalidGithubResource::new(resource, "the URL is malformed"))?;
211 let kind = match parsed.scheme() {
212 "issue" => GithubResourceKind::Issue,
213 "pr" => GithubResourceKind::PullRequest,
214 _ => return Err(InvalidGithubResource::new(resource, "unsupported scheme")),
215 };
216
217 if parsed.query().is_some()
218 || parsed.fragment().is_some()
219 || parsed.port().is_some()
220 || !parsed.username().is_empty()
221 || parsed.password().is_some()
222 {
223 return Err(InvalidGithubResource::new(
224 resource,
225 "unsupported URL authority or suffix",
226 ));
227 }
228
229 let authority = parsed
230 .host_str()
231 .ok_or_else(|| InvalidGithubResource::new(resource, "missing authority"))?;
232 let path_segments: Vec<_> = parsed
233 .path_segments()
234 .map(|segments| segments.collect())
235 .unwrap_or_default();
236
237 if parsed.path().is_empty() {
239 return Ok(GithubResource {
240 kind,
241 number: parse_number(resource, authority)?,
242 repository: None,
243 comment_selector: None,
244 });
245 }
246
247 if authority.bytes().all(|byte| byte.is_ascii_digit()) {
250 if path_segments.len() == 2 && path_segments[0] == "comments" {
251 return Ok(GithubResource {
252 kind,
253 number: parse_number(resource, authority)?,
254 repository: None,
255 comment_selector: Some(parse_comment_selector(resource, path_segments[1])?),
256 });
257 }
258 return Err(InvalidGithubResource::new(
259 resource,
260 "unsupported short-resource suffix",
261 ));
262 }
263
264 if !valid_repository_component(authority)
267 || path_segments.len() < 2
268 || !valid_repository_component(path_segments[0])
269 {
270 return Err(InvalidGithubResource::new(
271 resource,
272 "unsupported authority or malformed repository path",
273 ));
274 }
275 let comment_selector = match path_segments.as_slice() {
276 [_, _] => None,
277 [_, _, "comments", selector] => Some(parse_comment_selector(resource, selector)?),
278 _ => {
279 return Err(InvalidGithubResource::new(
280 resource,
281 "unsupported authority or malformed repository path",
282 ))
283 }
284 };
285
286 Ok(GithubResource {
287 kind,
288 number: parse_number(resource, path_segments[1])?,
289 repository: Some(format!("{authority}/{}", path_segments[0])),
290 comment_selector,
291 })
292}
293
294fn parse_number(resource: &str, value: &str) -> Result<u64, InvalidGithubResource> {
295 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
296 return Err(InvalidGithubResource::new(
297 resource,
298 "resource number must be numeric",
299 ));
300 }
301 let number = value
302 .parse::<u64>()
303 .map_err(|_| InvalidGithubResource::new(resource, "resource number is out of range"))?;
304 if number == 0 {
305 return Err(InvalidGithubResource::new(
306 resource,
307 "resource number must be greater than zero",
308 ));
309 }
310 Ok(number)
311}
312
313fn parse_comment_selector(
314 resource: &str,
315 selector: &str,
316) -> Result<GithubCommentSelector, InvalidGithubResource> {
317 if selector.is_empty() {
318 return Err(InvalidGithubResource::new(
319 resource,
320 "comment selector is empty",
321 ));
322 }
323 let mut items = Vec::new();
324 for item in selector.split(',') {
325 if item.is_empty() {
326 return Err(InvalidGithubResource::new(
327 resource,
328 "comment selector contains an empty item",
329 ));
330 }
331 let parsed = parse_selector_item(resource, item)?;
332 items.push(parsed);
333 }
334 Ok(GithubCommentSelector { items })
335}
336
337fn parse_selector_item(resource: &str, item: &str) -> Result<SelectorItem, InvalidGithubResource> {
338 if let Some(rest) = item.strip_prefix('-') {
339 if rest.is_empty() || rest.starts_with('-') {
340 return Err(InvalidGithubResource::new(
341 resource,
342 "comment selector ordinals must be non-zero integers (for example 3, 3-5, 3,7, or -1)",
343 ));
344 }
345 if let Some((start_str, after_hyphen)) = rest.split_once('-') {
346 let start_mag = parse_ordinal_magnitude(resource, start_str)?;
347 if after_hyphen.is_empty() {
348 Ok(SelectorItem::Negative {
349 start: start_mag,
350 end: 1,
351 })
352 } else if let Some(end_str) = after_hyphen.strip_prefix('-') {
353 if end_str.contains('-') {
354 return Err(InvalidGithubResource::new(
355 resource,
356 "comment selector ranges contain exactly one hyphen",
357 ));
358 }
359 let end_mag = parse_ordinal_magnitude(resource, end_str)?;
360 if start_mag < end_mag {
361 return Err(InvalidGithubResource::new(
362 resource,
363 "comment selector range start exceeds its end",
364 ));
365 }
366 Ok(SelectorItem::Negative {
367 start: start_mag,
368 end: end_mag,
369 })
370 } else {
371 Err(InvalidGithubResource::new(
372 resource,
373 "comment selector ordinals must be non-zero integers (for example 3, 3-5, 3,7, or -1)",
374 ))
375 }
376 } else {
377 let mag = parse_ordinal_magnitude(resource, rest)?;
378 Ok(SelectorItem::Negative {
379 start: mag,
380 end: mag,
381 })
382 }
383 } else if let Some((start_str, end_str)) = item.split_once('-') {
384 if end_str.contains('-') || end_str.starts_with('-') {
385 return Err(InvalidGithubResource::new(
386 resource,
387 "comment selector ranges contain exactly one hyphen",
388 ));
389 }
390 if end_str.is_empty() {
391 return Err(InvalidGithubResource::new(
392 resource,
393 "comment selector ordinals must be non-zero integers (for example 3, 3-5, 3,7, or -1)",
394 ));
395 }
396 let start = parse_ordinal_magnitude(resource, start_str)?;
397 let end = parse_ordinal_magnitude(resource, end_str)?;
398 if start > end {
399 return Err(InvalidGithubResource::new(
400 resource,
401 "comment selector range start exceeds its end",
402 ));
403 }
404 Ok(SelectorItem::Positive(start..=end))
405 } else {
406 let ordinal = parse_ordinal_magnitude(resource, item)?;
407 Ok(SelectorItem::Positive(ordinal..=ordinal))
408 }
409}
410
411fn parse_ordinal_magnitude(resource: &str, value: &str) -> Result<usize, InvalidGithubResource> {
412 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
413 return Err(InvalidGithubResource::new(
414 resource,
415 "comment selector ordinals must be non-zero integers (for example 3, 3-5, 3,7, or -1)",
416 ));
417 }
418 let ordinal = value.parse::<usize>().map_err(|_| {
419 InvalidGithubResource::new(resource, "comment selector ordinal is out of range")
420 })?;
421 if ordinal == 0 {
422 return Err(InvalidGithubResource::new(
423 resource,
424 "comment selector ordinals must be non-zero integers (for example 3, 3-5, 3,7, or -1)",
425 ));
426 }
427 Ok(ordinal)
428}
429
430fn valid_repository_component(value: &str) -> bool {
431 !value.is_empty()
432 && value.len() <= 100
433 && value
434 .bytes()
435 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
443 fn accepts_only_the_four_resource_forms() {
444 assert_eq!(
445 parse_resource("issue://373").unwrap(),
446 GithubResource {
447 kind: GithubResourceKind::Issue,
448 number: 373,
449 repository: None,
450 comment_selector: None,
451 }
452 );
453 assert_eq!(
454 parse_resource("pr://Owner/repo-name/45").unwrap(),
455 GithubResource {
456 kind: GithubResourceKind::PullRequest,
457 number: 45,
458 repository: Some("Owner/repo-name".to_string()),
459 comment_selector: None,
460 }
461 );
462
463 for value in [
464 "issue:///373",
465 "issue://373/",
466 "issue://owner/repo/not-a-number",
467 "issue://owner/repo/0",
468 "issue://owner/repo/1/extra",
469 "issue://owner/repo/1?view=full",
470 "issue://owner:secret@repo/1",
471 "issue://github.com/owner/repo/1",
472 "https://github.com/owner/repo/issues/1",
473 ] {
474 assert!(parse_resource(value).is_err(), "{value}");
475 }
476 }
477
478 #[test]
479 fn parses_comment_ordinal_selectors_for_short_and_explicit_resources() {
480 let short = parse_resource("issue://373/comments/3,7").unwrap();
481 let short_selector = short.comment_selector.as_ref().unwrap();
482 assert!(short_selector.contains(3));
483 assert!(short_selector.contains(7));
484 assert!(!short_selector.contains(4));
485 assert_eq!(short.base_spelling(), "issue://373");
486
487 let explicit = parse_resource("pr://Owner/repo-name/45/comments/3-5").unwrap();
488 let explicit_selector = explicit.comment_selector.as_ref().unwrap();
489 assert!((3..=5).all(|ordinal| explicit_selector.contains(ordinal)));
490 assert_eq!(explicit.base_spelling(), "pr://Owner/repo-name/45");
491
492 for value in [
493 "pr://45/comments/0",
494 "pr://45/comments/",
495 "pr://45/comments/5-3",
496 "pr://45/comments/3-5-7",
497 "pr://45/comments/3,,7",
498 "pr://owner/repo/45/comments/3/extra",
499 "pr://45/comments/--1",
500 "pr://45/comments/-0",
501 "pr://45/comments/1--3",
502 ] {
503 assert!(parse_resource(value).is_err(), "{value}");
504 }
505 }
506
507 #[test]
508 fn comment_selector_parser_table() {
509 for (input, expected) in [
511 ("3", vec![SelectorItem::Positive(3..=3)]),
512 ("3-5", vec![SelectorItem::Positive(3..=5)]),
513 (
514 "3,7",
515 vec![SelectorItem::Positive(3..=3), SelectorItem::Positive(7..=7)],
516 ),
517 ] {
518 let selector = GithubCommentSelector::parse(input).unwrap();
519 assert_eq!(selector.items, expected, "input: {input}");
520 }
521
522 for (input, expected) in [
524 ("-1", vec![SelectorItem::Negative { start: 1, end: 1 }]),
525 ("-3", vec![SelectorItem::Negative { start: 3, end: 3 }]),
526 ("-3-", vec![SelectorItem::Negative { start: 3, end: 1 }]),
527 ("-3--1", vec![SelectorItem::Negative { start: 3, end: 1 }]),
528 (
529 "-3,-1",
530 vec![
531 SelectorItem::Negative { start: 3, end: 3 },
532 SelectorItem::Negative { start: 1, end: 1 },
533 ],
534 ),
535 ] {
536 let selector = GithubCommentSelector::parse(input).unwrap();
537 assert_eq!(selector.items, expected, "input: {input}");
538 }
539
540 for (input, expected) in [
542 (
543 "2,-1",
544 vec![
545 SelectorItem::Positive(2..=2),
546 SelectorItem::Negative { start: 1, end: 1 },
547 ],
548 ),
549 (
550 "-3,5",
551 vec![
552 SelectorItem::Negative { start: 3, end: 3 },
553 SelectorItem::Positive(5..=5),
554 ],
555 ),
556 (
557 "1-3,-1",
558 vec![
559 SelectorItem::Positive(1..=3),
560 SelectorItem::Negative { start: 1, end: 1 },
561 ],
562 ),
563 (
564 "2,-3-",
565 vec![
566 SelectorItem::Positive(2..=2),
567 SelectorItem::Negative { start: 3, end: 1 },
568 ],
569 ),
570 ] {
571 let selector = GithubCommentSelector::parse(input).unwrap();
572 assert_eq!(selector.items, expected, "input: {input}");
573 }
574
575 for input in [
577 "--1", "-0", "1--3", "0", "1-", "-3-5", "-1--3", "5-3", "3-5-7", "-3--1--2", "-",
578 "---", "", "3,,7", ",1", "1,", "abc", "-abc", "1-abc", "-3--abc",
579 ] {
580 assert!(
581 GithubCommentSelector::parse(input).is_err(),
582 "malformed input must be rejected: {input}"
583 );
584 }
585 }
586
587 #[test]
588 fn comment_selector_resolution_against_5_item_fixture() {
589 let selector = GithubCommentSelector::parse("-1").unwrap();
591 let resolved = selector.resolve(5).unwrap();
592 assert_eq!(resolved.ordinals(), BTreeSet::from([5]));
593 assert_eq!(resolved.ranges(), &[5..=5]);
594
595 let selector = GithubCommentSelector::parse("-3-").unwrap();
597 let resolved = selector.resolve(5).unwrap();
598 assert_eq!(resolved.ranges(), &[3..=5]);
599 assert_eq!(resolved.ordinals(), BTreeSet::from([3, 4, 5]));
600
601 let selector = GithubCommentSelector::parse("-3--1").unwrap();
603 let resolved = selector.resolve(5).unwrap();
604 assert_eq!(resolved.ranges(), &[3..=5]);
605
606 let selector = GithubCommentSelector::parse("2,-1").unwrap();
608 let resolved = selector.resolve(5).unwrap();
609 assert_eq!(resolved.ordinals(), BTreeSet::from([2, 5]));
610 assert!(resolved.contains(2));
611 assert!(resolved.contains(5));
612 assert!(!resolved.contains(1));
613 assert!(!resolved.contains(3));
614 assert!(!resolved.contains(4));
615
616 let selector = GithubCommentSelector::parse("-9").unwrap();
618 let err = selector.resolve(5).unwrap_err();
619 assert_eq!(err, -9);
620
621 let selector = GithubCommentSelector::parse("9").unwrap();
623 let err = selector.resolve(5).unwrap_err();
624 assert_eq!(err, 9);
625
626 let selector = GithubCommentSelector::parse("-1").unwrap();
628 let err = selector.resolve(0).unwrap_err();
629 assert_eq!(err, -1);
630 }
631}