use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use confium_registry::paths::config_file;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConfigDocument {
#[serde(flatten)]
pub tables: BTreeMap<String, Table>,
}
pub type Table = BTreeMap<String, Value>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum Value {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
Array(Vec<Value>),
}
impl Value {
pub fn as_display_string(&self) -> String {
match self {
Value::String(s) => s.clone(),
Value::Integer(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::Boolean(b) => b.to_string(),
Value::Array(items) => {
let joined: Vec<String> = items.iter().map(|v| v.as_display_string()).collect();
format!("[{}]", joined.join(", "))
}
}
}
}
pub fn split_dotted(key: &str) -> Result<(String, String), String> {
let (head, tail) = key
.split_once('.')
.ok_or_else(|| format!("config key '{key}' must be dotted (e.g. registry.default)"))?;
if tail.is_empty() {
return Err(format!("config key '{key}' has empty field"));
}
Ok((head.to_string(), tail.to_string()))
}
pub struct ConfigFile {
path: PathBuf,
}
impl ConfigFile {
pub fn user() -> Self {
let path = config_file(None).unwrap_or_else(|_| PathBuf::from("config.toml"));
ConfigFile { path }
}
pub fn for_home(override_home: PathBuf) -> Self {
let path =
config_file(Some(&override_home)).unwrap_or_else(|_| PathBuf::from("config.toml"));
ConfigFile { path }
}
pub fn path(&self) -> &PathBuf {
&self.path
}
pub fn load(&self) -> std::io::Result<ConfigDocument> {
if !self.path.exists() {
return Ok(ConfigDocument::default());
}
let body = std::fs::read_to_string(&self.path)?;
let doc: ConfigDocument = toml::from_str(&body).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("TOML parse: {e}"))
})?;
Ok(doc)
}
pub fn save(&self, doc: &ConfigDocument) -> std::io::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = toml::to_string_pretty(doc).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("TOML serialize: {e}"),
)
})?;
std::fs::write(&self.path, body)
}
}
pub fn parse_value(raw: &str) -> Value {
let trimmed = raw.trim();
match trimmed {
"true" => return Value::Boolean(true),
"false" => return Value::Boolean(false),
_ => {}
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let inner = &trimmed[1..trimmed.len() - 1];
let items: Vec<Value> = inner.split(',').map(|s| parse_value(s.trim())).collect();
return Value::Array(items);
}
if let Ok(i) = trimmed.parse::<i64>() {
return Value::Integer(i);
}
if let Ok(f) = trimmed.parse::<f64>() {
return Value::Float(f);
}
Value::String(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_dotted_works() {
assert_eq!(
split_dotted("registry.default").unwrap(),
("registry".into(), "default".into())
);
}
#[test]
fn split_dotted_rejects_bare() {
assert!(split_dotted("registry").is_err());
}
#[test]
fn parse_value_recognises_kinds() {
assert_eq!(parse_value("true"), Value::Boolean(true));
assert_eq!(parse_value("42"), Value::Integer(42));
assert!(matches!(parse_value("2.5"), Value::Float(_)));
assert_eq!(parse_value("hello"), Value::String("hello".into()));
let arr = parse_value("[a, b, c]");
assert!(matches!(arr, Value::Array(_)));
}
#[test]
fn config_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let cfg = ConfigFile::for_home(PathBuf::from(tmp.path()));
let mut doc = ConfigDocument::default();
doc.tables.entry("registry".into()).or_default().insert(
"default".into(),
Value::String("https://example.test".into()),
);
cfg.save(&doc).unwrap();
let loaded = cfg.load().unwrap();
assert_eq!(
loaded.tables["registry"]["default"],
Value::String("https://example.test".into())
);
}
#[test]
fn load_missing_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let cfg = ConfigFile::for_home(PathBuf::from(tmp.path()));
let doc = cfg.load().unwrap();
assert!(doc.tables.is_empty());
}
}