cfgmatic-files 2.2.0

Configuration file discovery and reading with multiple format support
Documentation
//! Configuration file finder.

use crate::Format;
use crate::error::Result;
use crate::file::{ConfigFile, ConfigFiles};
use cfgmatic_paths::{ConfigTier, PathFinder, PathsBuilder};
use std::fs;
use std::path::{Path, PathBuf};

/// Builder for configuring file search.
///
/// # Example
///
/// ```
/// use cfgmatic_files::FileFinder;
///
/// let finder = FileFinder::new("myapp")
///     .formats(&[cfgmatic_files::Format::Toml, cfgmatic_files::Format::Json])
///     .base_name("config")
///     .require_existence(true);
/// ```
#[derive(Debug, Clone)]
pub struct FileFinder {
    app_name: String,
    formats: Vec<Format>,
    base_name: Option<String>,
    require_existence: bool,
    follow_symlinks: bool,
    search_depth: usize,
}

impl FileFinder {
    /// Create a new file finder for the given application.
    pub fn new(app_name: impl Into<String>) -> Self {
        Self {
            app_name: app_name.into(),
            formats: vec![Format::Toml, Format::Json],
            base_name: Some("config".to_string()),
            require_existence: true,
            follow_symlinks: false,
            search_depth: 3,
        }
    }

    /// Set the file formats to search for.
    #[must_use]
    pub fn formats(mut self, formats: &[Format]) -> Self {
        self.formats = formats.to_vec();
        self
    }

    /// Set the base name for config files.
    ///
    /// For example, if `base_name` is "app", files like "app.toml", "app.json" will be searched.
    #[must_use]
    pub fn base_name(mut self, name: impl Into<String>) -> Self {
        self.base_name = Some(name.into());
        self
    }

    /// Clear the base name, searching for any file with supported extensions.
    #[must_use]
    pub fn any_name(mut self) -> Self {
        self.base_name = None;
        self
    }

    /// Require that files exist (default: true).
    #[must_use]
    pub const fn require_existence(mut self, require: bool) -> Self {
        self.require_existence = require;
        self
    }

    /// Follow symbolic links when searching.
    #[must_use]
    pub const fn follow_symlinks(mut self, follow: bool) -> Self {
        self.follow_symlinks = follow;
        self
    }

    /// Set the maximum search depth for directory traversal.
    #[must_use]
    pub const fn search_depth(mut self, depth: usize) -> Self {
        self.search_depth = depth;
        self
    }

    /// Build the finder configuration.
    #[must_use]
    pub fn build(self) -> FileFinderState {
        let path_finder = PathsBuilder::new(&self.app_name).build();
        FileFinderState {
            config: self,
            path_finder,
        }
    }

    /// Find all matching configuration files.
    ///
    /// This is a convenience method that builds and searches immediately.
    ///
    /// # Errors
    ///
    /// Returns an error if directories cannot be accessed.
    pub fn find(self) -> Result<ConfigFiles> {
        self.build().find()
    }

    /// Find the first (highest priority) configuration file.
    ///
    /// # Errors
    ///
    /// Returns an error if directories cannot be accessed.
    pub fn find_first(self) -> Result<Option<ConfigFile>> {
        let files = self.find()?;
        Ok(files.first().cloned())
    }
}

/// Stateful file finder after configuration.
pub struct FileFinderState {
    config: FileFinder,
    path_finder: PathFinder,
}

impl FileFinderState {
    /// Find all matching configuration files.
    ///
    /// # Errors
    ///
    /// Returns an error if directories cannot be accessed.
    pub fn find(&self) -> Result<ConfigFiles> {
        let mut files = ConfigFiles::new();

        // Search in all tiers
        self.search_in_tier(&mut files, ConfigTier::User, self.path_finder.user_dirs())?;
        self.search_in_tier(&mut files, ConfigTier::Local, self.path_finder.local_dirs())?;
        self.search_in_tier(
            &mut files,
            ConfigTier::System,
            self.path_finder.system_dirs(),
        )?;

        Ok(files)
    }

    /// Search for files in directories of a specific tier.
    fn search_in_tier(
        &self,
        files: &mut ConfigFiles,
        tier: ConfigTier,
        dirs: Vec<PathBuf>,
    ) -> Result<()> {
        for dir in dirs {
            if !dir.exists() {
                continue;
            }

            if self.config.base_name.is_some() {
                // Search for specific named files
                self.search_named_files(files, &dir, tier);
            } else {
                // Search for any files with supported extensions
                self.search_any_files(files, &dir, tier, 0)?;
            }
        }
        Ok(())
    }

    /// Search for files with specific names.
    fn search_named_files(&self, files: &mut ConfigFiles, dir: &Path, tier: ConfigTier) {
        let Some(base_name) = self.config.base_name.as_ref() else {
            return;
        };

        for format in &self.config.formats {
            let file_name = format!("{}.{}", base_name, format.extension());
            let path = dir.join(&file_name);

            if self.should_include_file(&path)
                && let Some(file) = ConfigFile::new(path, tier)
            {
                files.push(file);
            }
        }
    }

    /// Search for any files with supported extensions.
    fn search_any_files(
        &self,
        files: &mut ConfigFiles,
        dir: &Path,
        tier: ConfigTier,
        depth: usize,
    ) -> Result<()> {
        if depth > self.config.search_depth {
            return Ok(());
        }

        let Ok(entries) = fs::read_dir(dir) else {
            return Ok(());
        };

        for entry in entries.flatten() {
            let path = entry.path();

            if path.is_file() {
                if let Some(format) = Format::from_path(&path)
                    && self.config.formats.contains(&format)
                    && self.should_include_file(&path)
                    && let Some(file) = ConfigFile::new(path, tier)
                {
                    files.push(file);
                }
            } else if path.is_dir()
                && self.config.follow_symlinks
                && depth < self.config.search_depth
            {
                // Recurse into subdirectories
                self.search_any_files(files, &path, tier, depth + 1)?;
            }
        }

        Ok(())
    }

    /// Check if a file should be included in results.
    fn should_include_file(&self, path: &Path) -> bool {
        if !self.config.require_existence {
            return true;
        }

        let Ok(metadata) = fs::symlink_metadata(path) else {
            return false;
        };

        if metadata.is_symlink() && !self.config.follow_symlinks {
            return false;
        }

        path.exists()
    }
}

/// Find configuration files using a simple API.
///
/// # Example
///
/// ```
/// use cfgmatic_files::find_files;
///
/// let files = find_files("myapp").expect("find config files");
/// for file in files.iter() {
///     println!("Found: {}", file.path.display());
/// }
/// ```
///
/// # Errors
///
/// Returns an error if directories cannot be accessed.
pub fn find_files(app_name: impl Into<String>) -> Result<ConfigFiles> {
    FileFinder::new(app_name).find()
}

/// Find the first configuration file.
///
/// # Errors
///
/// Returns an error if directories cannot be accessed.
pub fn find_first_file(app_name: impl Into<String>) -> Result<Option<ConfigFile>> {
    FileFinder::new(app_name).find_first()
}

/// Load configuration from the first found file.
///
/// # Errors
///
/// Returns an error if directories cannot be accessed or file cannot be parsed.
pub fn load_first<T: serde::de::DeserializeOwned>(
    app_name: impl Into<String>,
) -> Result<Option<T>> {
    match find_first_file(app_name)? {
        Some(mut file) => Ok(Some(file.parse()?)),
        None => Ok(None),
    }
}

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

    #[test]
    fn test_finder_builder() {
        let finder = FileFinder::new("myapp")
            .formats(&[Format::Toml])
            .base_name("app")
            .require_existence(false);

        assert_eq!(finder.formats.len(), 1);
        assert_eq!(finder.base_name, Some("app".to_string()));
        assert!(!finder.require_existence);
    }

    #[test]
    fn test_find_first_nonexistent() -> Result<()> {
        let result = FileFinder::new("nonexistent_app_12345").find_first()?;
        assert!(result.is_none());
        Ok(())
    }
}