cfgmatic-files 2.2.0

Configuration file discovery and reading with multiple format support
Documentation
//! Configuration loader with rule-based discovery and merge support.

use crate::{Format, Result};
use cfgmatic_merge::{ArrayMergeStrategy, Merge, MergeBehavior, MergeOptions};
use cfgmatic_paths::{ConfigCandidate, RuleBasedDiscovery};
use serde::de::DeserializeOwned;
use std::path::PathBuf;

/// Configuration loader with rule-based discovery.
///
/// Provides advanced configuration loading with support for:
/// - Rule-based file discovery
/// - Multi-tier configuration merging
/// - Fragment (conf.d) loading
/// - Custom merge strategies
///
/// # Example
///
/// ```
/// use cfgmatic_files::RuleBasedLoader;
/// use cfgmatic_paths::{ConfigRuleSet, ConfigFileRule, TierSearchMode};
/// use cfgmatic_merge::MergeBehavior;
///
/// let rules = ConfigRuleSet::builder()
///     .main_file(ConfigFileRule::toml("config")
///         .tiers(TierSearchMode::All)
///         .required(true))
///     .build();
///
/// let loader = RuleBasedLoader::new("myapp")
///     .rules(rules)
///     .merge_behavior(MergeBehavior::Deep);
///
/// // Load with discovery info
/// match loader.load_with_discovery::<serde_json::Value>() {
///     Ok((config, discovery)) => {
///         println!("Loaded from {} files", discovery.file_count());
///     }
///     Err(e) => eprintln!("Error: {}", e),
/// }
/// ```
#[derive(Debug, Clone)]
pub struct RuleBasedLoader {
    /// Application name.
    app_name: String,

    /// Rule set for discovery (optional).
    rules: Option<cfgmatic_paths::ConfigRuleSet>,

    /// Merge options.
    merge_options: MergeOptions,
}

impl RuleBasedLoader {
    /// Create a new loader for an application.
    #[must_use]
    pub fn new(app_name: impl Into<String>) -> Self {
        Self {
            app_name: app_name.into(),
            rules: None,
            merge_options: MergeOptions::new(),
        }
    }

    /// Set the rule set for file discovery.
    #[must_use]
    pub fn rules(mut self, rules: cfgmatic_paths::ConfigRuleSet) -> Self {
        self.rules = Some(rules);
        self
    }

    /// Set the merge behavior.
    #[must_use]
    pub fn merge_behavior(mut self, behavior: MergeBehavior) -> Self {
        self.merge_options = MergeOptions::new().behavior(behavior);
        self
    }

    /// Set the array merge strategy.
    #[must_use]
    pub const fn array_strategy(mut self, strategy: ArrayMergeStrategy) -> Self {
        self.merge_options = self.merge_options.array_strategy(strategy);
        self
    }

    /// Set full merge options.
    #[must_use]
    pub const fn merge_options(mut self, options: MergeOptions) -> Self {
        self.merge_options = options;
        self
    }

    /// Load configuration with discovery information.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Required files are not found
    /// - Files cannot be read or parsed
    /// - Merge fails in Error mode with conflicts
    ///
    /// # Example
    ///
    /// ```
    /// use cfgmatic_files::RuleBasedLoader;
    ///
    /// let loader = RuleBasedLoader::new("myapp");
    /// match loader.load_with_discovery::<serde_json::Value>() {
    ///     Ok((config, discovery)) => {
    ///         println!("Loaded config: {:#}", config);
    ///         for path in discovery.all_paths() {
    ///             println!("  - {}", path.display());
    ///         }
    ///     }
    ///     Err(e) => eprintln!("Error: {}", e),
    /// }
    /// ```
    pub fn load_with_discovery<T>(&self) -> Result<(T, RuleBasedDiscovery)>
    where
        T: DeserializeOwned + Merge + Default,
    {
        let path_finder = cfgmatic_paths::PathsBuilder::new(&self.app_name).build();
        let discovery = if let Some(rules) = &self.rules {
            path_finder.discover_with_rules(rules)
        } else {
            // Default discovery
            return self.load_default(&path_finder);
        };

        // Check required files
        if let Some(_missing) = discovery.missing_required() {
            return Err(crate::FileError::NotFound {
                pattern: "config".to_string(),
                locations: format!(
                    "searched in: {}",
                    discovery
                        .all_paths()
                        .iter()
                        .map(|p| p.display().to_string())
                        .collect::<Vec<_>>()
                        .join(", ")
                ),
            });
        }

        let config = self.load_from_discovery(&discovery)?;
        Ok((config, discovery))
    }

    /// Load configuration (without discovery info).
    ///
    /// # Errors
    ///
    /// Returns an error if configuration cannot be loaded.
    pub fn load<T>(&self) -> Result<T>
    where
        T: DeserializeOwned + Merge + Default,
    {
        let (config, _) = self.load_with_discovery()?;
        Ok(config)
    }

    /// Load from default discovery (without rules).
    fn load_default<T>(
        &self,
        path_finder: &cfgmatic_paths::PathFinder,
    ) -> Result<(T, RuleBasedDiscovery)>
    where
        T: DeserializeOwned + Merge + Default,
    {
        let candidates = path_finder.find_config_files(&cfgmatic_paths::FilePattern::extensions(
            "config",
            &["toml", "json"],
        ));

        if candidates.is_empty() {
            return Ok((
                T::default(),
                RuleBasedDiscovery {
                    rules: cfgmatic_paths::ConfigRuleSet::new(),
                    main_files: Vec::new(),
                    fragments: Vec::new(),
                },
            ));
        }

        // Convert to ConfigFile and load
        let mut result = T::default();

        for candidate in &candidates {
            if candidate.status.exists()
                && let Some(format) = Format::from_path(&candidate.path)
            {
                let value = Self::parse_file::<T>(&candidate.path, format)?;
                result = Merge::merge(result, value, &self.merge_options).map_err(|e| {
                    crate::FileError::Parse {
                        path: candidate.path.clone(),
                        format: format.extension(),
                        source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
                    }
                })?;
            }
        }

        Ok((
            result,
            RuleBasedDiscovery {
                rules: cfgmatic_paths::ConfigRuleSet::new(),
                main_files: vec![],
                fragments: Vec::new(),
            },
        ))
    }

    /// Load configuration from a discovery result.
    fn load_from_discovery<T>(&self, discovery: &RuleBasedDiscovery) -> Result<T>
    where
        T: DeserializeOwned + Merge + Default,
    {
        let mut result = T::default();

        // Load main files in merge order (lowest tier first)
        let mut main_files: Vec<(&ConfigCandidate, Format)> = Vec::new();
        for candidate in discovery.main_candidates() {
            if let Some(format) = Format::from_path(&candidate.path) {
                main_files.push((candidate, format));
            }
        }

        // Sort by tier (lowest first for merge order)
        main_files.sort_by_key(|(c, _)| u8::from(c.tier));

        // Merge main files
        for (candidate, format) in main_files {
            if candidate.status.exists() {
                let value = Self::parse_file::<T>(&candidate.path, format)?;
                result = Merge::merge(result, value, &self.merge_options).map_err(|e| {
                    crate::FileError::Parse {
                        path: candidate.path.clone(),
                        format: format.extension(),
                        source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
                    }
                })?;
            }
        }

        // Load and merge fragments
        let mut fragments: Vec<(&ConfigCandidate, Format)> = Vec::new();
        for candidate in discovery.fragment_candidates() {
            if let Some(format) = Format::from_path(&candidate.path) {
                fragments.push((candidate, format));
            }
        }

        // Sort fragments by tier (lowest first)
        fragments.sort_by_key(|(c, _)| u8::from(c.tier));

        // Merge fragments
        for (candidate, format) in fragments {
            if candidate.status.exists() {
                let value = Self::parse_file::<T>(&candidate.path, format)?;
                result = Merge::merge(result, value, &self.merge_options).map_err(|e| {
                    crate::FileError::Parse {
                        path: candidate.path.clone(),
                        format: format.extension(),
                        source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
                    }
                })?;
            }
        }

        Ok(result)
    }

    /// Parse a single file.
    fn parse_file<T>(path: &PathBuf, format: Format) -> std::result::Result<T, crate::FileError>
    where
        T: DeserializeOwned,
    {
        let content = std::fs::read_to_string(path).map_err(|e| {
            crate::FileError::Io(std::io::Error::other(format!(
                "Failed to read '{}': {}",
                path.display(),
                e
            )))
        })?;
        format
            .parse(&content, path)
            .map_err(|e| crate::FileError::Parse {
                path: path.clone(),
                format: format.extension(),
                source: Box::new(e) as Box<dyn std::error::Error + Send + Sync>,
            })
    }
}

/// Load configuration using rule-based discovery.
///
/// This is a convenience function that creates a loader with default settings.
///
/// # Errors
///
/// Returns an error if configuration cannot be loaded.
///
/// # Example
///
/// ```
/// use cfgmatic_files::load_with_rules;
/// use cfgmatic_paths::{ConfigRuleSet, ConfigFileRule};
///
/// let rules = ConfigRuleSet::builder()
///     .main_file(ConfigFileRule::toml("config"))
///     .build();
///
/// match load_with_rules::<serde_json::Value>("myapp", rules) {
///     Ok(config) => println!("Loaded: {:#}", config),
///     Err(e) => eprintln!("Error: {}", e),
/// }
/// ```
pub fn load_with_rules<T>(
    app_name: impl Into<String>,
    rules: cfgmatic_paths::ConfigRuleSet,
) -> Result<T>
where
    T: DeserializeOwned + Merge + Default,
{
    RuleBasedLoader::new(app_name).rules(rules).load()
}

#[cfg(test)]
mod tests {
    use super::*;
    use cfgmatic_paths::{ConfigFileRule, ConfigRuleSet};
    use std::fs;
    use std::io::Write;
    use tempfile::TempDir;

    #[test]
    fn test_loader_creation() {
        let loader = RuleBasedLoader::new("testapp");
        assert_eq!(loader.app_name, "testapp");
    }

    #[test]
    fn test_loader_with_rules() {
        let rules = ConfigRuleSet::builder()
            .main_file(ConfigFileRule::toml("config"))
            .build();

        let loader = RuleBasedLoader::new("testapp").rules(rules);
        assert!(loader.rules.is_some());
    }

    #[test]
    fn test_loader_with_merge_options() {
        let loader = RuleBasedLoader::new("testapp")
            .merge_behavior(MergeBehavior::Deep)
            .array_strategy(ArrayMergeStrategy::Append);

        assert_eq!(loader.merge_options.behavior, MergeBehavior::Deep);
        assert_eq!(
            loader.merge_options.array_strategy,
            ArrayMergeStrategy::Append
        );
    }

    #[test]
    fn test_load_empty_no_rules() -> Result<()> {
        // Use a non-existent app to avoid finding actual config
        let loader = RuleBasedLoader::new("nonexistent_test_app_12345");
        let result: serde_json::Value = loader.load()?;
        // Default for Value is Null, not empty object
        assert!(result.is_null());
        Ok(())
    }

    #[test]
    fn test_load_with_temp_files() -> Result<()> {
        let temp_dir = TempDir::new()?;
        let config_dir = temp_dir.path().join("config");
        fs::create_dir_all(&config_dir)?;

        let config_file = config_dir.join("config.toml");
        let mut file = std::fs::File::create(&config_file)?;
        writeln!(file, "name = \"test\"")?;
        writeln!(file, "value = 42")?;

        // Note: This test doesn't actually test the full loader
        // because we can't easily mock the PathFinder.
        // It just verifies the loader can be constructed.
        let loader = RuleBasedLoader::new("testapp");
        assert_eq!(loader.app_name, "testapp");

        Ok(())
    }
}