Files
mould-rs/src/config.rs
T
nvrlandClaude Opus 5 e8634c17a2
Version Check / check-version (pull_request) Successful in 10s
fix: make the template workflow real, and the UI legible
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
2026-09-05 05:01:39 +02:00

460 lines
16 KiB
Rust

//! 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;
/// Configuration for the application's appearance.
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(default)]
pub struct ThemeConfig {
/// If true, skip rendering the background block to let the terminal's transparency show.
pub transparent: bool,
/// Default background.
pub bg_normal: String,
/// Background for selected items and standard UI elements.
pub bg_highlight: String,
/// Active element background (e.g., insert mode).
pub bg_active: String,
/// Active element background (e.g., search mode).
pub bg_search: String,
/// Standard text.
pub fg_normal: String,
/// Secondary/inactive text.
pub fg_dimmed: String,
/// Text on selected items.
pub fg_highlight: String,
/// Red/Alert color for missing items.
pub fg_warning: String,
/// Accent color for items edited this session.
pub fg_modified: String,
/// High-contrast accent for titles and active UI elements.
pub fg_accent: String,
/// Borders.
pub border_normal: String,
/// Active borders (e.g., input mode).
pub border_active: String,
/// Color for tree indentation depth 1.
pub tree_depth_1: String,
/// Color for tree indentation depth 2.
pub tree_depth_2: String,
/// Color for tree indentation depth 3.
pub tree_depth_3: String,
/// Color for tree indentation depth 4.
pub tree_depth_4: String,
}
impl ThemeConfig {
/// Internal helper to parse a hex color string ("#RRGGBB") into a TUI Color.
fn parse_hex(hex: &str) -> Color {
let hex = hex.trim_start_matches('#');
if hex.len() == 6 {
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
Color::Rgb(r, g, b)
} else {
Color::Reset
}
}
pub fn bg_normal(&self) -> Color { Self::parse_hex(&self.bg_normal) }
pub fn bg_highlight(&self) -> Color { Self::parse_hex(&self.bg_highlight) }
pub fn bg_active(&self) -> Color { Self::parse_hex(&self.bg_active) }
pub fn bg_search(&self) -> Color { Self::parse_hex(&self.bg_search) }
pub fn fg_normal(&self) -> Color { Self::parse_hex(&self.fg_normal) }
pub fn fg_dimmed(&self) -> Color { Self::parse_hex(&self.fg_dimmed) }
pub fn fg_highlight(&self) -> Color { Self::parse_hex(&self.fg_highlight) }
pub fn fg_warning(&self) -> Color { Self::parse_hex(&self.fg_warning) }
pub fn fg_modified(&self) -> Color { Self::parse_hex(&self.fg_modified) }
pub fn fg_accent(&self) -> Color { Self::parse_hex(&self.fg_accent) }
pub fn border_normal(&self) -> Color { Self::parse_hex(&self.border_normal) }
pub fn border_active(&self) -> Color { Self::parse_hex(&self.border_active) }
pub fn tree_depth_1(&self) -> Color { Self::parse_hex(&self.tree_depth_1) }
pub fn tree_depth_2(&self) -> Color { Self::parse_hex(&self.tree_depth_2) }
pub fn tree_depth_3(&self) -> Color { Self::parse_hex(&self.tree_depth_3) }
pub fn tree_depth_4(&self) -> Color { Self::parse_hex(&self.tree_depth_4) }
}
impl Default for ThemeConfig {
/// Default theme: Semantic Catppuccin Mocha.
fn default() -> Self {
Self {
transparent: false,
bg_normal: "#1e1e2e".to_string(), // base
bg_highlight: "#89b4fa".to_string(), // blue
bg_active: "#a6e3a1".to_string(), // green
bg_search: "#cba6f7".to_string(), // mauve
fg_normal: "#cdd6f4".to_string(), // text
fg_dimmed: "#6c7086".to_string(), // overlay0
fg_highlight: "#1e1e2e".to_string(), // base (dark for contrast against highlights)
fg_warning: "#f38ba8".to_string(), // red
fg_modified: "#fab387".to_string(), // peach
fg_accent: "#b4befe".to_string(), // lavender
border_normal: "#45475a".to_string(), // surface1
border_active: "#a6e3a1".to_string(), // green
tree_depth_1: "#b4befe".to_string(), // lavender
tree_depth_2: "#cba6f7".to_string(), // mauve
tree_depth_3: "#89b4fa".to_string(), // blue
tree_depth_4: "#fab387".to_string(), // peach
}
}
}
/// 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 {
pub down: String,
pub up: String,
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,
/// 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,
pub jump_top: String,
pub jump_bottom: String,
pub append_item: String,
pub prepend_item: String,
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 {
fn default() -> Self {
Self {
down: "j".to_string(),
up: "k".to_string(),
edit: "i".to_string(),
edit_append: "a".to_string(),
edit_substitute: "s,S".to_string(),
save: ":w".to_string(),
quit: ":q".to_string(),
quit_key: "q".to_string(),
command: ":".to_string(),
search: "/".to_string(),
next_match: "n".to_string(),
previous_match: "N".to_string(),
jump_top: "gg".to_string(),
jump_bottom: "G".to_string(),
append_item: "o".to_string(),
prepend_item: "O".to_string(),
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 {
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default)]
pub keybinds: KeybindsConfig,
}
/// 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 !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>");
}
}