Skip to main content

cfgmatic_source/domain/
source.rs

1//! Source trait and related types.
2//!
3//! Sources are the fundamental abstraction for configuration loading.
4//! Each source has a kind, metadata, and can provide raw content.
5//!
6//! # Implementing Source
7//!
8//! ```rust,ignore
9//! use cfgmatic_source::domain::{Source, SourceKind, SourceMetadata};
10//! use cfgmatic_source::domain::{RawContent, Format};
11//! use cfgmatic_source::domain::Result;
12//!
13//! struct FileSource {
14//!     path: std::path::PathBuf,
15//! }
16//!
17//! impl Source for FileSource {
18//!     fn kind(&self) -> SourceKind {
19//!         SourceKind::File
20//!     }
21//!
22//!     fn metadata(&self) -> SourceMetadata {
23//!         SourceMetadata::new("file").with_path(self.path.clone())
24//!     }
25//!
26//!     fn load_raw(&self) -> Result<RawContent> {
27//!         let content = std::fs::read_to_string(&self.path)?;
28//!         Ok(RawContent::from_string(content))
29//!     }
30//!
31//!     fn detect_format(&self) -> Option<Format> {
32//!         Format::from_path(&self.path)
33//!     }
34//! }
35//! ```
36
37use serde::{Deserialize, Serialize};
38use std::path::PathBuf;
39
40use super::{Format, RawContent, Result};
41
42/// Kind of configuration source.
43///
44/// Identifies where the configuration originates from.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum SourceKind {
48    /// File-based source (TOML, JSON, YAML, etc.)
49    File,
50
51    /// Environment variable source.
52    Env,
53
54    /// Remote source (HTTP/HTTPS).
55    Remote,
56
57    /// In-memory source.
58    Memory,
59
60    /// Custom source type.
61    Custom,
62}
63
64impl SourceKind {
65    /// Get display name for this source kind.
66    #[must_use]
67    pub const fn as_str(&self) -> &'static str {
68        match self {
69            Self::File => "file",
70            Self::Env => "env",
71            Self::Remote => "remote",
72            Self::Memory => "memory",
73            Self::Custom => "custom",
74        }
75    }
76
77    /// Check if this source requires async operations.
78    #[must_use]
79    pub const fn is_async(&self) -> bool {
80        matches!(self, Self::Remote)
81    }
82}
83
84impl std::fmt::Display for SourceKind {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        write!(f, "{}", self.as_str())
87    }
88}
89
90/// Metadata about a configuration source.
91///
92/// Contains identifying information and optional attributes.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct SourceMetadata {
95    /// Unique identifier for this source.
96    pub name: String,
97
98    /// Optional path (for file sources).
99    pub path: Option<PathBuf>,
100
101    /// Optional URL (for remote sources).
102    pub url: Option<String>,
103
104    /// Optional environment variable name.
105    pub env_var: Option<String>,
106
107    /// Optional priority (higher = more important).
108    pub priority: i32,
109
110    /// Whether this source is optional.
111    pub optional: bool,
112
113    /// Source-specific labels/tags.
114    pub labels: Vec<String>,
115}
116
117impl SourceMetadata {
118    /// Create new metadata with a name.
119    #[must_use]
120    pub fn new(name: impl Into<String>) -> Self {
121        Self {
122            name: name.into(),
123            path: None,
124            url: None,
125            env_var: None,
126            priority: 0,
127            optional: false,
128            labels: Vec::new(),
129        }
130    }
131
132    /// Set the path.
133    #[must_use]
134    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
135        self.path = Some(path.into());
136        self
137    }
138
139    /// Set the URL.
140    #[must_use]
141    pub fn with_url(mut self, url: impl Into<String>) -> Self {
142        self.url = Some(url.into());
143        self
144    }
145
146    /// Set the environment variable name.
147    #[must_use]
148    pub fn with_env_var(mut self, env_var: impl Into<String>) -> Self {
149        self.env_var = Some(env_var.into());
150        self
151    }
152
153    /// Set the priority.
154    #[must_use]
155    pub fn with_priority(mut self, priority: i32) -> Self {
156        self.priority = priority;
157        self
158    }
159
160    /// Set whether this source is optional.
161    #[must_use]
162    pub fn with_optional(mut self, optional: bool) -> Self {
163        self.optional = optional;
164        self
165    }
166
167    /// Add a label.
168    #[must_use]
169    pub fn with_label(mut self, label: impl Into<String>) -> Self {
170        self.labels.push(label.into());
171        self
172    }
173
174    /// Get display identifier for this source.
175    #[must_use]
176    pub fn display_id(&self) -> String {
177        if let Some(ref path) = self.path {
178            format!("{}:{}", self.name, path.display())
179        } else if let Some(ref url) = self.url {
180            format!("{}:{}", self.name, url)
181        } else if let Some(ref env_var) = self.env_var {
182            format!("{}:{}", self.name, env_var)
183        } else {
184            self.name.clone()
185        }
186    }
187}
188
189impl Default for SourceMetadata {
190    fn default() -> Self {
191        Self::new("unnamed")
192    }
193}
194
195/// A configuration source.
196///
197/// This trait defines the interface for any configuration source.
198/// Sources can be files, environment variables, remote URLs, or custom.
199///
200/// # Type Parameters
201///
202/// The type must implement:
203/// - `Send + Sync + 'static` - for thread safety
204///
205/// # Example
206///
207/// ```rust,ignore
208/// use cfgmatic_source::domain::{Source, SourceKind, SourceMetadata};
209///
210/// struct EnvSource {
211///     var_name: String,
212/// }
213///
214/// impl Source for EnvSource {
215///     fn kind(&self) -> SourceKind {
216///         SourceKind::Env
217///     }
218///
219///     fn metadata(&self) -> SourceMetadata {
220///         SourceMetadata::new(&self.var_name)
221///             .with_env_var(&self.var_name)
222///     }
223///
224///     fn load_raw(&self) -> Result<RawContent> {
225///         let value = std::env::var(&self.var_name)?;
226///         Ok(RawContent::from_string(value))
227///     }
228///
229///     fn detect_format(&self) -> Option<Format> {
230///         Some(Format::Json) // Assume JSON format
231///     }
232/// }
233/// ```
234pub trait Source: Send + Sync + 'static {
235    /// Get the kind of this source.
236    fn kind(&self) -> SourceKind;
237
238    /// Get metadata about this source.
239    fn metadata(&self) -> SourceMetadata;
240
241    /// Load raw content from this source.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the source cannot be read.
246    fn load_raw(&self) -> Result<RawContent>;
247
248    /// Detect the format of this source.
249    ///
250    /// Returns `None` if the format cannot be determined.
251    fn detect_format(&self) -> Option<Format>;
252
253    /// Validate this source.
254    ///
255    /// Override to add custom validation logic.
256    ///
257    /// # Errors
258    ///
259    /// Returns an error if validation fails.
260    fn validate(&self) -> Result<()> {
261        Ok(())
262    }
263
264    /// Check if this source is required.
265    ///
266    /// Required sources cause errors if not found.
267    #[must_use]
268    fn is_required(&self) -> bool {
269        !self.metadata().optional
270    }
271
272    /// Check if this source is optional.
273    ///
274    /// Optional sources do not cause errors if not found.
275    #[must_use]
276    fn is_optional(&self) -> bool {
277        self.metadata().optional
278    }
279
280    /// Get the display name for this source.
281    #[must_use]
282    fn display_name(&self) -> String {
283        let meta = self.metadata();
284        format!("{}:{}", self.kind(), meta.name)
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn test_source_kind_as_str() {
294        assert_eq!(SourceKind::File.as_str(), "file");
295        assert_eq!(SourceKind::Env.as_str(), "env");
296        assert_eq!(SourceKind::Remote.as_str(), "remote");
297    }
298
299    #[test]
300    fn test_source_kind_is_async() {
301        assert!(!SourceKind::File.is_async());
302        assert!(SourceKind::Remote.is_async());
303    }
304
305    #[test]
306    fn test_source_kind_display() {
307        assert_eq!(format!("{}", SourceKind::File), "file");
308    }
309
310    #[test]
311    fn test_source_metadata_new() {
312        let meta = SourceMetadata::new("test");
313        assert_eq!(meta.name, "test");
314        assert!(meta.path.is_none());
315        assert_eq!(meta.priority, 0);
316    }
317
318    #[test]
319    fn test_source_metadata_builders() {
320        let meta = SourceMetadata::new("test")
321            .with_path("/etc/config.toml")
322            .with_priority(10)
323            .with_optional(true)
324            .with_label("production");
325
326        assert_eq!(meta.path.unwrap().to_str(), Some("/etc/config.toml"));
327        assert_eq!(meta.priority, 10);
328        assert!(meta.optional);
329        assert!(meta.labels.contains(&"production".to_string()));
330    }
331
332    #[test]
333    fn test_source_metadata_display_id() {
334        let meta = SourceMetadata::new("test").with_path("/config.toml");
335        assert_eq!(meta.display_id(), "test:/config.toml");
336
337        let meta = SourceMetadata::new("test").with_url("https://example.com/config");
338        assert_eq!(meta.display_id(), "test:https://example.com/config");
339    }
340
341    #[test]
342    fn test_source_metadata_serialization() {
343        let meta = SourceMetadata::new("test").with_priority(5);
344        let json = serde_json::to_string(&meta).unwrap();
345        let decoded: SourceMetadata = serde_json::from_str(&json).unwrap();
346        assert_eq!(meta, decoded);
347    }
348
349    #[test]
350    fn test_source_kind_serialization() {
351        let kind = SourceKind::File;
352        let json = serde_json::to_string(&kind).unwrap();
353        assert_eq!(json, "\"file\"");
354    }
355}