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 Linear {
24 owner: String,
25 repo: String,
26 id: String,
27 },
28}
29
30impl IssueRef {
31 pub fn parse(s: &str) -> Result<Self> {
39 let s = s.trim();
40
41 if s.starts_with("worktree://") {
43 let (issue, _opts) = Self::parse_worktree_url(s)?;
44 return Ok(issue);
45 }
46
47 if s.starts_with("https://github.com") || s.starts_with("http://github.com") {
49 return Self::parse_github_url(s);
50 }
51
52 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 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 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 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 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 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 pub fn branch_name(&self) -> String {
213 self.workspace_dir_name()
214 }
215
216 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 pub fn temp_path(&self) -> PathBuf {
227 self.bare_clone_path().join(self.workspace_dir_name())
228 }
229
230 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
245fn 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 #[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")); }
464}