use std::path::{Path, PathBuf};
use cfgmatic_merge::Merge;
use crate::domain::{Format, RawContent, Result, Source, SourceError, SourceKind, SourceMetadata};
#[derive(Debug)]
pub struct FileSourceBuilder {
paths: Vec<PathBuf>,
required: bool,
}
impl FileSourceBuilder {
#[must_use]
pub fn new() -> Self {
Self {
paths: Vec::new(),
required: true,
}
}
#[must_use]
pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
self.paths.push(path.into());
self
}
#[must_use]
pub fn paths(mut self, paths: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
self.paths.extend(paths.into_iter().map(|p| p.into()));
self
}
#[must_use]
pub fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
pub fn build(self) -> Result<FileSource> {
if self.paths.is_empty() {
return Err(SourceError::validation("No file paths configured"));
}
Ok(FileSource {
paths: self.paths,
required: self.required,
})
}
}
impl Default for FileSourceBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub struct FileSource {
pub(crate) paths: Vec<PathBuf>,
required: bool,
}
impl FileSource {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
paths: vec![path.into()],
required: true,
}
}
#[must_use]
pub fn builder() -> FileSourceBuilder {
FileSourceBuilder::new()
}
fn detect_format_from_path(path: &Path) -> Option<Format> {
Format::from_path(path)
}
fn read_file(path: &Path) -> Result<String> {
std::fs::read_to_string(path)
.map_err(|e| SourceError::read_failed(&format!("{}: {}", path.display(), e)))
}
fn parse_to_json_value(
content: &str,
format: Format,
path: &Path,
) -> Result<serde_json::Value> {
match format {
#[cfg(feature = "toml")]
Format::Toml => {
let v: toml::Value = toml::from_str(content).map_err(|e| {
SourceError::parse_failed(&path.display().to_string(), "toml", &e.to_string())
})?;
serde_json::to_value(v).map_err(|e| SourceError::serialization(&e.to_string()))
}
#[cfg(feature = "json")]
Format::Json => serde_json::from_str(content).map_err(|e| {
SourceError::parse_failed(&path.display().to_string(), "json", &e.to_string())
}),
#[cfg(feature = "yaml")]
Format::Yaml => serde_yaml::from_str(content).map_err(|e| {
SourceError::parse_failed(&path.display().to_string(), "yaml", &e.to_string())
}),
Format::Unknown => Err(SourceError::unsupported("unknown format")),
}
}
}
impl Source for FileSource {
fn kind(&self) -> SourceKind {
SourceKind::File
}
fn metadata(&self) -> SourceMetadata {
let path = self.paths.first().cloned().unwrap_or_default();
SourceMetadata::new("file")
.with_path(path)
.with_priority(100)
.with_optional(!self.required)
}
fn load_raw(&self) -> Result<RawContent> {
if self.paths.len() == 1 {
let path = &self.paths[0];
if !path.exists() {
if self.required {
return Err(SourceError::not_found(&path.display().to_string()));
}
return Ok(RawContent::from_string(""));
}
let content = Self::read_file(path)?;
return Ok(RawContent::from_string(content).with_source_path(path.clone()));
}
let mut merged = serde_json::Value::Object(serde_json::Map::new());
let mut any_loaded = false;
for path in &self.paths {
if !path.exists() {
if self.required {
return Err(SourceError::not_found(&path.display().to_string()));
}
continue;
}
let content = Self::read_file(path)?;
let format = Self::detect_format_from_path(path).ok_or_else(|| {
SourceError::invalid_format(
"config",
&path.extension().unwrap_or_default().to_string_lossy(),
)
})?;
let value = Self::parse_to_json_value(&content, format, path)?;
merged = merged
.merge_deep(value)
.map_err(|e| SourceError::serialization(&e.to_string()))?;
any_loaded = true;
}
if !any_loaded && self.required {
return Err(SourceError::not_found("No configuration files found"));
}
let content = serde_json::to_string(&merged)
.map_err(|e| SourceError::serialization(&e.to_string()))?;
Ok(RawContent::from_string(content))
}
fn detect_format(&self) -> Option<Format> {
self.paths.first().and_then(|p| Format::from_path(p))
}
fn is_required(&self) -> bool {
self.required
}
}
#[cfg(feature = "async")]
mod async_impl {
use super::*;
use async_trait::async_trait;
#[async_trait]
impl Source for FileSource {
async fn load_raw_async(&self) -> Result<RawContent> {
if self.paths.len() == 1 {
let path = &self.paths[0];
let path_exists = tokio::fs::try_exists(path)
.await
.map_err(|e| SourceError::read_failed(&format!("{}: {}", path.display(), e)))?;
if !path_exists {
if self.required {
return Err(SourceError::not_found(&path.display().to_string()));
}
return Ok(RawContent::from_string(""));
}
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| SourceError::read_failed(&e.to_string()))?;
return Ok(RawContent::from_string(content).with_source_path(path.clone()));
}
let mut merged = serde_json::Value::Object(serde_json::Map::new());
let mut any_loaded = false;
for path in &self.paths {
let path_exists = tokio::fs::try_exists(path)
.await
.map_err(|e| SourceError::read_failed(&format!("{}: {}", path.display(), e)))?;
if !path_exists {
if self.required {
return Err(SourceError::not_found(&path.display().to_string()));
}
continue;
}
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| SourceError::read_failed(&e.to_string()))?;
let format = Self::detect_format_from_path(path).ok_or_else(|| {
SourceError::invalid_format(
"config",
&path.extension().unwrap_or_default().to_string_lossy(),
)
})?;
let value = Self::parse_to_json_value(&content, format, path)?;
merged = merged
.merge_deep(value)
.map_err(|e| SourceError::serialization(&e.to_string()))?;
any_loaded = true;
}
if !any_loaded && self.required {
return Err(SourceError::not_found("No configuration files found"));
}
let content = serde_json::to_string(&merged)
.map_err(|e| SourceError::serialization(&e.to_string()))?;
Ok(RawContent::from_string(content))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_temp_file(content: &str, extension: &str) -> NamedTempFile {
let mut file = NamedTempFile::with_suffix(extension).unwrap();
write!(file, "{}", content).unwrap();
file
}
#[test]
fn test_file_source_builder() {
let source = FileSource::builder()
.path("/etc/config.toml")
.required(false)
.build()
.unwrap();
assert!(!source.is_required());
assert_eq!(source.paths.len(), 1);
}
#[test]
fn test_file_source_builder_no_paths() {
let result = FileSource::builder().build();
assert!(result.is_err());
}
#[test]
fn test_detect_format() {
assert_eq!(
FileSource::detect_format_from_path(Path::new("config.toml")),
Some(Format::Toml)
);
assert_eq!(
FileSource::detect_format_from_path(Path::new("config.json")),
Some(Format::Json)
);
#[cfg(feature = "yaml")]
{
assert_eq!(
FileSource::detect_format_from_path(Path::new("config.yaml")),
Some(Format::Yaml)
);
assert_eq!(
FileSource::detect_format_from_path(Path::new("config.yml")),
Some(Format::Yaml)
);
}
assert_eq!(
FileSource::detect_format_from_path(Path::new("config.txt")),
None
);
}
#[cfg(feature = "toml")]
#[test]
fn test_load_raw_toml() {
let content = r#"
name = "test"
value = 42
"#;
let file = create_temp_file(content, ".toml");
let source = FileSource::new(file.path());
let raw = source.load_raw().unwrap();
let str_content = raw.as_str().unwrap();
assert!(str_content.as_ref().contains("test"));
}
#[cfg(feature = "json")]
#[test]
fn test_load_raw_json() {
let content = r#"{"name": "test", "value": 42}"#;
let file = create_temp_file(content, ".json");
let source = FileSource::new(file.path());
let raw = source.load_raw().unwrap();
let str_content = raw.as_str().unwrap();
assert!(str_content.as_ref().contains("test"));
}
#[test]
fn test_load_file_not_found() {
let source = FileSource::new("/nonexistent/config.toml");
let result = source.load_raw();
assert!(result.is_err());
}
#[test]
fn test_load_optional_file_not_found() {
let source = FileSource::builder()
.path("/nonexistent/config.toml")
.required(false)
.build()
.unwrap();
let raw = source.load_raw().unwrap();
assert!(raw.is_empty());
}
#[test]
fn test_metadata() {
let source = FileSource::new("config.toml");
let meta = source.metadata();
assert_eq!(meta.name, "file");
assert_eq!(source.kind(), SourceKind::File);
assert_eq!(meta.priority, 100);
}
#[cfg(feature = "async")]
#[cfg(feature = "toml")]
#[tokio::test]
async fn test_load_raw_async_toml() {
let content = r#"
name = "async_test"
value = 123
"#;
let file = create_temp_file(content, ".toml");
let source = FileSource::new(file.path());
let raw = source.load_raw_async().await.unwrap();
let str_content = raw.as_str().unwrap();
assert!(str_content.as_ref().contains("async_test"));
}
#[cfg(feature = "async")]
#[cfg(feature = "json")]
#[tokio::test]
async fn test_load_raw_async_json() {
let content = r#"{"name": "async_test", "value": 123}"#;
let file = create_temp_file(content, ".json");
let source = FileSource::new(file.path());
let raw = source.load_raw_async().await.unwrap();
let str_content = raw.as_str().unwrap();
assert!(str_content.as_ref().contains("async_test"));
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_load_raw_async_file_not_found() {
let source = FileSource::new("/nonexistent/async_config.toml");
let result = source.load_raw_async().await;
assert!(result.is_err());
}
#[cfg(feature = "async")]
#[tokio::test]
async fn test_load_raw_async_optional_not_found() {
let source = FileSource::builder()
.path("/nonexistent/async_config.toml")
.required(false)
.build()
.unwrap();
let raw = source.load_raw_async().await.unwrap();
assert!(raw.is_empty());
}
#[cfg(feature = "async")]
#[cfg(feature = "toml")]
#[tokio::test]
async fn test_load_raw_async_multiple_files() {
let content1 = r#"[section1]
key1 = "value1""#;
let content2 = r#"[section2]
key2 = "value2""#;
let file1 = create_temp_file(content1, ".toml");
let file2 = create_temp_file(content2, ".toml");
let source = FileSource::builder()
.path(file1.path())
.path(file2.path())
.build()
.unwrap();
let raw = source.load_raw_async().await.unwrap();
let str_content = raw.as_str().unwrap();
assert!(str_content.as_ref().contains("section1"));
assert!(str_content.as_ref().contains("section2"));
}
}