1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitRemoteNameError {
10 Empty,
12 InvalidName,
14 MissingRemoteOrBranch,
16 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
61pub struct GitRemoteName(String);
62
63impl GitRemoteName {
64 pub fn new(value: impl AsRef<str>) -> Result<Self, GitRemoteNameError> {
70 validate_remote_name(value).map(Self)
71 }
72
73 #[must_use]
75 pub fn origin() -> Self {
76 Self(String::from("origin"))
77 }
78
79 #[must_use]
81 pub fn upstream() -> Self {
82 Self(String::from("upstream"))
83 }
84
85 #[must_use]
87 pub fn is_origin(&self) -> bool {
88 self.as_str() == "origin"
89 }
90
91 #[must_use]
93 pub fn is_upstream(&self) -> bool {
94 self.as_str() == "upstream"
95 }
96
97 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
134pub enum GitRemoteKind {
135 Origin,
137 Upstream,
139 Mirror,
141 Other,
143}
144
145impl GitRemoteKind {
146 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
181pub struct RemoteRefName(String);
182
183impl RemoteRefName {
184 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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
223pub struct RemoteTrackingRef(RemoteRefName);
224
225impl RemoteTrackingRef {
226 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 #[must_use]
247 pub fn remote(&self) -> Option<&str> {
248 self.0.as_str().split_once('/').map(|(remote, _)| remote)
249 }
250
251 #[must_use]
253 pub fn branch(&self) -> Option<&str> {
254 self.0.as_str().split_once('/').map(|(_, branch)| branch)
255 }
256
257 #[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}