1use std::fmt;
2use std::ops::RangeInclusive;
3
4use url::Url;
5
6#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
8pub enum GithubResourceKind {
9 Issue,
10 PullRequest,
11}
12
13impl GithubResourceKind {
14 pub const fn scheme(self) -> &'static str {
15 match self {
16 Self::Issue => "issue",
17 Self::PullRequest => "pr",
18 }
19 }
20
21 pub const fn command(self) -> &'static str {
22 match self {
23 Self::Issue => "issue",
24 Self::PullRequest => "pr",
25 }
26 }
27
28 pub const fn label(self) -> &'static str {
29 match self {
30 Self::Issue => "Issue",
31 Self::PullRequest => "Pull request",
32 }
33 }
34}
35
36#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct GithubCommentSelector {
39 ranges: Vec<RangeInclusive<usize>>,
40}
41
42impl GithubCommentSelector {
43 pub fn contains(&self, ordinal: usize) -> bool {
44 self.ranges.iter().any(|range| range.contains(&ordinal))
45 }
46
47 pub fn first_out_of_range(&self, valid_end: usize) -> Option<usize> {
48 self.ranges
49 .iter()
50 .flat_map(|range| [*range.start(), *range.end()])
51 .find(|ordinal| *ordinal > valid_end)
52 }
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct GithubResource {
62 pub kind: GithubResourceKind,
63 pub number: u64,
64 pub repository: Option<String>,
65 pub comment_selector: Option<GithubCommentSelector>,
66}
67
68impl GithubResource {
69 pub fn is_explicit(&self) -> bool {
70 self.repository.is_some()
71 }
72
73 pub fn base_spelling(&self) -> String {
74 match &self.repository {
75 Some(repository) => format!("{}://{repository}/{}", self.kind.scheme(), self.number),
76 None => format!("{}://{}", self.kind.scheme(), self.number),
77 }
78 }
79
80 pub fn without_comment_selector(&self) -> Self {
81 let mut resource = self.clone();
82 resource.comment_selector = None;
83 resource
84 }
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct InvalidGithubResource {
90 resource: String,
91 reason: String,
92}
93
94impl InvalidGithubResource {
95 fn new(resource: &str, reason: impl Into<String>) -> Self {
96 Self {
97 resource: resource.to_string(),
98 reason: reason.into(),
99 }
100 }
101
102 pub fn code(&self) -> &'static str {
103 "invalid_resource"
104 }
105}
106
107impl fmt::Display for InvalidGithubResource {
108 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109 write!(
110 formatter,
111 "invalid GitHub resource '{}': {}. Use issue://NUMBER, pr://NUMBER, issue://OWNER/REPO/NUMBER, or pr://OWNER/REPO/NUMBER; append /comments/<sel> for discussion ordinals",
112 self.resource, self.reason
113 )
114 }
115}
116
117impl std::error::Error for InvalidGithubResource {}
118
119pub fn parse_resource(resource: &str) -> Result<GithubResource, InvalidGithubResource> {
125 let parsed = Url::parse(resource)
126 .map_err(|_| InvalidGithubResource::new(resource, "the URL is malformed"))?;
127 let kind = match parsed.scheme() {
128 "issue" => GithubResourceKind::Issue,
129 "pr" => GithubResourceKind::PullRequest,
130 _ => return Err(InvalidGithubResource::new(resource, "unsupported scheme")),
131 };
132
133 if parsed.query().is_some()
134 || parsed.fragment().is_some()
135 || parsed.port().is_some()
136 || !parsed.username().is_empty()
137 || parsed.password().is_some()
138 {
139 return Err(InvalidGithubResource::new(
140 resource,
141 "unsupported URL authority or suffix",
142 ));
143 }
144
145 let authority = parsed
146 .host_str()
147 .ok_or_else(|| InvalidGithubResource::new(resource, "missing authority"))?;
148 let path_segments: Vec<_> = parsed
149 .path_segments()
150 .map(|segments| segments.collect())
151 .unwrap_or_default();
152
153 if parsed.path().is_empty() {
155 return Ok(GithubResource {
156 kind,
157 number: parse_number(resource, authority)?,
158 repository: None,
159 comment_selector: None,
160 });
161 }
162
163 if authority.bytes().all(|byte| byte.is_ascii_digit()) {
166 if path_segments.len() == 2 && path_segments[0] == "comments" {
167 return Ok(GithubResource {
168 kind,
169 number: parse_number(resource, authority)?,
170 repository: None,
171 comment_selector: Some(parse_comment_selector(resource, path_segments[1])?),
172 });
173 }
174 return Err(InvalidGithubResource::new(
175 resource,
176 "unsupported short-resource suffix",
177 ));
178 }
179
180 if !valid_repository_component(authority)
183 || path_segments.len() < 2
184 || !valid_repository_component(path_segments[0])
185 {
186 return Err(InvalidGithubResource::new(
187 resource,
188 "unsupported authority or malformed repository path",
189 ));
190 }
191 let comment_selector = match path_segments.as_slice() {
192 [_, _] => None,
193 [_, _, "comments", selector] => Some(parse_comment_selector(resource, selector)?),
194 _ => {
195 return Err(InvalidGithubResource::new(
196 resource,
197 "unsupported authority or malformed repository path",
198 ))
199 }
200 };
201
202 Ok(GithubResource {
203 kind,
204 number: parse_number(resource, path_segments[1])?,
205 repository: Some(format!("{authority}/{}", path_segments[0])),
206 comment_selector,
207 })
208}
209
210fn parse_number(resource: &str, value: &str) -> Result<u64, InvalidGithubResource> {
211 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
212 return Err(InvalidGithubResource::new(
213 resource,
214 "resource number must be numeric",
215 ));
216 }
217 let number = value
218 .parse::<u64>()
219 .map_err(|_| InvalidGithubResource::new(resource, "resource number is out of range"))?;
220 if number == 0 {
221 return Err(InvalidGithubResource::new(
222 resource,
223 "resource number must be greater than zero",
224 ));
225 }
226 Ok(number)
227}
228
229fn parse_comment_selector(
230 resource: &str,
231 selector: &str,
232) -> Result<GithubCommentSelector, InvalidGithubResource> {
233 let mut ranges = Vec::new();
234 for item in selector.split(',') {
235 if item.is_empty() {
236 return Err(InvalidGithubResource::new(
237 resource,
238 "comment selector contains an empty item",
239 ));
240 }
241 let range = if let Some((start, end)) = item.split_once('-') {
242 if end.contains('-') {
243 return Err(InvalidGithubResource::new(
244 resource,
245 "comment selector ranges contain exactly one hyphen",
246 ));
247 }
248 let start = parse_ordinal(resource, start)?;
249 let end = parse_ordinal(resource, end)?;
250 if start > end {
251 return Err(InvalidGithubResource::new(
252 resource,
253 "comment selector range start exceeds its end",
254 ));
255 }
256 start..=end
257 } else {
258 let ordinal = parse_ordinal(resource, item)?;
259 ordinal..=ordinal
260 };
261 ranges.push(range);
262 }
263 if ranges.is_empty() {
264 return Err(InvalidGithubResource::new(
265 resource,
266 "comment selector is empty",
267 ));
268 }
269 Ok(GithubCommentSelector { ranges })
270}
271
272fn parse_ordinal(resource: &str, value: &str) -> Result<usize, InvalidGithubResource> {
273 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
274 return Err(InvalidGithubResource::new(
275 resource,
276 "comment selector ordinals must be positive integers",
277 ));
278 }
279 let ordinal = value.parse::<usize>().map_err(|_| {
280 InvalidGithubResource::new(resource, "comment selector ordinal is out of range")
281 })?;
282 if ordinal == 0 {
283 return Err(InvalidGithubResource::new(
284 resource,
285 "comment selector ordinals must be greater than zero",
286 ));
287 }
288 Ok(ordinal)
289}
290
291fn valid_repository_component(value: &str) -> bool {
292 !value.is_empty()
293 && value.len() <= 100
294 && value
295 .bytes()
296 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn accepts_only_the_four_resource_forms() {
305 assert_eq!(
306 parse_resource("issue://373").unwrap(),
307 GithubResource {
308 kind: GithubResourceKind::Issue,
309 number: 373,
310 repository: None,
311 comment_selector: None,
312 }
313 );
314 assert_eq!(
315 parse_resource("pr://Owner/repo-name/45").unwrap(),
316 GithubResource {
317 kind: GithubResourceKind::PullRequest,
318 number: 45,
319 repository: Some("Owner/repo-name".to_string()),
320 comment_selector: None,
321 }
322 );
323
324 for value in [
325 "issue:///373",
326 "issue://373/",
327 "issue://owner/repo/not-a-number",
328 "issue://owner/repo/0",
329 "issue://owner/repo/1/extra",
330 "issue://owner/repo/1?view=full",
331 "issue://owner:secret@repo/1",
332 "issue://github.com/owner/repo/1",
333 "https://github.com/owner/repo/issues/1",
334 ] {
335 assert!(parse_resource(value).is_err(), "{value}");
336 }
337 }
338
339 #[test]
340 fn parses_comment_ordinal_selectors_for_short_and_explicit_resources() {
341 let short = parse_resource("issue://373/comments/3,7").unwrap();
342 let short_selector = short.comment_selector.as_ref().unwrap();
343 assert!(short_selector.contains(3));
344 assert!(short_selector.contains(7));
345 assert!(!short_selector.contains(4));
346 assert_eq!(short.base_spelling(), "issue://373");
347
348 let explicit = parse_resource("pr://Owner/repo-name/45/comments/3-5").unwrap();
349 let explicit_selector = explicit.comment_selector.as_ref().unwrap();
350 assert!((3..=5).all(|ordinal| explicit_selector.contains(ordinal)));
351 assert_eq!(explicit.base_spelling(), "pr://Owner/repo-name/45");
352
353 for value in [
354 "pr://45/comments/0",
355 "pr://45/comments/",
356 "pr://45/comments/5-3",
357 "pr://45/comments/3-5-7",
358 "pr://45/comments/3,,7",
359 "pr://owner/repo/45/comments/3/extra",
360 ] {
361 assert!(parse_resource(value).is_err(), "{value}");
362 }
363 }
364}