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 DockerIgnoreParseError {
10 EmptyPattern,
12 UnknownScope,
14}
15
16impl fmt::Display for DockerIgnoreParseError {
17 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
18 match self {
19 Self::EmptyPattern => formatter.write_str("Docker ignore pattern cannot be empty"),
20 Self::UnknownScope => formatter.write_str("unknown Docker ignore scope"),
21 }
22 }
23}
24
25impl Error for DockerIgnoreParseError {}
26
27#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub enum DockerIgnoreNegation {
30 Normal,
32 Negated,
34}
35
36impl DockerIgnoreNegation {
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 DockerIgnoreScope {
47 Blank,
49 Comment,
51 Pattern,
53 Directory,
55}
56
57impl DockerIgnoreScope {
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 DockerIgnoreScope {
71 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72 formatter.write_str(self.as_str())
73 }
74}
75
76impl FromStr for DockerIgnoreScope {
77 type Err = DockerIgnoreParseError;
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(DockerIgnoreParseError::UnknownScope),
86 }
87 }
88}
89
90#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92pub struct DockerIgnorePattern(String);
93
94impl DockerIgnorePattern {
95 pub fn new(value: impl AsRef<str>) -> Result<Self, DockerIgnoreParseError> {
97 let trimmed = value.as_ref().trim();
98 if trimmed.is_empty() {
99 Err(DockerIgnoreParseError::EmptyPattern)
100 } else {
101 Ok(Self(trimmed.to_string()))
102 }
103 }
104
105 #[must_use]
107 pub fn as_str(&self) -> &str {
108 &self.0
109 }
110}
111
112impl AsRef<str> for DockerIgnorePattern {
113 fn as_ref(&self) -> &str {
114 self.as_str()
115 }
116}
117
118impl fmt::Display for DockerIgnorePattern {
119 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120 formatter.write_str(self.as_str())
121 }
122}
123
124#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct DockerIgnoreRule {
127 original: String,
128 pattern: Option<DockerIgnorePattern>,
129 negation: DockerIgnoreNegation,
130 scope: DockerIgnoreScope,
131}
132
133impl DockerIgnoreRule {
134 pub fn parse(value: impl AsRef<str>) -> Result<Self, DockerIgnoreParseError> {
136 let original = value.as_ref().to_string();
137 let trimmed = value.as_ref().trim();
138
139 if trimmed.is_empty() {
140 return Ok(Self {
141 original,
142 pattern: None,
143 negation: DockerIgnoreNegation::Normal,
144 scope: DockerIgnoreScope::Blank,
145 });
146 }
147 if trimmed.starts_with('#') {
148 return Ok(Self {
149 original,
150 pattern: None,
151 negation: DockerIgnoreNegation::Normal,
152 scope: DockerIgnoreScope::Comment,
153 });
154 }
155
156 let (negation, pattern_text) = trimmed
157 .strip_prefix('!')
158 .map_or((DockerIgnoreNegation::Normal, trimmed), |rest| {
159 (DockerIgnoreNegation::Negated, rest)
160 });
161 let pattern = DockerIgnorePattern::new(pattern_text)?;
162 let scope = if pattern.as_str().ends_with('/') {
163 DockerIgnoreScope::Directory
164 } else {
165 DockerIgnoreScope::Pattern
166 };
167
168 Ok(Self {
169 original,
170 pattern: Some(pattern),
171 negation,
172 scope,
173 })
174 }
175
176 #[must_use]
178 pub fn original(&self) -> &str {
179 &self.original
180 }
181
182 #[must_use]
184 pub const fn pattern(&self) -> Option<&DockerIgnorePattern> {
185 self.pattern.as_ref()
186 }
187
188 #[must_use]
190 pub const fn negation(&self) -> DockerIgnoreNegation {
191 self.negation
192 }
193
194 #[must_use]
196 pub const fn scope(&self) -> DockerIgnoreScope {
197 self.scope
198 }
199
200 #[must_use]
202 pub const fn is_comment(&self) -> bool {
203 matches!(self.scope, DockerIgnoreScope::Comment)
204 }
205
206 #[must_use]
208 pub const fn is_blank(&self) -> bool {
209 matches!(self.scope, DockerIgnoreScope::Blank)
210 }
211
212 #[must_use]
214 pub const fn is_directory_only(&self) -> bool {
215 matches!(self.scope, DockerIgnoreScope::Directory)
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::{DockerIgnoreNegation, DockerIgnoreRule, DockerIgnoreScope};
222
223 #[test]
224 fn classifies_dockerignore_lines() -> Result<(), Box<dyn std::error::Error>> {
225 let rule = DockerIgnoreRule::parse("!target/")?;
226 let comment = DockerIgnoreRule::parse("# generated files")?;
227
228 assert_eq!(rule.negation(), DockerIgnoreNegation::Negated);
229 assert_eq!(rule.scope(), DockerIgnoreScope::Directory);
230 assert!(rule.is_directory_only());
231 assert!(comment.is_comment());
232 Ok(())
233 }
234}