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,no_run
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    /// User-defined URL-backed source.
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. URL metadata is
93/// available for custom user-defined sources; this crate does not ship a
94/// built-in HTTP source.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct SourceMetadata {
97    /// Unique identifier for this source.
98    pub name: String,
99
100    /// Optional path (for file sources).
101    pub path: Option<PathBuf>,
102
103    /// Optional URL (for remote sources).
104    pub url: Option<String>,
105
106    /// Optional environment variable name.
107    pub env_var: Option<String>,
108
109    /// Optional priority (higher = more important).
110    pub priority: i32,
111
112    /// Whether this source is optional.
113    pub optional: bool,
114
115    /// Source-specific labels/tags.
116    pub labels: Vec<String>,
117}
118
119impl SourceMetadata {
120    /// Create new metadata with a name.
121    #[must_use]
122    pub fn new(name: impl Into<String>) -> Self {
123        Self {
124            name: name.into(),
125            path: None,
126            url: None,
127            env_var: None,
128            priority: 0,
129            optional: false,
130            labels: Vec::new(),
131        }
132    }
133
134    /// Set the path.
135    #[must_use]
136    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
137        self.path = Some(path.into());
138        self
139    }
140
141    /// Set the URL.
142    #[must_use]
143    pub fn with_url(mut self, url: impl Into<String>) -> Self {
144        self.url = Some(url.into());
145        self
146    }
147
148    /// Set the environment variable name.
149    #[must_use]
150    pub fn with_env_var(mut self, env_var: impl Into<String>) -> Self {
151        self.env_var = Some(env_var.into());
152        self
153    }
154
155    /// Set the priority.
156    #[must_use]
157    pub const fn with_priority(mut self, priority: i32) -> Self {
158        self.priority = priority;
159        self
160    }
161
162    /// Set whether this source is optional.
163    #[must_use]
164    pub const fn with_optional(mut self, optional: bool) -> Self {
165        self.optional = optional;
166        self
167    }
168
169    /// Add a label.
170    #[must_use]
171    pub fn with_label(mut self, label: impl Into<String>) -> Self {
172        self.labels.push(label.into());
173        self
174    }
175
176    /// Get display identifier for this source.
177    #[must_use]
178    pub fn display_id(&self) -> String {
179        self.path
180            .as_ref()
181            .map(|path| path.display().to_string())
182            .or_else(|| self.url.clone())
183            .or_else(|| self.env_var.clone())
184            .map_or_else(
185                || self.name.clone(),
186                |target| format!("{}:{target}", self.name),
187            )
188    }
189}
190
191impl Default for SourceMetadata {
192    fn default() -> Self {
193        Self::new("unnamed")
194    }
195}
196
197/// A configuration source.
198///
199/// This trait defines the interface for any configuration source.
200/// Sources can be files, environment variables, memory values, or user-defined
201/// custom adapters.
202///
203/// # Type Parameters
204///
205/// The type must implement:
206/// - `Send + Sync + 'static` - for thread safety
207///
208/// # Example
209///
210/// ```rust,no_run
211/// use cfgmatic_source::domain::{Format, RawContent, Result, Source, SourceKind, SourceMetadata};
212///
213/// struct EnvSource {
214///     var_name: String,
215/// }
216///
217/// impl Source for EnvSource {
218///     fn kind(&self) -> SourceKind {
219///         SourceKind::Env
220///     }
221///
222///     fn metadata(&self) -> SourceMetadata {
223///         SourceMetadata::new(&self.var_name)
224///             .with_env_var(&self.var_name)
225///     }
226///
227///     fn load_raw(&self) -> Result<RawContent> {
228///         let value = std::env::var(&self.var_name)?;
229///         Ok(RawContent::from_string(value))
230///     }
231///
232///     fn detect_format(&self) -> Option<Format> {
233///         Some(Format::Json) // Assume JSON format
234///     }
235/// }
236/// ```
237pub trait Source: Send + Sync + 'static {
238    /// Get the kind of this source.
239    fn kind(&self) -> SourceKind;
240
241    /// Get metadata about this source.
242    fn metadata(&self) -> SourceMetadata;
243
244    /// Load raw content from this source.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if the source cannot be read.
249    fn load_raw(&self) -> Result<RawContent>;
250
251    /// Detect the format of this source.
252    ///
253    /// Returns `None` if the format cannot be determined.
254    fn detect_format(&self) -> Option<Format>;
255
256    /// Validate this source.
257    ///
258    /// Override to add custom validation logic.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if validation fails.
263    fn validate(&self) -> Result<()> {
264        Ok(())
265    }
266
267    /// Check if this source is required.
268    ///
269    /// Required sources cause errors if not found.
270    #[must_use]
271    fn is_required(&self) -> bool {
272        !self.metadata().optional
273    }
274
275    /// Check if this source is optional.
276    ///
277    /// Optional sources do not cause errors if not found.
278    #[must_use]
279    fn is_optional(&self) -> bool {
280        self.metadata().optional
281    }
282
283    /// Get the display name for this source.
284    #[must_use]
285    fn display_name(&self) -> String {
286        let meta = self.metadata();
287        meta.display_id()
288    }
289
290    /// Get a stable cache key for this source.
291    #[must_use]
292    fn cache_key(&self) -> String {
293        format!("{}::{}", self.kind(), self.metadata().display_id())
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_source_kind_as_str() {
303        assert_eq!(SourceKind::File.as_str(), "file");
304        assert_eq!(SourceKind::Env.as_str(), "env");
305        assert_eq!(SourceKind::Remote.as_str(), "remote");
306    }
307
308    #[test]
309    fn test_source_kind_is_async() {
310        assert!(!SourceKind::File.is_async());
311        assert!(SourceKind::Remote.is_async());
312    }
313
314    #[test]
315    fn test_source_kind_display() {
316        assert_eq!(format!("{}", SourceKind::File), "file");
317    }
318
319    #[test]
320    fn test_source_metadata_new() {
321        let meta = SourceMetadata::new("test");
322        assert_eq!(meta.name, "test");
323        assert!(meta.path.is_none());
324        assert_eq!(meta.priority, 0);
325    }
326
327    #[test]
328    fn test_source_metadata_builders() {
329        let meta = SourceMetadata::new("test")
330            .with_path("/etc/config.toml")
331            .with_priority(10)
332            .with_optional(true)
333            .with_label("production");
334
335        assert_eq!(meta.path.unwrap().to_str(), Some("/etc/config.toml"));
336        assert_eq!(meta.priority, 10);
337        assert!(meta.optional);
338        assert!(meta.labels.contains(&"production".to_string()));
339    }
340
341    #[test]
342    fn test_source_metadata_display_id() {
343        let meta = SourceMetadata::new("test").with_path("/config.toml");
344        assert_eq!(meta.display_id(), "test:/config.toml");
345
346        let meta = SourceMetadata::new("test").with_url("https://example.com/config");
347        assert_eq!(meta.display_id(), "test:https://example.com/config");
348    }
349
350    #[test]
351    fn test_source_metadata_serialization() {
352        let meta = SourceMetadata::new("test").with_priority(5);
353        let json = serde_json::to_string(&meta).unwrap();
354        let decoded: SourceMetadata = serde_json::from_str(&json).unwrap();
355        assert_eq!(meta, decoded);
356    }
357
358    #[test]
359    fn test_source_kind_serialization() {
360        let kind = SourceKind::File;
361        let json = serde_json::to_string(&kind).unwrap();
362        assert_eq!(json, "\"file\"");
363    }
364}