version bumped to 0.5.1

This commit is contained in:
nvrl committed 2026-03-19 10:02:22 +01:00
1 parent b07a2d8f75
commit 53dc317d7d
11 files changed
+526 -391

No files matched your search

+63 -55
View File
@@ -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"));
}
}
+33 -4
View File
@@ -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>() {
+51
View File
@@ -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),
+1 -1
View File
@@ -59,7 +59,7 @@ impl FormatHandler for PropertiesHandler {
}
}
// We don't sort here to preserve the original file order!
Ok(vars)
}