added yaml,json + configurable keys
This commit is contained in:
142
src/format/env.rs
Normal file
142
src/format/env.rs
Normal 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
249
src/format/hierarchical.rs
Normal 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
58
src/format/mod.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user