Skip to main content

synaptic_config/
source.rs

1use std::path::{Path, PathBuf};
2
3use serde::de::DeserializeOwned;
4use synaptic_core::SynapticError;
5
6use crate::format::{parse_config, ConfigFormat};
7
8/// Configuration source abstraction.
9///
10/// Future implementations (Apollo, Nacos, etcd) can implement this trait
11/// to provide configuration from remote sources.
12pub trait ConfigSource: Send + Sync {
13    /// Fetch the current configuration content and its format.
14    fn fetch(&self) -> Result<(String, ConfigFormat), SynapticError>;
15}
16
17/// Load configuration from a local file, auto-detecting format by extension.
18pub struct FileConfigSource {
19    path: PathBuf,
20    format: Option<ConfigFormat>,
21}
22
23impl FileConfigSource {
24    pub fn new(path: impl Into<PathBuf>) -> Self {
25        Self {
26            path: path.into(),
27            format: None,
28        }
29    }
30
31    /// Override the auto-detected format.
32    pub fn with_format(mut self, format: ConfigFormat) -> Self {
33        self.format = Some(format);
34        self
35    }
36}
37
38impl ConfigSource for FileConfigSource {
39    fn fetch(&self) -> Result<(String, ConfigFormat), SynapticError> {
40        let format = self
41            .format
42            .or_else(|| ConfigFormat::from_path(&self.path))
43            .ok_or_else(|| {
44                SynapticError::Config(format!(
45                    "cannot detect config format from extension: {}",
46                    self.path.display()
47                ))
48            })?;
49
50        let content = std::fs::read_to_string(&self.path).map_err(|e| {
51            SynapticError::Config(format!("failed to read {}: {e}", self.path.display()))
52        })?;
53
54        Ok((content, format))
55    }
56}
57
58/// Load configuration from an in-memory string (useful for tests or config-center payloads).
59pub struct StringConfigSource {
60    content: String,
61    format: ConfigFormat,
62}
63
64impl StringConfigSource {
65    pub fn new(content: impl Into<String>, format: ConfigFormat) -> Self {
66        Self {
67            content: content.into(),
68            format,
69        }
70    }
71}
72
73impl ConfigSource for StringConfigSource {
74    fn fetch(&self) -> Result<(String, ConfigFormat), SynapticError> {
75        Ok((self.content.clone(), self.format))
76    }
77}
78
79/// Load and parse configuration from any [`ConfigSource`].
80pub fn load_from_source<T: DeserializeOwned>(
81    source: &dyn ConfigSource,
82) -> Result<T, SynapticError> {
83    let (content, format) = source.fetch()?;
84    parse_config(&content, format)
85}
86
87/// Load and parse configuration from a file path (convenience wrapper).
88pub fn load_from_file<T: DeserializeOwned>(path: &Path) -> Result<T, SynapticError> {
89    load_from_source(&FileConfigSource::new(path))
90}
91
92/// File-discovery search order for config files.
93const EXTENSIONS: &[&str] = &["toml", "json", "yaml", "yml"];
94
95/// Discover a configuration file and load it as `T`.
96///
97/// Search order:
98/// 1. Explicit `path` (if provided) — format detected by extension
99/// 2. `./synaptic.{toml,json,yaml,yml}` in the current directory
100/// 3. `~/.synaptic/config.{toml,json,yaml,yml}` in the home directory
101pub fn discover_and_load<T: DeserializeOwned>(path: Option<&Path>) -> Result<T, SynapticError> {
102    if let Some(p) = path {
103        if p.exists() {
104            return load_from_file(p);
105        } else {
106            return Err(SynapticError::Config(format!(
107                "config file not found: {}",
108                p.display()
109            )));
110        }
111    }
112
113    // Search current directory
114    for ext in EXTENSIONS {
115        let candidate = PathBuf::from(format!("./synaptic.{ext}"));
116        if candidate.exists() {
117            return load_from_file(&candidate);
118        }
119    }
120
121    // Search home directory
122    if let Some(home) = dirs::home_dir() {
123        for ext in EXTENSIONS {
124            let candidate = home.join(".synaptic").join(format!("config.{ext}"));
125            if candidate.exists() {
126                return load_from_file(&candidate);
127            }
128        }
129    }
130
131    Err(SynapticError::Config(
132        "no config file found: tried ./synaptic.{toml,json,yaml} and ~/.synaptic/config.{toml,json,yaml}".to_string(),
133    ))
134}