Skip to main content

worktree_io/
issue.rs

1use anyhow::{bail, Context, Result};
2use std::path::PathBuf;
3use url::Url;
4
5/// Options extracted from a `worktree://` deep link.
6#[derive(Debug, Clone, Default)]
7pub struct DeepLinkOptions {
8    /// Editor override from the `editor` query param. May be a symbolic name
9    /// (`cursor`, `code`, `zed`, `nvim`, etc.) or a raw percent-decoded command.
10    pub editor: Option<String>,
11}
12
13/// A reference to a GitHub issue that identifies a workspace.
14#[derive(Debug, Clone, PartialEq)]
15pub enum IssueRef {
16    GitHub {
17        owner: String,
18        repo: String,
19        number: u64,
20    },
21}
22
23impl IssueRef {
24    /// Parse any of the supported input formats:
25    /// - `https://github.com/owner/repo/issues/42`
26    /// - `worktree://open?owner=X&repo=Y&issue=42`
27    /// - `worktree://open?url=<encoded-github-url>`
28    /// - `owner/repo#42`
29    pub fn parse(s: &str) -> Result<Self> {
30        let s = s.trim();
31
32        // Try worktree:// scheme first
33        if s.starts_with("worktree://") {
34            let (issue, _opts) = Self::parse_worktree_url(s)?;
35            return Ok(issue);
36        }
37
38        // Try https://github.com URL
39        if s.starts_with("https://github.com") || s.starts_with("http://github.com") {
40            return Self::parse_github_url(s);
41        }
42
43        // Try owner/repo#N shorthand
44        if let Some(result) = Self::try_parse_shorthand(s) {
45            return result;
46        }
47
48        bail!(
49            "Could not parse issue reference: {s:?}\n\
50             Supported formats:\n\
51             - https://github.com/owner/repo/issues/42\n\
52             - worktree://open?owner=owner&repo=repo&issue=42\n\
53             - owner/repo#42"
54        )
55    }
56
57    /// Like [`parse`] but also returns any [`DeepLinkOptions`] embedded in a
58    /// `worktree://` URL (e.g. the `editor` query param).
59    pub fn parse_with_options(s: &str) -> Result<(Self, DeepLinkOptions)> {
60        let s = s.trim();
61        if s.starts_with("worktree://") {
62            return Self::parse_worktree_url(s);
63        }
64        Ok((Self::parse(s)?, DeepLinkOptions::default()))
65    }
66
67    fn parse_worktree_url(s: &str) -> Result<(Self, DeepLinkOptions)> {
68        let url = Url::parse(s).with_context(|| format!("Invalid URL: {s}"))?;
69        let mut owner = None;
70        let mut repo = None;
71        let mut issue_num = None;
72        let mut url_param = None;
73        let mut editor = None;
74
75        for (key, val) in url.query_pairs() {
76            match key.as_ref() {
77                "owner" => owner = Some(val.into_owned()),
78                "repo" => repo = Some(val.into_owned()),
79                "issue" => {
80                    issue_num = Some(
81                        val.parse::<u64>()
82                            .with_context(|| format!("Invalid issue number: {val}"))?,
83                    );
84                }
85                "url" => {
86                    // query_pairs() already percent-decodes the value for us
87                    url_param = Some(val.into_owned());
88                }
89                "editor" => editor = Some(val.into_owned()),
90                _ => {}
91            }
92        }
93
94        let opts = DeepLinkOptions { editor };
95
96        if let Some(url_str) = url_param {
97            return Ok((Self::parse_github_url(&url_str)?, opts));
98        }
99
100        Ok((
101            Self::GitHub {
102                owner: owner.context("Missing 'owner' query param")?,
103                repo: repo.context("Missing 'repo' query param")?,
104                number: issue_num.context("Missing 'issue' query param")?,
105            },
106            opts,
107        ))
108    }
109
110    fn parse_github_url(s: &str) -> Result<Self> {
111        let url = Url::parse(s).with_context(|| format!("Invalid URL: {s}"))?;
112
113        let segments: Vec<&str> = url
114            .path_segments()
115            .context("URL has no path")?
116            .filter(|s| !s.is_empty())
117            .collect();
118
119        // Expect: owner / repo / "issues" / number
120        if segments.len() < 4 || segments[2] != "issues" {
121            bail!(
122                "Expected GitHub issue URL like https://github.com/owner/repo/issues/42, got: {s}"
123            );
124        }
125
126        let owner = segments[0].to_string();
127        let repo = segments[1].to_string();
128        let number = segments[3]
129            .parse::<u64>()
130            .with_context(|| format!("Invalid issue number in URL: {}", segments[3]))?;
131
132        Ok(Self::GitHub { owner, repo, number })
133    }
134
135    fn try_parse_shorthand(s: &str) -> Option<Result<Self>> {
136        // Format: owner/repo#42
137        let (repo_part, num_str) = s.split_once('#')?;
138        let (owner, repo) = repo_part.split_once('/')?;
139
140        if owner.is_empty() || repo.is_empty() {
141            return Some(Err(anyhow::anyhow!("Invalid shorthand format: {s}")));
142        }
143
144        let number = match num_str.parse::<u64>() {
145            Ok(n) => n,
146            Err(_) => return Some(Err(anyhow::anyhow!("Invalid issue number in shorthand: {num_str}"))),
147        };
148
149        Some(Ok(Self::GitHub {
150            owner: owner.to_string(),
151            repo: repo.to_string(),
152            number,
153        }))
154    }
155
156    /// Directory name used inside the bare clone for this worktree.
157    pub fn workspace_dir_name(&self) -> String {
158        match self {
159            Self::GitHub { number, .. } => format!("issue-{number}"),
160        }
161    }
162
163    /// Git branch name for this issue worktree.
164    pub fn branch_name(&self) -> String {
165        self.workspace_dir_name()
166    }
167
168    /// HTTPS clone URL for the repository.
169    pub fn clone_url(&self) -> String {
170        match self {
171            Self::GitHub { owner, repo, .. } => {
172                format!("https://github.com/{owner}/{repo}.git")
173            }
174        }
175    }
176
177    /// Path to the worktree checkout: `$TMPDIR/worktree-io/github/owner/repo/issue-N`
178    pub fn temp_path(&self) -> PathBuf {
179        self.bare_clone_path().join(self.workspace_dir_name())
180    }
181
182    /// Path to the bare clone: `$TMPDIR/worktree-io/github/owner/repo`
183    pub fn bare_clone_path(&self) -> PathBuf {
184        match self {
185            Self::GitHub { owner, repo, .. } => std::env::temp_dir()
186                .join("worktree-io")
187                .join("github")
188                .join(owner)
189                .join(repo),
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_parse_shorthand() {
200        let r = IssueRef::parse("owner/repo#42").unwrap();
201        assert_eq!(
202            r,
203            IssueRef::GitHub {
204                owner: "owner".into(),
205                repo: "repo".into(),
206                number: 42
207            }
208        );
209    }
210
211    #[test]
212    fn test_parse_github_url() {
213        let r = IssueRef::parse("https://github.com/microsoft/vscode/issues/12345").unwrap();
214        assert_eq!(
215            r,
216            IssueRef::GitHub {
217                owner: "microsoft".into(),
218                repo: "vscode".into(),
219                number: 12345
220            }
221        );
222    }
223
224    #[test]
225    fn test_parse_worktree_url() {
226        let r = IssueRef::parse("worktree://open?owner=acme&repo=api&issue=7").unwrap();
227        assert_eq!(
228            r,
229            IssueRef::GitHub {
230                owner: "acme".into(),
231                repo: "api".into(),
232                number: 7
233            }
234        );
235    }
236
237    #[test]
238    fn test_parse_worktree_url_with_editor_symbolic() {
239        let (r, opts) =
240            IssueRef::parse_with_options("worktree://open?owner=acme&repo=api&issue=42&editor=cursor")
241                .unwrap();
242        assert_eq!(
243            r,
244            IssueRef::GitHub {
245                owner: "acme".into(),
246                repo: "api".into(),
247                number: 42,
248            }
249        );
250        assert_eq!(opts.editor.as_deref(), Some("cursor"));
251    }
252
253    #[test]
254    fn test_parse_worktree_url_with_editor_raw_command() {
255        let (r, opts) =
256            IssueRef::parse_with_options("worktree://open?owner=acme&repo=api&issue=42&editor=my-editor%20.")
257                .unwrap();
258        assert_eq!(r, IssueRef::GitHub { owner: "acme".into(), repo: "api".into(), number: 42 });
259        assert_eq!(opts.editor.as_deref(), Some("my-editor ."));
260    }
261
262    #[test]
263    fn test_parse_with_options_no_editor() {
264        let (_r, opts) =
265            IssueRef::parse_with_options("worktree://open?owner=acme&repo=api&issue=42").unwrap();
266        assert!(opts.editor.is_none());
267    }
268
269    #[test]
270    fn test_parse_with_options_non_deep_link() {
271        let (_r, opts) = IssueRef::parse_with_options("acme/api#42").unwrap();
272        assert!(opts.editor.is_none());
273    }
274
275    #[test]
276    fn test_paths() {
277        let r = IssueRef::GitHub {
278            owner: "acme".into(),
279            repo: "api".into(),
280            number: 7,
281        };
282        assert!(r.bare_clone_path().ends_with("worktree-io/github/acme/api"));
283        assert!(r.temp_path().ends_with("worktree-io/github/acme/api/issue-7"));
284    }
285
286    #[test]
287    fn test_clone_url() {
288        let r = IssueRef::GitHub {
289            owner: "acme".into(),
290            repo: "api".into(),
291            number: 7,
292        };
293        assert_eq!(r.clone_url(), "https://github.com/acme/api.git");
294    }
295}