cfgmatic-paths 0.1.3

Cross-platform configuration path discovery following XDG and platform conventions
Documentation
//! File pattern definitions for configuration file matching.

use std::path::Path;

/// Pattern for matching configuration files.
///
/// Supports exact filenames, multiple extensions, and glob patterns.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilePattern {
    /// Exact filename match.
    Exact(String),

    /// Match file with any of the specified extensions.
    Extensions {
        /// Base filename without extension.
        base: String,
        /// List of extensions to try.
        extensions: Vec<String>,
    },

    /// Glob pattern match (e.g., "*.conf", "**/*.toml").
    Glob(String),

    /// Match any of the provided patterns.
    Any(Vec<Self>),
}

impl FilePattern {
    /// Create an exact filename pattern.
    pub fn exact(name: impl Into<String>) -> Self {
        Self::Exact(name.into())
    }

    /// Create a pattern that matches multiple extensions.
    pub fn extensions(base: impl Into<String>, exts: &[&str]) -> Self {
        Self::Extensions {
            base: base.into(),
            extensions: exts.iter().map(|&s| s.to_string()).collect(),
        }
    }

    /// Create a glob pattern.
    pub fn glob(pattern: impl Into<String>) -> Self {
        Self::Glob(pattern.into())
    }

    /// Check if a path matches this pattern.
    #[must_use]
    pub fn matches(&self, path: &Path) -> bool {
        let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("");

        match self {
            Self::Exact(name) => filename == name,
            Self::Extensions { base, extensions } => extensions
                .iter()
                .any(|ext| filename == format!("{base}.{ext}")),
            Self::Glob(pattern) => glob_match(pattern, filename),
            Self::Any(patterns) => patterns.iter().any(|p| p.matches(path)),
        }
    }

    /// Returns a list of concrete filenames if this pattern represents exact files.
    /// Returns `None` for glob patterns.
    #[must_use]
    pub fn concrete_filenames(&self) -> Option<Vec<String>> {
        match self {
            Self::Exact(name) => Some(vec![name.clone()]),
            Self::Extensions { base, extensions } => Some(
                extensions
                    .iter()
                    .map(|ext| format!("{base}.{ext}"))
                    .collect(),
            ),
            Self::Glob(_) => None,
            Self::Any(patterns) => {
                let mut result = Vec::new();
                for pattern in patterns {
                    if let Some(names) = pattern.concrete_filenames() {
                        result.extend(names);
                    } else {
                        // If any sub-pattern is a glob, return None
                        return None;
                    }
                }
                Some(result)
            }
        }
    }

    /// Check if this pattern contains recursive glob (`**`).
    pub fn has_recursive_glob(&self) -> bool {
        match self {
            Self::Glob(pattern) => pattern.contains("**"),
            Self::Any(patterns) => patterns.iter().any(Self::has_recursive_glob),
            _ => false,
        }
    }
}

impl Default for FilePattern {
    fn default() -> Self {
        Self::exact("config")
    }
}

/// Simple glob matching for basic patterns.
fn glob_match(pattern: &str, text: &str) -> bool {
    // Handle ** (recursive) by treating it as *
    let pattern = pattern.replace("**", "*");

    // Use a simple recursive approach for clarity
    glob_match_inner(&pattern, text)
}

/// Inner recursive glob matching function.
fn glob_match_inner(pattern: &str, text: &str) -> bool {
    let mut pat_chars = pattern.chars();
    let mut text_chars = text.chars();

    while let Some(p) = pat_chars.next() {
        match p {
            '*' => {
                // Try matching 0 or more characters
                let remaining_pattern: String = pat_chars.collect();
                let remaining_text: String = text_chars.clone().collect();

                // Empty pattern with * at end matches everything
                if remaining_pattern.is_empty() {
                    return true;
                }

                // Try consuming 0 to remaining_text.len() characters
                for i in 0..=remaining_text.len() {
                    let suffix = &remaining_text[i..];
                    if glob_match_inner(&remaining_pattern, suffix) {
                        return true;
                    }
                }
                return false;
            }
            '?' => {
                if text_chars.next().is_none() {
                    return false;
                }
            }
            c => {
                if text_chars.next() != Some(c) {
                    return false;
                }
            }
        }
    }

    // Pattern exhausted, text should be too
    text_chars.next().is_none()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_exact_match() {
        let pattern = FilePattern::exact("config.toml");
        assert!(pattern.matches(Path::new("/path/config.toml")));
        assert!(!pattern.matches(Path::new("/path/config.json")));
    }

    #[test]
    fn test_extensions_match() {
        let pattern = FilePattern::extensions("config", &["toml", "json", "yaml"]);
        assert!(pattern.matches(Path::new("/path/config.toml")));
        assert!(pattern.matches(Path::new("/path/config.json")));
        assert!(!pattern.matches(Path::new("/path/config.yml")));
        assert!(!pattern.matches(Path::new("/path/other.toml")));
    }

    #[test]
    fn test_glob_match_simple() {
        let pattern = FilePattern::glob("*.toml");
        assert!(pattern.matches(Path::new("/path/config.toml")));
        assert!(pattern.matches(Path::new("/path/app.toml")));
        assert!(!pattern.matches(Path::new("/path/config.json")));
    }

    #[test]
    fn test_concrete_filenames() {
        let pattern = FilePattern::extensions("config", &["toml", "json"]);
        let names = pattern.concrete_filenames().unwrap();
        assert_eq!(names, vec!["config.toml", "config.json"]);

        let glob = FilePattern::glob("*.toml");
        assert!(glob.concrete_filenames().is_none());
    }

    #[test]
    fn test_has_recursive_glob() {
        assert!(FilePattern::glob("**/*.toml").has_recursive_glob());
        assert!(!FilePattern::glob("*.toml").has_recursive_glob());
        assert!(!FilePattern::exact("config.toml").has_recursive_glob());
    }

    #[test]
    fn test_any_pattern() {
        let pattern = FilePattern::Any(vec![
            FilePattern::exact("config.toml"),
            FilePattern::exact("config.json"),
        ]);
        assert!(pattern.matches(Path::new("/path/config.toml")));
        assert!(pattern.matches(Path::new("/path/config.json")));
        assert!(!pattern.matches(Path::new("/path/config.yaml")));
    }
}