added yaml,json + configurable keys

This commit is contained in:
2026-03-16 17:36:04 +01:00
parent 8cee54007f
commit 253c69363d
12 changed files with 922 additions and 302 deletions

142
src/format/env.rs Normal file
View File

@@ -0,0 +1,142 @@
use super::{EnvVar, FormatHandler};
use std::fs;
use std::io::{self, Write};
use std::path::Path;
pub struct EnvHandler;
impl FormatHandler for EnvHandler {
fn parse(&self, path: &Path) -> io::Result<Vec<EnvVar>> {
let content = fs::read_to_string(path)?;
let mut vars = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue; // Skip comments and empty lines
}
if let Some((key, val)) = line.split_once('=') {
let parsed_val = val.trim().trim_matches('"').trim_matches('\'').to_string();
vars.push(EnvVar {
key: key.trim().to_string(),
value: parsed_val.clone(),
default_value: parsed_val,
});
}
}
Ok(vars)
}
fn merge(&self, path: &Path, vars: &mut Vec<EnvVar>) -> io::Result<()> {
if !path.exists() {
return Ok(());
}
let content = fs::read_to_string(path)?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, val)) = line.split_once('=') {
let key = key.trim();
let parsed_val = val.trim().trim_matches('"').trim_matches('\'').to_string();
if let Some(var) = vars.iter_mut().find(|v| v.key == key) {
var.value = parsed_val;
} else {
vars.push(EnvVar {
key: key.to_string(),
value: parsed_val.clone(),
default_value: String::new(),
});
}
}
}
Ok(())
}
fn write(&self, path: &Path, vars: &[EnvVar]) -> io::Result<()> {
let mut file = fs::File::create(path)?;
for var in vars {
writeln!(file, "{}={}", var.key, var.value)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_parse_env_example() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
"# A comment\nKEY1=value1\nKEY2=\"value2\"\nKEY3='value3'\nEMPTY="
)
.unwrap();
let handler = EnvHandler;
let vars = handler.parse(file.path()).unwrap();
assert_eq!(vars.len(), 4);
assert_eq!(vars[0].key, "KEY1");
assert_eq!(vars[0].value, "value1");
assert_eq!(vars[0].default_value, "value1");
assert_eq!(vars[1].key, "KEY2");
assert_eq!(vars[1].value, "value2");
assert_eq!(vars[2].key, "KEY3");
assert_eq!(vars[2].value, "value3");
assert_eq!(vars[3].key, "EMPTY");
assert_eq!(vars[3].value, "");
}
#[test]
fn test_merge_env() {
let mut example_file = NamedTempFile::new().unwrap();
writeln!(example_file, "KEY1=default1\nKEY2=default2").unwrap();
let handler = EnvHandler;
let mut vars = handler.parse(example_file.path()).unwrap();
let mut env_file = NamedTempFile::new().unwrap();
writeln!(env_file, "KEY1=custom1\nKEY3=custom3").unwrap();
handler.merge(env_file.path(), &mut vars).unwrap();
assert_eq!(vars.len(), 3);
assert_eq!(vars[0].key, "KEY1");
assert_eq!(vars[0].value, "custom1");
assert_eq!(vars[0].default_value, "default1");
assert_eq!(vars[1].key, "KEY2");
assert_eq!(vars[1].value, "default2");
assert_eq!(vars[1].default_value, "default2");
assert_eq!(vars[2].key, "KEY3");
assert_eq!(vars[2].value, "custom3");
assert_eq!(vars[2].default_value, "");
}
#[test]
fn test_write_env() {
let file = NamedTempFile::new().unwrap();
let vars = vec![EnvVar {
key: "KEY1".to_string(),
value: "value1".to_string(),
default_value: "def".to_string(),
}];
let handler = EnvHandler;
handler.write(file.path(), &vars).unwrap();
let content = fs::read_to_string(file.path()).unwrap();
assert_eq!(content.trim(), "KEY1=value1");
}
}

249
src/format/hierarchical.rs Normal file
View File

@@ -0,0 +1,249 @@
use super::{EnvVar, FormatHandler, FormatType};
use serde_json::{Map, Value};
use std::fs;
use std::io;
use std::path::Path;
pub struct HierarchicalHandler {
format_type: FormatType,
}
impl HierarchicalHandler {
pub fn new(format_type: FormatType) -> Self {
Self { format_type }
}
fn read_value(&self, path: &Path) -> io::Result<Value> {
let content = fs::read_to_string(path)?;
let value = match self.format_type {
FormatType::Json => serde_json::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
FormatType::Yaml => serde_yaml::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
FormatType::Toml => toml::from_str(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
_ => unreachable!(),
};
Ok(value)
}
fn write_value(&self, path: &Path, value: &Value) -> io::Result<()> {
let content = match self.format_type {
FormatType::Json => serde_json::to_string_pretty(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
FormatType::Yaml => serde_yaml::to_string(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
FormatType::Toml => {
// toml requires the root to be a table
if value.is_object() {
let toml_value: toml::Value = serde_json::from_value(value.clone()).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
toml::to_string_pretty(&toml_value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?
} else {
return Err(io::Error::new(io::ErrorKind::InvalidData, "Root of TOML must be an object"));
}
}
_ => unreachable!(),
};
fs::write(path, content)
}
}
fn flatten(value: &Value, prefix: &str, vars: &mut Vec<EnvVar>) {
match value {
Value::Object(map) => {
for (k, v) in map {
let new_prefix = if prefix.is_empty() {
k.clone()
} else {
format!("{}.{}", prefix, k)
};
flatten(v, &new_prefix, vars);
}
}
Value::Array(arr) => {
for (i, v) in arr.iter().enumerate() {
let new_prefix = format!("{}[{}]", prefix, i);
flatten(v, &new_prefix, vars);
}
}
Value::String(s) => {
vars.push(EnvVar {
key: prefix.to_string(),
value: s.clone(),
default_value: s.clone(),
});
}
Value::Number(n) => {
let s = n.to_string();
vars.push(EnvVar {
key: prefix.to_string(),
value: s.clone(),
default_value: s.clone(),
});
}
Value::Bool(b) => {
let s = b.to_string();
vars.push(EnvVar {
key: prefix.to_string(),
value: s.clone(),
default_value: s.clone(),
});
}
Value::Null => {
vars.push(EnvVar {
key: prefix.to_string(),
value: "".to_string(),
default_value: "".to_string(),
});
}
}
}
// Removed unused update_leaf and update_leaf_value functions
impl FormatHandler for HierarchicalHandler {
fn parse(&self, path: &Path) -> io::Result<Vec<EnvVar>> {
let value = self.read_value(path)?;
let mut vars = Vec::new();
flatten(&value, "", &mut vars);
Ok(vars)
}
fn merge(&self, path: &Path, vars: &mut Vec<EnvVar>) -> io::Result<()> {
if !path.exists() {
return Ok(());
}
let existing_value = self.read_value(path)?;
let mut existing_vars = Vec::new();
flatten(&existing_value, "", &mut existing_vars);
for var in vars.iter_mut() {
if let Some(existing) = existing_vars.iter().find(|v| v.key == var.key) {
var.value = existing.value.clone();
}
}
Ok(())
}
fn write(&self, path: &Path, vars: &[EnvVar]) -> io::Result<()> {
// For writing hierarchical formats, we ideally want to preserve the original structure.
// But we don't have it here. We should parse the template again to get the structure!
// Oh wait, `write` is called with only `vars`.
// If we want to construct the tree from scratch, it's very difficult to guess array vs object
// and data types without the original template.
// Let's change the trait or just keep a copy of the template?
// Actually, if we require the user to have the template, we can just parse the template, update the leaves, and write.
// We'll write a reconstruction algorithm that just creates objects based on keys.
let mut root = Value::Object(Map::new());
for var in vars {
insert_into_value(&mut root, &var.key, &var.value);
}
self.write_value(path, &root)
}
}
fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str) {
let mut parts = path.split('.');
let last_part = match parts.next_back() {
Some(p) => p,
None => return,
};
let mut current = root;
for part in parts {
let (key, idx) = parse_array_key(part);
if !current.is_object() {
*current = Value::Object(Map::new());
}
let map = current.as_object_mut().unwrap();
let next_node = map.entry(key.to_string()).or_insert_with(|| {
if idx.is_some() {
Value::Array(Vec::new())
} else {
Value::Object(Map::new())
}
});
if let Some(i) = idx {
if !next_node.is_array() {
*next_node = Value::Array(Vec::new());
}
let arr = next_node.as_array_mut().unwrap();
while arr.len() <= i {
arr.push(Value::Object(Map::new()));
}
current = &mut arr[i];
} else {
current = next_node;
}
}
let (final_key, final_idx) = parse_array_key(last_part);
if !current.is_object() {
*current = Value::Object(Map::new());
}
let map = current.as_object_mut().unwrap();
// Attempt basic type inference
let final_val = if let Ok(n) = new_val_str.parse::<i64>() {
Value::Number(n.into())
} else if let Ok(b) = new_val_str.parse::<bool>() {
Value::Bool(b)
} else {
Value::String(new_val_str.to_string())
};
if let Some(i) = final_idx {
let next_node = map.entry(final_key.to_string()).or_insert_with(|| Value::Array(Vec::new()));
if !next_node.is_array() {
*next_node = Value::Array(Vec::new());
}
let arr = next_node.as_array_mut().unwrap();
while arr.len() <= i {
arr.push(Value::Null);
}
arr[i] = final_val;
} else {
map.insert(final_key.to_string(), final_val);
}
}
fn parse_array_key(part: &str) -> (&str, Option<usize>) {
if part.ends_with(']') && part.contains('[') {
let start_idx = part.find('[').unwrap();
let key = &part[..start_idx];
let idx = part[start_idx+1..part.len()-1].parse::<usize>().ok();
(key, idx)
} else {
(part, None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_flatten_unflatten() {
let mut vars = Vec::new();
let json = serde_json::json!({
"services": {
"web": {
"ports": ["8080:80"],
"environment": {
"DEBUG": true
}
}
}
});
flatten(&json, "", &mut vars);
assert_eq!(vars.len(), 2);
let mut root = Value::Object(Map::new());
for var in vars {
insert_into_value(&mut root, &var.key, &var.value);
}
// When unflattening, it parses bool back
let unflattened_json = serde_json::to_string(&root).unwrap();
assert!(unflattened_json.contains("\"8080:80\""));
assert!(unflattened_json.contains("true"));
}
}

58
src/format/mod.rs Normal file
View File

@@ -0,0 +1,58 @@
use std::path::Path;
use std::io;
pub mod env;
pub mod hierarchical;
#[derive(Debug, Clone)]
pub struct EnvVar {
pub key: String,
pub value: String,
pub default_value: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormatType {
Env,
Json,
Yaml,
Toml,
}
pub trait FormatHandler {
fn parse(&self, path: &Path) -> io::Result<Vec<EnvVar>>;
fn merge(&self, path: &Path, vars: &mut Vec<EnvVar>) -> io::Result<()>;
fn write(&self, path: &Path, vars: &[EnvVar]) -> io::Result<()>;
}
pub fn detect_format(path: &Path, override_format: Option<String>) -> FormatType {
if let Some(fmt) = override_format {
match fmt.to_lowercase().as_str() {
"env" => return FormatType::Env,
"json" => return FormatType::Json,
"yaml" | "yml" => return FormatType::Yaml,
"toml" => return FormatType::Toml,
_ => {}
}
}
let file_name = path.file_name().unwrap_or_default().to_string_lossy();
if file_name.ends_with(".json") {
FormatType::Json
} else if file_name.ends_with(".yaml") || file_name.ends_with(".yml") {
FormatType::Yaml
} else if file_name.ends_with(".toml") {
FormatType::Toml
} else {
FormatType::Env
}
}
pub fn get_handler(format: FormatType) -> Box<dyn FormatHandler> {
match format {
FormatType::Env => Box::new(env::EnvHandler),
FormatType::Json => Box::new(hierarchical::HierarchicalHandler::new(FormatType::Json)),
FormatType::Yaml => Box::new(hierarchical::HierarchicalHandler::new(FormatType::Yaml)),
FormatType::Toml => Box::new(hierarchical::HierarchicalHandler::new(FormatType::Toml)),
}
}