Skip to main content

rpi_tools/utils/
git.rs

1//! Git URL parsing and manipulation utilities.
2//!
3//! Provides functionality to parse, validate, and manipulate Git repository URLs
4//! in various formats (HTTPS, SSH, git://, etc.).
5//!
6//! # Examples
7//!
8//! ```rust
9//! use rpi_tools::utils::git::GitUrl;
10//!
11//! let url = GitUrl::parse("https://github.com/user/repo.git").unwrap();
12//! assert_eq!(url.host(), "github.com");
13//! assert_eq!(url.owner(), "user");
14//! assert_eq!(url.repo(), "repo");
15//! ```
16
17use std::fmt;
18
19/// Represents a parsed Git URL with its components.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct GitUrl {
22    /// The protocol/scheme (https, ssh, git, etc.)
23    scheme: String,
24    /// The host (e.g., github.com)
25    host: String,
26    /// Optional port number
27    port: Option<u16>,
28    /// The repository owner/namespace
29    owner: String,
30    /// The repository name
31    repo: String,
32    /// Optional branch or tag
33    reference: Option<String>,
34    /// Optional subdirectory path
35    path: Option<String>,
36}
37
38impl GitUrl {
39    /// Parse a Git URL string into a `GitUrl` structure.
40    ///
41    /// Supports various formats:
42    /// - HTTPS: `https://github.com/user/repo.git`
43    /// - SSH: `git@github.com:user/repo.git`
44    /// - Git protocol: `git://github.com/user/repo.git`
45    /// - With branch: `https://github.com/user/repo.git#branch`
46    /// - With subdirectory: `https://github.com/user/repo.git#path=subdir`
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the URL cannot be parsed or is missing required components.
51    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        // Check for reference (branch/tag) suffix
58        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        // Parse SSH format: git@host:owner/repo
66        if url.starts_with("git@") {
67            return Self::parse_ssh(url, reference);
68        }
69
70        // Parse URL format: scheme://host/owner/repo
71        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        // Split host and path
76        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        // Parse host and optional port
81        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        // Remove .git suffix if present
93        let path_part = if path_part.ends_with(".git") {
94            &path_part[..path_part.len() - 4]
95        } else {
96            path_part
97        };
98
99        // Split owner and repo
100        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        // Check for subdirectory path
109        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    /// Parse SSH format Git URL.
127    fn parse_ssh(url: &str, reference: Option<String>) -> Result<Self, GitUrlError> {
128        // Format: git@host:owner/repo
129        let rest = &url[4..]; // Skip "git@"
130        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        // Remove .git suffix if present
135        let path_part = if path_part.ends_with(".git") {
136            &path_part[..path_part.len() - 4]
137        } else {
138            path_part
139        };
140
141        // Split owner and repo
142        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        // Check for subdirectory path
151        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    /// Get the protocol scheme (https, ssh, git, etc.).
169    pub fn scheme(&self) -> &str {
170        &self.scheme
171    }
172
173    /// Get the host (e.g., github.com).
174    pub fn host(&self) -> &str {
175        &self.host
176    }
177
178    /// Get the optional port number.
179    pub fn port(&self) -> Option<u16> {
180        self.port
181    }
182
183    /// Get the repository owner/namespace.
184    pub fn owner(&self) -> &str {
185        &self.owner
186    }
187
188    /// Get the repository name.
189    pub fn repo(&self) -> &str {
190        &self.repo
191    }
192
193    /// Get the optional branch or tag reference.
194    pub fn reference(&self) -> Option<&str> {
195        self.reference.as_deref()
196    }
197
198    /// Get the optional subdirectory path.
199    pub fn path(&self) -> Option<&str> {
200        self.path.as_deref()
201    }
202
203    /// Convert to HTTPS URL format.
204    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    /// Convert to SSH URL format.
225    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    /// Convert to git:// protocol URL format.
237    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    /// Check if this is a GitHub repository.
249    pub fn is_github(&self) -> bool {
250        self.host.contains("github.com")
251    }
252
253    /// Check if this is a GitLab repository.
254    pub fn is_gitlab(&self) -> bool {
255        self.host.contains("gitlab.com") || self.host.contains("gitlab")
256    }
257
258    /// Check if this is a Bitbucket repository.
259    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/// Errors that can occur when parsing a Git URL.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum GitUrlError {
277    /// The URL string is empty.
278    EmptyUrl,
279    /// The URL format is invalid.
280    InvalidFormat,
281    /// The URL is missing the repository path.
282    MissingPath,
283    /// The URL is missing the repository name.
284    MissingRepo,
285    /// The port number is invalid.
286    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}