Skip to main content

use_dockerignore/
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 `.dockerignore` vocabulary.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum DockerIgnoreParseError {
10    /// A pattern-bearing rule had no pattern text.
11    EmptyPattern,
12    /// The supplied scope label was not recognized.
13    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/// Pattern negation vocabulary.
28#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub enum DockerIgnoreNegation {
30    /// A normal ignore pattern.
31    Normal,
32    /// A negated ignore pattern prefixed by `!`.
33    Negated,
34}
35
36impl DockerIgnoreNegation {
37    /// Returns true when the pattern is negated.
38    #[must_use]
39    pub const fn is_negated(self) -> bool {
40        matches!(self, Self::Negated)
41    }
42}
43
44/// Ignore rule classification.
45#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub enum DockerIgnoreScope {
47    /// A blank line.
48    Blank,
49    /// A comment line.
50    Comment,
51    /// A file or path pattern.
52    Pattern,
53    /// A directory-only pattern.
54    Directory,
55}
56
57impl DockerIgnoreScope {
58    /// Returns the stable scope label.
59    #[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/// A non-empty `.dockerignore` pattern.
91#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92pub struct DockerIgnorePattern(String);
93
94impl DockerIgnorePattern {
95    /// Creates an ignore pattern from text.
96    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    /// Returns the pattern text.
106    #[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/// A classified `.dockerignore` rule.
125#[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    /// Parses a `.dockerignore`-style line.
135    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    /// Returns the original line text.
177    #[must_use]
178    pub fn original(&self) -> &str {
179        &self.original
180    }
181
182    /// Returns the pattern when this is a pattern-bearing rule.
183    #[must_use]
184    pub const fn pattern(&self) -> Option<&DockerIgnorePattern> {
185        self.pattern.as_ref()
186    }
187
188    /// Returns the negation marker.
189    #[must_use]
190    pub const fn negation(&self) -> DockerIgnoreNegation {
191        self.negation
192    }
193
194    /// Returns the rule scope.
195    #[must_use]
196    pub const fn scope(&self) -> DockerIgnoreScope {
197        self.scope
198    }
199
200    /// Returns true when this is a comment line.
201    #[must_use]
202    pub const fn is_comment(&self) -> bool {
203        matches!(self.scope, DockerIgnoreScope::Comment)
204    }
205
206    /// Returns true when this is a blank line.
207    #[must_use]
208    pub const fn is_blank(&self) -> bool {
209        matches!(self.scope, DockerIgnoreScope::Blank)
210    }
211
212    /// Returns true when the rule carries a directory-only pattern.
213    #[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}