Skip to main content

cfgmatic_paths/core/
pattern.rs

1//! File pattern definitions for configuration file matching.
2
3use std::path::Path;
4
5/// Pattern for matching configuration files.
6///
7/// Supports exact filenames, multiple extensions, and glob patterns.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum FilePattern {
10    /// Exact filename match.
11    Exact(String),
12
13    /// Match file with any of the specified extensions.
14    Extensions {
15        /// Base filename without extension.
16        base: String,
17        /// List of extensions to try.
18        extensions: Vec<String>,
19    },
20
21    /// Glob pattern match (e.g., "*.conf", "**/*.toml").
22    Glob(String),
23
24    /// Match any of the provided patterns.
25    Any(Vec<Self>),
26}
27
28impl FilePattern {
29    /// Create an exact filename pattern.
30    pub fn exact(name: impl Into<String>) -> Self {
31        Self::Exact(name.into())
32    }
33
34    /// Create a pattern that matches multiple extensions.
35    pub fn extensions(base: impl Into<String>, exts: &[&str]) -> Self {
36        Self::Extensions {
37            base: base.into(),
38            extensions: exts.iter().map(|&s| s.to_string()).collect(),
39        }
40    }
41
42    /// Create a glob pattern.
43    pub fn glob(pattern: impl Into<String>) -> Self {
44        Self::Glob(pattern.into())
45    }
46
47    /// Check if a path matches this pattern.
48    #[must_use]
49    pub fn matches(&self, path: &Path) -> bool {
50        let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
51
52        match self {
53            Self::Exact(name) => filename == name,
54            Self::Extensions { base, extensions } => extensions
55                .iter()
56                .any(|ext| filename == format!("{base}.{ext}")),
57            Self::Glob(pattern) => glob_match(pattern, filename),
58            Self::Any(patterns) => patterns.iter().any(|p| p.matches(path)),
59        }
60    }
61
62    /// Returns a list of concrete filenames if this pattern represents exact files.
63    /// Returns `None` for glob patterns.
64    #[must_use]
65    pub fn concrete_filenames(&self) -> Option<Vec<String>> {
66        match self {
67            Self::Exact(name) => Some(vec![name.clone()]),
68            Self::Extensions { base, extensions } => Some(
69                extensions
70                    .iter()
71                    .map(|ext| format!("{base}.{ext}"))
72                    .collect(),
73            ),
74            Self::Glob(_) => None,
75            Self::Any(patterns) => {
76                let mut result = Vec::new();
77                for pattern in patterns {
78                    if let Some(names) = pattern.concrete_filenames() {
79                        result.extend(names);
80                    } else {
81                        // If any sub-pattern is a glob, return None
82                        return None;
83                    }
84                }
85                Some(result)
86            }
87        }
88    }
89
90    /// Check if this pattern contains recursive glob (`**`).
91    pub fn has_recursive_glob(&self) -> bool {
92        match self {
93            Self::Glob(pattern) => pattern.contains("**"),
94            Self::Any(patterns) => patterns.iter().any(Self::has_recursive_glob),
95            _ => false,
96        }
97    }
98}
99
100impl Default for FilePattern {
101    fn default() -> Self {
102        Self::exact("config")
103    }
104}
105
106/// Simple glob matching for basic patterns.
107fn glob_match(pattern: &str, text: &str) -> bool {
108    // Handle ** (recursive) by treating it as *
109    let pattern = pattern.replace("**", "*");
110
111    // Use a simple recursive approach for clarity
112    glob_match_inner(&pattern, text)
113}
114
115/// Inner recursive glob matching function.
116fn glob_match_inner(pattern: &str, text: &str) -> bool {
117    let mut pat_chars = pattern.chars();
118    let mut text_chars = text.chars();
119
120    while let Some(p) = pat_chars.next() {
121        match p {
122            '*' => {
123                // Try matching 0 or more characters
124                let remaining_pattern: String = pat_chars.collect();
125                let remaining_text: String = text_chars.clone().collect();
126
127                // Empty pattern with * at end matches everything
128                if remaining_pattern.is_empty() {
129                    return true;
130                }
131
132                // Try consuming 0 to remaining_text.len() characters
133                for i in 0..=remaining_text.len() {
134                    let suffix = &remaining_text[i..];
135                    if glob_match_inner(&remaining_pattern, suffix) {
136                        return true;
137                    }
138                }
139                return false;
140            }
141            '?' => {
142                if text_chars.next().is_none() {
143                    return false;
144                }
145            }
146            c => {
147                if text_chars.next() != Some(c) {
148                    return false;
149                }
150            }
151        }
152    }
153
154    // Pattern exhausted, text should be too
155    text_chars.next().is_none()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_exact_match() {
164        let pattern = FilePattern::exact("config.toml");
165        assert!(pattern.matches(Path::new("/path/config.toml")));
166        assert!(!pattern.matches(Path::new("/path/config.json")));
167    }
168
169    #[test]
170    fn test_extensions_match() {
171        let pattern = FilePattern::extensions("config", &["toml", "json", "yaml"]);
172        assert!(pattern.matches(Path::new("/path/config.toml")));
173        assert!(pattern.matches(Path::new("/path/config.json")));
174        assert!(!pattern.matches(Path::new("/path/config.yml")));
175        assert!(!pattern.matches(Path::new("/path/other.toml")));
176    }
177
178    #[test]
179    fn test_glob_match_simple() {
180        let pattern = FilePattern::glob("*.toml");
181        assert!(pattern.matches(Path::new("/path/config.toml")));
182        assert!(pattern.matches(Path::new("/path/app.toml")));
183        assert!(!pattern.matches(Path::new("/path/config.json")));
184    }
185
186    #[test]
187    fn test_concrete_filenames() {
188        let pattern = FilePattern::extensions("config", &["toml", "json"]);
189        let names = pattern.concrete_filenames().unwrap();
190        assert_eq!(names, vec!["config.toml", "config.json"]);
191
192        let glob = FilePattern::glob("*.toml");
193        assert!(glob.concrete_filenames().is_none());
194    }
195
196    #[test]
197    fn test_has_recursive_glob() {
198        assert!(FilePattern::glob("**/*.toml").has_recursive_glob());
199        assert!(!FilePattern::glob("*.toml").has_recursive_glob());
200        assert!(!FilePattern::exact("config.toml").has_recursive_glob());
201    }
202
203    #[test]
204    fn test_any_pattern() {
205        let pattern = FilePattern::Any(vec![
206            FilePattern::exact("config.toml"),
207            FilePattern::exact("config.json"),
208        ]);
209        assert!(pattern.matches(Path::new("/path/config.toml")));
210        assert!(pattern.matches(Path::new("/path/config.json")));
211        assert!(!pattern.matches(Path::new("/path/config.yaml")));
212    }
213}