Skip to main content

use_git_remote/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned while parsing remote vocabulary.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitRemoteNameError {
10    /// The supplied remote text was empty.
11    Empty,
12    /// The supplied remote text used syntax this crate rejects.
13    InvalidName,
14    /// The supplied remote-tracking ref missed a remote or branch part.
15    MissingRemoteOrBranch,
16    /// The supplied remote kind label was not recognized.
17    UnknownKind,
18}
19
20impl fmt::Display for GitRemoteNameError {
21    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
22        match self {
23            Self::Empty => formatter.write_str("Git remote name cannot be empty"),
24            Self::InvalidName => formatter.write_str("invalid Git remote name"),
25            Self::MissingRemoteOrBranch => {
26                formatter.write_str("remote-tracking ref must contain remote and branch names")
27            },
28            Self::UnknownKind => formatter.write_str("unknown Git remote kind"),
29        }
30    }
31}
32
33impl Error for GitRemoteNameError {}
34
35fn validate_remote_name(value: impl AsRef<str>) -> Result<String, GitRemoteNameError> {
36    let trimmed = value.as_ref().trim();
37
38    if trimmed.is_empty() {
39        return Err(GitRemoteNameError::Empty);
40    }
41
42    let invalid = trimmed.contains('/')
43        || trimmed.starts_with('.')
44        || trimmed.ends_with('.')
45        || trimmed.contains("..")
46        || trimmed.chars().any(|character| {
47            character.is_ascii_control()
48                || character.is_ascii_whitespace()
49                || matches!(character, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
50        });
51
52    if invalid {
53        Err(GitRemoteNameError::InvalidName)
54    } else {
55        Ok(trimmed.to_string())
56    }
57}
58
59/// A validated remote name.
60#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
61pub struct GitRemoteName(String);
62
63impl GitRemoteName {
64    /// Creates a remote name from text.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`GitRemoteNameError`] when the remote name is empty or invalid.
69    pub fn new(value: impl AsRef<str>) -> Result<Self, GitRemoteNameError> {
70        validate_remote_name(value).map(Self)
71    }
72
73    /// Returns the conventional `origin` remote name.
74    #[must_use]
75    pub fn origin() -> Self {
76        Self(String::from("origin"))
77    }
78
79    /// Returns the conventional `upstream` remote name.
80    #[must_use]
81    pub fn upstream() -> Self {
82        Self(String::from("upstream"))
83    }
84
85    /// Returns true when this is `origin`.
86    #[must_use]
87    pub fn is_origin(&self) -> bool {
88        self.as_str() == "origin"
89    }
90
91    /// Returns true when this is `upstream`.
92    #[must_use]
93    pub fn is_upstream(&self) -> bool {
94        self.as_str() == "upstream"
95    }
96
97    /// Returns the remote name text.
98    #[must_use]
99    pub fn as_str(&self) -> &str {
100        &self.0
101    }
102}
103
104impl AsRef<str> for GitRemoteName {
105    fn as_ref(&self) -> &str {
106        self.as_str()
107    }
108}
109
110impl fmt::Display for GitRemoteName {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        formatter.write_str(self.as_str())
113    }
114}
115
116impl FromStr for GitRemoteName {
117    type Err = GitRemoteNameError;
118
119    fn from_str(value: &str) -> Result<Self, Self::Err> {
120        Self::new(value)
121    }
122}
123
124impl TryFrom<&str> for GitRemoteName {
125    type Error = GitRemoteNameError;
126
127    fn try_from(value: &str) -> Result<Self, Self::Error> {
128        Self::new(value)
129    }
130}
131
132/// Remote kind vocabulary.
133#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
134pub enum GitRemoteKind {
135    /// The primary `origin` remote label.
136    Origin,
137    /// An `upstream` remote label.
138    Upstream,
139    /// A mirror remote label.
140    Mirror,
141    /// Another remote role.
142    Other,
143}
144
145impl GitRemoteKind {
146    /// Returns the stable kind label.
147    #[must_use]
148    pub const fn as_str(self) -> &'static str {
149        match self {
150            Self::Origin => "origin",
151            Self::Upstream => "upstream",
152            Self::Mirror => "mirror",
153            Self::Other => "other",
154        }
155    }
156}
157
158impl fmt::Display for GitRemoteKind {
159    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
160        formatter.write_str(self.as_str())
161    }
162}
163
164impl FromStr for GitRemoteKind {
165    type Err = GitRemoteNameError;
166
167    fn from_str(value: &str) -> Result<Self, Self::Err> {
168        match value.trim().to_ascii_lowercase().as_str() {
169            "origin" => Ok(Self::Origin),
170            "upstream" => Ok(Self::Upstream),
171            "mirror" => Ok(Self::Mirror),
172            "other" => Ok(Self::Other),
173            "" => Err(GitRemoteNameError::Empty),
174            _ => Err(GitRemoteNameError::UnknownKind),
175        }
176    }
177}
178
179/// A remote ref name.
180#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
181pub struct RemoteRefName(String);
182
183impl RemoteRefName {
184    /// Creates a remote ref name from text.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`GitRemoteNameError`] when the remote ref is empty or invalid.
189    pub fn new(value: impl AsRef<str>) -> Result<Self, GitRemoteNameError> {
190        let trimmed = value.as_ref().trim();
191        if trimmed.is_empty() {
192            return Err(GitRemoteNameError::Empty);
193        }
194        if trimmed.contains(char::is_whitespace) || trimmed.contains("//") {
195            return Err(GitRemoteNameError::InvalidName);
196        }
197        Ok(Self(trimmed.to_string()))
198    }
199
200    /// Returns the remote ref text.
201    #[must_use]
202    pub fn as_str(&self) -> &str {
203        &self.0
204    }
205}
206
207impl fmt::Display for RemoteRefName {
208    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
209        formatter.write_str(self.as_str())
210    }
211}
212
213impl FromStr for RemoteRefName {
214    type Err = GitRemoteNameError;
215
216    fn from_str(value: &str) -> Result<Self, Self::Err> {
217        Self::new(value)
218    }
219}
220
221/// A remote-tracking ref spelling such as `origin/main`.
222#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
223pub struct RemoteTrackingRef(RemoteRefName);
224
225impl RemoteTrackingRef {
226    /// Creates a remote-tracking ref.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`GitRemoteNameError`] when the ref is invalid or missing parts.
231    pub fn new(value: impl AsRef<str>) -> Result<Self, GitRemoteNameError> {
232        let value = value.as_ref().trim();
233        let Some((remote, branch)) = value.split_once('/') else {
234            return Err(GitRemoteNameError::MissingRemoteOrBranch);
235        };
236
237        if remote.is_empty() || branch.is_empty() {
238            return Err(GitRemoteNameError::MissingRemoteOrBranch);
239        }
240
241        validate_remote_name(remote)?;
242        RemoteRefName::new(value).map(Self)
243    }
244
245    /// Returns the remote name portion.
246    #[must_use]
247    pub fn remote(&self) -> Option<&str> {
248        self.0.as_str().split_once('/').map(|(remote, _)| remote)
249    }
250
251    /// Returns the branch ref portion.
252    #[must_use]
253    pub fn branch(&self) -> Option<&str> {
254        self.0.as_str().split_once('/').map(|(_, branch)| branch)
255    }
256
257    /// Returns the remote-tracking ref text.
258    #[must_use]
259    pub fn as_str(&self) -> &str {
260        self.0.as_str()
261    }
262}
263
264impl fmt::Display for RemoteTrackingRef {
265    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
266        formatter.write_str(self.as_str())
267    }
268}
269
270impl FromStr for RemoteTrackingRef {
271    type Err = GitRemoteNameError;
272
273    fn from_str(value: &str) -> Result<Self, Self::Err> {
274        Self::new(value)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::{GitRemoteKind, GitRemoteName, GitRemoteNameError, RemoteTrackingRef};
281
282    #[test]
283    fn models_common_remotes() {
284        let origin = GitRemoteName::origin();
285        let upstream = GitRemoteName::upstream();
286
287        assert!(origin.is_origin());
288        assert!(upstream.is_upstream());
289        assert_eq!(GitRemoteKind::Mirror.to_string(), "mirror");
290    }
291
292    #[test]
293    fn parses_remote_tracking_refs() -> Result<(), GitRemoteNameError> {
294        let tracking = RemoteTrackingRef::new("origin/main")?;
295
296        assert_eq!(tracking.remote(), Some("origin"));
297        assert_eq!(tracking.branch(), Some("main"));
298        Ok(())
299    }
300
301    #[test]
302    fn rejects_invalid_remote_names() {
303        assert_eq!(GitRemoteName::new(""), Err(GitRemoteNameError::Empty));
304        assert_eq!(
305            GitRemoteName::new("origin/main"),
306            Err(GitRemoteNameError::InvalidName)
307        );
308        assert_eq!(
309            RemoteTrackingRef::new("origin"),
310            Err(GitRemoteNameError::MissingRemoteOrBranch)
311        );
312    }
313}