cfgmatic-files 2.2.0

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

use crate::Format;
use crate::error::Result;
use cfgmatic_merge::{Merge, MergeBehavior, MergeOptions};
use cfgmatic_paths::ConfigTier;
use serde::de::DeserializeOwned;
use std::fs;
use std::path::PathBuf;

/// A discovered configuration file.
#[derive(Debug, Clone)]
pub struct ConfigFile {
    /// Full path to the file.
    pub path: PathBuf,

    /// Configuration tier (determines priority).
    pub tier: ConfigTier,

    /// File format.
    pub format: Format,

    /// Cached content (loaded on demand).
    pub content: Option<String>,
}

impl ConfigFile {
    /// Create a new config file reference.
    pub(crate) fn new(path: PathBuf, tier: ConfigTier) -> Option<Self> {
        let format = Format::from_path(&path)?;
        Some(Self {
            path,
            tier,
            format,
            content: None,
        })
    }

    /// Get the file name.
    #[must_use]
    pub fn name(&self) -> Option<&str> {
        self.path.file_name()?.to_str()
    }

    /// Read the file content.
    ///
    /// # Errors
    ///
    /// Returns an error if the file cannot be read.
    ///
    /// # Panics
    ///
    /// Panics if the content was not set after reading (this should never happen in practice).
    pub fn read(&mut self) -> Result<&str> {
        if let Some(ref content) = self.content {
            return Ok(content);
        }
        let content = fs::read_to_string(&self.path)?;
        self.content = Some(content);
        Ok(self.content.as_ref().unwrap()) // Safe: content was just set
    }

    /// Parse the file into a type T.
    ///
    /// # Errors
    ///
    /// Returns an error if the file cannot be read or parsed.
    pub fn parse<T: DeserializeOwned>(&mut self) -> Result<T> {
        if self.content.is_none() {
            let content = fs::read_to_string(&self.path)?;
            self.content = Some(content);
        }
        // Safe: content was set above or was already cached
        let content = self.content.as_ref().unwrap();
        self.format.parse(content, &self.path)
    }

    /// Parse without caching content.
    ///
    /// # Errors
    ///
    /// Returns an error if the file cannot be read or parsed.
    pub fn parse_uncached<T: DeserializeOwned>(&self) -> Result<T> {
        let content = fs::read_to_string(&self.path)?;
        self.format.parse(&content, &self.path)
    }

    /// Check if the file exists.
    #[must_use]
    pub fn exists(&self) -> bool {
        self.path.exists()
    }

    /// Get the file modification time.
    ///
    /// # Errors
    ///
    /// Returns an error if file metadata cannot be retrieved.
    pub fn modified(&self) -> Result<std::time::SystemTime> {
        Ok(fs::metadata(&self.path)?.modified()?)
    }
}

impl PartialEq for ConfigFile {
    fn eq(&self, other: &Self) -> bool {
        self.path == other.path
    }
}

impl Eq for ConfigFile {}

impl PartialOrd for ConfigFile {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for ConfigFile {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        // Higher tier = higher priority = "greater" for sorting
        u8::from(self.tier).cmp(&u8::from(other.tier))
    }
}

impl std::fmt::Display for ConfigFile {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{} ({:?}, {})",
            self.path.display(),
            self.tier,
            self.format
        )
    }
}

/// A collection of configuration files sorted by priority.
#[derive(Debug, Clone, Default)]
pub struct ConfigFiles {
    files: Vec<ConfigFile>,
}

impl ConfigFiles {
    /// Create a new empty collection.
    #[must_use]
    pub const fn new() -> Self {
        Self { files: Vec::new() }
    }

    /// Add a file to the collection.
    pub fn push(&mut self, file: ConfigFile) {
        self.files.push(file);
        self.sort();
    }

    /// Sort files by priority (highest first).
    fn sort(&mut self) {
        self.files.sort_by(|a, b| b.cmp(a)); // Reverse order for highest priority first
    }

    /// Get the number of files.
    #[must_use]
    pub const fn len(&self) -> usize {
        self.files.len()
    }

    /// Check if collection is empty.
    #[must_use]
    pub const fn is_empty(&self) -> bool {
        self.files.is_empty()
    }

    /// Get the highest priority file.
    #[must_use]
    pub fn first(&self) -> Option<&ConfigFile> {
        self.files.first()
    }

    /// Get the highest priority file (mutable).
    pub fn first_mut(&mut self) -> Option<&mut ConfigFile> {
        self.files.first_mut()
    }

    /// Iterate over files in priority order.
    pub fn iter(&self) -> impl Iterator<Item = &ConfigFile> {
        self.files.iter()
    }

    /// Merge all files into a single value.
    ///
    /// Files are merged in priority order (highest priority wins).
    ///
    /// # Errors
    ///
    /// Returns an error if any file cannot be read or parsed.
    pub fn merge<T>(&mut self) -> Result<T>
    where
        T: DeserializeOwned + Mergeable + Default,
    {
        let mut result = T::default();
        for file in self.files.iter_mut().rev() {
            let value: T = file.parse()?;
            result = result.merge(value);
        }
        Ok(result)
    }
}

impl IntoIterator for ConfigFiles {
    type Item = ConfigFile;
    type IntoIter = std::vec::IntoIter<ConfigFile>;

    fn into_iter(self) -> Self::IntoIter {
        self.files.into_iter()
    }
}

impl<'a> IntoIterator for &'a ConfigFiles {
    type Item = &'a ConfigFile;
    type IntoIter = std::slice::Iter<'a, ConfigFile>;

    fn into_iter(self) -> Self::IntoIter {
        self.files.iter()
    }
}

/// Trait for types that can be merged.
///
/// Provides a simple merge interface. For advanced merge options,
/// use the `Merge` trait from cfgmatic-merge directly.
pub trait Mergeable {
    /// Merge another value into self using deep merge strategy.
    #[must_use]
    fn merge(self, other: Self) -> Self;
}

impl Mergeable for serde_json::Value {
    fn merge(self, other: Self) -> Self {
        let opts = MergeOptions::new().behavior(MergeBehavior::Deep);
        // On merge error, fall back to replacing with other value.
        // This is a simplification for the ergonomic Mergeable trait.
        Merge::merge(self, other.clone(), &opts).unwrap_or(other)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    #[test]
    fn test_config_file_sorting() {
        let file1 = ConfigFile {
            path: PathBuf::from("/etc/config.toml"),
            tier: ConfigTier::System,
            format: Format::Toml,
            content: None,
        };
        let file2 = ConfigFile {
            path: PathBuf::from("~/.config.toml"),
            tier: ConfigTier::User,
            format: Format::Toml,
            content: None,
        };

        // User tier has higher priority
        assert!(file2 > file1);
    }

    #[test]
    fn test_parse_toml() -> Result<()> {
        #[derive(Debug, serde::Deserialize, PartialEq)]
        struct Config {
            timeout: u32,
            host: String,
        }

        let mut temp = NamedTempFile::with_suffix(".toml")?;
        write!(temp, "timeout = 30\nhost = \"localhost\"")?;

        let mut file =
            ConfigFile::new(temp.path().to_path_buf(), ConfigTier::User).expect("valid toml file");

        let config: Config = file.parse()?;
        assert_eq!(config.timeout, 30);
        assert_eq!(config.host, "localhost");

        Ok(())
    }

    #[test]
    fn test_parse_json() -> Result<()> {
        let mut temp = NamedTempFile::with_suffix(".json")?;
        write!(temp, "{{\"port\": 8080, \"enabled\": true}}")?;

        let mut file =
            ConfigFile::new(temp.path().to_path_buf(), ConfigTier::User).expect("valid json file");

        let value: serde_json::Value = file.parse()?;
        assert_eq!(value["port"], 8080);
        assert_eq!(value["enabled"], true);

        Ok(())
    }

    #[test]
    fn test_config_files_collection() {
        let mut files = ConfigFiles::new();
        assert!(files.is_empty());

        let file = ConfigFile {
            path: PathBuf::from("config.toml"),
            tier: ConfigTier::User,
            format: Format::Toml,
            content: None,
        };

        files.push(file);
        assert_eq!(files.len(), 1);
        assert!(files.first().is_some());
    }
}