version bumped to 0.5.1
This commit is contained in:
1 parent
b07a2d8f75
commit
53dc317d7d
11 files changed
+526
-391
No files matched your search
+63
-55
@@ -1,30 +1,43 @@
|
||||
//! Handler for flat `.env` (Environment) configuration files.
|
||||
//!
|
||||
//! This handler manages simple `KEY=VALUE` pairs. It does not support
|
||||
//! native nesting or grouping, treating all entries as root-level variables.
|
||||
|
||||
use super::{ConfigItem, FormatHandler, ItemStatus, ValueType, PathSegment};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// A format handler for parsing and writing `.env` files.
|
||||
pub struct EnvHandler;
|
||||
|
||||
impl FormatHandler for EnvHandler {
|
||||
/// Parses an environment file into a flat list of `ConfigItem`s.
|
||||
fn parse(&self, path: &Path) -> anyhow::Result<Vec<ConfigItem>> {
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut vars = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
// Skip empty lines and comments.
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue; // Skip comments and empty lines
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, val)) = line.split_once('=') {
|
||||
let parsed_val = val.trim().trim_matches('"').trim_matches('\'').to_string();
|
||||
let key_str = key.trim().to_string();
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim().to_string();
|
||||
|
||||
vars.push(ConfigItem {
|
||||
key: key_str.clone(),
|
||||
path: vec![PathSegment::Key(key_str)],
|
||||
value: Some(parsed_val.clone()),
|
||||
template_value: Some(parsed_val.clone()),
|
||||
default_value: Some(parsed_val),
|
||||
key: key.clone(),
|
||||
path: vec![PathSegment::Key(key)],
|
||||
value: Some(value.clone()),
|
||||
template_value: Some(value.clone()),
|
||||
default_value: Some(value.clone()),
|
||||
depth: 0,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
@@ -36,9 +49,11 @@ impl FormatHandler for EnvHandler {
|
||||
Ok(vars)
|
||||
}
|
||||
|
||||
/// Writes the list of variables back to a flat `.env` file.
|
||||
fn write(&self, path: &Path, vars: &[ConfigItem]) -> anyhow::Result<()> {
|
||||
let mut file = fs::File::create(path)?;
|
||||
for var in vars {
|
||||
// .env files ignore structural groups.
|
||||
if !var.is_group {
|
||||
let val = var.value.as_deref()
|
||||
.or(var.template_value.as_deref())
|
||||
@@ -53,63 +68,21 @@ impl FormatHandler for EnvHandler {
|
||||
#[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();
|
||||
|
||||
writeln!(file, "# Comment\nKEY1=value1\n KEY2 = value2 ").unwrap();
|
||||
|
||||
let handler = EnvHandler;
|
||||
let vars = handler.parse(file.path()).unwrap();
|
||||
assert_eq!(vars.len(), 4);
|
||||
|
||||
assert_eq!(vars.len(), 2);
|
||||
assert_eq!(vars[0].key, "KEY1");
|
||||
assert_eq!(vars[0].value.as_deref(), Some("value1"));
|
||||
assert_eq!(vars[1].key, "KEY2");
|
||||
assert_eq!(vars[1].value.as_deref(), Some("value2"));
|
||||
assert_eq!(vars[2].key, "KEY3");
|
||||
assert_eq!(vars[2].value.as_deref(), Some("value3"));
|
||||
assert_eq!(vars[3].key, "EMPTY");
|
||||
assert_eq!(vars[3].value.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_env() {
|
||||
let handler = EnvHandler;
|
||||
|
||||
let mut env_file = NamedTempFile::new().unwrap();
|
||||
writeln!(env_file, "KEY1=custom1\nKEY3=custom3").unwrap();
|
||||
let mut vars = handler.parse(env_file.path()).unwrap(); // Active vars
|
||||
|
||||
let mut example_file = NamedTempFile::new().unwrap();
|
||||
writeln!(example_file, "KEY1=default1\nKEY2=default2").unwrap();
|
||||
|
||||
handler.merge(example_file.path(), &mut vars).unwrap(); // Merge template into active
|
||||
|
||||
// Should preserve order of active, then append template
|
||||
assert_eq!(vars.len(), 3);
|
||||
|
||||
// Active key that exists in template
|
||||
assert_eq!(vars[0].key, "KEY1");
|
||||
assert_eq!(vars[0].value.as_deref(), Some("custom1")); // Keeps active value
|
||||
assert_eq!(vars[0].template_value.as_deref(), Some("default1")); // Gets template default
|
||||
assert_eq!(vars[0].status, ItemStatus::Modified);
|
||||
|
||||
// Active key that DOES NOT exist in template
|
||||
assert_eq!(vars[1].key, "KEY3");
|
||||
assert_eq!(vars[1].value.as_deref(), Some("custom3"));
|
||||
assert_eq!(vars[1].status, ItemStatus::Present);
|
||||
|
||||
// Template key that DOES NOT exist in active
|
||||
assert_eq!(vars[2].key, "KEY2");
|
||||
assert_eq!(vars[2].value.as_deref(), None); // Missing from active
|
||||
assert_eq!(vars[2].template_value.as_deref(), Some("default2"));
|
||||
assert_eq!(vars[2].status, ItemStatus::MissingFromActive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -133,4 +106,39 @@ mod tests {
|
||||
let content = fs::read_to_string(file.path()).unwrap();
|
||||
assert_eq!(content.trim(), "KEY1=value1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_env() {
|
||||
let template = NamedTempFile::new().unwrap();
|
||||
writeln!(template.as_file(), "KEY1=template_val\nKEY2=default_val").unwrap();
|
||||
|
||||
let mut active_vars = vec![ConfigItem {
|
||||
key: "KEY1".to_string(),
|
||||
path: vec![PathSegment::Key("KEY1".to_string())],
|
||||
value: Some("active_val".to_string()),
|
||||
template_value: None,
|
||||
default_value: None,
|
||||
depth: 0,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::String,
|
||||
}];
|
||||
|
||||
let handler = EnvHandler;
|
||||
handler.merge(template.path(), &mut active_vars).unwrap();
|
||||
|
||||
assert_eq!(active_vars.len(), 2);
|
||||
|
||||
// KEY1 should be marked modified
|
||||
let key1 = active_vars.iter().find(|v| v.key == "KEY1").unwrap();
|
||||
assert_eq!(key1.status, ItemStatus::Modified);
|
||||
assert_eq!(key1.value.as_deref(), Some("active_val"));
|
||||
assert_eq!(key1.template_value.as_deref(), Some("template_val"));
|
||||
|
||||
// KEY2 should be marked missing
|
||||
let key2 = active_vars.iter().find(|v| v.key == "KEY2").unwrap();
|
||||
assert_eq!(key2.status, ItemStatus::MissingFromActive);
|
||||
assert_eq!(key2.value, None);
|
||||
assert_eq!(key2.template_value.as_deref(), Some("default_val"));
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
//! Provides a generalized handler for hierarchical configuration formats
|
||||
//! (JSON, YAML, TOML, XML).
|
||||
//!
|
||||
//! This handler works by using `serde_json::Value` as an intermediary
|
||||
//! representation. It "flattens" nested structures into a list of
|
||||
//! `ConfigItem`s for the TUI, and "unflattens" them back into their
|
||||
//! nested form when saving.
|
||||
|
||||
use super::{ConfigItem, FormatHandler, FormatType, ItemStatus, ValueType, PathSegment};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// A format handler capable of processing nested configuration structures.
|
||||
pub struct HierarchicalHandler {
|
||||
/// The specific file format this instance is configured to handle.
|
||||
format_type: FormatType,
|
||||
}
|
||||
|
||||
impl HierarchicalHandler {
|
||||
/// Creates a new hierarchical handler for the specified format.
|
||||
pub fn new(format_type: FormatType) -> Self {
|
||||
Self { format_type }
|
||||
}
|
||||
|
||||
/// Reads a file and parses it into an intermediary `serde_json::Value`.
|
||||
fn read_value(&self, path: &Path) -> anyhow::Result<Value> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let value = match self.format_type {
|
||||
@@ -24,12 +36,13 @@ impl HierarchicalHandler {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Serializes a `serde_json::Value` and writes it to the specified path.
|
||||
fn write_value(&self, path: &Path, value: &Value) -> anyhow::Result<()> {
|
||||
let content = match self.format_type {
|
||||
FormatType::Json => serde_json::to_string_pretty(value)?,
|
||||
FormatType::Yaml => serde_yaml::to_string(value)?,
|
||||
FormatType::Toml => {
|
||||
// toml requires the root to be a table
|
||||
// TOML requires the root to be a table (Object).
|
||||
if value.is_object() {
|
||||
let toml_value: toml::Value = serde_json::from_value(value.clone())?;
|
||||
toml::to_string_pretty(&toml_value)?
|
||||
@@ -45,6 +58,10 @@ impl HierarchicalHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an XML string into an intermediary `serde_json::Value`.
|
||||
///
|
||||
/// This implementation handles basic element nesting and text content.
|
||||
/// Attributes are currently not supported.
|
||||
fn xml_to_json(content: &str) -> anyhow::Result<Value> {
|
||||
use quick_xml::reader::Reader;
|
||||
use quick_xml::events::Event;
|
||||
@@ -53,6 +70,7 @@ fn xml_to_json(content: &str) -> anyhow::Result<Value> {
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
/// Recursively parses XML elements into a nested Map structure.
|
||||
fn parse_recursive(reader: &mut Reader<&[u8]>) -> anyhow::Result<Value> {
|
||||
let mut map = Map::new();
|
||||
let mut text = String::new();
|
||||
@@ -64,6 +82,7 @@ fn xml_to_json(content: &str) -> anyhow::Result<Value> {
|
||||
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
||||
let val = parse_recursive(reader)?;
|
||||
|
||||
// If multiple elements have the same name, they are collected into an Array.
|
||||
if let Some(existing) = map.get_mut(&name) {
|
||||
if let Some(arr) = existing.as_array_mut() {
|
||||
arr.push(val);
|
||||
@@ -93,6 +112,7 @@ fn xml_to_json(content: &str) -> anyhow::Result<Value> {
|
||||
}
|
||||
} else {
|
||||
if !text.is_empty() {
|
||||
// Special key used to store raw text content of an element that also has children.
|
||||
map.insert("$text".to_string(), Value::String(text));
|
||||
}
|
||||
Ok(Value::Object(map))
|
||||
@@ -118,12 +138,14 @@ fn xml_to_json(content: &str) -> anyhow::Result<Value> {
|
||||
Ok(Value::Object(Map::new()))
|
||||
}
|
||||
|
||||
/// Converts a nested `serde_json::Value` back into an XML string.
|
||||
fn json_to_xml(value: &Value) -> String {
|
||||
use quick_xml::Writer;
|
||||
use quick_xml::events::{Event, BytesStart, BytesEnd, BytesText};
|
||||
|
||||
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
|
||||
|
||||
/// Recursively writes JSON values as XML elements.
|
||||
fn write_recursive(writer: &mut Writer<Vec<u8>>, value: &Value, key_name: Option<&str>) {
|
||||
if let Some(k) = key_name
|
||||
&& k == "$text" {
|
||||
@@ -205,7 +227,6 @@ fn json_to_xml(value: &Value) -> String {
|
||||
write_recursive(&mut writer, value, None);
|
||||
}
|
||||
|
||||
// Quick-XML adds a trailing newline occasionally, or we might need one
|
||||
let mut out = String::from_utf8(writer.into_inner()).unwrap();
|
||||
if !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
@@ -213,6 +234,10 @@ fn json_to_xml(value: &Value) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Recursively flattens a nested `serde_json::Value` into a list of `ConfigItem`s.
|
||||
///
|
||||
/// This function translates the structural hierarchy of the JSON into
|
||||
/// linear entries with associated paths and depth levels for UI rendering.
|
||||
fn flatten(value: &Value, current_path: Vec<PathSegment>, key_name: Option<String>, depth: usize, vars: &mut Vec<ConfigItem>) {
|
||||
let mut next_path = current_path.clone();
|
||||
|
||||
@@ -354,6 +379,9 @@ impl FormatHandler for HierarchicalHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs a nested `serde_json::Value` from a hierarchical path and a leaf value.
|
||||
///
|
||||
/// This is the core "unflattening" logic used when saving modified configuration.
|
||||
fn insert_into_value(root: &mut Value, path: &[PathSegment], new_val_str: &str, value_type: ValueType) {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
@@ -361,7 +389,7 @@ fn insert_into_value(root: &mut Value, path: &[PathSegment], new_val_str: &str,
|
||||
|
||||
let mut current = root;
|
||||
|
||||
// Traverse all but the last segment
|
||||
// Traverse all but the last segment to build the intermediate structure.
|
||||
for i in 0..path.len() - 1 {
|
||||
let segment = &path[i];
|
||||
let next_segment = &path[i + 1];
|
||||
@@ -397,9 +425,10 @@ fn insert_into_value(root: &mut Value, path: &[PathSegment], new_val_str: &str,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the final segment
|
||||
// Insert the actual leaf value at the end of the path.
|
||||
let final_segment = &path[path.len() - 1];
|
||||
|
||||
// Attempt to preserve the original data type during insertion.
|
||||
let final_val = match value_type {
|
||||
ValueType::Number => {
|
||||
if let Ok(n) = new_val_str.parse::<i64>() {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
//! This module defines the unified data model used by `mould` to represent
|
||||
//! configuration data across all supported file formats.
|
||||
//!
|
||||
//! By normalizing heterogeneous structures (like nested YAML or flat .env)
|
||||
//! into a standard tree-like representation, the TUI logic remains
|
||||
//! independent of the underlying file format.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub mod env;
|
||||
@@ -5,24 +12,39 @@ pub mod hierarchical;
|
||||
pub mod ini;
|
||||
pub mod properties;
|
||||
|
||||
/// Represents the status of a configuration item relative to a template.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ItemStatus {
|
||||
/// Item exists in the active configuration and matches the template (or no template exists).
|
||||
Present,
|
||||
/// Item exists in the template but is missing from the active configuration.
|
||||
MissingFromActive,
|
||||
/// Item has been changed by the user during the current session.
|
||||
Modified,
|
||||
}
|
||||
|
||||
/// Hints about the original data type to ensure correct serialization during writes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValueType {
|
||||
/// Standard text.
|
||||
String,
|
||||
/// Numeric values (integers or floats).
|
||||
Number,
|
||||
/// True/False values.
|
||||
Bool,
|
||||
/// Representing an explicit null or empty value.
|
||||
Null,
|
||||
}
|
||||
|
||||
/// A single segment in a hierarchical configuration path.
|
||||
///
|
||||
/// For example, `services[0].image` would be represented as:
|
||||
/// `[Key("services"), Index(0), Key("image")]`
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum PathSegment {
|
||||
/// A named key in an object/map.
|
||||
Key(String),
|
||||
/// A numeric index in an array/list.
|
||||
Index(usize),
|
||||
}
|
||||
|
||||
@@ -35,20 +57,35 @@ impl std::fmt::Display for PathSegment {
|
||||
}
|
||||
}
|
||||
|
||||
/// The unified representation of a single configuration entry.
|
||||
///
|
||||
/// This model is used for UI rendering and internal manipulation.
|
||||
/// Format-specific handlers are responsible for translating their native
|
||||
/// data into this structure.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigItem {
|
||||
/// The short display name of the key (e.g., `port`).
|
||||
pub key: String,
|
||||
/// The full hierarchical path defining this item's location in the config tree.
|
||||
pub path: Vec<PathSegment>,
|
||||
/// The active value of the configuration entry.
|
||||
pub value: Option<String>,
|
||||
/// The value found in the template file (if any).
|
||||
pub template_value: Option<String>,
|
||||
/// A fallback value to use if the item is missing.
|
||||
pub default_value: Option<String>,
|
||||
/// Visual depth in the tree (used for indentation in the TUI).
|
||||
pub depth: usize,
|
||||
/// True if this item represents a structural node (object or array) rather than a leaf value.
|
||||
pub is_group: bool,
|
||||
/// Comparison status relative to the template.
|
||||
pub status: ItemStatus,
|
||||
/// Metadata about the original data type.
|
||||
pub value_type: ValueType,
|
||||
}
|
||||
|
||||
impl ConfigItem {
|
||||
/// Returns a human-readable string representation of the full path (e.g., `server.port`).
|
||||
pub fn path_string(&self) -> String {
|
||||
let mut s = String::new();
|
||||
for (i, segment) in self.path.iter().enumerate() {
|
||||
@@ -67,6 +104,8 @@ impl ConfigItem {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported configuration file formats.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FormatType {
|
||||
Env,
|
||||
@@ -78,8 +117,16 @@ pub enum FormatType {
|
||||
Properties,
|
||||
}
|
||||
|
||||
/// Defines the interface for parsing, merging, and writing configuration files.
|
||||
///
|
||||
/// Implementing this trait allows `mould` to support new file formats.
|
||||
pub trait FormatHandler {
|
||||
/// Parses a file into the unified `Vec<ConfigItem>` representation.
|
||||
fn parse(&self, path: &Path) -> anyhow::Result<Vec<ConfigItem>>;
|
||||
|
||||
/// Merges an active configuration with a template file.
|
||||
///
|
||||
/// This identifies missing keys, marks modifications, and syncs default values.
|
||||
fn merge(&self, path: &Path, vars: &mut Vec<ConfigItem>) -> anyhow::Result<()> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
@@ -115,9 +162,12 @@ pub trait FormatHandler {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes the unified representation back to the original file format.
|
||||
fn write(&self, path: &Path, vars: &[ConfigItem]) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
/// Automatically detects the configuration format based on file extension or an explicit override.
|
||||
pub fn detect_format(path: &Path, override_format: Option<String>) -> FormatType {
|
||||
if let Some(fmt) = override_format {
|
||||
match fmt.to_lowercase().as_str() {
|
||||
@@ -144,6 +194,7 @@ pub fn detect_format(path: &Path, override_format: Option<String>) -> FormatType
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory function to return the appropriate handler implementation for a given format.
|
||||
pub fn get_handler(format: FormatType) -> Box<dyn FormatHandler> {
|
||||
match format {
|
||||
FormatType::Env => Box::new(env::EnvHandler),
|
||||
|
||||
@@ -59,7 +59,7 @@ impl FormatHandler for PropertiesHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// We don't sort here to preserve the original file order!
|
||||
|
||||
Ok(vars)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user