Skip to main content

use_git_attribute/
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 attribute vocabulary.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitAttributeParseError {
10    /// The supplied attribute name was empty.
11    EmptyName,
12    /// The supplied attribute name used syntax this crate rejects.
13    InvalidName,
14    /// The supplied attribute value was empty.
15    EmptyValue,
16    /// The supplied rule pattern was empty.
17    EmptyPattern,
18    /// The supplied state label was not recognized.
19    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/// A validated attribute name.
37#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
38pub struct GitAttributeName(String);
39
40impl GitAttributeName {
41    /// Creates an attribute name from text.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`GitAttributeParseError`] when the name is empty or invalid.
46    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    /// Returns the attribute name text.
62    #[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/// A plain attribute value.
89#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
90pub struct GitAttributeValue(String);
91
92impl GitAttributeValue {
93    /// Creates an attribute value.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`GitAttributeParseError::EmptyValue`] when the value is empty.
98    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    /// Returns the value text.
108    #[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/// Attribute state vocabulary.
127#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
128pub enum GitAttributeState {
129    /// Attribute is set.
130    Set,
131    /// Attribute is unset.
132    Unset,
133    /// Attribute has an explicit value.
134    Value(GitAttributeValue),
135    /// Attribute is explicitly unspecified.
136    Unspecified,
137}
138
139impl GitAttributeState {
140    /// Returns true when the state is set.
141    #[must_use]
142    pub const fn is_set(&self) -> bool {
143        matches!(self, Self::Set)
144    }
145
146    /// Returns true when the state is unset.
147    #[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/// A `gitattributes`-style rule model.
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub struct GitAttributeRule {
181    pattern: String,
182    attributes: Vec<(GitAttributeName, GitAttributeState)>,
183}
184
185impl GitAttributeRule {
186    /// Creates a pattern plus attribute states.
187    ///
188    /// # Errors
189    ///
190    /// Returns [`GitAttributeParseError::EmptyPattern`] when the pattern is empty.
191    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    /// Returns the rule pattern.
207    #[must_use]
208    pub fn pattern(&self) -> &str {
209        &self.pattern
210    }
211
212    /// Returns the attribute states.
213    #[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}