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 an 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    /// A Linear issue identified by its UUID, paired with the GitHub repo that
22    /// hosts the code for that project.
23    Linear {
24        owner: String,
25        repo: String,
26        id: String,
27    },
28}
29
30impl IssueRef {
31    /// Parse any of the supported input formats:
32    /// - `https://github.com/owner/repo/issues/42`
33    /// - `worktree://open?owner=X&repo=Y&issue=42`
34    /// - `worktree://open?url=<encoded-github-url>`
35    /// - `worktree://open?owner=X&repo=Y&linear_id=<uuid>`
36    /// - `owner/repo#42`
37    /// - `owner/repo@<linear-uuid>`
38    pub fn parse(s: &str) -> Result<Self> {
39        let s = s.trim();
40
41        // Try worktree:// scheme first
42        if s.starts_with("worktree://") {
43            let (issue, _opts) = Self::parse_worktree_url(s)?;
44            return Ok(issue);
45        }
46
47        // Try https://github.com URL
48        if s.starts_with("https://github.com") || s.starts_with("http://github.com") {
49            return Self::parse_github_url(s);
50        }
51
52        // Try owner/repo#N shorthand or owner/repo@<uuid>
53        if let Some(result) = Self::try_parse_shorthand(s) {
54            return result;
55        }
56
57        bail!(
58            "Could not parse issue reference: {s:?}\n\
59             Supported formats:\n\
60             - https://github.com/owner/repo/issues/42\n\
61             - worktree://open?owner=owner&repo=repo&issue=42\n\
62             - worktree://open?owner=owner&repo=repo&linear_id=<uuid>\n\
63             - owner/repo#42\n\
64             - owner/repo@<linear-uuid>"
65        )
66    }
67
68    /// Like [`parse`] but also returns any [`DeepLinkOptions`] embedded in a
69    /// `worktree://` URL (e.g. the `editor` query param).
70    pub fn parse_with_options(s: &str) -> Result<(Self, DeepLinkOptions)> {
71        let s = s.trim();
72        if s.starts_with("worktree://") {
73            return Self::parse_worktree_url(s);
74        }
75        Ok((Self::parse(s)?, DeepLinkOptions::default()))
76    }
77
78    fn parse_worktree_url(s: &str) -> Result<(Self, DeepLinkOptions)> {
79        let url = Url::parse(s).with_context(|| format!("Invalid URL: {s}"))?;
80        let mut owner = None;
81        let mut repo = None;
82        let mut issue_num = None;
83        let mut linear_id = None;
84        let mut url_param = None;
85        let mut editor = None;
86
87        for (key, val) in url.query_pairs() {
88            match key.as_ref() {
89                "owner" => owner = Some(val.into_owned()),
90                "repo" => repo = Some(val.into_owned()),
91                "issue" => {
92                    issue_num = Some(
93                        val.parse::<u64>()
94                            .with_context(|| format!("Invalid issue number: {val}"))?,
95                    );
96                }
97                "linear_id" => {
98                    let id = val.into_owned();
99                    if !is_uuid(&id) {
100                        bail!("Invalid Linear issue UUID: {id}");
101                    }
102                    linear_id = Some(id);
103                }
104                "url" => {
105                    // query_pairs() already percent-decodes the value for us
106                    url_param = Some(val.into_owned());
107                }
108                "editor" => editor = Some(val.into_owned()),
109                _ => {}
110            }
111        }
112
113        let opts = DeepLinkOptions { editor };
114
115        if let Some(url_str) = url_param {
116            return Ok((Self::parse_github_url(&url_str)?, opts));
117        }
118
119        if let Some(id) = linear_id {
120            return Ok((
121                Self::Linear {
122                    owner: owner.context("Missing 'owner' query param")?,
123                    repo: repo.context("Missing 'repo' query param")?,
124                    id,
125                },
126                opts,
127            ));
128        }
129
130        Ok((
131            Self::GitHub {
132                owner: owner.context("Missing 'owner' query param")?,
133                repo: repo.context("Missing 'repo' query param")?,
134                number: issue_num.context("Missing 'issue' query param")?,
135            },
136            opts,
137        ))
138    }
139
140    fn parse_github_url(s: &str) -> Result<Self> {
141        let url = Url::parse(s).with_context(|| format!("Invalid URL: {s}"))?;
142
143        let segments: Vec<&str> = url
144            .path_segments()
145            .context("URL has no path")?
146            .filter(|s| !s.is_empty())
147            .collect();
148
149        // Expect: owner / repo / "issues" / number
150        if segments.len() < 4 || segments[2] != "issues" {
151            bail!(
152                "Expected GitHub issue URL like https://github.com/owner/repo/issues/42, got: {s}"
153            );
154        }
155
156        let owner = segments[0].to_string();
157        let repo = segments[1].to_string();
158        let number = segments[3]
159            .parse::<u64>()
160            .with_context(|| format!("Invalid issue number in URL: {}", segments[3]))?;
161
162        Ok(Self::GitHub { owner, repo, number })
163    }
164
165    fn try_parse_shorthand(s: &str) -> Option<Result<Self>> {
166        // Format: owner/repo#42  or  owner/repo@<linear-uuid>
167        if let Some((repo_part, id)) = s.split_once('@') {
168            let (owner, repo) = repo_part.split_once('/')?;
169            if owner.is_empty() || repo.is_empty() {
170                return Some(Err(anyhow::anyhow!("Invalid shorthand format: {s}")));
171            }
172            if !is_uuid(id) {
173                return Some(Err(anyhow::anyhow!(
174                    "Invalid Linear issue UUID in shorthand: {id}"
175                )));
176            }
177            return Some(Ok(Self::Linear {
178                owner: owner.to_string(),
179                repo: repo.to_string(),
180                id: id.to_string(),
181            }));
182        }
183
184        let (repo_part, num_str) = s.split_once('#')?;
185        let (owner, repo) = repo_part.split_once('/')?;
186
187        if owner.is_empty() || repo.is_empty() {
188            return Some(Err(anyhow::anyhow!("Invalid shorthand format: {s}")));
189        }
190
191        let number = match num_str.parse::<u64>() {
192            Ok(n) => n,
193            Err(_) => return Some(Err(anyhow::anyhow!("Invalid issue number in shorthand: {num_str}"))),
194        };
195
196        Some(Ok(Self::GitHub {
197            owner: owner.to_string(),
198            repo: repo.to_string(),
199            number,
200        }))
201    }
202
203    /// Directory name used inside the bare clone for this worktree.
204    pub fn workspace_dir_name(&self) -> String {
205        match self {
206            Self::GitHub { number, .. } => format!("issue-{number}"),
207            Self::Linear { id, .. } => format!("linear-{id}"),
208        }
209    }
210
211    /// Git branch name for this issue worktree.
212    pub fn branch_name(&self) -> String {
213        self.workspace_dir_name()
214    }
215
216    /// HTTPS clone URL for the repository.
217    pub fn clone_url(&self) -> String {
218        match self {
219            Self::GitHub { owner, repo, .. } | Self::Linear { owner, repo, .. } => {
220                format!("https://github.com/{owner}/{repo}.git")
221            }
222        }
223    }
224
225    /// Path to the worktree checkout: `~/worktrees/github/owner/repo/issue-N`
226    pub fn temp_path(&self) -> PathBuf {
227        self.bare_clone_path().join(self.workspace_dir_name())
228    }
229
230    /// Path to the bare clone: `~/worktrees/github/owner/repo`
231    pub fn bare_clone_path(&self) -> PathBuf {
232        match self {
233            Self::GitHub { owner, repo, .. } | Self::Linear { owner, repo, .. } => {
234                dirs::home_dir()
235                    .expect("could not determine home directory")
236                    .join("worktrees")
237                    .join("github")
238                    .join(owner)
239                    .join(repo)
240            }
241        }
242    }
243}
244
245/// Returns `true` if `s` matches the standard UUID format
246/// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` (all hex, case-insensitive).
247fn is_uuid(s: &str) -> bool {
248    let parts: Vec<&str> = s.split('-').collect();
249    if parts.len() != 5 {
250        return false;
251    }
252    let expected_lengths = [8, 4, 4, 4, 12];
253    parts
254        .iter()
255        .zip(expected_lengths.iter())
256        .all(|(part, &len)| part.len() == len && part.chars().all(|c| c.is_ascii_hexdigit()))
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_parse_shorthand() {
265        let r = IssueRef::parse("owner/repo#42").unwrap();
266        assert_eq!(
267            r,
268            IssueRef::GitHub {
269                owner: "owner".into(),
270                repo: "repo".into(),
271                number: 42
272            }
273        );
274    }
275
276    #[test]
277    fn test_parse_github_url() {
278        let r = IssueRef::parse("https://github.com/microsoft/vscode/issues/12345").unwrap();
279        assert_eq!(
280            r,
281            IssueRef::GitHub {
282                owner: "microsoft".into(),
283                repo: "vscode".into(),
284                number: 12345
285            }
286        );
287    }
288
289    #[test]
290    fn test_parse_worktree_url() {
291        let r = IssueRef::parse("worktree://open?owner=acme&repo=api&issue=7").unwrap();
292        assert_eq!(
293            r,
294            IssueRef::GitHub {
295                owner: "acme".into(),
296                repo: "api".into(),
297                number: 7
298            }
299        );
300    }
301
302    #[test]
303    fn test_parse_worktree_url_with_editor_symbolic() {
304        let (r, opts) =
305            IssueRef::parse_with_options("worktree://open?owner=acme&repo=api&issue=42&editor=cursor")
306                .unwrap();
307        assert_eq!(
308            r,
309            IssueRef::GitHub {
310                owner: "acme".into(),
311                repo: "api".into(),
312                number: 42,
313            }
314        );
315        assert_eq!(opts.editor.as_deref(), Some("cursor"));
316    }
317
318    #[test]
319    fn test_parse_worktree_url_with_editor_raw_command() {
320        let (r, opts) =
321            IssueRef::parse_with_options("worktree://open?owner=acme&repo=api&issue=42&editor=my-editor%20.")
322                .unwrap();
323        assert_eq!(r, IssueRef::GitHub { owner: "acme".into(), repo: "api".into(), number: 42 });
324        assert_eq!(opts.editor.as_deref(), Some("my-editor ."));
325    }
326
327    #[test]
328    fn test_parse_with_options_no_editor() {
329        let (_r, opts) =
330            IssueRef::parse_with_options("worktree://open?owner=acme&repo=api&issue=42").unwrap();
331        assert!(opts.editor.is_none());
332    }
333
334    #[test]
335    fn test_parse_with_options_non_deep_link() {
336        let (_r, opts) = IssueRef::parse_with_options("acme/api#42").unwrap();
337        assert!(opts.editor.is_none());
338    }
339
340    #[test]
341    fn test_paths() {
342        let r = IssueRef::GitHub {
343            owner: "acme".into(),
344            repo: "api".into(),
345            number: 7,
346        };
347        assert!(r.bare_clone_path().ends_with("worktrees/github/acme/api"));
348        assert!(r.temp_path().ends_with("worktrees/github/acme/api/issue-7"));
349    }
350
351    #[test]
352    fn test_clone_url() {
353        let r = IssueRef::GitHub {
354            owner: "acme".into(),
355            repo: "api".into(),
356            number: 7,
357        };
358        assert_eq!(r.clone_url(), "https://github.com/acme/api.git");
359    }
360
361    // --- Linear tests ---
362
363    #[test]
364    fn test_parse_linear_shorthand() {
365        let uuid = "9cad7a4b-9426-4788-9dbc-e784df999053";
366        let r = IssueRef::parse(&format!("acme/api@{uuid}")).unwrap();
367        assert_eq!(
368            r,
369            IssueRef::Linear {
370                owner: "acme".into(),
371                repo: "api".into(),
372                id: uuid.into(),
373            }
374        );
375    }
376
377    #[test]
378    fn test_parse_linear_shorthand_invalid_uuid() {
379        let err = IssueRef::parse("acme/api@not-a-uuid").unwrap_err();
380        assert!(err.to_string().contains("Invalid Linear issue UUID"));
381    }
382
383    #[test]
384    fn test_parse_linear_worktree_url() {
385        let uuid = "9cad7a4b-9426-4788-9dbc-e784df999053";
386        let url = format!("worktree://open?owner=acme&repo=api&linear_id={uuid}");
387        let r = IssueRef::parse(&url).unwrap();
388        assert_eq!(
389            r,
390            IssueRef::Linear {
391                owner: "acme".into(),
392                repo: "api".into(),
393                id: uuid.into(),
394            }
395        );
396    }
397
398    #[test]
399    fn test_parse_linear_worktree_url_with_editor() {
400        let uuid = "9cad7a4b-9426-4788-9dbc-e784df999053";
401        let url = format!("worktree://open?owner=acme&repo=api&linear_id={uuid}&editor=cursor");
402        let (r, opts) = IssueRef::parse_with_options(&url).unwrap();
403        assert_eq!(
404            r,
405            IssueRef::Linear {
406                owner: "acme".into(),
407                repo: "api".into(),
408                id: uuid.into(),
409            }
410        );
411        assert_eq!(opts.editor.as_deref(), Some("cursor"));
412    }
413
414    #[test]
415    fn test_linear_workspace_dir_name() {
416        let uuid = "9cad7a4b-9426-4788-9dbc-e784df999053";
417        let r = IssueRef::Linear {
418            owner: "acme".into(),
419            repo: "api".into(),
420            id: uuid.into(),
421        };
422        assert_eq!(r.workspace_dir_name(), format!("linear-{uuid}"));
423        assert_eq!(r.branch_name(), format!("linear-{uuid}"));
424    }
425
426    #[test]
427    fn test_linear_clone_url() {
428        let r = IssueRef::Linear {
429            owner: "acme".into(),
430            repo: "api".into(),
431            id: "9cad7a4b-9426-4788-9dbc-e784df999053".into(),
432        };
433        assert_eq!(r.clone_url(), "https://github.com/acme/api.git");
434    }
435
436    #[test]
437    fn test_linear_paths() {
438        let uuid = "9cad7a4b-9426-4788-9dbc-e784df999053";
439        let r = IssueRef::Linear {
440            owner: "acme".into(),
441            repo: "api".into(),
442            id: uuid.into(),
443        };
444        assert!(r.bare_clone_path().ends_with("worktrees/github/acme/api"));
445        assert!(r
446            .temp_path()
447            .ends_with(format!("worktrees/github/acme/api/linear-{uuid}")));
448    }
449
450    #[test]
451    fn test_is_uuid_valid() {
452        assert!(is_uuid("9cad7a4b-9426-4788-9dbc-e784df999053"));
453        assert!(is_uuid("00000000-0000-0000-0000-000000000000"));
454        assert!(is_uuid("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"));
455    }
456
457    #[test]
458    fn test_is_uuid_invalid() {
459        assert!(!is_uuid("not-a-uuid"));
460        assert!(!is_uuid("9cad7a4b-9426-4788-9dbc"));
461        assert!(!is_uuid("9cad7a4b94264788-9dbc-e784df999053"));
462        assert!(!is_uuid("9cad7a4b-9426-4788-9dbc-e784df99905z")); // 'z' invalid
463    }
464}