cfgmatic-source 5.0.1

Configuration sources (file, env, memory) for cfgmatic framework
Documentation
//! Source trait and related types.
//!
//! Sources are the fundamental abstraction for configuration loading.
//! Each source has a kind, metadata, and can provide raw content.
//!
//! # Implementing Source
//!
//! ```rust,no_run
//! use cfgmatic_source::domain::{Source, SourceKind, SourceMetadata};
//! use cfgmatic_source::domain::{RawContent, Format};
//! use cfgmatic_source::domain::Result;
//!
//! struct FileSource {
//!     path: std::path::PathBuf,
//! }
//!
//! impl Source for FileSource {
//!     fn kind(&self) -> SourceKind {
//!         SourceKind::File
//!     }
//!
//!     fn metadata(&self) -> SourceMetadata {
//!         SourceMetadata::new("file").with_path(self.path.clone())
//!     }
//!
//!     fn load_raw(&self) -> Result<RawContent> {
//!         let content = std::fs::read_to_string(&self.path)?;
//!         Ok(RawContent::from_string(content))
//!     }
//!
//!     fn detect_format(&self) -> Option<Format> {
//!         Format::from_path(&self.path)
//!     }
//! }
//! ```

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

use super::{Format, RawContent, Result};

/// Kind of configuration source.
///
/// Identifies where the configuration originates from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceKind {
    /// File-based source (TOML, JSON, YAML, etc.)
    File,

    /// Environment variable source.
    Env,

    /// User-defined URL-backed source.
    Remote,

    /// In-memory source.
    Memory,

    /// Custom source type.
    Custom,
}

impl SourceKind {
    /// Get display name for this source kind.
    #[must_use]
    pub const fn as_str(&self) -> &'static str {
        match self {
            Self::File => "file",
            Self::Env => "env",
            Self::Remote => "remote",
            Self::Memory => "memory",
            Self::Custom => "custom",
        }
    }

    /// Check if this source requires async operations.
    #[must_use]
    pub const fn is_async(&self) -> bool {
        matches!(self, Self::Remote)
    }
}

impl std::fmt::Display for SourceKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

/// Metadata about a configuration source.
///
/// Contains identifying information and optional attributes. URL metadata is
/// available for custom user-defined sources; this crate does not ship a
/// built-in HTTP source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceMetadata {
    /// Unique identifier for this source.
    pub name: String,

    /// Optional path (for file sources).
    pub path: Option<PathBuf>,

    /// Optional URL (for remote sources).
    pub url: Option<String>,

    /// Optional environment variable name.
    pub env_var: Option<String>,

    /// Optional priority (higher = more important).
    pub priority: i32,

    /// Whether this source is optional.
    pub optional: bool,

    /// Source-specific labels/tags.
    pub labels: Vec<String>,
}

impl SourceMetadata {
    /// Create new metadata with a name.
    #[must_use]
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            path: None,
            url: None,
            env_var: None,
            priority: 0,
            optional: false,
            labels: Vec::new(),
        }
    }

    /// Set the path.
    #[must_use]
    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
        self.path = Some(path.into());
        self
    }

    /// Set the URL.
    #[must_use]
    pub fn with_url(mut self, url: impl Into<String>) -> Self {
        self.url = Some(url.into());
        self
    }

    /// Set the environment variable name.
    #[must_use]
    pub fn with_env_var(mut self, env_var: impl Into<String>) -> Self {
        self.env_var = Some(env_var.into());
        self
    }

    /// Set the priority.
    #[must_use]
    pub const fn with_priority(mut self, priority: i32) -> Self {
        self.priority = priority;
        self
    }

    /// Set whether this source is optional.
    #[must_use]
    pub const fn with_optional(mut self, optional: bool) -> Self {
        self.optional = optional;
        self
    }

    /// Add a label.
    #[must_use]
    pub fn with_label(mut self, label: impl Into<String>) -> Self {
        self.labels.push(label.into());
        self
    }

    /// Get display identifier for this source.
    #[must_use]
    pub fn display_id(&self) -> String {
        self.path
            .as_ref()
            .map(|path| path.display().to_string())
            .or_else(|| self.url.clone())
            .or_else(|| self.env_var.clone())
            .map_or_else(
                || self.name.clone(),
                |target| format!("{}:{target}", self.name),
            )
    }
}

impl Default for SourceMetadata {
    fn default() -> Self {
        Self::new("unnamed")
    }
}

/// A configuration source.
///
/// This trait defines the interface for any configuration source.
/// Sources can be files, environment variables, memory values, or user-defined
/// custom adapters.
///
/// # Type Parameters
///
/// The type must implement:
/// - `Send + Sync + 'static` - for thread safety
///
/// # Example
///
/// ```rust,no_run
/// use cfgmatic_source::domain::{Format, RawContent, Result, Source, SourceKind, SourceMetadata};
///
/// struct EnvSource {
///     var_name: String,
/// }
///
/// impl Source for EnvSource {
///     fn kind(&self) -> SourceKind {
///         SourceKind::Env
///     }
///
///     fn metadata(&self) -> SourceMetadata {
///         SourceMetadata::new(&self.var_name)
///             .with_env_var(&self.var_name)
///     }
///
///     fn load_raw(&self) -> Result<RawContent> {
///         let value = std::env::var(&self.var_name)?;
///         Ok(RawContent::from_string(value))
///     }
///
///     fn detect_format(&self) -> Option<Format> {
///         Some(Format::Json) // Assume JSON format
///     }
/// }
/// ```
pub trait Source: Send + Sync + 'static {
    /// Get the kind of this source.
    fn kind(&self) -> SourceKind;

    /// Get metadata about this source.
    fn metadata(&self) -> SourceMetadata;

    /// Load raw content from this source.
    ///
    /// # Errors
    ///
    /// Returns an error if the source cannot be read.
    fn load_raw(&self) -> Result<RawContent>;

    /// Detect the format of this source.
    ///
    /// Returns `None` if the format cannot be determined.
    fn detect_format(&self) -> Option<Format>;

    /// Validate this source.
    ///
    /// Override to add custom validation logic.
    ///
    /// # Errors
    ///
    /// Returns an error if validation fails.
    fn validate(&self) -> Result<()> {
        Ok(())
    }

    /// Check if this source is required.
    ///
    /// Required sources cause errors if not found.
    #[must_use]
    fn is_required(&self) -> bool {
        !self.metadata().optional
    }

    /// Check if this source is optional.
    ///
    /// Optional sources do not cause errors if not found.
    #[must_use]
    fn is_optional(&self) -> bool {
        self.metadata().optional
    }

    /// Get the display name for this source.
    #[must_use]
    fn display_name(&self) -> String {
        let meta = self.metadata();
        meta.display_id()
    }

    /// Get a stable cache key for this source.
    #[must_use]
    fn cache_key(&self) -> String {
        format!("{}::{}", self.kind(), self.metadata().display_id())
    }
}

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

    #[test]
    fn test_source_kind_as_str() {
        assert_eq!(SourceKind::File.as_str(), "file");
        assert_eq!(SourceKind::Env.as_str(), "env");
        assert_eq!(SourceKind::Remote.as_str(), "remote");
    }

    #[test]
    fn test_source_kind_is_async() {
        assert!(!SourceKind::File.is_async());
        assert!(SourceKind::Remote.is_async());
    }

    #[test]
    fn test_source_kind_display() {
        assert_eq!(format!("{}", SourceKind::File), "file");
    }

    #[test]
    fn test_source_metadata_new() {
        let meta = SourceMetadata::new("test");
        assert_eq!(meta.name, "test");
        assert!(meta.path.is_none());
        assert_eq!(meta.priority, 0);
    }

    #[test]
    fn test_source_metadata_builders() {
        let meta = SourceMetadata::new("test")
            .with_path("/etc/config.toml")
            .with_priority(10)
            .with_optional(true)
            .with_label("production");

        assert_eq!(meta.path.unwrap().to_str(), Some("/etc/config.toml"));
        assert_eq!(meta.priority, 10);
        assert!(meta.optional);
        assert!(meta.labels.contains(&"production".to_string()));
    }

    #[test]
    fn test_source_metadata_display_id() {
        let meta = SourceMetadata::new("test").with_path("/config.toml");
        assert_eq!(meta.display_id(), "test:/config.toml");

        let meta = SourceMetadata::new("test").with_url("https://example.com/config");
        assert_eq!(meta.display_id(), "test:https://example.com/config");
    }

    #[test]
    fn test_source_metadata_serialization() {
        let meta = SourceMetadata::new("test").with_priority(5);
        let json = serde_json::to_string(&meta).unwrap();
        let decoded: SourceMetadata = serde_json::from_str(&json).unwrap();
        assert_eq!(meta, decoded);
    }

    #[test]
    fn test_source_kind_serialization() {
        let kind = SourceKind::File;
        let json = serde_json::to_string(&kind).unwrap();
        assert_eq!(json, "\"file\"");
    }
}