Skip to main content

worktree_io/issue/
parse.rs

1use anyhow::{bail, Context, Result};
2use url::Url;
3
4use super::{DeepLinkOptions, IssueRef};
5
6impl IssueRef {
7    /// Parse any of the supported input formats:
8    /// - `https://github.com/owner/repo/issues/42`
9    /// - `worktree://open?owner=X&repo=Y&issue=42`
10    /// - `worktree://open?url=<encoded-github-url>`
11    /// - `worktree://open?owner=X&repo=Y&linear_id=<uuid>`
12    /// - `owner/repo#42`
13    /// - `owner/repo@<linear-uuid>`
14    pub fn parse(s: &str) -> Result<Self> {
15        let s = s.trim();
16
17        // Try worktree:// scheme first
18        if s.starts_with("worktree://") {
19            let (issue, _opts) = Self::parse_worktree_url(s)?;
20            return Ok(issue);
21        }
22
23        // Try https://github.com URL
24        if s.starts_with("https://github.com") || s.starts_with("http://github.com") {
25            return Self::parse_github_url(s);
26        }
27
28        // Try owner/repo#N shorthand or owner/repo@<uuid>
29        if let Some(result) = Self::try_parse_shorthand(s) {
30            return result;
31        }
32
33        bail!(
34            "Could not parse issue reference: {s:?}\n\
35             Supported formats:\n\
36             - https://github.com/owner/repo/issues/42\n\
37             - worktree://open?owner=owner&repo=repo&issue=42\n\
38             - worktree://open?owner=owner&repo=repo&linear_id=<uuid>\n\
39             - owner/repo#42\n\
40             - owner/repo@<linear-uuid>"
41        )
42    }
43
44    /// Like [`parse`] but also returns any [`DeepLinkOptions`] embedded in a
45    /// `worktree://` URL (e.g. the `editor` query param).
46    pub fn parse_with_options(s: &str) -> Result<(Self, DeepLinkOptions)> {
47        let s = s.trim();
48        if s.starts_with("worktree://") {
49            return Self::parse_worktree_url(s);
50        }
51        Ok((Self::parse(s)?, DeepLinkOptions::default()))
52    }
53
54    fn parse_worktree_url(s: &str) -> Result<(Self, DeepLinkOptions)> {
55        let url = Url::parse(s).with_context(|| format!("Invalid URL: {s}"))?;
56        let mut owner = None;
57        let mut repo = None;
58        let mut issue_num = None;
59        let mut linear_id = None;
60        let mut url_param = None;
61        let mut editor = None;
62
63        for (key, val) in url.query_pairs() {
64            match key.as_ref() {
65                "owner" => owner = Some(val.into_owned()),
66                "repo" => repo = Some(val.into_owned()),
67                "issue" => {
68                    issue_num = Some(
69                        val.parse::<u64>()
70                            .with_context(|| format!("Invalid issue number: {val}"))?,
71                    );
72                }
73                "linear_id" => {
74                    let id = val.into_owned();
75                    if uuid::Uuid::parse_str(&id).is_err() {
76                        bail!("Invalid Linear issue UUID: {id}");
77                    }
78                    linear_id = Some(id);
79                }
80                "url" => {
81                    // query_pairs() already percent-decodes the value for us
82                    url_param = Some(val.into_owned());
83                }
84                "editor" => editor = Some(val.into_owned()),
85                _ => {}
86            }
87        }
88
89        let opts = DeepLinkOptions { editor };
90
91        if let Some(url_str) = url_param {
92            return Ok((Self::parse_github_url(&url_str)?, opts));
93        }
94
95        if let Some(id) = linear_id {
96            return Ok((
97                Self::Linear {
98                    owner: owner.context("Missing 'owner' query param")?,
99                    repo: repo.context("Missing 'repo' query param")?,
100                    id,
101                },
102                opts,
103            ));
104        }
105
106        Ok((
107            Self::GitHub {
108                owner: owner.context("Missing 'owner' query param")?,
109                repo: repo.context("Missing 'repo' query param")?,
110                number: issue_num.context("Missing 'issue' query param")?,
111            },
112            opts,
113        ))
114    }
115
116    fn parse_github_url(s: &str) -> Result<Self> {
117        let url = Url::parse(s).with_context(|| format!("Invalid URL: {s}"))?;
118
119        let segments: Vec<&str> = url
120            .path_segments()
121            .context("URL has no path")?
122            .filter(|s| !s.is_empty())
123            .collect();
124
125        // Expect: owner / repo / "issues" / number
126        if segments.len() < 4 || segments[2] != "issues" {
127            bail!(
128                "Expected GitHub issue URL like https://github.com/owner/repo/issues/42, got: {s}"
129            );
130        }
131
132        let owner = segments[0].to_string();
133        let repo = segments[1].to_string();
134        let number = segments[3]
135            .parse::<u64>()
136            .with_context(|| format!("Invalid issue number in URL: {}", segments[3]))?;
137
138        Ok(Self::GitHub { owner, repo, number })
139    }
140
141    fn try_parse_shorthand(s: &str) -> Option<Result<Self>> {
142        // Format: owner/repo#42  or  owner/repo@<linear-uuid>
143        if let Some((repo_part, id)) = s.split_once('@') {
144            let (owner, repo) = repo_part.split_once('/')?;
145            if owner.is_empty() || repo.is_empty() {
146                return Some(Err(anyhow::anyhow!("Invalid shorthand format: {s}")));
147            }
148            if uuid::Uuid::parse_str(id).is_err() {
149                return Some(Err(anyhow::anyhow!(
150                    "Invalid Linear issue UUID in shorthand: {id}"
151                )));
152            }
153            return Some(Ok(Self::Linear {
154                owner: owner.to_string(),
155                repo: repo.to_string(),
156                id: id.to_string(),
157            }));
158        }
159
160        let (repo_part, num_str) = s.split_once('#')?;
161        let (owner, repo) = repo_part.split_once('/')?;
162
163        if owner.is_empty() || repo.is_empty() {
164            return Some(Err(anyhow::anyhow!("Invalid shorthand format: {s}")));
165        }
166
167        let number = match num_str.parse::<u64>() {
168            Ok(n) => n,
169            Err(_) => return Some(Err(anyhow::anyhow!("Invalid issue number in shorthand: {num_str}"))),
170        };
171
172        Some(Ok(Self::GitHub {
173            owner: owner.to_string(),
174            repo: repo.to_string(),
175            number,
176        }))
177    }
178}