fix: make the template workflow real, and the UI legible
Version Check / check-version (pull_request) Successful in 10s
Version Check / check-version (pull_request) Successful in 10s
The headline flow — fill a config from its template — did not work. Three bugs, each individually enough to make the tool feel unfinished: - `add_missing` was unreachable. `find_binding` returned the first match in a linear array and `edit_append` was also bound to `a`, 17 entries earlier. Bindings now resolve through one table built from the config, and `KeybindsConfig::conflicts()` reports any key bound twice instead of letting one silently shadow the other. `add_missing` moves to `<Space>`, with `A` to take every missing default at once. - Item status was ignored on write. Every writer fell back to `template_value`, so "(missing)" keys landed in the output regardless and saving was close to a copy. Unaccepted entries are now omitted. - Parse errors were swallowed, then the good file was overwritten. A malformed target opened as an empty editor and `:w` truncated it. Parse failures are now fatal, reported with context, before the TUI starts. Also: - Split "differs from the template" (ItemStatus::Overridden) from "you edited this" (ConfigItem::dirty). A real config differs from its example on nearly every line, so the old Modified flag painted the whole list orange and carried no signal. - Splice template-only entries in at their natural position instead of appending them, so a missing nested key no longer strands itself at the bottom of the tree at depth 3. - Preserve .env comments and blank lines across a save. - Header naming the output file and the template it is compared against, plus missing/changed counts. Previously nothing on screen said which files were in play or where `:w` would write. - `?` opens a two-column keybinding reference built from the user's own config, scrollable on short terminals. The status bar drops to a few contextual hints; the old one joined 11+ hints into an unwrapped line that truncated at 80 columns. - Foldable groups, which the `+ ` prefix had always implied. - Search covers values as well as keys, reports `match n/m`, and `Esc` restores the pre-search cursor. - Quitting with unsaved changes is refused; added `:q!`, `:x`, `:w <path>`. - Status messages expire after 4s instead of permanently replacing hints. - Cancelling a rename on a just-created item removes it again. - Editing a Number field with text demotes the type instead of failing obscurely at write time. - Undo-tree branches are reachable via `g-`/`g+` and reported on undo. - Poll instead of blocking, so resizes repaint; drop the unused mouse capture that was costing terminal text selection for nothing. - `--format` accepts xml, ini and properties, which were already supported. 36 tests, up from 13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C5mrbEFmwv2A4cdzbcSiS1
This commit is contained in:
1 parent
2e974e0503
commit
e8634c17a2
16 files changed
+2218
-915
No files matched your search
+98
-46
@@ -2,8 +2,13 @@
|
||||
//!
|
||||
//! This handler manages simple `KEY=VALUE` pairs. It does not support
|
||||
//! native nesting or grouping, treating all entries as root-level variables.
|
||||
//!
|
||||
//! Comments and blank lines are preserved: each entry remembers the lines that
|
||||
//! preceded it, and they are replayed on write. `.env.example` files carry most
|
||||
//! of their documentation in comments, so dropping them on save would throw away
|
||||
//! the very thing that makes the template useful.
|
||||
|
||||
use super::{ConfigItem, FormatHandler, ItemStatus, ValueType, PathSegment};
|
||||
use super::{ConfigItem, FormatHandler, ValueType, PathSegment};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
@@ -20,29 +25,30 @@ impl FormatHandler for EnvHandler {
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut vars = Vec::new();
|
||||
let mut trivia: Vec<String> = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
// Skip empty lines and comments.
|
||||
for raw_line in content.lines() {
|
||||
let line = raw_line.trim();
|
||||
|
||||
// Hold comments and blank lines until the entry they document.
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
trivia.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
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.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,
|
||||
value_type: ValueType::String,
|
||||
});
|
||||
|
||||
let mut item = ConfigItem::leaf(
|
||||
key.clone(),
|
||||
vec![PathSegment::Key(key)],
|
||||
value,
|
||||
0,
|
||||
ValueType::String,
|
||||
);
|
||||
item.leading_trivia = std::mem::take(&mut trivia);
|
||||
vars.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,13 +59,15 @@ impl FormatHandler for EnvHandler {
|
||||
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())
|
||||
.unwrap_or("");
|
||||
writeln!(file, "{}={}", var.key, val)?;
|
||||
// Groups are structural only, and template-only entries the user has
|
||||
// not accepted must not leak into the output.
|
||||
if !var.should_write() {
|
||||
continue;
|
||||
}
|
||||
for line in &var.leading_trivia {
|
||||
writeln!(file, "{}", line)?;
|
||||
}
|
||||
writeln!(file, "{}={}", var.key, var.write_value())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -68,6 +76,7 @@ impl FormatHandler for EnvHandler {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::format::ItemStatus;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
@@ -88,17 +97,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_write_env() {
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
let vars = vec![ConfigItem {
|
||||
key: "KEY1".to_string(),
|
||||
path: vec![PathSegment::Key("KEY1".to_string())],
|
||||
value: Some("value1".to_string()),
|
||||
template_value: None,
|
||||
default_value: None,
|
||||
depth: 0,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::String,
|
||||
}];
|
||||
let vars = vec![ConfigItem::leaf(
|
||||
"KEY1",
|
||||
vec![PathSegment::Key("KEY1".to_string())],
|
||||
"value1",
|
||||
0,
|
||||
ValueType::String,
|
||||
)];
|
||||
|
||||
let handler = EnvHandler;
|
||||
handler.write(file.path(), &vars).unwrap();
|
||||
@@ -112,26 +117,23 @@ mod tests {
|
||||
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 mut active_vars = vec![ConfigItem::leaf(
|
||||
"KEY1",
|
||||
vec![PathSegment::Key("KEY1".to_string())],
|
||||
"active_val",
|
||||
0,
|
||||
ValueType::String,
|
||||
)];
|
||||
|
||||
let handler = EnvHandler;
|
||||
handler.merge(template.path(), &mut active_vars).unwrap();
|
||||
|
||||
assert_eq!(active_vars.len(), 2);
|
||||
|
||||
// KEY1 should be marked modified
|
||||
// KEY1 differs from the template default, but the user has not touched it.
|
||||
let key1 = active_vars.iter().find(|v| v.key == "KEY1").unwrap();
|
||||
assert_eq!(key1.status, ItemStatus::Modified);
|
||||
assert_eq!(key1.status, ItemStatus::Overridden);
|
||||
assert!(!key1.dirty);
|
||||
assert_eq!(key1.value.as_deref(), Some("active_val"));
|
||||
assert_eq!(key1.template_value.as_deref(), Some("template_val"));
|
||||
|
||||
@@ -141,4 +143,54 @@ mod tests {
|
||||
assert_eq!(key2.value, None);
|
||||
assert_eq!(key2.template_value.as_deref(), Some("default_val"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comments_survive_roundtrip() {
|
||||
let mut file = NamedTempFile::new().unwrap();
|
||||
writeln!(file, "# database settings\nDB_HOST=localhost\n\n# api key, keep secret\nAPI_KEY=abc").unwrap();
|
||||
|
||||
let handler = EnvHandler;
|
||||
let vars = handler.parse(file.path()).unwrap();
|
||||
assert_eq!(vars[0].leading_trivia, vec!["# database settings"]);
|
||||
assert_eq!(vars[1].leading_trivia, vec!["", "# api key, keep secret"]);
|
||||
|
||||
let out = NamedTempFile::new().unwrap();
|
||||
handler.write(out.path(), &vars).unwrap();
|
||||
let content = fs::read_to_string(out.path()).unwrap();
|
||||
|
||||
assert!(content.contains("# database settings"));
|
||||
assert!(content.contains("# api key, keep secret"));
|
||||
assert!(content.contains("DB_HOST=localhost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_items_are_not_written() {
|
||||
let handler = EnvHandler;
|
||||
let mut accepted = ConfigItem::leaf(
|
||||
"KEEP",
|
||||
vec![PathSegment::Key("KEEP".to_string())],
|
||||
"yes",
|
||||
0,
|
||||
ValueType::String,
|
||||
);
|
||||
accepted.status = ItemStatus::Present;
|
||||
|
||||
let mut missing = ConfigItem::leaf(
|
||||
"SKIP",
|
||||
vec![PathSegment::Key("SKIP".to_string())],
|
||||
"",
|
||||
0,
|
||||
ValueType::String,
|
||||
);
|
||||
missing.status = ItemStatus::MissingFromActive;
|
||||
missing.value = None;
|
||||
missing.template_value = Some("from_template".to_string());
|
||||
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
handler.write(file.path(), &[accepted, missing]).unwrap();
|
||||
|
||||
let content = fs::read_to_string(file.path()).unwrap();
|
||||
assert!(content.contains("KEEP=yes"));
|
||||
assert!(!content.contains("SKIP"));
|
||||
}
|
||||
}
|
||||
+84
-84
@@ -6,7 +6,7 @@
|
||||
//! `ConfigItem`s for the TUI, and "unflattens" them back into their
|
||||
//! nested form when saving.
|
||||
|
||||
use super::{ConfigItem, FormatHandler, FormatType, ItemStatus, ValueType, PathSegment};
|
||||
use super::{ConfigItem, FormatHandler, FormatType, ValueType, PathSegment};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -262,17 +262,7 @@ fn flatten(value: &Value, current_path: Vec<PathSegment>, key_name: Option<Strin
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if !next_path.is_empty() {
|
||||
vars.push(ConfigItem {
|
||||
key: display_key,
|
||||
path: next_path.clone(),
|
||||
value: None,
|
||||
template_value: None,
|
||||
default_value: None,
|
||||
depth,
|
||||
is_group: true,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::Null,
|
||||
});
|
||||
vars.push(ConfigItem::group(display_key, next_path.clone(), depth));
|
||||
}
|
||||
let next_depth = if next_path.is_empty() { depth } else { depth + 1 };
|
||||
for (k, v) in map {
|
||||
@@ -281,17 +271,7 @@ fn flatten(value: &Value, current_path: Vec<PathSegment>, key_name: Option<Strin
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
if !next_path.is_empty() {
|
||||
vars.push(ConfigItem {
|
||||
key: display_key,
|
||||
path: next_path.clone(),
|
||||
value: None,
|
||||
template_value: None,
|
||||
default_value: None,
|
||||
depth,
|
||||
is_group: true,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::Null,
|
||||
});
|
||||
vars.push(ConfigItem::group(display_key, next_path.clone(), depth));
|
||||
}
|
||||
let next_depth = if next_path.is_empty() { depth } else { depth + 1 };
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
@@ -301,58 +281,18 @@ fn flatten(value: &Value, current_path: Vec<PathSegment>, key_name: Option<Strin
|
||||
}
|
||||
}
|
||||
Value::String(s) => {
|
||||
vars.push(ConfigItem {
|
||||
key: display_key,
|
||||
path: next_path.clone(),
|
||||
value: Some(s.clone()),
|
||||
template_value: Some(s.clone()),
|
||||
default_value: Some(s.clone()),
|
||||
depth,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::String,
|
||||
});
|
||||
vars.push(ConfigItem::leaf(display_key, next_path.clone(), s.clone(), depth, ValueType::String));
|
||||
}
|
||||
Value::Number(n) => {
|
||||
let s = n.to_string();
|
||||
vars.push(ConfigItem {
|
||||
key: display_key,
|
||||
path: next_path.clone(),
|
||||
value: Some(s.clone()),
|
||||
template_value: Some(s.clone()),
|
||||
default_value: Some(s.clone()),
|
||||
depth,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::Number,
|
||||
});
|
||||
vars.push(ConfigItem::leaf(display_key, next_path.clone(), s.clone(), depth, ValueType::Number));
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
let s = b.to_string();
|
||||
vars.push(ConfigItem {
|
||||
key: display_key,
|
||||
path: next_path.clone(),
|
||||
value: Some(s.clone()),
|
||||
template_value: Some(s.clone()),
|
||||
default_value: Some(s.clone()),
|
||||
depth,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::Bool,
|
||||
});
|
||||
vars.push(ConfigItem::leaf(display_key, next_path.clone(), s.clone(), depth, ValueType::Bool));
|
||||
}
|
||||
Value::Null => {
|
||||
vars.push(ConfigItem {
|
||||
key: display_key,
|
||||
path: next_path.clone(),
|
||||
value: Some("".to_string()),
|
||||
template_value: Some("".to_string()),
|
||||
default_value: Some("".to_string()),
|
||||
depth,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::Null,
|
||||
});
|
||||
vars.push(ConfigItem::leaf(display_key, next_path.clone(), "", depth, ValueType::Null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,11 +308,8 @@ impl FormatHandler for HierarchicalHandler {
|
||||
fn write(&self, path: &Path, vars: &[ConfigItem]) -> anyhow::Result<()> {
|
||||
let mut root = Value::Object(Map::new());
|
||||
for var in vars {
|
||||
if !var.is_group {
|
||||
let val = var.value.as_deref()
|
||||
.or(var.template_value.as_deref())
|
||||
.unwrap_or("");
|
||||
insert_into_value(&mut root, &var.path, val, var.value_type);
|
||||
if var.should_write() {
|
||||
insert_into_value(&mut root, &var.path, var.write_value(), var.value_type);
|
||||
}
|
||||
}
|
||||
self.write_value(path, &root)
|
||||
@@ -478,6 +415,7 @@ fn insert_into_value(root: &mut Value, path: &[PathSegment], new_val_str: &str,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::format::ItemStatus;
|
||||
|
||||
#[test]
|
||||
fn test_json_flatten_unflatten() {
|
||||
@@ -616,22 +554,18 @@ enabled = true
|
||||
flatten(&json, Vec::new(), Some("".to_string()), 0, &mut vars);
|
||||
assert_eq!(vars.len(), 2);
|
||||
assert_eq!(vars[0].key, "old_group");
|
||||
assert_eq!(vars[0].is_group, true);
|
||||
assert!(vars[0].is_group);
|
||||
assert_eq!(vars[1].key, "key");
|
||||
assert_eq!(vars[1].path_string(), "old_group.key");
|
||||
|
||||
// Manually simulate a rename of "old_group" to "new_group"
|
||||
let old_path = vars[0].path.clone();
|
||||
let new_key = "new_group".to_string();
|
||||
let mut new_path = vec![PathSegment::Key(new_key.clone())];
|
||||
|
||||
vars[0].key = new_key;
|
||||
vars[0].path = new_path.clone();
|
||||
|
||||
// Update child path
|
||||
vars[1].path = vec![PathSegment::Key("new_group".to_string()), PathSegment::Key("key".to_string())];
|
||||
|
||||
let handler = HierarchicalHandler::new(FormatType::Json);
|
||||
vars[0].key = "new_group".to_string();
|
||||
vars[0].path = vec![PathSegment::Key("new_group".to_string())];
|
||||
vars[1].path = vec![
|
||||
PathSegment::Key("new_group".to_string()),
|
||||
PathSegment::Key("key".to_string()),
|
||||
];
|
||||
|
||||
let mut root = Value::Object(Map::new());
|
||||
for var in &vars {
|
||||
if !var.is_group {
|
||||
@@ -644,4 +578,70 @@ enabled = true
|
||||
assert!(out.contains("\"key\":\"val\""));
|
||||
assert!(!out.contains("\"old_group\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_nested_keys_are_spliced_beside_their_siblings() {
|
||||
use std::io::Write;
|
||||
let handler = HierarchicalHandler::new(FormatType::Json);
|
||||
|
||||
let mut template = tempfile::NamedTempFile::new().unwrap();
|
||||
write!(
|
||||
template,
|
||||
r#"{{"server":{{"host":"localhost","port":8080}},"debug":false}}"#
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The active config is missing `server.port` entirely.
|
||||
let mut active = Vec::new();
|
||||
flatten(
|
||||
&serde_json::json!({"server": {"host": "example.com"}, "debug": true}),
|
||||
Vec::new(),
|
||||
Some("".to_string()),
|
||||
0,
|
||||
&mut active,
|
||||
);
|
||||
|
||||
handler.merge(template.path(), &mut active).unwrap();
|
||||
|
||||
let order: Vec<String> = active.iter().map(|v| v.path_string()).collect();
|
||||
assert_eq!(order, vec!["server", "server.host", "server.port", "debug"]);
|
||||
|
||||
let port = active.iter().find(|v| v.key == "port").unwrap();
|
||||
assert_eq!(port.status, ItemStatus::MissingFromActive);
|
||||
assert_eq!(port.depth, 1);
|
||||
assert_eq!(port.template_value.as_deref(), Some("8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_file_is_an_error_not_an_empty_document() {
|
||||
use std::io::Write;
|
||||
let handler = HierarchicalHandler::new(FormatType::Json);
|
||||
let mut file = tempfile::NamedTempFile::new().unwrap();
|
||||
write!(file, "{{ not json").unwrap();
|
||||
|
||||
assert!(handler.parse(file.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unaccepted_template_entries_are_not_written() {
|
||||
let handler = HierarchicalHandler::new(FormatType::Json);
|
||||
let mut vars = Vec::new();
|
||||
flatten(
|
||||
&serde_json::json!({"kept": "yes", "skipped": "no"}),
|
||||
Vec::new(),
|
||||
Some("".to_string()),
|
||||
0,
|
||||
&mut vars,
|
||||
);
|
||||
let skipped = vars.iter_mut().find(|v| v.key == "skipped").unwrap();
|
||||
skipped.status = ItemStatus::MissingFromActive;
|
||||
skipped.template_value = skipped.value.take();
|
||||
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
handler.write(file.path(), &vars).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(file.path()).unwrap();
|
||||
assert!(content.contains("kept"));
|
||||
assert!(!content.contains("skipped"));
|
||||
}
|
||||
}
|
||||
+23
-50
@@ -1,4 +1,4 @@
|
||||
use super::{ConfigItem, FormatHandler, ItemStatus, ValueType, PathSegment};
|
||||
use super::{ConfigItem, FormatHandler, ValueType, PathSegment};
|
||||
use ini::Ini;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -13,17 +13,11 @@ impl FormatHandler for IniHandler {
|
||||
let section_name = section.unwrap_or_default();
|
||||
|
||||
if !section_name.is_empty() {
|
||||
vars.push(ConfigItem {
|
||||
key: section_name.to_string(),
|
||||
path: vec![PathSegment::Key(section_name.to_string())],
|
||||
value: None,
|
||||
template_value: None,
|
||||
default_value: None,
|
||||
depth: 0,
|
||||
is_group: true,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::Null,
|
||||
});
|
||||
vars.push(ConfigItem::group(
|
||||
section_name,
|
||||
vec![PathSegment::Key(section_name.to_string())],
|
||||
0,
|
||||
));
|
||||
}
|
||||
|
||||
for (key, value) in prop {
|
||||
@@ -33,17 +27,13 @@ impl FormatHandler for IniHandler {
|
||||
vec![PathSegment::Key(section_name.to_string()), PathSegment::Key(key.to_string())]
|
||||
};
|
||||
|
||||
vars.push(ConfigItem {
|
||||
key: key.to_string(),
|
||||
vars.push(ConfigItem::leaf(
|
||||
key,
|
||||
path,
|
||||
value: Some(value.to_string()),
|
||||
template_value: Some(value.to_string()),
|
||||
default_value: Some(value.to_string()),
|
||||
depth: if section_name.is_empty() { 0 } else { 1 },
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::String,
|
||||
});
|
||||
value,
|
||||
if section_name.is_empty() { 0 } else { 1 },
|
||||
ValueType::String,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +43,9 @@ impl FormatHandler for IniHandler {
|
||||
fn write(&self, path: &Path, vars: &[ConfigItem]) -> anyhow::Result<()> {
|
||||
let mut conf = Ini::new();
|
||||
for var in vars {
|
||||
if !var.is_group {
|
||||
let val = var.value.as_deref()
|
||||
.or(var.template_value.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
if var.should_write() {
|
||||
let val = var.write_value();
|
||||
|
||||
if var.path.len() == 2 {
|
||||
if let (PathSegment::Key(section), PathSegment::Key(key)) = (&var.path[0], &var.path[1]) {
|
||||
conf.with_section(Some(section)).set(key, val);
|
||||
@@ -77,34 +65,19 @@ impl FormatHandler for IniHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::NamedTempFile;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_section_rename_write() {
|
||||
let handler = IniHandler;
|
||||
let mut vars = vec![
|
||||
ConfigItem {
|
||||
key: "server".to_string(),
|
||||
path: vec![PathSegment::Key("server".to_string())],
|
||||
value: None,
|
||||
template_value: None,
|
||||
default_value: None,
|
||||
depth: 0,
|
||||
is_group: true,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::Null,
|
||||
},
|
||||
ConfigItem {
|
||||
key: "port".to_string(),
|
||||
path: vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())],
|
||||
value: Some("8080".to_string()),
|
||||
template_value: Some("8080".to_string()),
|
||||
default_value: Some("8080".to_string()),
|
||||
depth: 1,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::String,
|
||||
}
|
||||
ConfigItem::group("server", vec![PathSegment::Key("server".to_string())], 0),
|
||||
ConfigItem::leaf(
|
||||
"port",
|
||||
vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())],
|
||||
"8080",
|
||||
1,
|
||||
ValueType::String,
|
||||
),
|
||||
];
|
||||
|
||||
// Rename "server" to "srv"
|
||||
|
||||
+120
-20
@@ -12,15 +12,20 @@ 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)]
|
||||
/// Represents the status of a configuration item relative to its template.
|
||||
///
|
||||
/// This describes the *template comparison* only. Whether the user has edited
|
||||
/// an item during this session is tracked separately by [`ConfigItem::dirty`],
|
||||
/// so that a config which legitimately differs from its example file does not
|
||||
/// render as if every line had just been touched.
|
||||
#[derive(Debug, Clone, Copy, 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,
|
||||
/// Item exists in both, but the active value differs from the template default.
|
||||
Overridden,
|
||||
}
|
||||
|
||||
/// Hints about the original data type to ensure correct serialization during writes.
|
||||
@@ -82,9 +87,71 @@ pub struct ConfigItem {
|
||||
pub status: ItemStatus,
|
||||
/// Metadata about the original data type.
|
||||
pub value_type: ValueType,
|
||||
/// True if the user changed this item during the current session.
|
||||
pub dirty: bool,
|
||||
/// True if this group's children are folded away in the tree view.
|
||||
pub collapsed: bool,
|
||||
/// Comment and blank lines that preceded this entry in the source file.
|
||||
///
|
||||
/// Replayed verbatim on write by formats that carry inline documentation
|
||||
/// (currently `.env`), so that annotated templates survive a save.
|
||||
pub leading_trivia: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ConfigItem {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
key: String::new(),
|
||||
path: Vec::new(),
|
||||
value: None,
|
||||
template_value: None,
|
||||
default_value: None,
|
||||
depth: 0,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::String,
|
||||
dirty: false,
|
||||
collapsed: false,
|
||||
leading_trivia: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigItem {
|
||||
/// Builds a structural node (object, array, or section).
|
||||
pub fn group(key: impl Into<String>, path: Vec<PathSegment>, depth: usize) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
path,
|
||||
depth,
|
||||
is_group: true,
|
||||
value_type: ValueType::Null,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a leaf entry holding an actual value.
|
||||
///
|
||||
/// `template_value` and `default_value` are intentionally left unset: they are
|
||||
/// populated by [`FormatHandler::merge`] from the template file, and mean
|
||||
/// nothing when a file is parsed on its own.
|
||||
pub fn leaf(
|
||||
key: impl Into<String>,
|
||||
path: Vec<PathSegment>,
|
||||
value: impl Into<String>,
|
||||
depth: usize,
|
||||
value_type: ValueType,
|
||||
) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
path,
|
||||
value: Some(value.into()),
|
||||
depth,
|
||||
value_type,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -103,6 +170,25 @@ impl ConfigItem {
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// True if this entry belongs in the output file.
|
||||
///
|
||||
/// Items that exist only in the template and have not been accepted by the
|
||||
/// user are deliberately omitted, which is what makes the "(missing)" marker
|
||||
/// mean something: nothing is written until it is pulled in explicitly.
|
||||
pub fn should_write(&self) -> bool {
|
||||
!self.is_group && self.status != ItemStatus::MissingFromActive
|
||||
}
|
||||
|
||||
/// The string that should be serialized for this entry.
|
||||
pub fn write_value(&self) -> &str {
|
||||
self.value.as_deref().unwrap_or("")
|
||||
}
|
||||
|
||||
/// True if this item sits inside the subtree rooted at `prefix` (and is not `prefix` itself).
|
||||
pub fn is_descendant_of(&self, prefix: &[PathSegment]) -> bool {
|
||||
self.path.len() > prefix.len() && self.path.starts_with(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported configuration file formats.
|
||||
@@ -126,38 +212,52 @@ pub trait FormatHandler {
|
||||
|
||||
/// Merges an active configuration with a template file.
|
||||
///
|
||||
/// This identifies missing keys, marks modifications, and syncs default values.
|
||||
/// This identifies missing keys, records template defaults, and splices
|
||||
/// template-only entries in at their natural position so that the tree view
|
||||
/// keeps reflecting the real structure.
|
||||
fn merge(&self, path: &Path, vars: &mut Vec<ConfigItem>) -> anyhow::Result<()> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let template_vars = self.parse(path).unwrap_or_default();
|
||||
let template_vars = self.parse(path)?;
|
||||
|
||||
for var in vars.iter_mut() {
|
||||
if let Some(template_var) = template_vars.iter().find(|v| v.path == var.path) {
|
||||
var.template_value = template_var.value.clone();
|
||||
var.default_value = template_var.value.clone();
|
||||
|
||||
if var.value != template_var.value {
|
||||
var.status = ItemStatus::Modified;
|
||||
|
||||
var.status = if var.value == template_var.value {
|
||||
ItemStatus::Present
|
||||
} else {
|
||||
var.status = ItemStatus::Present;
|
||||
}
|
||||
ItemStatus::Overridden
|
||||
};
|
||||
} else {
|
||||
// Exists in active, but not in template
|
||||
// Exists in active, but not in template.
|
||||
var.status = ItemStatus::Present;
|
||||
}
|
||||
}
|
||||
|
||||
// Add items from template that are missing in active
|
||||
for template_var in template_vars {
|
||||
if !vars.iter().any(|v| v.path == template_var.path) {
|
||||
let mut new_item = template_var.clone();
|
||||
new_item.status = ItemStatus::MissingFromActive;
|
||||
new_item.value = None;
|
||||
vars.push(new_item);
|
||||
|
||||
// Splice in template-only entries. `template_vars` is in document order
|
||||
// (a pre-order walk of the tree), so tracking an anchor as we go keeps
|
||||
// each missing entry next to the siblings it belongs with instead of
|
||||
// stranding it at the end of the list.
|
||||
let mut anchor = 0usize;
|
||||
for template_var in &template_vars {
|
||||
if let Some(pos) = vars.iter().position(|v| v.path == template_var.path) {
|
||||
anchor = pos + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut new_item = template_var.clone();
|
||||
new_item.status = ItemStatus::MissingFromActive;
|
||||
new_item.template_value = template_var.value.clone();
|
||||
new_item.default_value = template_var.value.clone();
|
||||
new_item.value = None;
|
||||
|
||||
let at = anchor.min(vars.len());
|
||||
vars.insert(at, new_item);
|
||||
anchor = at + 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+19
-51
@@ -1,4 +1,4 @@
|
||||
use super::{ConfigItem, FormatHandler, ItemStatus, ValueType, PathSegment};
|
||||
use super::{ConfigItem, FormatHandler, ValueType, PathSegment};
|
||||
use java_properties::{LineContent, PropertiesIter, PropertiesWriter};
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
@@ -27,17 +27,7 @@ impl FormatHandler for PropertiesHandler {
|
||||
current_path.push(PathSegment::Key(part.to_string()));
|
||||
|
||||
if groups.insert(current_path.clone()) {
|
||||
vars.push(ConfigItem {
|
||||
key: part.to_string(),
|
||||
path: current_path.clone(),
|
||||
value: None,
|
||||
template_value: None,
|
||||
default_value: None,
|
||||
depth: i,
|
||||
is_group: true,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::Null,
|
||||
});
|
||||
vars.push(ConfigItem::group(*part, current_path.clone(), i));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,17 +35,13 @@ impl FormatHandler for PropertiesHandler {
|
||||
let last_key = parts.last().unwrap_or(&"").to_string();
|
||||
final_path.push(PathSegment::Key(last_key.clone()));
|
||||
|
||||
vars.push(ConfigItem {
|
||||
key: last_key,
|
||||
path: final_path,
|
||||
value: Some(value.clone()),
|
||||
template_value: Some(value.clone()),
|
||||
default_value: Some(value.clone()),
|
||||
depth: parts.len().saturating_sub(1),
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::String,
|
||||
});
|
||||
vars.push(ConfigItem::leaf(
|
||||
last_key,
|
||||
final_path,
|
||||
value,
|
||||
parts.len().saturating_sub(1),
|
||||
ValueType::String,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,11 +55,8 @@ impl FormatHandler for PropertiesHandler {
|
||||
let mut prop_writer = PropertiesWriter::new(writer);
|
||||
|
||||
for var in vars {
|
||||
if !var.is_group {
|
||||
let val = var.value.as_deref()
|
||||
.or(var.template_value.as_deref())
|
||||
.unwrap_or("");
|
||||
prop_writer.write(&var.path_string(), val)?;
|
||||
if var.should_write() {
|
||||
prop_writer.write(&var.path_string(), var.write_value())?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,34 +69,19 @@ impl FormatHandler for PropertiesHandler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::NamedTempFile;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_group_rename_write() {
|
||||
let handler = PropertiesHandler;
|
||||
let mut vars = vec![
|
||||
ConfigItem {
|
||||
key: "server".to_string(),
|
||||
path: vec![PathSegment::Key("server".to_string())],
|
||||
value: None,
|
||||
template_value: None,
|
||||
default_value: None,
|
||||
depth: 0,
|
||||
is_group: true,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::Null,
|
||||
},
|
||||
ConfigItem {
|
||||
key: "port".to_string(),
|
||||
path: vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())],
|
||||
value: Some("8080".to_string()),
|
||||
template_value: Some("8080".to_string()),
|
||||
default_value: Some("8080".to_string()),
|
||||
depth: 1,
|
||||
is_group: false,
|
||||
status: ItemStatus::Present,
|
||||
value_type: ValueType::String,
|
||||
}
|
||||
ConfigItem::group("server", vec![PathSegment::Key("server".to_string())], 0),
|
||||
ConfigItem::leaf(
|
||||
"port",
|
||||
vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())],
|
||||
"8080",
|
||||
1,
|
||||
ValueType::String,
|
||||
),
|
||||
];
|
||||
|
||||
// Rename "server" to "srv"
|
||||
|
||||
Reference in new issue
Block a user