fix: make the template workflow real, and the UI legible
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:
nvrlandClaude Opus 5 committed 2026-09-05 05:01:39 +02:00
1 parent 2e974e0503
commit e8634c17a2
16 files changed
+2218 -915

No files matched your search

+98 -46
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"