Skip to main content

ito_common/
git_url.rs

1//! Git remote URL parsing utilities.
2//!
3//! Pure parsing logic with no domain knowledge and no I/O. Handles the common
4//! git remote URL formats used by GitHub, GitLab, and similar hosts.
5
6/// Parse an `<org>/<repo>` pair from a git remote URL.
7///
8/// Handles the four common formats:
9///
10/// | Format | Example |
11/// |---|---|
12/// | SCP-style SSH | `git@github.com:withakay/ito.git` |
13/// | HTTPS with `.git` | `https://github.com/withakay/ito.git` |
14/// | HTTPS without `.git` | `https://github.com/withakay/ito` |
15/// | SSH with explicit port | `ssh://git@github.com:22/withakay/ito.git` |
16///
17/// Returns `Some((org, repo))` when the URL contains a recognisable two-component
18/// path, or `None` when the URL is empty, malformed, or has fewer than two path
19/// components.
20///
21/// The `.git` suffix is stripped from the repository name when present.
22///
23/// # Examples
24///
25/// ```
26/// use ito_common::git_url::parse_remote_url_org_repo;
27///
28/// assert_eq!(
29///     parse_remote_url_org_repo("git@github.com:withakay/ito.git"),
30///     Some(("withakay".to_string(), "ito".to_string())),
31/// );
32/// assert_eq!(
33///     parse_remote_url_org_repo("https://github.com/withakay/ito.git"),
34///     Some(("withakay".to_string(), "ito".to_string())),
35/// );
36/// assert_eq!(
37///     parse_remote_url_org_repo("https://github.com/withakay/ito"),
38///     Some(("withakay".to_string(), "ito".to_string())),
39/// );
40/// assert_eq!(
41///     parse_remote_url_org_repo("ssh://git@github.com:22/withakay/ito.git"),
42///     Some(("withakay".to_string(), "ito".to_string())),
43/// );
44/// assert_eq!(parse_remote_url_org_repo(""), None);
45/// ```
46pub fn parse_remote_url_org_repo(url: &str) -> Option<(String, String)> {
47    let url = url.trim();
48    if url.is_empty() {
49        return None;
50    }
51
52    // Extract the path portion depending on URL format.
53    let path = if let Some(rest) = url
54        .strip_prefix("ssh://")
55        .or_else(|| url.strip_prefix("git://"))
56    {
57        // ssh://[user@]host[:port]/path  or  git://host/path
58        // Drop everything up to and including the first '/' after the authority.
59        rest.split_once('/')?.1
60    } else if url.contains("://") {
61        // HTTPS (or any other scheme): https://host/path
62        // Strip scheme + authority, keep the path.
63        let after_scheme = url.split_once("://")?.1;
64        after_scheme.split_once('/')?.1
65    } else if let Some(colon_pos) = url.find(':') {
66        // SCP-style SSH: git@github.com:org/repo.git
67        // The colon separates host from path; there must be no '/' before the colon.
68        let before_colon = &url[..colon_pos];
69        if before_colon.contains('/') {
70            // Looks like a Windows absolute path or something unexpected — bail.
71            return None;
72        }
73        &url[colon_pos + 1..]
74    } else {
75        return None;
76    };
77
78    extract_org_repo_from_path(path)
79}
80
81/// Extract `(org, repo)` from the last two path components.
82///
83/// Strips a leading `/`, splits on `/`, and takes the last two non-empty
84/// segments. The `.git` suffix is removed from the repo component.
85fn extract_org_repo_from_path(path: &str) -> Option<(String, String)> {
86    let path = path.trim_start_matches('/');
87    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
88
89    if segments.len() < 2 {
90        return None;
91    }
92
93    let org = segments[segments.len() - 2];
94    let repo_raw = segments[segments.len() - 1];
95    let repo = repo_raw.strip_suffix(".git").unwrap_or(repo_raw);
96
97    if org.is_empty() || repo.is_empty() {
98        return None;
99    }
100
101    Some((org.to_string(), repo.to_string()))
102}
103
104#[cfg(test)]
105#[path = "git_url_tests.rs"]
106mod git_url_tests;