Skip to main content

aft/github_read/
resource.rs

1use std::collections::BTreeSet;
2use std::fmt;
3use std::ops::RangeInclusive;
4
5use url::Url;
6
7/// One of the two GitHub resource kinds supported by the read scheme.
8#[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/// An item in a discussion comment selector.
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub enum SelectorItem {
40    /// 1-based ordinal range from the start: `start..=end`.
41    Positive(RangeInclusive<usize>),
42    /// 1-based ordinal range from the end:
43    /// `start` is distance from end (e.g. 3 for -3), `end` is distance from end (e.g. 1 for -1).
44    /// In chronological order, `start >= end`.
45    Negative { start: usize, end: usize },
46}
47
48/// One resolved selection of discussion ordinals.
49#[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/// Ordinals selected by a `/comments/<sel>` discussion drill-down.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct GithubCommentSelector {
77    items: Vec<SelectorItem>,
78}
79
80impl GithubCommentSelector {
81    /// Parse the selector grammar shared by `/comments/<sel>` and GitHub zoom.
82    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    /// Resolve the selector against a discussion's total item count.
94    ///
95    /// Returns the resolved set of 1-based ordinal ranges, or the first ordinal
96    /// (positive or negative) that was out of range.
97    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    /// Return the one positive ordinal required by comment-edit resource paths.
131    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/// A validated `issue://` or `pr://` resource.
140///
141/// Short resources deliberately retain no inferred repository. `gh` resolves
142/// those resources from the request's working directory, while explicit
143/// resources carry the exact `OWNER/REPO` argument to pass to `gh -R`.
144#[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/// A typed error produced before a GitHub resource can enter the fetch path.
172#[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
203/// Parse only the GitHub resource forms that the read command exposes.
204///
205/// This rejects query strings, fragments, ports, credentials, empty path
206/// components, and alternate URL shapes rather than silently treating them as
207/// filesystem paths or a different remote resource.
208pub 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    // `issue://373` uses the URL authority for the number. It has no path.
238    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    // A short resource keeps its number in the authority, so its only accepted
248    // path is the discussion drill-down suffix.
249    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    // Explicit resources use the authority for OWNER and begin their path with
265    // REPO/NUMBER, optionally followed by comments/SELECTOR.
266    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        // Positive
510        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        // Negative
523        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        // Mixed
541        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        // Malformed (including explicitly required: `--1`, `-0`, `1--3`)
576        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        // -1 -> 5
590        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        // -3- -> 3..=5
596        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        // -3--1 -> 3..=5
602        let selector = GithubCommentSelector::parse("-3--1").unwrap();
603        let resolved = selector.resolve(5).unwrap();
604        assert_eq!(resolved.ranges(), &[3..=5]);
605
606        // 2,-1 -> {2, 5}
607        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        // -9 error
617        let selector = GithubCommentSelector::parse("-9").unwrap();
618        let err = selector.resolve(5).unwrap_err();
619        assert_eq!(err, -9);
620
621        // Positive out of range error
622        let selector = GithubCommentSelector::parse("9").unwrap();
623        let err = selector.resolve(5).unwrap_err();
624        assert_eq!(err, 9);
625
626        // Empty discussion error
627        let selector = GithubCommentSelector::parse("-1").unwrap();
628        let err = selector.resolve(0).unwrap_err();
629        assert_eq!(err, -1);
630    }
631}