1use std::fmt;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct GitUrl {
22 scheme: String,
24 host: String,
26 port: Option<u16>,
28 owner: String,
30 repo: String,
32 reference: Option<String>,
34 path: Option<String>,
36}
37
38impl GitUrl {
39 pub fn parse(url: &str) -> Result<Self, GitUrlError> {
52 let url = url.trim();
53 if url.is_empty() {
54 return Err(GitUrlError::EmptyUrl);
55 }
56
57 let (url, reference) = if let Some(hash_pos) = url.find('#') {
59 let reference = url[hash_pos + 1..].to_string();
60 (&url[..hash_pos], Some(reference))
61 } else {
62 (url, None)
63 };
64
65 if url.starts_with("git@") {
67 return Self::parse_ssh(url, reference);
68 }
69
70 let scheme_end = url.find("://").ok_or(GitUrlError::InvalidFormat)?;
72 let scheme = url[..scheme_end].to_string();
73 let rest = &url[scheme_end + 3..];
74
75 let slash_pos = rest.find('/').ok_or(GitUrlError::MissingPath)?;
77 let host_part = &rest[..slash_pos];
78 let path_part = &rest[slash_pos + 1..];
79
80 let (host, port) = if let Some(colon_pos) = host_part.find(':') {
82 let host = host_part[..colon_pos].to_string();
83 let port_str = &host_part[colon_pos + 1..];
84 let port = port_str
85 .parse::<u16>()
86 .map_err(|_| GitUrlError::InvalidPort)?;
87 (host, Some(port))
88 } else {
89 (host_part.to_string(), None)
90 };
91
92 let path_part = if path_part.ends_with(".git") {
94 &path_part[..path_part.len() - 4]
95 } else {
96 path_part
97 };
98
99 let parts: Vec<&str> = path_part.split('/').collect();
101 if parts.len() < 2 {
102 return Err(GitUrlError::MissingRepo);
103 }
104
105 let owner = parts[0].to_string();
106 let repo = parts[1].to_string();
107
108 let path = if parts.len() > 2 {
110 Some(parts[2..].join("/"))
111 } else {
112 None
113 };
114
115 Ok(GitUrl {
116 scheme,
117 host,
118 port,
119 owner,
120 repo,
121 reference,
122 path,
123 })
124 }
125
126 fn parse_ssh(url: &str, reference: Option<String>) -> Result<Self, GitUrlError> {
128 let rest = &url[4..]; let colon_pos = rest.find(':').ok_or(GitUrlError::InvalidFormat)?;
131 let host = rest[..colon_pos].to_string();
132 let path_part = &rest[colon_pos + 1..];
133
134 let path_part = if path_part.ends_with(".git") {
136 &path_part[..path_part.len() - 4]
137 } else {
138 path_part
139 };
140
141 let parts: Vec<&str> = path_part.split('/').collect();
143 if parts.len() < 2 {
144 return Err(GitUrlError::MissingRepo);
145 }
146
147 let owner = parts[0].to_string();
148 let repo = parts[1].to_string();
149
150 let path = if parts.len() > 2 {
152 Some(parts[2..].join("/"))
153 } else {
154 None
155 };
156
157 Ok(GitUrl {
158 scheme: "ssh".to_string(),
159 host,
160 port: None,
161 owner,
162 repo,
163 reference,
164 path,
165 })
166 }
167
168 pub fn scheme(&self) -> &str {
170 &self.scheme
171 }
172
173 pub fn host(&self) -> &str {
175 &self.host
176 }
177
178 pub fn port(&self) -> Option<u16> {
180 self.port
181 }
182
183 pub fn owner(&self) -> &str {
185 &self.owner
186 }
187
188 pub fn repo(&self) -> &str {
190 &self.repo
191 }
192
193 pub fn reference(&self) -> Option<&str> {
195 self.reference.as_deref()
196 }
197
198 pub fn path(&self) -> Option<&str> {
200 self.path.as_deref()
201 }
202
203 pub fn to_https_url(&self) -> String {
205 let mut url = format!("https://{}/{}", self.host, self.owner);
206 url.push('/');
207 url.push_str(&self.repo);
208
209 if let Some(port) = self.port {
210 url = format!(
211 "https://{}:{}/{}/{}",
212 self.host, port, self.owner, self.repo
213 );
214 }
215
216 if let Some(ref reference) = self.reference {
217 url.push('#');
218 url.push_str(reference);
219 }
220
221 url
222 }
223
224 pub fn to_ssh_url(&self) -> String {
226 let mut url = format!("git@{}:{}/{}", self.host, self.owner, self.repo);
227
228 if let Some(ref reference) = self.reference {
229 url.push('#');
230 url.push_str(reference);
231 }
232
233 url
234 }
235
236 pub fn to_git_url(&self) -> String {
238 let mut url = format!("git://{}/{}/{}", self.host, self.owner, self.repo);
239
240 if let Some(ref reference) = self.reference {
241 url.push('#');
242 url.push_str(reference);
243 }
244
245 url
246 }
247
248 pub fn is_github(&self) -> bool {
250 self.host.contains("github.com")
251 }
252
253 pub fn is_gitlab(&self) -> bool {
255 self.host.contains("gitlab.com") || self.host.contains("gitlab")
256 }
257
258 pub fn is_bitbucket(&self) -> bool {
260 self.host.contains("bitbucket.org")
261 }
262}
263
264impl fmt::Display for GitUrl {
265 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266 match self.scheme.as_str() {
267 "ssh" => write!(f, "{}", self.to_ssh_url()),
268 "git" => write!(f, "{}", self.to_git_url()),
269 _ => write!(f, "{}", self.to_https_url()),
270 }
271 }
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum GitUrlError {
277 EmptyUrl,
279 InvalidFormat,
281 MissingPath,
283 MissingRepo,
285 InvalidPort,
287}
288
289impl fmt::Display for GitUrlError {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 match self {
292 GitUrlError::EmptyUrl => write!(f, "URL is empty"),
293 GitUrlError::InvalidFormat => write!(f, "Invalid URL format"),
294 GitUrlError::MissingPath => write!(f, "Missing repository path"),
295 GitUrlError::MissingRepo => write!(f, "Missing repository name"),
296 GitUrlError::InvalidPort => write!(f, "Invalid port number"),
297 }
298 }
299}
300
301impl std::error::Error for GitUrlError {}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn test_parse_https_url() {
309 let url = GitUrl::parse("https://github.com/user/repo.git").unwrap();
310 assert_eq!(url.scheme(), "https");
311 assert_eq!(url.host(), "github.com");
312 assert_eq!(url.owner(), "user");
313 assert_eq!(url.repo(), "repo");
314 assert_eq!(url.port(), None);
315 assert_eq!(url.reference(), None);
316 }
317
318 #[test]
319 fn test_parse_ssh_url() {
320 let url = GitUrl::parse("git@github.com:user/repo.git").unwrap();
321 assert_eq!(url.scheme(), "ssh");
322 assert_eq!(url.host(), "github.com");
323 assert_eq!(url.owner(), "user");
324 assert_eq!(url.repo(), "repo");
325 }
326
327 #[test]
328 fn test_parse_with_branch() {
329 let url = GitUrl::parse("https://github.com/user/repo.git#main").unwrap();
330 assert_eq!(url.reference(), Some("main"));
331 }
332
333 #[test]
334 fn test_parse_with_port() {
335 let url = GitUrl::parse("https://gitlab.example.com:8443/user/repo.git").unwrap();
336 assert_eq!(url.host(), "gitlab.example.com");
337 assert_eq!(url.port(), Some(8443));
338 }
339
340 #[test]
341 fn test_to_https_url() {
342 let url = GitUrl::parse("git@github.com:user/repo.git").unwrap();
343 assert_eq!(url.to_https_url(), "https://github.com/user/repo");
344 }
345
346 #[test]
347 fn test_to_ssh_url() {
348 let url = GitUrl::parse("https://github.com/user/repo.git").unwrap();
349 assert_eq!(url.to_ssh_url(), "git@github.com:user/repo");
350 }
351
352 #[test]
353 fn test_is_github() {
354 let url = GitUrl::parse("https://github.com/user/repo.git").unwrap();
355 assert!(url.is_github());
356 assert!(!url.is_gitlab());
357 }
358
359 #[test]
360 fn test_invalid_url() {
361 assert_eq!(GitUrl::parse(""), Err(GitUrlError::EmptyUrl));
362 assert_eq!(GitUrl::parse("not-a-url"), Err(GitUrlError::InvalidFormat));
363 }
364}