1use anyhow::{bail, Context, Result};
2use std::path::PathBuf;
3use url::Url;
4
5#[derive(Debug, Clone, Default)]
7pub struct DeepLinkOptions {
8 pub editor: Option<String>,
11}
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum IssueRef {
16 GitHub {
17 owner: String,
18 repo: String,
19 number: u64,
20 },
21}
22
23impl IssueRef {
24 pub fn parse(s: &str) -> Result<Self> {
30 let s = s.trim();
31
32 if s.starts_with("worktree://") {
34 let (issue, _opts) = Self::parse_worktree_url(s)?;
35 return Ok(issue);
36 }
37
38 if s.starts_with("https://github.com") || s.starts_with("http://github.com") {
40 return Self::parse_github_url(s);
41 }
42
43 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 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 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 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 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 pub fn workspace_dir_name(&self) -> String {
158 match self {
159 Self::GitHub { number, .. } => format!("issue-{number}"),
160 }
161 }
162
163 pub fn branch_name(&self) -> String {
165 self.workspace_dir_name()
166 }
167
168 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 pub fn temp_path(&self) -> PathBuf {
179 self.bare_clone_path().join(self.workspace_dir_name())
180 }
181
182 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}