1use url::Url;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7#[non_exhaustive]
8pub enum RemoteHost {
9 GitHub,
10 GitLab,
11 Bitbucket,
12}
13
14impl RemoteHost {
15 #[must_use]
18 pub fn from_url(url: &str) -> Option<Self> {
19 if let Some(host) = Self::parse_ssh_host(url) {
21 return Self::from_host_str(&host);
22 }
23
24 if let Some(host) = Self::parse_http_host(url) {
26 return Self::from_host_str(&host);
27 }
28
29 None
30 }
31
32 fn parse_ssh_host(url: &str) -> Option<String> {
34 let trimmed = url.trim();
35
36 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 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 fn parse_http_host(url: &str) -> Option<String> {
54 if let Ok(parsed) = Url::parse(url) {
56 return parsed.host_str().map(str::to_string);
57 }
58
59 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
89#[non_exhaustive]
90pub enum RemoteKind {
91 Upstream, Origin, Other, }
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#[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 #[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 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 assert!(github_upstream.priority() < github_origin.priority());
239
240 assert!(github_origin.priority() < gitlab_origin.priority());
242
243 assert!(github_upstream.priority() < gitlab_origin.priority());
245 }
246}