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

+295 -18
View File
@@ -1,3 +1,9 @@
//! User configuration: theming and keybindings.
//!
//! Everything the TUI reacts to is declared here. Actions are resolved through
//! one table built from the user's config, so a rebound key takes effect
//! everywhere — including the on-screen hints and the help overlay.
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::fs;
@@ -24,7 +30,7 @@ pub struct ThemeConfig {
pub fg_highlight: String,
/// Red/Alert color for missing items.
pub fg_warning: String,
/// Accent color for modified items.
/// Accent color for items edited this session.
pub fg_modified: String,
/// High-contrast accent for titles and active UI elements.
pub fg_accent: String,
@@ -99,7 +105,109 @@ impl Default for ThemeConfig {
}
}
/// Every action the user can trigger from Normal mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Down,
Up,
JumpTop,
JumpBottom,
Search,
NextMatch,
PreviousMatch,
Edit,
EditAppend,
EditSubstitute,
Rename,
AppendItem,
PrependItem,
AppendGroup,
PrependGroup,
ToggleGroup,
DeleteItem,
AddMissing,
AcceptAll,
ToggleFold,
FoldAll,
UnfoldAll,
Undo,
Redo,
UndoBranchPrev,
UndoBranchNext,
Help,
Command,
Quit,
}
impl Action {
/// Short human-readable label, used by the status bar and help overlay.
pub fn label(self) -> &'static str {
match self {
Action::Down => "move down",
Action::Up => "move up",
Action::JumpTop => "jump to top",
Action::JumpBottom => "jump to bottom",
Action::Search => "search keys and values",
Action::NextMatch => "next match",
Action::PreviousMatch => "previous match",
Action::Edit => "edit value (cursor at start)",
Action::EditAppend => "edit value (cursor at end)",
Action::EditSubstitute => "replace value",
Action::Rename => "rename key",
Action::AppendItem => "add item below",
Action::PrependItem => "add item above",
Action::AppendGroup => "add child group below",
Action::PrependGroup => "add child group above",
Action::ToggleGroup => "toggle group / value",
Action::DeleteItem => "delete item",
Action::AddMissing => "take template default",
Action::AcceptAll => "take all missing defaults",
Action::ToggleFold => "fold / unfold group",
Action::FoldAll => "fold everything",
Action::UnfoldAll => "unfold everything",
Action::Undo => "undo",
Action::Redo => "redo",
Action::UndoBranchPrev => "previous undo branch",
Action::UndoBranchNext => "next undo branch",
Action::Help => "show this help",
Action::Command => "command prompt",
Action::Quit => "quit",
}
}
/// Help-overlay grouping.
pub fn section(self) -> &'static str {
match self {
Action::Down
| Action::Up
| Action::JumpTop
| Action::JumpBottom
| Action::Search
| Action::NextMatch
| Action::PreviousMatch
| Action::ToggleFold
| Action::FoldAll
| Action::UnfoldAll => "Navigate",
Action::Edit | Action::EditAppend | Action::EditSubstitute | Action::Rename => "Edit",
Action::AppendItem
| Action::PrependItem
| Action::AppendGroup
| Action::PrependGroup
| Action::ToggleGroup
| Action::DeleteItem => "Structure",
Action::AddMissing | Action::AcceptAll => "Template",
Action::Undo | Action::Redo | Action::UndoBranchPrev | Action::UndoBranchNext => {
"History"
}
Action::Help | Action::Command | Action::Quit => "Session",
}
}
}
/// Custom keybindings for navigation and application control.
///
/// A binding may list several alternatives separated by commas (`"<Tab>,za"`).
/// Non-character keys are written in angle brackets: `<Space>`, `<Tab>`.
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(default)]
pub struct KeybindsConfig {
@@ -108,9 +216,14 @@ pub struct KeybindsConfig {
pub edit: String,
pub edit_append: String,
pub edit_substitute: String,
/// Command string that saves, entered at the `:` prompt.
pub save: String,
/// Command string that quits, entered at the `:` prompt.
pub quit: String,
pub normal_mode: String,
/// Normal-mode key that quits directly.
pub quit_key: String,
/// Normal-mode key that opens the `:` prompt.
pub command: String,
pub search: String,
pub next_match: String,
pub previous_match: String,
@@ -121,23 +234,32 @@ pub struct KeybindsConfig {
pub delete_item: String,
pub undo: String,
pub redo: String,
pub undo_branch_prev: String,
pub undo_branch_next: String,
pub rename: String,
pub append_group: String,
pub prepend_group: String,
pub toggle_group: String,
}
pub add_missing: String,
pub accept_all: String,
pub toggle_fold: String,
pub fold_all: String,
pub unfold_all: String,
pub help: String,
}
impl Default for KeybindsConfig {
impl Default for KeybindsConfig {
fn default() -> Self {
Self {
down: "j".to_string(),
up: "k".to_string(),
edit: "i".to_string(),
edit_append: "a".to_string(),
edit_substitute: "s".to_string(),
edit_substitute: "s,S".to_string(),
save: ":w".to_string(),
quit: ":q".to_string(),
normal_mode: "Esc".to_string(),
quit_key: "q".to_string(),
command: ":".to_string(),
search: "/".to_string(),
next_match: "n".to_string(),
previous_match: "N".to_string(),
@@ -148,14 +270,104 @@ pub struct KeybindsConfig {
delete_item: "dd".to_string(),
undo: "u".to_string(),
redo: "U".to_string(),
undo_branch_prev: "g-".to_string(),
undo_branch_next: "g+".to_string(),
rename: "r".to_string(),
append_group: "alt+o".to_string(),
prepend_group: "alt+O".to_string(),
toggle_group: "t".to_string(),
add_missing: "<Space>".to_string(),
accept_all: "A".to_string(),
toggle_fold: "<Tab>,za".to_string(),
fold_all: "zM".to_string(),
unfold_all: "zR".to_string(),
help: "?".to_string(),
}
}
}
impl KeybindsConfig {
/// The full Normal-mode action table, in help-overlay order.
///
/// Returned as `(binding spec, action)`; a spec may hold comma-separated
/// alternatives, which [`Self::resolved`] expands.
pub fn table(&self) -> Vec<(&str, Action)> {
vec![
(&self.down, Action::Down),
(&self.up, Action::Up),
(&self.jump_top, Action::JumpTop),
(&self.jump_bottom, Action::JumpBottom),
(&self.search, Action::Search),
(&self.next_match, Action::NextMatch),
(&self.previous_match, Action::PreviousMatch),
(&self.toggle_fold, Action::ToggleFold),
(&self.fold_all, Action::FoldAll),
(&self.unfold_all, Action::UnfoldAll),
(&self.edit, Action::Edit),
(&self.edit_append, Action::EditAppend),
(&self.edit_substitute, Action::EditSubstitute),
(&self.rename, Action::Rename),
(&self.append_item, Action::AppendItem),
(&self.prepend_item, Action::PrependItem),
(&self.append_group, Action::AppendGroup),
(&self.prepend_group, Action::PrependGroup),
(&self.toggle_group, Action::ToggleGroup),
(&self.delete_item, Action::DeleteItem),
(&self.add_missing, Action::AddMissing),
(&self.accept_all, Action::AcceptAll),
(&self.undo, Action::Undo),
(&self.redo, Action::Redo),
(&self.undo_branch_prev, Action::UndoBranchPrev),
(&self.undo_branch_next, Action::UndoBranchNext),
(&self.help, Action::Help),
(&self.command, Action::Command),
(&self.quit_key, Action::Quit),
]
}
/// Every individual key sequence with the action it triggers.
pub fn resolved(&self) -> Vec<(String, Action)> {
let mut out = Vec::new();
for (spec, action) in self.table() {
for key in spec.split(',').map(str::trim).filter(|k| !k.is_empty()) {
out.push((key.to_string(), action));
}
}
out
}
/// The first key sequence bound to an action, for display in hints.
pub fn key_for(&self, action: Action) -> String {
self.table()
.iter()
.find(|(_, a)| *a == action)
.and_then(|(spec, _)| spec.split(',').next())
.unwrap_or("")
.trim()
.to_string()
}
/// Reports any key sequence bound to more than one action.
///
/// Without this an accidental collision silently shadows an action, which is
/// exactly how `add_missing` ended up unreachable behind `edit_append`.
pub fn conflicts(&self) -> Vec<String> {
let resolved = self.resolved();
let mut conflicts = Vec::new();
for (i, (key, action)) in resolved.iter().enumerate() {
if let Some((_, other)) = resolved[..i].iter().find(|(k, _)| k == key) {
conflicts.push(format!(
"'{}' is bound to both {} and {}",
key,
other.label(),
action.label()
));
}
}
conflicts
}
}
/// Root configuration structure for mould.
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct Config {
@@ -165,18 +377,83 @@ pub struct Config {
pub keybinds: KeybindsConfig,
}
/// Loads the configuration from the user's home config directory (~/.config/mould/config.toml).
/// Falls back to default settings if no configuration is found.
pub fn load_config() -> Config {
if let Some(mut config_dir) = dirs::config_dir() {
config_dir.push("mould");
config_dir.push("config.toml");
/// Loads the configuration from the user's config directory (`~/.config/mould/config.toml`).
///
/// Returns the config alongside an optional problem description. A broken config
/// still falls back to defaults, but the caller surfaces the reason instead of
/// leaving the user wondering why their settings did nothing.
pub fn load_config() -> (Config, Option<String>) {
let Some(mut path) = dirs::config_dir() else {
return (Config::default(), None);
};
path.push("mould");
path.push("config.toml");
if config_dir.exists()
&& let Ok(content) = fs::read_to_string(config_dir)
&& let Ok(config) = toml::from_str(&content) {
return config;
}
if !path.exists() {
return (Config::default(), None);
}
let config = match fs::read_to_string(&path) {
Ok(content) => match toml::from_str::<Config>(&content) {
Ok(config) => config,
Err(e) => return (Config::default(), Some(e.message().to_string())),
},
Err(e) => return (Config::default(), Some(e.to_string())),
};
let conflicts = config.keybinds.conflicts();
let warning = conflicts.first().cloned();
(config, warning)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_have_no_conflicting_binds() {
assert!(KeybindsConfig::default().conflicts().is_empty());
}
#[test]
fn conflicts_are_reported() {
let kb = KeybindsConfig {
add_missing: "a".to_string(),
..Default::default()
};
let conflicts = kb.conflicts();
assert_eq!(conflicts.len(), 1);
assert!(conflicts[0].contains("'a'"));
}
/// The shipped example must stay loadable, or it teaches people a broken config.
#[test]
fn example_config_parses_and_matches_defaults() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/config.example.toml");
let content = std::fs::read_to_string(path).unwrap();
let config: Config = toml::from_str(&content).unwrap();
assert!(config.keybinds.conflicts().is_empty());
let defaults = KeybindsConfig::default();
for ((spec, action), (default_spec, _)) in
config.keybinds.table().iter().zip(defaults.table())
{
assert_eq!(
*spec, default_spec,
"example config drifted from the default for {:?}",
action
);
}
assert_eq!(config.theme.bg_normal, ThemeConfig::default().bg_normal);
}
#[test]
fn alternatives_expand() {
let kb = KeybindsConfig::default();
let resolved = kb.resolved();
assert!(resolved.iter().any(|(k, a)| k == "<Tab>" && *a == Action::ToggleFold));
assert!(resolved.iter().any(|(k, a)| k == "za" && *a == Action::ToggleFold));
// Hints show the first alternative.
assert_eq!(kb.key_for(Action::ToggleFold), "<Tab>");
}
Config::default()
}