Skip to main content

aft/github_read/
resource.rs

1use std::fmt;
2use std::ops::RangeInclusive;
3
4use url::Url;
5
6/// One of the two GitHub resource kinds supported by the read scheme.
7#[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/// Ordinals selected by a `/comments/<sel>` discussion drill-down.
37#[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/// A validated `issue://` or `pr://` resource.
56///
57/// Short resources deliberately retain no inferred repository. `gh` resolves
58/// those resources from the request's working directory, while explicit
59/// resources carry the exact `OWNER/REPO` argument to pass to `gh -R`.
60#[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/// A typed error produced before a GitHub resource can enter the fetch path.
88#[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
119/// Parse only the GitHub resource forms that the read command exposes.
120///
121/// This rejects query strings, fragments, ports, credentials, empty path
122/// components, and alternate URL shapes rather than silently treating them as
123/// filesystem paths or a different remote resource.
124pub 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    // `issue://373` uses the URL authority for the number. It has no path.
154    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    // A short resource keeps its number in the authority, so its only accepted
164    // path is the discussion drill-down suffix.
165    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    // Explicit resources use the authority for OWNER and begin their path with
181    // REPO/NUMBER, optionally followed by comments/SELECTOR.
182    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}