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 GitAttributeParseError {
10 EmptyName,
12 InvalidName,
14 EmptyValue,
16 EmptyPattern,
18 UnknownState,
20}
21
22impl fmt::Display for GitAttributeParseError {
23 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
24 match self {
25 Self::EmptyName => formatter.write_str("Git attribute name cannot be empty"),
26 Self::InvalidName => formatter.write_str("invalid Git attribute name"),
27 Self::EmptyValue => formatter.write_str("Git attribute value cannot be empty"),
28 Self::EmptyPattern => formatter.write_str("Git attribute rule pattern cannot be empty"),
29 Self::UnknownState => formatter.write_str("unknown Git attribute state"),
30 }
31 }
32}
33
34impl Error for GitAttributeParseError {}
35
36#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub struct GitAttributeName(String);
39
40impl GitAttributeName {
41 pub fn new(value: impl AsRef<str>) -> Result<Self, GitAttributeParseError> {
47 let trimmed = value.as_ref().trim();
48 if trimmed.is_empty() {
49 return Err(GitAttributeParseError::EmptyName);
50 }
51 if trimmed.chars().any(|character| {
52 character.is_ascii_control()
53 || character.is_ascii_whitespace()
54 || matches!(character, '=' | '-' | '!')
55 }) {
56 return Err(GitAttributeParseError::InvalidName);
57 }
58 Ok(Self(trimmed.to_string()))
59 }
60
61 #[must_use]
63 pub fn as_str(&self) -> &str {
64 &self.0
65 }
66}
67
68impl AsRef<str> for GitAttributeName {
69 fn as_ref(&self) -> &str {
70 self.as_str()
71 }
72}
73
74impl fmt::Display for GitAttributeName {
75 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76 formatter.write_str(self.as_str())
77 }
78}
79
80impl FromStr for GitAttributeName {
81 type Err = GitAttributeParseError;
82
83 fn from_str(value: &str) -> Result<Self, Self::Err> {
84 Self::new(value)
85 }
86}
87
88#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
90pub struct GitAttributeValue(String);
91
92impl GitAttributeValue {
93 pub fn new(value: impl AsRef<str>) -> Result<Self, GitAttributeParseError> {
99 let trimmed = value.as_ref().trim();
100 if trimmed.is_empty() {
101 Err(GitAttributeParseError::EmptyValue)
102 } else {
103 Ok(Self(trimmed.to_string()))
104 }
105 }
106
107 #[must_use]
109 pub fn as_str(&self) -> &str {
110 &self.0
111 }
112}
113
114impl AsRef<str> for GitAttributeValue {
115 fn as_ref(&self) -> &str {
116 self.as_str()
117 }
118}
119
120impl fmt::Display for GitAttributeValue {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 formatter.write_str(self.as_str())
123 }
124}
125
126#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
128pub enum GitAttributeState {
129 Set,
131 Unset,
133 Value(GitAttributeValue),
135 Unspecified,
137}
138
139impl GitAttributeState {
140 #[must_use]
142 pub const fn is_set(&self) -> bool {
143 matches!(self, Self::Set)
144 }
145
146 #[must_use]
148 pub const fn is_unset(&self) -> bool {
149 matches!(self, Self::Unset)
150 }
151}
152
153impl fmt::Display for GitAttributeState {
154 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155 match self {
156 Self::Set => formatter.write_str("set"),
157 Self::Unset => formatter.write_str("unset"),
158 Self::Value(value) => formatter.write_str(value.as_str()),
159 Self::Unspecified => formatter.write_str("unspecified"),
160 }
161 }
162}
163
164impl FromStr for GitAttributeState {
165 type Err = GitAttributeParseError;
166
167 fn from_str(value: &str) -> Result<Self, Self::Err> {
168 match value.trim().to_ascii_lowercase().as_str() {
169 "set" => Ok(Self::Set),
170 "unset" => Ok(Self::Unset),
171 "unspecified" => Ok(Self::Unspecified),
172 "" => Err(GitAttributeParseError::EmptyValue),
173 _ => GitAttributeValue::new(value).map(Self::Value),
174 }
175 }
176}
177
178#[derive(Clone, Debug, Eq, PartialEq)]
180pub struct GitAttributeRule {
181 pattern: String,
182 attributes: Vec<(GitAttributeName, GitAttributeState)>,
183}
184
185impl GitAttributeRule {
186 pub fn new<I>(pattern: impl AsRef<str>, attributes: I) -> Result<Self, GitAttributeParseError>
192 where
193 I: IntoIterator<Item = (GitAttributeName, GitAttributeState)>,
194 {
195 let pattern = pattern.as_ref().trim();
196 if pattern.is_empty() {
197 return Err(GitAttributeParseError::EmptyPattern);
198 }
199
200 Ok(Self {
201 pattern: pattern.to_string(),
202 attributes: attributes.into_iter().collect(),
203 })
204 }
205
206 #[must_use]
208 pub fn pattern(&self) -> &str {
209 &self.pattern
210 }
211
212 #[must_use]
214 pub fn attributes(&self) -> &[(GitAttributeName, GitAttributeState)] {
215 &self.attributes
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::{
222 GitAttributeName, GitAttributeParseError, GitAttributeRule, GitAttributeState,
223 GitAttributeValue,
224 };
225
226 #[test]
227 fn models_attribute_rules() -> Result<(), GitAttributeParseError> {
228 let name = GitAttributeName::new("text")?;
229 let state = GitAttributeState::Set;
230 let rule = GitAttributeRule::new("*.rs", [(name, state)])?;
231
232 assert_eq!(rule.pattern(), "*.rs");
233 assert_eq!(rule.attributes().len(), 1);
234 Ok(())
235 }
236
237 #[test]
238 fn models_attribute_values() -> Result<(), GitAttributeParseError> {
239 let value = GitAttributeValue::new("diff=rust")?;
240 let state = GitAttributeState::Value(value);
241
242 assert_eq!(state.to_string(), "diff=rust");
243 Ok(())
244 }
245
246 #[test]
247 fn rejects_invalid_attribute_names() {
248 assert_eq!(
249 GitAttributeName::new(""),
250 Err(GitAttributeParseError::EmptyName)
251 );
252 assert_eq!(
253 GitAttributeName::new("bad name"),
254 Err(GitAttributeParseError::InvalidName)
255 );
256 }
257}