Skip to main content

wtg_cli/
remote.rs

1//! Remote type definitions for git repository remotes.
2
3use url::Url;
4
5/// The hosting platform for a git remote.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7#[non_exhaustive]
8pub enum RemoteHost {
9    GitHub,
10    GitLab,
11    Bitbucket,
12}
13
14impl RemoteHost {
15    /// Detect host from a remote URL (HTTP/HTTPS or SSH).
16    /// Uses proper URL parsing, not string containment.
17    #[must_use]
18    pub fn from_url(url: &str) -> Option<Self> {
19        // Try SSH format first: git@host:path
20        if let Some(host) = Self::parse_ssh_host(url) {
21            return Self::from_host_str(&host);
22        }
23
24        // Try HTTP/HTTPS URL parsing
25        if let Some(host) = Self::parse_http_host(url) {
26            return Self::from_host_str(&host);
27        }
28
29        None
30    }
31
32    /// Parse SSH URL format: `git@host:path` or `ssh://git@host/path`
33    fn parse_ssh_host(url: &str) -> Option<String> {
34        let trimmed = url.trim();
35
36        // Handle ssh:// scheme
37        if trimmed.starts_with("ssh://")
38            && let Ok(parsed) = Url::parse(trimmed)
39        {
40            return parsed.host_str().map(str::to_string);
41        }
42
43        // Handle git@host:path format
44        if let Some(after_at) = trimmed.strip_prefix("git@") {
45            let host = after_at.split(':').next()?;
46            return Some(host.to_string());
47        }
48
49        None
50    }
51
52    /// Parse HTTP/HTTPS URL and extract host
53    fn parse_http_host(url: &str) -> Option<String> {
54        // Try direct parse
55        if let Ok(parsed) = Url::parse(url) {
56            return parsed.host_str().map(str::to_string);
57        }
58
59        // Try with https:// prefix (handles "github.com/..." format)
60        let prefixed = if url.starts_with("//") {
61            format!("https:{url}")
62        } else if !url.contains("://") {
63            format!("https://{url}")
64        } else {
65            return None;
66        };
67
68        Url::parse(&prefixed).ok()?.host_str().map(str::to_string)
69    }
70
71    /// Map a host string to a `RemoteHost`
72    fn from_host_str(host: &str) -> Option<Self> {
73        let normalized = host.trim_start_matches("www.").to_ascii_lowercase();
74
75        if normalized == "github.com" || normalized == "api.github.com" {
76            Some(Self::GitHub)
77        } else if normalized == "gitlab.com" || normalized.ends_with(".gitlab.com") {
78            Some(Self::GitLab)
79        } else if normalized == "bitbucket.org" || normalized.ends_with(".bitbucket.org") {
80            Some(Self::Bitbucket)
81        } else {
82            None
83        }
84    }
85}
86
87/// Which named remote this is.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
89#[non_exhaustive]
90pub enum RemoteKind {
91    Upstream, // Highest priority (canonical repo in fork workflows)
92    Origin,   // Second priority
93    Other,    // Lowest priority
94}
95
96impl RemoteKind {
97    #[must_use]
98    pub fn from_name(name: &str) -> Self {
99        match name {
100            "origin" => Self::Origin,
101            "upstream" => Self::Upstream,
102            _ => Self::Other,
103        }
104    }
105}
106
107/// Information about a git remote.
108#[derive(Debug, Clone)]
109pub struct RemoteInfo {
110    pub name: String,
111    pub url: String,
112    pub kind: RemoteKind,
113    pub host: Option<RemoteHost>,
114}
115
116impl RemoteInfo {
117    /// Priority for sorting (lower = higher priority).
118    /// Upstream < Origin < Other, within same kind: GitHub first.
119    #[must_use]
120    pub fn priority(&self) -> (RemoteKind, bool) {
121        (self.kind, self.host != Some(RemoteHost::GitHub))
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_remote_host_from_https_url() {
131        assert_eq!(
132            RemoteHost::from_url("https://github.com/owner/repo"),
133            Some(RemoteHost::GitHub)
134        );
135        assert_eq!(
136            RemoteHost::from_url("https://github.com/owner/repo.git"),
137            Some(RemoteHost::GitHub)
138        );
139        assert_eq!(
140            RemoteHost::from_url("https://www.github.com/owner/repo"),
141            Some(RemoteHost::GitHub)
142        );
143        assert_eq!(
144            RemoteHost::from_url("https://api.github.com/repos/owner/repo"),
145            Some(RemoteHost::GitHub)
146        );
147    }
148
149    #[test]
150    fn test_remote_host_from_ssh_url() {
151        assert_eq!(
152            RemoteHost::from_url("git@github.com:owner/repo.git"),
153            Some(RemoteHost::GitHub)
154        );
155        assert_eq!(
156            RemoteHost::from_url("git@gitlab.com:owner/repo.git"),
157            Some(RemoteHost::GitLab)
158        );
159        assert_eq!(
160            RemoteHost::from_url("git@bitbucket.org:owner/repo.git"),
161            Some(RemoteHost::Bitbucket)
162        );
163    }
164
165    #[test]
166    fn test_remote_host_from_ssh_scheme_url() {
167        assert_eq!(
168            RemoteHost::from_url("ssh://git@github.com/owner/repo.git"),
169            Some(RemoteHost::GitHub)
170        );
171    }
172
173    #[test]
174    fn test_remote_host_gitlab_variants() {
175        assert_eq!(
176            RemoteHost::from_url("https://gitlab.com/owner/repo"),
177            Some(RemoteHost::GitLab)
178        );
179        assert_eq!(
180            RemoteHost::from_url("https://gitlab.example.gitlab.com/owner/repo"),
181            Some(RemoteHost::GitLab)
182        );
183    }
184
185    #[test]
186    fn test_remote_host_bitbucket_variants() {
187        assert_eq!(
188            RemoteHost::from_url("https://bitbucket.org/owner/repo"),
189            Some(RemoteHost::Bitbucket)
190        );
191    }
192
193    #[test]
194    fn test_remote_host_unknown() {
195        assert_eq!(RemoteHost::from_url("https://example.com/owner/repo"), None);
196        assert_eq!(RemoteHost::from_url("git@example.com:owner/repo.git"), None);
197    }
198
199    #[test]
200    fn test_remote_kind_from_name() {
201        assert_eq!(RemoteKind::from_name("origin"), RemoteKind::Origin);
202        assert_eq!(RemoteKind::from_name("upstream"), RemoteKind::Upstream);
203        assert_eq!(RemoteKind::from_name("fork"), RemoteKind::Other);
204        assert_eq!(RemoteKind::from_name("backup"), RemoteKind::Other);
205    }
206
207    #[test]
208    fn test_remote_kind_ordering() {
209        // Upstream has highest priority (lowest value)
210        assert!(RemoteKind::Upstream < RemoteKind::Origin);
211        assert!(RemoteKind::Origin < RemoteKind::Other);
212    }
213
214    #[test]
215    fn test_remote_info_priority() {
216        let github_origin = RemoteInfo {
217            name: "origin".to_string(),
218            url: "https://github.com/owner/repo".to_string(),
219            kind: RemoteKind::Origin,
220            host: Some(RemoteHost::GitHub),
221        };
222
223        let gitlab_origin = RemoteInfo {
224            name: "origin".to_string(),
225            url: "https://gitlab.com/owner/repo".to_string(),
226            kind: RemoteKind::Origin,
227            host: Some(RemoteHost::GitLab),
228        };
229
230        let github_upstream = RemoteInfo {
231            name: "upstream".to_string(),
232            url: "https://github.com/owner/repo".to_string(),
233            kind: RemoteKind::Upstream,
234            host: Some(RemoteHost::GitHub),
235        };
236
237        // Upstream beats origin (canonical repo in fork workflows)
238        assert!(github_upstream.priority() < github_origin.priority());
239
240        // Within same kind, GitHub beats non-GitHub
241        assert!(github_origin.priority() < gitlab_origin.priority());
242
243        // GitHub upstream beats non-GitHub origin
244        assert!(github_upstream.priority() < gitlab_origin.priority());
245    }
246}