use crate::Format;
use crate::error::Result;
use cfgmatic_merge::{Merge, MergeBehavior, MergeOptions};
use cfgmatic_paths::ConfigTier;
use serde::de::DeserializeOwned;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct ConfigFile {
pub path: PathBuf,
pub tier: ConfigTier,
pub format: Format,
pub content: Option<String>,
}
impl ConfigFile {
pub(crate) fn new(path: PathBuf, tier: ConfigTier) -> Option<Self> {
let format = Format::from_path(&path)?;
Some(Self {
path,
tier,
format,
content: None,
})
}
#[must_use]
pub fn name(&self) -> Option<&str> {
self.path.file_name()?.to_str()
}
pub fn read(&mut self) -> Result<&str> {
if let Some(ref content) = self.content {
return Ok(content);
}
let content = fs::read_to_string(&self.path)?;
self.content = Some(content);
Ok(self.content.as_ref().unwrap()) }
pub fn parse<T: DeserializeOwned>(&mut self) -> Result<T> {
if self.content.is_none() {
let content = fs::read_to_string(&self.path)?;
self.content = Some(content);
}
let content = self.content.as_ref().unwrap();
self.format.parse(content, &self.path)
}
pub fn parse_uncached<T: DeserializeOwned>(&self) -> Result<T> {
let content = fs::read_to_string(&self.path)?;
self.format.parse(&content, &self.path)
}
#[must_use]
pub fn exists(&self) -> bool {
self.path.exists()
}
pub fn modified(&self) -> Result<std::time::SystemTime> {
Ok(fs::metadata(&self.path)?.modified()?)
}
}
impl PartialEq for ConfigFile {
fn eq(&self, other: &Self) -> bool {
self.path == other.path
}
}
impl Eq for ConfigFile {}
impl PartialOrd for ConfigFile {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ConfigFile {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
u8::from(self.tier).cmp(&u8::from(other.tier))
}
}
impl std::fmt::Display for ConfigFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} ({:?}, {})",
self.path.display(),
self.tier,
self.format
)
}
}
#[derive(Debug, Clone, Default)]
pub struct ConfigFiles {
files: Vec<ConfigFile>,
}
impl ConfigFiles {
#[must_use]
pub const fn new() -> Self {
Self { files: Vec::new() }
}
pub fn push(&mut self, file: ConfigFile) {
self.files.push(file);
self.sort();
}
fn sort(&mut self) {
self.files.sort_by(|a, b| b.cmp(a)); }
#[must_use]
pub const fn len(&self) -> usize {
self.files.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.files.is_empty()
}
#[must_use]
pub fn first(&self) -> Option<&ConfigFile> {
self.files.first()
}
pub fn first_mut(&mut self) -> Option<&mut ConfigFile> {
self.files.first_mut()
}
pub fn iter(&self) -> impl Iterator<Item = &ConfigFile> {
self.files.iter()
}
pub fn merge<T>(&mut self) -> Result<T>
where
T: DeserializeOwned + Mergeable + Default,
{
let mut result = T::default();
for file in self.files.iter_mut().rev() {
let value: T = file.parse()?;
result = result.merge(value);
}
Ok(result)
}
}
impl IntoIterator for ConfigFiles {
type Item = ConfigFile;
type IntoIter = std::vec::IntoIter<ConfigFile>;
fn into_iter(self) -> Self::IntoIter {
self.files.into_iter()
}
}
impl<'a> IntoIterator for &'a ConfigFiles {
type Item = &'a ConfigFile;
type IntoIter = std::slice::Iter<'a, ConfigFile>;
fn into_iter(self) -> Self::IntoIter {
self.files.iter()
}
}
pub trait Mergeable {
#[must_use]
fn merge(self, other: Self) -> Self;
}
impl Mergeable for serde_json::Value {
fn merge(self, other: Self) -> Self {
let opts = MergeOptions::new().behavior(MergeBehavior::Deep);
Merge::merge(self, other.clone(), &opts).unwrap_or(other)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_config_file_sorting() {
let file1 = ConfigFile {
path: PathBuf::from("/etc/config.toml"),
tier: ConfigTier::System,
format: Format::Toml,
content: None,
};
let file2 = ConfigFile {
path: PathBuf::from("~/.config.toml"),
tier: ConfigTier::User,
format: Format::Toml,
content: None,
};
assert!(file2 > file1);
}
#[test]
fn test_parse_toml() -> Result<()> {
#[derive(Debug, serde::Deserialize, PartialEq)]
struct Config {
timeout: u32,
host: String,
}
let mut temp = NamedTempFile::with_suffix(".toml")?;
write!(temp, "timeout = 30\nhost = \"localhost\"")?;
let mut file =
ConfigFile::new(temp.path().to_path_buf(), ConfigTier::User).expect("valid toml file");
let config: Config = file.parse()?;
assert_eq!(config.timeout, 30);
assert_eq!(config.host, "localhost");
Ok(())
}
#[test]
fn test_parse_json() -> Result<()> {
let mut temp = NamedTempFile::with_suffix(".json")?;
write!(temp, "{{\"port\": 8080, \"enabled\": true}}")?;
let mut file =
ConfigFile::new(temp.path().to_path_buf(), ConfigTier::User).expect("valid json file");
let value: serde_json::Value = file.parse()?;
assert_eq!(value["port"], 8080);
assert_eq!(value["enabled"], true);
Ok(())
}
#[test]
fn test_config_files_collection() {
let mut files = ConfigFiles::new();
assert!(files.is_empty());
let file = ConfigFile {
path: PathBuf::from("config.toml"),
tier: ConfigTier::User,
format: Format::Toml,
content: None,
};
files.push(file);
assert_eq!(files.len(), 1);
assert!(files.first().is_some());
}
}