atomcode_core/atomgit/
url.rs1use anyhow::{anyhow, Result};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct IssueRef {
6 pub owner: String,
7 pub repo: String,
8 pub number: u64,
9}
10
11#[derive(Debug, Clone)]
15pub struct RepoRef {
16 pub owner: String,
17 pub repo: String,
18}
19
20impl RepoRef {
21 pub fn matches(&self, other: &RepoRef) -> bool {
22 self.owner.eq_ignore_ascii_case(&other.owner) && self.repo.eq_ignore_ascii_case(&other.repo)
23 }
24}
25
26impl From<&IssueRef> for RepoRef {
27 fn from(r: &IssueRef) -> Self {
28 Self {
29 owner: r.owner.clone(),
30 repo: r.repo.clone(),
31 }
32 }
33}
34
35const ATOMGIT_MIRROR_HOSTS: &[&str] = &["atomgit.com", "gitcode.com"];
44
45fn is_atomgit_host(host: &str) -> bool {
46 ATOMGIT_MIRROR_HOSTS
47 .iter()
48 .any(|h| host.eq_ignore_ascii_case(h))
49}
50
51pub fn parse_repo_url(url: &str) -> Option<RepoRef> {
67 let trimmed = url.trim();
68
69 if let Some(rest) = trimmed.strip_prefix("git@") {
71 let (host_with_port, path) = rest.split_once(':')?;
72 let host = strip_port(host_with_port);
73 if !is_atomgit_host(host) {
74 return None;
75 }
76 return split_owner_repo(path);
77 }
78
79 let without_scheme = if let Some(rest) = trimmed.strip_prefix("https://") {
81 rest
82 } else if let Some(rest) = trimmed.strip_prefix("http://") {
83 rest
84 } else if let Some(rest) = trimmed.strip_prefix("ssh://") {
85 rest
86 } else {
87 return None;
88 };
89
90 let after_userinfo = match without_scheme.split_once('@') {
103 Some((userinfo, rest)) if !userinfo.contains('/') => rest,
104 _ => without_scheme,
105 };
106
107 let (host_with_port, path) = after_userinfo.split_once('/')?;
108 let host = strip_port(host_with_port);
109 if !is_atomgit_host(host) {
110 return None;
111 }
112 split_owner_repo(path)
113}
114
115fn strip_port(host: &str) -> &str {
118 host.split_once(':').map(|(h, _)| h).unwrap_or(host)
119}
120
121fn split_owner_repo(path: &str) -> Option<RepoRef> {
122 let mut parts = path
123 .trim_start_matches('/')
124 .split('/')
125 .filter(|s| !s.is_empty());
126 let owner = parts.next()?.to_string();
127 let repo = parts.next()?.to_string();
128 let repo = repo.strip_suffix(".git").unwrap_or(&repo).to_string();
129 if owner.is_empty() || repo.is_empty() {
130 return None;
131 }
132 Some(RepoRef { owner, repo })
133}
134
135pub fn detect_cwd_atomgit_repo(cwd: &std::path::Path) -> std::io::Result<Option<RepoRef>> {
142 let mut cmd = std::process::Command::new("git");
143 cmd.args(["remote", "get-url", "origin"])
144 .current_dir(cwd);
145 crate::process_utils::suppress_console_window_sync(&mut cmd);
146 let output = cmd.output()?;
147 if !output.status.success() {
148 return Ok(None);
151 }
152 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
153 if url.is_empty() {
154 return Ok(None);
155 }
156 Ok(parse_repo_url(&url))
157}
158
159impl IssueRef {
160 pub fn parse(url: &str) -> Result<Self> {
164 let trimmed = url.trim();
165 let without_scheme = trimmed
166 .strip_prefix("https://")
167 .or_else(|| trimmed.strip_prefix("http://"))
168 .ok_or_else(|| anyhow!("issue URL must start with http(s)://"))?;
169
170 let path_only = without_scheme
172 .split(['?', '#'])
173 .next()
174 .unwrap_or(without_scheme);
175
176 let mut parts = path_only.split('/').filter(|s| !s.is_empty());
177 let host = parts
178 .next()
179 .ok_or_else(|| anyhow!("missing host in issue URL"))?;
180 let host = strip_port(host);
181 if !is_atomgit_host(host) {
182 return Err(anyhow!(
183 "only atomgit.com/gitcode.com issue URLs are supported (got host {})",
184 host
185 ));
186 }
187
188 let owner = parts
189 .next()
190 .ok_or_else(|| anyhow!("missing owner in issue URL"))?
191 .to_string();
192 let repo = parts
193 .next()
194 .ok_or_else(|| anyhow!("missing repo in issue URL"))?
195 .to_string();
196 let issues_seg = parts
197 .next()
198 .ok_or_else(|| anyhow!("missing 'issues' segment in URL"))?;
199 if issues_seg != "issues" {
200 return Err(anyhow!(
201 "expected '/issues/' in URL, got '/{}/'",
202 issues_seg
203 ));
204 }
205 let number_str = parts
206 .next()
207 .ok_or_else(|| anyhow!("missing issue number in URL"))?;
208 let number = number_str
209 .parse::<u64>()
210 .map_err(|_| anyhow!("issue number '{}' is not a positive integer", number_str))?;
211
212 Ok(Self {
213 owner,
214 repo,
215 number,
216 })
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn parses_canonical_url() {
226 let r = IssueRef::parse("https://atomgit.com/atomgit_atomcode/atomcode/issues/42").unwrap();
227 assert_eq!(r.owner, "atomgit_atomcode");
228 assert_eq!(r.repo, "atomcode");
229 assert_eq!(r.number, 42);
230 }
231
232 #[test]
233 fn parses_with_trailing_slash() {
234 let r = IssueRef::parse("https://atomgit.com/a/b/issues/1/").unwrap();
235 assert_eq!(r.number, 1);
236 }
237
238 #[test]
239 fn parses_with_query_and_fragment() {
240 let r = IssueRef::parse("https://atomgit.com/a/b/issues/7?x=1#comment").unwrap();
241 assert_eq!(r.number, 7);
242 }
243
244 #[test]
245 fn parses_gitcode_mirror_issue_url() {
246 let r = IssueRef::parse("https://gitcode.com/atomgit_atomcode/atomcode/issues/340")
247 .unwrap();
248 assert_eq!(r.owner, "atomgit_atomcode");
249 assert_eq!(r.repo, "atomcode");
250 assert_eq!(r.number, 340);
251 }
252
253 #[test]
254 fn parses_issue_url_with_host_port() {
255 let r = IssueRef::parse("https://gitcode.com:443/a/b/issues/8").unwrap();
256 assert_eq!(r.owner, "a");
257 assert_eq!(r.repo, "b");
258 assert_eq!(r.number, 8);
259 }
260
261 #[test]
262 fn rejects_non_atomgit_host() {
263 assert!(IssueRef::parse("https://github.com/a/b/issues/1").is_err());
264 }
265
266 #[test]
267 fn rejects_missing_number() {
268 assert!(IssueRef::parse("https://atomgit.com/a/b/issues").is_err());
269 }
270
271 #[test]
272 fn rejects_non_numeric() {
273 assert!(IssueRef::parse("https://atomgit.com/a/b/issues/abc").is_err());
274 }
275
276 #[test]
277 fn rejects_wrong_path() {
278 assert!(IssueRef::parse("https://atomgit.com/a/b/pulls/1").is_err());
279 }
280
281 #[test]
282 fn parse_repo_url_https() {
283 let r = parse_repo_url("https://atomgit.com/owner/repo.git").unwrap();
284 assert_eq!(r.owner, "owner");
285 assert_eq!(r.repo, "repo");
286 }
287
288 #[test]
289 fn parse_repo_url_https_no_git_suffix() {
290 let r = parse_repo_url("https://atomgit.com/owner/repo").unwrap();
291 assert_eq!(r.repo, "repo");
292 }
293
294 #[test]
295 fn parse_repo_url_ssh_shorthand() {
296 let r = parse_repo_url("git@atomgit.com:owner/repo.git").unwrap();
297 assert_eq!(r.owner, "owner");
298 assert_eq!(r.repo, "repo");
299 }
300
301 #[test]
302 fn parse_repo_url_ssh_full() {
303 let r = parse_repo_url("ssh://git@atomgit.com/owner/repo.git").unwrap();
304 assert_eq!(r.owner, "owner");
305 assert_eq!(r.repo, "repo");
306 }
307
308 #[test]
309 fn parse_repo_url_rejects_non_atomgit() {
310 assert!(parse_repo_url("https://github.com/foo/bar.git").is_none());
311 assert!(parse_repo_url("git@github.com:foo/bar.git").is_none());
312 }
313
314 #[test]
315 fn parse_repo_url_strips_oauth_userinfo() {
316 let r = parse_repo_url("https://oauth2:abc123token@atomgit.com/owner/repo.git").unwrap();
322 assert_eq!(r.owner, "owner");
323 assert_eq!(r.repo, "repo");
324 }
325
326 #[test]
327 fn parse_repo_url_strips_basic_auth_userinfo() {
328 let r = parse_repo_url("https://user:password@atomgit.com/owner/repo.git").unwrap();
331 assert_eq!(r.owner, "owner");
332 assert_eq!(r.repo, "repo");
333 }
334
335 #[test]
336 fn parse_repo_url_strips_user_only_userinfo() {
337 let r = parse_repo_url("https://alice@gitcode.com/atomgit_atomcode/atomcode").unwrap();
339 assert_eq!(r.owner, "atomgit_atomcode");
340 assert_eq!(r.repo, "atomcode");
341 }
342
343 #[test]
344 fn parse_repo_url_strips_host_port() {
345 let r = parse_repo_url("https://atomgit.com:443/owner/repo.git").unwrap();
347 assert_eq!(r.owner, "owner");
348 assert_eq!(r.repo, "repo");
349 }
350
351 #[test]
352 fn parse_repo_url_ssh_with_port() {
353 let r = parse_repo_url("ssh://git@atomgit.com:22/owner/repo.git").unwrap();
355 assert_eq!(r.owner, "owner");
356 assert_eq!(r.repo, "repo");
357 }
358
359 #[test]
360 fn parse_repo_url_oauth_token_does_not_match_non_atomgit() {
361 assert!(parse_repo_url("https://oauth2:TOKEN@github.com/foo/bar.git").is_none());
364 }
365
366 #[test]
367 fn parse_repo_url_accepts_gitcode_mirror() {
368 for url in [
376 "git@gitcode.com:atomgit_atomcode/atomcode.git",
377 "https://gitcode.com/atomgit_atomcode/atomcode.git",
378 "https://gitcode.com/atomgit_atomcode/atomcode",
379 "ssh://git@gitcode.com/atomgit_atomcode/atomcode.git",
380 ] {
381 let r = parse_repo_url(url)
382 .unwrap_or_else(|| panic!("should parse gitcode mirror URL: {}", url));
383 assert_eq!(r.owner, "atomgit_atomcode");
384 assert_eq!(r.repo, "atomcode");
385 }
386 }
387
388 #[test]
389 fn repo_ref_matches_case_insensitive() {
390 let a = RepoRef {
391 owner: "Atomgit_Atomcode".into(),
392 repo: "AtomCode".into(),
393 };
394 let b = RepoRef {
395 owner: "atomgit_atomcode".into(),
396 repo: "atomcode".into(),
397 };
398 assert!(a.matches(&b));
399 }
400
401 #[test]
402 fn issue_ref_to_repo_ref() {
403 let r = IssueRef::parse("https://atomgit.com/o/r/issues/1").unwrap();
404 let rr: RepoRef = (&r).into();
405 assert_eq!(rr.owner, "o");
406 assert_eq!(rr.repo, "r");
407 }
408}