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 GitIgnoreParseError {
10 EmptyPattern,
12 UnknownScope,
14}
15
16impl fmt::Display for GitIgnoreParseError {
17 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
18 match self {
19 Self::EmptyPattern => formatter.write_str("Git ignore pattern cannot be empty"),
20 Self::UnknownScope => formatter.write_str("unknown Git ignore scope"),
21 }
22 }
23}
24
25impl Error for GitIgnoreParseError {}
26
27#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub enum GitIgnoreNegation {
30 Normal,
32 Negated,
34}
35
36impl GitIgnoreNegation {
37 #[must_use]
39 pub const fn is_negated(self) -> bool {
40 matches!(self, Self::Negated)
41 }
42}
43
44#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub enum GitIgnoreScope {
47 Blank,
49 Comment,
51 Pattern,
53 Directory,
55}
56
57impl GitIgnoreScope {
58 #[must_use]
60 pub const fn as_str(self) -> &'static str {
61 match self {
62 Self::Blank => "blank",
63 Self::Comment => "comment",
64 Self::Pattern => "pattern",
65 Self::Directory => "directory",
66 }
67 }
68}
69
70impl fmt::Display for GitIgnoreScope {
71 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72 formatter.write_str(self.as_str())
73 }
74}
75
76impl FromStr for GitIgnoreScope {
77 type Err = GitIgnoreParseError;
78
79 fn from_str(value: &str) -> Result<Self, Self::Err> {
80 match value.trim().to_ascii_lowercase().as_str() {
81 "blank" => Ok(Self::Blank),
82 "comment" => Ok(Self::Comment),
83 "pattern" => Ok(Self::Pattern),
84 "directory" | "dir" => Ok(Self::Directory),
85 _ => Err(GitIgnoreParseError::UnknownScope),
86 }
87 }
88}
89
90#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92pub struct GitIgnorePattern(String);
93
94impl GitIgnorePattern {
95 pub fn new(value: impl AsRef<str>) -> Result<Self, GitIgnoreParseError> {
101 let trimmed = value.as_ref().trim();
102 if trimmed.is_empty() {
103 Err(GitIgnoreParseError::EmptyPattern)
104 } else {
105 Ok(Self(trimmed.to_string()))
106 }
107 }
108
109 #[must_use]
111 pub fn as_str(&self) -> &str {
112 &self.0
113 }
114}
115
116impl AsRef<str> for GitIgnorePattern {
117 fn as_ref(&self) -> &str {
118 self.as_str()
119 }
120}
121
122impl fmt::Display for GitIgnorePattern {
123 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124 formatter.write_str(self.as_str())
125 }
126}
127
128#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct GitIgnoreRule {
131 original: String,
132 pattern: Option<GitIgnorePattern>,
133 negation: GitIgnoreNegation,
134 scope: GitIgnoreScope,
135}
136
137impl GitIgnoreRule {
138 pub fn parse(value: impl AsRef<str>) -> Result<Self, GitIgnoreParseError> {
144 let original = value.as_ref().to_string();
145 let trimmed = value.as_ref().trim();
146
147 if trimmed.is_empty() {
148 return Ok(Self {
149 original,
150 pattern: None,
151 negation: GitIgnoreNegation::Normal,
152 scope: GitIgnoreScope::Blank,
153 });
154 }
155
156 if trimmed.starts_with('#') {
157 return Ok(Self {
158 original,
159 pattern: None,
160 negation: GitIgnoreNegation::Normal,
161 scope: GitIgnoreScope::Comment,
162 });
163 }
164
165 let (negation, pattern_text) = trimmed
166 .strip_prefix('!')
167 .map_or((GitIgnoreNegation::Normal, trimmed), |rest| {
168 (GitIgnoreNegation::Negated, rest)
169 });
170 let pattern = GitIgnorePattern::new(pattern_text)?;
171 let scope = if pattern.as_str().ends_with('/') {
172 GitIgnoreScope::Directory
173 } else {
174 GitIgnoreScope::Pattern
175 };
176
177 Ok(Self {
178 original,
179 pattern: Some(pattern),
180 negation,
181 scope,
182 })
183 }
184
185 #[must_use]
187 pub fn original(&self) -> &str {
188 &self.original
189 }
190
191 #[must_use]
193 pub const fn pattern(&self) -> Option<&GitIgnorePattern> {
194 self.pattern.as_ref()
195 }
196
197 #[must_use]
199 pub const fn negation(&self) -> GitIgnoreNegation {
200 self.negation
201 }
202
203 #[must_use]
205 pub const fn scope(&self) -> GitIgnoreScope {
206 self.scope
207 }
208
209 #[must_use]
211 pub const fn is_comment(&self) -> bool {
212 matches!(self.scope, GitIgnoreScope::Comment)
213 }
214
215 #[must_use]
217 pub const fn is_blank(&self) -> bool {
218 matches!(self.scope, GitIgnoreScope::Blank)
219 }
220
221 #[must_use]
223 pub const fn is_directory_only(&self) -> bool {
224 matches!(self.scope, GitIgnoreScope::Directory)
225 }
226}
227
228impl FromStr for GitIgnoreRule {
229 type Err = GitIgnoreParseError;
230
231 fn from_str(value: &str) -> Result<Self, Self::Err> {
232 Self::parse(value)
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::{GitIgnoreNegation, GitIgnoreParseError, GitIgnoreRule, GitIgnoreScope};
239
240 #[test]
241 fn classifies_ignore_rules() -> Result<(), GitIgnoreParseError> {
242 let comment = GitIgnoreRule::parse("# target files")?;
243 let blank = GitIgnoreRule::parse(" ")?;
244 let rule = GitIgnoreRule::parse("!target/")?;
245
246 assert!(comment.is_comment());
247 assert!(blank.is_blank());
248 assert_eq!(rule.negation(), GitIgnoreNegation::Negated);
249 assert_eq!(rule.scope(), GitIgnoreScope::Directory);
250 assert!(rule.is_directory_only());
251 Ok(())
252 }
253
254 #[test]
255 fn rejects_empty_negated_pattern() {
256 assert_eq!(
257 GitIgnoreRule::parse("!"),
258 Err(GitIgnoreParseError::EmptyPattern)
259 );
260 }
261}