From 53dc317d7dda320191c0acc641add232c055f60e Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 19 Mar 2026 10:02:22 +0100 Subject: [PATCH 1/2] version bumped to 0.5.1 --- Cargo.toml | 30 ++++- README.md | 89 ++++++++----- src/app.rs | 91 ++++++++----- src/format/env.rs | 118 +++++++++-------- src/format/hierarchical.rs | 37 +++++- src/format/mod.rs | 51 ++++++++ src/format/properties.rs | 2 +- src/resolver.rs | 259 +++++++++++-------------------------- src/runner.rs | 164 ++++++++++++++--------- src/ui.rs | 45 ++++--- src/undo.rs | 31 ++++- 11 files changed, 526 insertions(+), 391 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4f958c8..90443f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ authors = ["Nils Pukropp "] edition = "2024" name = "mould" -version = "0.5.0" +version = "0.5.1" [[bin]] name = "mould" @@ -17,14 +17,32 @@ java-properties = "2.0.0" log = "0.4.29" ratatui = "0.30.0" rust-ini = "0.21.3" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = { version = "1.0.149", features = ["preserve_order"] } serde_yaml = "0.9.34" thiserror = "2.0.18" -toml = { version = "1.0.7", features = ["preserve_order"] } tui-input = "0.15.0" -clap = { version = "4.6.0", features = ["derive"] } -quick-xml = { version = "0.39.2", features = ["serde", "serialize"] } + +[dependencies.serde] +version = "1.0.228" +features = ["derive"] + +[dependencies.serde_json] +version = "1.0.149" +features = ["preserve_order"] + +[dependencies.toml] +version = "1.0.7" +features = ["preserve_order"] + +[dependencies.clap] +version = "4.6.0" +features = ["derive"] + +[dependencies.quick-xml] +version = "0.39.2" +features = [ + "serde", + "serialize", +] [dev-dependencies] tempfile = "3.27.0" diff --git a/README.md b/README.md index bcd7358..99cca45 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mould -mould is a modern Terminal User Interface (TUI) tool designed for interactively generating and editing configuration files from templates. Whether you are setting up a `.env` file from an example, creating a `docker-compose.override.yml`, or editing nested `JSON`, `YAML`, `TOML`, `XML`, `INI`, or `Properties` configurations, mould provides a fast, Vim-inspired workflow to get your environment ready. +`mould` is a modern Terminal User Interface (TUI) tool designed for interactively generating and editing configuration files from templates. Whether you are setting up a `.env` file from an example, creating a `docker-compose.override.yml`, or editing nested `JSON`, `YAML`, `TOML`, `XML`, `INI`, or `Properties` configurations, `mould` provides a fast, Vim-inspired workflow to get your environment ready. ## Features @@ -9,7 +9,7 @@ mould is a modern Terminal User Interface (TUI) tool designed for interactively - **Smart Template Discovery**: Rule-based resolver automatically discovers relationships (e.g., `.env.example` vs `.env`, `config.template.properties` vs `config.properties`) and highlights missing keys. - **Strict Type Preservation**: Intelligently preserves data types (integers, booleans, strings) during edit-save cycles, ensuring your configuration stays valid. - **Add Missing Keys**: Instantly pull missing keys and their default values from your template into your active configuration with a single keystroke. -- **Advanced Undo/Redo Engine**: Features a tree-based undo/redo history that ensures you never lose changes during complex branching edits. +- **Advanced Undo/Redo Engine**: Features a tree-based undo/redo history that ensures you never lose changes during complex branching edits. It properly tracks all modifications, including nested renames and item additions/deletions. - **Vim-inspired Workflow**: Navigate with `j`/`k`, `gg`/`G`, edit with `i`, search with `/`, and save with `:w`. - **Modern UI**: A polished, rounded interface featuring a semantic Catppuccin Mocha palette with support for terminal transparency. - **Highly Configurable**: Customize keybindings and semantic themes via a simple TOML user configuration. @@ -74,15 +74,16 @@ Open any configuration file in Neovim and run `:Mould`. It will launch a floatin - `k` / `Up`: Move selection up - `gg`: Jump to the top - `G`: Jump to the bottom - - `i`: Edit value (cursor at start) + - `i`: Edit value (cursor at start). If selected is a group, enters rename mode. - `a`: Edit value (cursor at end) - - `s`: Substitute value (clear and edit) - - `r`: Rename the current key - - `o`: Append a new item (as a sibling or array element) - - `O`: Prepend a new item - - `alt+o` / `alt+O`: Append/Prepend a new group/object - - `t`: Toggle between group/object and standard value - - `dd`: Delete the currently selected variable or group (removes all nested children) + - `s` / `S`: Substitute value (clear and edit) + - `r`: Rename the current key or group. (Cannot rename array indices). + - `o`: Append a new item (as a sibling or array element). Enters rename mode for non-array items. + - `O`: Prepend a new item (as a sibling or array element). Enters rename mode for non-array items. + - `alt+o`: Append a new group/object as a child. Enters rename mode for the new group. + - `alt+O`: Prepend a new group/object as a child. Enters rename mode for the new group. + - `t`: Toggle the selected item between a group/object and a standard variable. + - `dd`: Delete the currently selected variable or group (removes all nested children). - `u`: Undo the last change - `U`: Redo the reverted change - `a`: Add missing value from template to active config @@ -96,8 +97,8 @@ Open any configuration file in Neovim and run `:Mould`. It will launch a floatin - **Insert / Rename Modes** - Type your value/key string. - Arrow keys: Navigate within the input field - - `Enter`: Commit the value and return to Normal Mode - - `Esc`: Cancel edits and return to Normal Mode + - `Enter`: Commit the value/key and return to Normal Mode. If renaming, checks for key collisions. + - `Esc`: Cancel edits and return to Normal Mode. Reverts changes to the current field. --- @@ -160,28 +161,54 @@ toggle_group = "t" `mould` is written in Rust and architected to decouple the file format parsing from the UI representation. This allows the TUI to render complex, nested configuration files in a unified tree-view. -### Core Architecture +### Core Architectural Principles: -1. **State Management & Undo Tree (`src/app.rs` & `src/undo.rs`)** - - The central state is maintained in the `App` struct, which tracks the currently loaded configuration variables, application modes (`Normal`, `Insert`, `InsertKey`, `Search`), and user input buffer. - - It integrates an **UndoTree**, providing non-linear, branching history tracking for complex edits (additions, deletions, nested renaming). +- **Separation of Concerns**: Clear boundaries between UI rendering, application state, input handling, and file format logic. +- **Unified Data Model**: All parsed configuration data is normalized into a single `Vec` structure, simplifying application logic across different file types. +- **Vim-inspired Modality**: The application operates in distinct modes (`Normal`, `Insert`, `InsertKey`, `Search`), each with specific keybinding behaviors, enabling efficient interaction. +- **Non-linear Undo/Redo**: A robust undo tree allows users to revert and re-apply changes across complex branching edit histories. -2. **Unified Data Model (`src/format/mod.rs`)** - - Regardless of the underlying format, all data is translated into a flattened `Vec`. - - A `ConfigItem` contains its structural path (`Vec` handling nested Object Keys and Array Indices), values, type metadata (`ValueType`), and template comparison statuses (e.g., `MissingFromActive`). +### Key Components -3. **Format Handlers (`src/format/*`)** - - **`env.rs` & `properties.rs`**: Handlers for flat key-value files. - - **`hierarchical.rs`**: A generalized processor leveraging `serde_json::Value` as an intermediary layer to parse and write nested `JSON`, `YAML`, `TOML`, and even `XML` (via `quick-xml` transposition). - - **`ini.rs`**: Handles traditional `[Section]` based INI configurations. +1. **State Management & Undo Tree (`src/app.rs` & `src/undo.rs`)** + * The central state is maintained in the `App` struct, which tracks the currently loaded configuration variables, application modes, and user input buffer. + * It integrates an **UndoTree**, providing non-linear, branching history tracking for complex edits (additions, deletions, nested renaming). Each significant state change (`save_undo_state`) pushes a snapshot to this tree. -4. **Template Resolver (`src/resolver.rs`)** - - Automatically determines structural pairings without explicit instruction. - - Uses hardcoded exact rules (e.g., `compose.yml` -> `compose.override.yml`) and generic fallback rules to strip `.example` or `.template` suffixes to find target output files dynamically. +2. **Unified Data Model (`src/format/mod.rs`)** + * Regardless of the underlying file format (JSON, YAML, .env, etc.), all data is translated into a flattened `Vec`. + * A `ConfigItem` represents a single configuration entry. It contains: + * `key`: The display key (e.g., `port` or `[0]`). + * `path`: A `Vec` (composed of `PathSegment::Key(String)` for object keys and `PathSegment::Index(usize)` for array indices) that defines its full hierarchical location. + * `value`: `Option` holding the actual value. + * `is_group`: A boolean indicating if this item is a structural node (object or array). + * `status`: (`ItemStatus::Present`, `MissingFromActive`, `Modified`) reflecting comparison with a template. + * `value_type`: (`ValueType::String`, `Number`, `Bool`, `Null`) to ensure type preservation during writes. -5. **Terminal UI & Event Loop (`src/ui.rs` & `src/runner.rs`)** - - **UI Rendering**: Powered by `ratatui`. Renders a conditional side-by-side or vertical layout consisting of a styled hierarchical List, an active Input field, and a status bar indicating keybind availability. - - **Event Runner**: Powered by `crossterm`. Intercepts keystrokes, resolves sequences (like `dd` or `gg`), delegates to the `tui-input` backend during active editing, and interacts with the internal API to mutate the configuration tree. +3. **Format Handlers (`src/format/*`)** + * Each file format has a dedicated handler (`EnvHandler`, `IniHandler`, `HierarchicalHandler`, `PropertiesHandler`) implementing the `FormatHandler` trait. + * **`HierarchicalHandler`**: A generalized processor leveraging `serde_json::Value` as an intermediary layer to parse and write nested `JSON`, `YAML`, `TOML`, and even `XML` (via `quick-xml` transposition). This allows complex structures to be flattened for editing and then re-nested accurately. + * These handlers are responsible for translating between the file's native format and the `Vec` internal representation. -6. **Neovim Plugin (`lua/mould/init.lua`)** - - Implements a Lua wrapper that calculates terminal geometries and launches the CLI `mould` binary inside an ephemeral, floating terminal buffer, triggering automatic Neovim `checktime` syncs on exit. +4. **Template Resolver (`src/resolver.rs`)** + * Automatically determines template-active file pairings without explicit user instruction. + * Uses hardcoded exact rules (e.g., `compose.yml` -> `compose.override.yml`) and generic fallback rules to strip `.example` or `.template` suffixes to find target output files dynamically. + +5. **Terminal UI & Event Loop (`src/ui.rs` & `src/runner.rs`)** + * **UI Rendering (`src/ui.rs`)**: Powered by the `ratatui` library. Renders a flexible layout consisting of a styled hierarchical list, an active input field for editing, and a dynamic status bar. + * **Event Runner (`src/runner.rs`)**: Powered by `crossterm`. It intercepts raw keyboard events, resolves multi-key sequences (like `dd` or `gg`), delegates character input to the `tui-input` backend during active editing, and dispatches actions to mutate the `App` state. It includes logic to prevent "one-key-behind" issues and manage complex keybindings like `alt+o`. + +6. **Neovim Plugin (`lua/mould/init.lua`)** + * Implements a Lua wrapper that calculates terminal geometries and launches the CLI `mould` binary inside an ephemeral, floating terminal buffer, ensuring automatic Neovim `checktime` synchronization upon `mould`'s exit. + +### Development Process Highlights: + +- **Iterative Refinement**: Features like key renaming, group creation, and advanced undo/redo were developed iteratively, responding to user feedback and progressively enhancing the core data model and interaction logic. +- **Robust Error Handling**: Key functions (`commit_input`, `enter_insert_key`) include collision detection and validation to ensure data integrity during user modifications. +- **Modality-driven Design**: The introduction of `InsertKey` mode and careful handling of `InsertVariant` demonstrates a commitment to a precise, context-aware user experience, minimizing ambiguity during editing. +- **Debugging and Performance**: Issues like UI "hangs" were traced to subtle interactions in key event processing and input buffer management, leading to refactored key sequence logic for improved responsiveness. + +--- + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/src/app.rs b/src/app.rs index 29d0b68..bc97e07 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,9 @@ use tui_input::Input; use crate::undo::UndoTree; /// Represents the current operating mode of the application. +/// +/// Modality allows the application to reuse the same keyboard events +/// for different contextual actions (navigation vs. text entry). pub enum Mode { /// Standard navigation and command mode. Normal, @@ -14,34 +17,43 @@ pub enum Mode { Search, } +/// Defines where the cursor starts when entering Insert mode. pub enum InsertVariant { + /// Cursor at the beginning of the text. Start, + /// Cursor at the end of the text. End, + /// Text is cleared before entry. Substitute, } /// The core application state, holding all configuration variables and UI status. +/// +/// This struct is the "Single Source of Truth" for the TUI. It manages +/// selection, filtering, history, and structural mutations. pub struct App { - /// The list of configuration variables being edited. + /// The flattened list of configuration variables being edited. pub vars: Vec, /// Index of the currently selected variable in the list. pub selected: usize, - /// The current interaction mode (Normal or Insert). + /// The current interaction mode (Normal, Insert, etc.). pub mode: Mode, /// Whether the main application loop should continue running. pub running: bool, - /// An optional message to display in the status bar (e.g., "Saved to .env"). + /// An optional message to display in the status bar. pub status_message: Option, /// The active text input buffer for the selected variable. pub input: Input, /// The current search query for filtering keys. pub search_query: String, - /// Undo history structured as a tree + /// Undo history structured as a tree. pub undo_tree: UndoTree, } impl App { /// Initializes a new application instance with the provided variables. + /// + /// It automatically initializes the undo tree with the starting state. pub fn new(vars: Vec) -> Self { let initial_input = vars.first().and_then(|v| v.value.clone()).unwrap_or_default(); let undo_tree = UndoTree::new(vars.clone(), 0); @@ -57,7 +69,7 @@ impl App { } } - /// Returns the indices of variables that match the search query. + /// Returns the indices of variables that match the search query (case-insensitive). pub fn matching_indices(&self) -> Vec { if self.search_query.is_empty() { return Vec::new(); @@ -91,7 +103,7 @@ impl App { } } - /// Jumps to the top of the list. + /// Jumps the selection to the top of the list. pub fn jump_top(&mut self) { if !self.vars.is_empty() { self.selected = 0; @@ -99,7 +111,7 @@ impl App { } } - /// Jumps to the bottom of the list. + /// Jumps the selection to the bottom of the list. pub fn jump_bottom(&mut self) { if !self.vars.is_empty() { self.selected = self.vars.len() - 1; @@ -144,7 +156,10 @@ impl App { } } - /// Updates the input buffer to reflect the value of the currently selected variable. + /// Updates the input buffer to reflect the current state of the selected item. + /// + /// If in `InsertKey` mode, the buffer is synced with the item's `key`. + /// Otherwise, it is synced with the item's `value`. pub fn sync_input_with_selected(&mut self) { if let Some(var) = self.vars.get(self.selected) { let val = match self.mode { @@ -155,8 +170,10 @@ impl App { } } - /// Commits the current text in the input buffer back to the selected variable's value or key. - /// Returns true if commit was successful, false if there was an error (e.g. collision). + /// Commits the current text in the input buffer back to the selected variable. + /// + /// Returns true if commit was successful, false if there was an error + /// (e.g., a key name collision or empty key). pub fn commit_input(&mut self) -> bool { match self.mode { Mode::Insert => { @@ -179,7 +196,7 @@ impl App { return true; } - // Collision check: siblings share the same parent path + // Collision check: ensure siblings don't already have this key. let parent_path = if selected_var.path.len() > 1 { &selected_var.path[..selected_var.path.len() - 1] } else { @@ -198,7 +215,7 @@ impl App { return false; } - // Update selected item's key and path + // Update selected item's key and its full internal path. let old_path = selected_var.path.clone(); let mut new_path = parent_path.to_vec(); new_path.push(PathSegment::Key(new_key.clone())); @@ -210,7 +227,7 @@ impl App { var.status = crate::format::ItemStatus::Modified; } - // Update paths of all children if it's a group + // Recursively update paths of all children if the renamed item is a group. if selected_var.is_group { for var in self.vars.iter_mut() { if var.path.starts_with(&old_path) && var.path.len() > old_path.len() { @@ -227,7 +244,10 @@ impl App { } } - /// Transitions the application into Insert Mode for keys. + /// Transitions the application into `InsertKey` mode to modify item names. + /// + /// Renaming is blocked for array indices (e.g., `[0]`) as they are + /// managed automatically by the application logic. pub fn enter_insert_key(&mut self) { if !self.vars.is_empty() { if let Some(var) = self.vars.get(self.selected) @@ -240,7 +260,10 @@ impl App { } } - /// Transitions the application into Insert Mode with a specific variant. + /// Transitions the application into `Insert` mode to modify variable values. + /// + /// If the selected item is a group, it automatically routes to + /// `enter_insert_key` instead. pub fn enter_insert(&mut self, variant: InsertVariant) { if let Some(var) = self.vars.get(self.selected) { if var.is_group { @@ -267,7 +290,7 @@ impl App { } } - /// Commits the current input and transitions the application into Normal Mode. + /// Commits the current input and transitions back to `Normal` mode. pub fn enter_normal(&mut self) { if self.commit_input() { self.save_undo_state(); @@ -275,14 +298,17 @@ impl App { } } - /// Cancels the current input and transitions the application into Normal Mode. + /// Aborts the current input and reverts to `Normal` mode without saving changes. pub fn cancel_insert(&mut self) { self.mode = Mode::Normal; self.sync_input_with_selected(); self.status_message = None; } - /// Deletes the currently selected item. If it's a group, deletes all children. + /// Deletes the currently selected item and all its nested children. + /// + /// If the deleted item is part of an array, subsequent indices are + /// automatically shifted and renamed to maintain a continuous sequence. pub fn delete_selected(&mut self) { if self.vars.is_empty() { return; @@ -291,7 +317,7 @@ impl App { let selected_path = self.vars[self.selected].path.clone(); let is_group = self.vars[self.selected].is_group; - // 1. Identify all items to remove + // 1. Identify all items to remove (the item itself + all children) let mut to_remove = Vec::new(); to_remove.push(self.selected); @@ -300,7 +326,6 @@ impl App { if i == self.selected { continue; } - // An item is a child if its path starts with the selected path if var.path.starts_with(&selected_path) { to_remove.push(i); } @@ -319,13 +344,11 @@ impl App { for var in self.vars.iter_mut() { if var.path.starts_with(base_path) && var.path.len() >= selected_path.len() { - // Check if the element at the level of the removed index is an index if let PathSegment::Index(i) = var.path[selected_path.len() - 1] && i > *removed_idx { let new_idx = i - 1; var.path[selected_path.len() - 1] = PathSegment::Index(new_idx); - // If this was an array element itself (not a child property), update its key if var.path.len() == selected_path.len() { var.key = format!("[{}]", new_idx); } @@ -343,6 +366,13 @@ impl App { } /// Adds a new item relative to the selected item. + /// + /// - `after`: If true, adds below the selection; otherwise adds above. + /// - `is_group`: If true, creates a new structural node (object/array). + /// - `as_child`: If true, adds inside the selected group. + /// + /// The method automatically detects if the parent is an array and + /// formats the new key accordingly (e.g., `[1]`). pub fn add_item(&mut self, after: bool, is_group: bool, as_child: bool) { if self.vars.is_empty() { let new_key = if is_group { "NEW_GROUP".to_string() } else { "NEW_VAR".to_string() }; @@ -404,7 +434,6 @@ impl App { new_path = selected_item.path.clone(); new_depth = selected_item.depth + 1; - // Check if this group already contains array items if self.is_array_group(&selected_item.path) { is_array_item = true; let new_idx = 0; // Prepend to array @@ -442,7 +471,6 @@ impl App { new_path = parent_path; new_depth = selected_item.depth; - // If the parent is an array group, this is also an array item if !new_path.is_empty() && self.is_array_group(&new_path) { is_array_item = true; if let Some(PathSegment::Index(idx)) = selected_item.path.last() { @@ -505,9 +533,11 @@ impl App { } /// Toggles the group status of the currently selected item. + /// + /// Changing a group to a variable clears its children (visually) + /// and resets its value. Changing a variable to a group removes its value. pub fn toggle_group_selected(&mut self) { if let Some(var) = self.vars.get_mut(self.selected) { - // Cannot toggle array items (always vars) if matches!(var.path.last(), Some(PathSegment::Index(_))) { self.status_message = Some("Cannot toggle array items".to_string()); return; @@ -526,11 +556,12 @@ impl App { } } - /// Status bar helpers + /// Returns true if the selected item is a structural node (group/object). pub fn selected_is_group(&self) -> bool { self.vars.get(self.selected).map(|v| v.is_group).unwrap_or(false) } + /// Returns true if the provided path identifies a node that contains array elements. pub fn is_array_group(&self, group_path: &[PathSegment]) -> bool { self.vars.iter().any(|v| v.path.starts_with(group_path) @@ -539,24 +570,26 @@ impl App { ) } + /// Returns true if the selected item is an indexed array element. pub fn selected_is_array(&self) -> bool { self.vars.get(self.selected) .map(|v| !v.is_group && matches!(v.path.last(), Some(PathSegment::Index(_)))) .unwrap_or(false) } + /// Returns true if the selected item exists in the template but not the active config. pub fn selected_is_missing(&self) -> bool { self.vars.get(self.selected) .map(|v| v.status == crate::format::ItemStatus::MissingFromActive) .unwrap_or(false) } - /// Saves the current state of variables to the undo tree. + /// Saves a snapshot of the current state to the undo history tree. pub fn save_undo_state(&mut self) { self.undo_tree.push(self.vars.clone(), self.selected); } - /// Reverts to the previous state in the undo tree. + /// Reverts the application state to the previous history point. pub fn undo(&mut self) { if let Some(action) = self.undo_tree.undo() { self.vars = action.state.clone(); @@ -571,7 +604,7 @@ impl App { } } - /// Advances to the next state in the undo tree. + /// Advances the application state to the next history point in the active branch. pub fn redo(&mut self) { if let Some(action) = self.undo_tree.redo() { self.vars = action.state.clone(); diff --git a/src/format/env.rs b/src/format/env.rs index d02eacf..c31b3d3 100644 --- a/src/format/env.rs +++ b/src/format/env.rs @@ -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> { + 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")); + } } diff --git a/src/format/hierarchical.rs b/src/format/hierarchical.rs index 29a336b..f8ba8d7 100644 --- a/src/format/hierarchical.rs +++ b/src/format/hierarchical.rs @@ -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 { 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 { use quick_xml::reader::Reader; use quick_xml::events::Event; @@ -53,6 +70,7 @@ fn xml_to_json(content: &str) -> anyhow::Result { 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 { let mut map = Map::new(); let mut text = String::new(); @@ -64,6 +82,7 @@ fn xml_to_json(content: &str) -> anyhow::Result { 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 { } } 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 { 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>, 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, key_name: Option, depth: usize, vars: &mut Vec) { 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::() { diff --git a/src/format/mod.rs b/src/format/mod.rs index afd44c3..3c70175 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -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, + /// The active value of the configuration entry. pub value: Option, + /// The value found in the template file (if any). pub template_value: Option, + /// A fallback value to use if the item is missing. pub default_value: Option, + /// 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` representation. fn parse(&self, path: &Path) -> anyhow::Result>; + + /// 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) -> 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) -> 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) -> FormatType } } +/// Factory function to return the appropriate handler implementation for a given format. pub fn get_handler(format: FormatType) -> Box { match format { FormatType::Env => Box::new(env::EnvHandler), diff --git a/src/format/properties.rs b/src/format/properties.rs index c363849..64cbc59 100644 --- a/src/format/properties.rs +++ b/src/format/properties.rs @@ -59,7 +59,7 @@ impl FormatHandler for PropertiesHandler { } } - // We don't sort here to preserve the original file order! + Ok(vars) } diff --git a/src/resolver.rs b/src/resolver.rs index 7b19da6..cc8306d 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -1,197 +1,88 @@ +//! Automatically resolves relationships between template and active configuration files. +//! +//! The resolver allows `mould` to be run without explicit output arguments +//! by intelligently guessing the counterpart of a given input file based +//! on common naming conventions. + use std::path::{Path, PathBuf}; -pub struct Rule { - pub template_suffix: &'static str, - pub active_suffix: &'static str, - pub is_exact_match: bool, -} +/// Logic for determining which files to parse and where to save the results. +pub struct TemplateResolver; -pub const RULES: &[Rule] = &[ - // Exact matches - Rule { template_suffix: "compose.yml", active_suffix: "compose.override.yml", is_exact_match: true }, - Rule { template_suffix: "compose.yaml", active_suffix: "compose.override.yaml", is_exact_match: true }, - Rule { template_suffix: "docker-compose.yml", active_suffix: "docker-compose.override.yml", is_exact_match: true }, - Rule { template_suffix: "docker-compose.yaml", active_suffix: "docker-compose.override.yaml", is_exact_match: true }, - - // Pattern matches - Rule { template_suffix: ".env.example", active_suffix: ".env", is_exact_match: false }, - Rule { template_suffix: ".env.template", active_suffix: ".env", is_exact_match: false }, - Rule { template_suffix: ".example.json", active_suffix: ".json", is_exact_match: false }, - Rule { template_suffix: ".template.json", active_suffix: ".json", is_exact_match: false }, - Rule { template_suffix: ".example.yml", active_suffix: ".yml", is_exact_match: false }, - Rule { template_suffix: ".template.yml", active_suffix: ".yml", is_exact_match: false }, - Rule { template_suffix: ".example.yaml", active_suffix: ".yaml", is_exact_match: false }, - Rule { template_suffix: ".template.yaml", active_suffix: ".yaml", is_exact_match: false }, - Rule { template_suffix: ".example.toml", active_suffix: ".toml", is_exact_match: false }, - Rule { template_suffix: ".template.toml", active_suffix: ".toml", is_exact_match: false }, - Rule { template_suffix: ".example.xml", active_suffix: ".xml", is_exact_match: false }, - Rule { template_suffix: ".template.xml", active_suffix: ".xml", is_exact_match: false }, - Rule { template_suffix: ".example.ini", active_suffix: ".ini", is_exact_match: false }, - Rule { template_suffix: ".template.ini", active_suffix: ".ini", is_exact_match: false }, - Rule { template_suffix: ".example.properties", active_suffix: ".properties", is_exact_match: false }, - Rule { template_suffix: ".template.properties", active_suffix: ".properties", is_exact_match: false }, -]; - -pub const DEFAULT_CANDIDATES: &[&str] = &[ - ".env.example", - "compose.yml", - "docker-compose.yml", - ".env.template", - "compose.yaml", - "docker-compose.yaml", -]; - -/// Helper to automatically determine the output file path based on common naming conventions. -pub fn determine_output_path(input: &Path) -> PathBuf { - let file_name = input.file_name().unwrap_or_default().to_string_lossy(); - - for rule in RULES { - if rule.is_exact_match { - if file_name == rule.template_suffix { - return input.with_file_name(rule.active_suffix); - } - } else if file_name == rule.template_suffix { - return input.with_file_name(rule.active_suffix); - } else if let Some(base) = file_name.strip_suffix(rule.template_suffix) { - return input.with_file_name(format!("{}{}", base, rule.active_suffix)); +impl TemplateResolver { + /// Determines the template and output paths based on the provided input. + /// + /// If an output path is explicitly provided via CLI arguments, it is used. + /// Otherwise, the resolver applies a set of heuristic rules to find a matching pairing. + pub fn resolve( + input: &Path, + output_override: Option, + ) -> (PathBuf, PathBuf) { + if let Some(out) = output_override { + return (input.to_path_buf(), out); } - } - input.with_extension(format!( - "{}.out", - input.extension().unwrap_or_default().to_string_lossy() - )) -} - -/// Discovers common configuration template files in the current directory. -pub fn find_input_file() -> Option { - // Priority 1: Exact matches for well-known defaults - for &name in DEFAULT_CANDIDATES { - let path = PathBuf::from(name); - if path.exists() { - return Some(path); - } - } - - // Priority 2: Pattern matches - if let Ok(entries) = std::fs::read_dir(".") { - let mut fallback = None; - for entry in entries.flatten() { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - - for rule in RULES { - if !rule.is_exact_match && name_str.ends_with(rule.template_suffix) { - if name_str.contains(".env") || name_str.contains("compose") { - return Some(entry.path()); - } - if fallback.is_none() { - fallback = Some(entry.path()); - } - break; - } - } - } - if let Some(path) = fallback { - return Some(path); - } - } - - None -} - -/// Resolves the active and template paths given an input path. -/// Returns `(active_path, template_path)`. -pub fn resolve_paths(input: &Path) -> (Option, Option) { - let file_name = input.file_name().unwrap_or_default().to_string_lossy(); - - // Check if the input matches any known template pattern - let mut is_template = false; - for rule in RULES { - if rule.is_exact_match { - if file_name == rule.template_suffix { - is_template = true; - break; - } - } else if file_name.ends_with(rule.template_suffix) { - is_template = true; - break; - } - } - - // Fallback template detection - if !is_template && (file_name.contains(".example") || file_name.contains(".template")) { - is_template = true; - } - - if is_template { - let expected_active = determine_output_path(input); - let active = if expected_active.exists() { - Some(expected_active) + // Apply automatic discovery rules based on file name patterns. + if let Some((template, output)) = Self::discover_pairing(input) { + (template, output) } else { - None - }; - (active, Some(input.to_path_buf())) - } else { - // Input is treated as the active config - let active = Some(input.to_path_buf()); - let mut template = None; - - // Try to reverse match rules to find a template - for rule in RULES { - if rule.is_exact_match { - if file_name == rule.active_suffix { - let t = input.with_file_name(rule.template_suffix); - if t.exists() { - template = Some(t); - break; - } - } - } else if file_name.ends_with(rule.active_suffix) { - if file_name == rule.active_suffix { - let t = input.with_file_name(rule.template_suffix); - if t.exists() { - template = Some(t); - break; - } - } else if let Some(base) = file_name.strip_suffix(rule.active_suffix) { - let t = input.with_file_name(format!("{}{}", base, rule.template_suffix)); - if t.exists() { - template = Some(t); - break; - } - } - } + // Fallback: If no pairing is found, use the input as both + // the template source and the save target. + (input.to_path_buf(), input.to_path_buf()) + } + } + + /// Attempts to find a known template/active pairing for a given file path. + /// + /// Naming Rules Applied: + /// 1. `.env.example` <-> `.env` (Standard environment file pattern). + /// 2. `compose.yml` -> `compose.override.yml` (Docker Compose convention). + /// 3. `.template.` -> `.` (General template pattern). + /// 4. `..example` -> `.` (General example pattern). + fn discover_pairing(path: &Path) -> Option<(PathBuf, PathBuf)> { + let file_name = path.file_name()?.to_str()?; + + // Rule 1: Standard .env pairing + if file_name == ".env" || file_name == ".env.example" { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + return Some((dir.join(".env.example"), dir.join(".env"))); + } + + // Rule 2: Docker Compose pairing + if file_name == "docker-compose.yml" || file_name == "docker-compose.yaml" || file_name == "compose.yml" { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + let override_file = if file_name == "compose.yml" { + "compose.override.yml" + } else { + "docker-compose.override.yml" + }; + return Some((path.to_path_buf(), dir.join(override_file))); + } + + // Rule 3: .template or .example suffix removal + if file_name.contains(".template.") { + let output_name = file_name.replace(".template.", "."); + return Some((path.to_path_buf(), path.with_file_name(output_name))); } - // Fallback reverse detection - if template.is_none() { - let possible_templates = [ - format!("{}.example", file_name), - format!("{}.template", file_name), - ]; - for t in possible_templates { - let p = input.with_file_name(t); - if p.exists() { - template = Some(p); - break; - } + if file_name.ends_with(".example") { + let output_name = &file_name[..file_name.len() - 8]; + return Some((path.to_path_buf(), path.with_file_name(output_name))); + } + + // Inverse Rule 3: If running against the active file, look for the template counterpart. + let template_candidates = [ + format!("{}.example", file_name), + file_name.replace('.', ".template."), + ]; + + for t in template_candidates { + let p = path.with_file_name(t); + if p.exists() { + return Some((p, path.to_path_buf())); } } - (active, template) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_determine_output_path() { - assert_eq!(determine_output_path(Path::new(".env.example")), PathBuf::from(".env")); - assert_eq!(determine_output_path(Path::new("compose.yml")), PathBuf::from("compose.override.yml")); - assert_eq!(determine_output_path(Path::new("config.template.json")), PathBuf::from("config.json")); - assert_eq!(determine_output_path(Path::new("config.example")), PathBuf::from("config.example.out")); + None } } diff --git a/src/runner.rs b/src/runner.rs index bb38ceb..06b04f9 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,3 +1,8 @@ +//! Orchestrates the main application execution, terminal events, and TUI rendering. +//! +//! The `AppRunner` is responsible for the event loop, intercepting raw +//! keyboard input, and translating it into high-level application actions. + use crate::app::{App, InsertVariant, Mode}; use crate::config::Config; use crate::format::FormatHandler; @@ -14,7 +19,7 @@ pub struct AppRunner<'a, B: Backend> { terminal: &'a mut Terminal, /// Mutable reference to the application state. app: &'a mut App, - /// Loaded user configuration. + /// Loaded user configuration (keybinds, theme). config: &'a Config, /// Path where the final configuration will be saved. output_path: &'a Path, @@ -22,7 +27,7 @@ pub struct AppRunner<'a, B: Backend> { handler: &'a dyn FormatHandler, /// Buffer for storing active command entry (e.g., ":w"). command_buffer: String, - /// Buffer for storing sequence of key presses (e.g., "gg"). + /// Buffer for storing multi-key sequence of presses (e.g., "gg"). key_sequence: String, } @@ -30,7 +35,7 @@ impl<'a, B: Backend> AppRunner<'a, B> where io::Error: From, { - /// Creates a new runner instance. + /// Creates a new runner instance with all required dependencies. pub fn new( terminal: &'a mut Terminal, app: &'a mut App, @@ -50,6 +55,9 @@ where } /// Starts the main application loop. + /// + /// This loop continues until `self.app.running` is set to false. + /// Each iteration draws the UI and waits for a keyboard event. pub fn run(&mut self) -> io::Result<()> { while self.app.running { self.terminal @@ -63,6 +71,9 @@ where } /// Primary dispatcher for all keyboard events. + /// + /// It delegates handling to specialized methods based on the + /// current application mode. fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> { match self.app.mode { Mode::Normal => self.handle_normal_mode(key), @@ -108,7 +119,11 @@ where } } - /// Handles primary navigation (j/k) and transitions to insert or command modes. + /// Handles primary navigation and transitions to insert or command modes. + /// + /// This method manages multi-key sequences (like `gg`) and immediate + /// actions (like `i`). It correctly re-evaluates sequences to prevent + /// "one-key-behind" responsiveness bugs. fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> { let key_str = if let KeyCode::Char(c) = key.code { let mut s = String::new(); @@ -124,61 +139,20 @@ where if !key_str.is_empty() { self.key_sequence.push_str(&key_str); - let mut exact_match = None; - let mut prefix_match = false; - - // Collect all configured keybinds - let binds = [ - (&self.config.keybinds.down, "down"), - (&self.config.keybinds.up, "up"), - (&self.config.keybinds.edit, "edit"), - (&self.config.keybinds.edit_append, "edit_append"), - (&self.config.keybinds.edit_substitute, "edit_substitute"), - (&"S".to_string(), "edit_substitute"), - (&self.config.keybinds.search, "search"), - (&self.config.keybinds.next_match, "next_match"), - (&self.config.keybinds.previous_match, "previous_match"), - (&self.config.keybinds.jump_top, "jump_top"), - (&self.config.keybinds.jump_bottom, "jump_bottom"), - (&self.config.keybinds.append_item, "append_item"), - (&self.config.keybinds.prepend_item, "prepend_item"), - (&self.config.keybinds.delete_item, "delete_item"), - (&self.config.keybinds.undo, "undo"), - (&self.config.keybinds.redo, "redo"), - (&self.config.keybinds.rename, "rename"), - (&self.config.keybinds.append_group, "append_group"), - (&self.config.keybinds.prepend_group, "prepend_group"), - (&self.config.keybinds.toggle_group, "toggle_group"), - (&"a".to_string(), "add_missing"), - (&":".to_string(), "command"), - (&"q".to_string(), "quit"), - ]; - - for (bind, action) in binds.iter() { - if bind == &&self.key_sequence { - exact_match = Some(*action); - break; - } else if bind.starts_with(&self.key_sequence) { - prefix_match = true; + let mut match_result = self.find_binding(); + if match_result.is_none() { + if self.is_prefix_binding() { + // It's a prefix for a multi-key bind (like first 'g' in 'gg'), wait for more. + return Ok(()); + } else { + // Not a match and not a prefix, restart the buffer with the current key. + self.key_sequence.clear(); + self.key_sequence.push_str(&key_str); + match_result = self.find_binding(); } } - if exact_match.is_none() && !prefix_match { - // Not a match and not a prefix, restart with current key - self.key_sequence.clear(); - self.key_sequence.push_str(&key_str); - - for (bind, action) in binds.iter() { - if bind == &&self.key_sequence { - exact_match = Some(*action); - break; - } else if bind.starts_with(&self.key_sequence) { - prefix_match = true; - } - } - } - - if let Some(action) = exact_match { + if let Some(action) = match_result { self.key_sequence.clear(); match action { "down" => self.app.next(), @@ -217,11 +191,9 @@ where "quit" => self.app.running = false, _ => {} } - } else if !prefix_match { - self.key_sequence.clear(); } } else { - // Non-character keys reset the sequence buffer + // Reset the sequence buffer if a non-character key (like Arrow Keys) is pressed. self.key_sequence.clear(); match key.code { KeyCode::Down => self.app.next(), @@ -234,6 +206,74 @@ where Ok(()) } + /// Looks up the current `key_sequence` in the configured keybindings. + fn find_binding(&self) -> Option<&'static str> { + let binds = [ + (&self.config.keybinds.down, "down"), + (&self.config.keybinds.up, "up"), + (&self.config.keybinds.edit, "edit"), + (&self.config.keybinds.edit_append, "edit_append"), + (&self.config.keybinds.edit_substitute, "edit_substitute"), + (&"S".to_string(), "edit_substitute"), // Semantic alias for Vim compatibility. + (&self.config.keybinds.search, "search"), + (&self.config.keybinds.next_match, "next_match"), + (&self.config.keybinds.previous_match, "previous_match"), + (&self.config.keybinds.jump_top, "jump_top"), + (&self.config.keybinds.jump_bottom, "jump_bottom"), + (&self.config.keybinds.append_item, "append_item"), + (&self.config.keybinds.prepend_item, "prepend_item"), + (&self.config.keybinds.delete_item, "delete_item"), + (&self.config.keybinds.undo, "undo"), + (&self.config.keybinds.redo, "redo"), + (&self.config.keybinds.rename, "rename"), + (&self.config.keybinds.append_group, "append_group"), + (&self.config.keybinds.prepend_group, "prepend_group"), + (&self.config.keybinds.toggle_group, "toggle_group"), + (&"a".to_string(), "add_missing"), + (&":".to_string(), "command"), + (&"q".to_string(), "quit"), + ]; + + for (bind, action) in binds.iter() { + if bind == &&self.key_sequence { + return Some(*action); + } + } + None + } + + /// Returns true if the current `key_sequence` is a partial prefix of any configured bind. + fn is_prefix_binding(&self) -> bool { + let binds = [ + &self.config.keybinds.down, + &self.config.keybinds.up, + &self.config.keybinds.edit, + &self.config.keybinds.edit_append, + &self.config.keybinds.edit_substitute, + &self.config.keybinds.search, + &self.config.keybinds.next_match, + &self.config.keybinds.previous_match, + &self.config.keybinds.jump_top, + &self.config.keybinds.jump_bottom, + &self.config.keybinds.append_item, + &self.config.keybinds.prepend_item, + &self.config.keybinds.delete_item, + &self.config.keybinds.undo, + &self.config.keybinds.redo, + &self.config.keybinds.rename, + &self.config.keybinds.append_group, + &self.config.keybinds.prepend_group, + &self.config.keybinds.toggle_group, + ]; + + for bind in binds.iter() { + if bind.starts_with(&self.key_sequence) && bind.as_str() != self.key_sequence { + return true; + } + } + false + } + /// Adds a missing item from the template to the active configuration. fn add_missing_item(&mut self) { if let Some(var) = self.app.vars.get_mut(self.app.selected) @@ -247,7 +287,7 @@ where } } - /// Delegates key events to the `tui_input` handler during active editing. + /// Delegates key events to the `tui_input` handler during active value editing. fn handle_insert_mode(&mut self, key: KeyEvent) -> io::Result<()> { match key.code { KeyCode::Esc => { @@ -263,7 +303,7 @@ where Ok(()) } - /// Handles keys in InsertKey mode. + /// Delegates key events to the `tui_input` handler during active key renaming. fn handle_insert_key_mode(&mut self, key: KeyEvent) -> io::Result<()> { match key.code { KeyCode::Esc => { @@ -279,7 +319,7 @@ where Ok(()) } - /// Handles search mode key events. + /// Handles search mode key events and live-updates search filtering. fn handle_search_mode(&mut self, key: KeyEvent) -> io::Result<()> { match key.code { KeyCode::Enter | KeyCode::Esc => { diff --git a/src/ui.rs b/src/ui.rs index 0f70069..41f56bc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,3 +1,8 @@ +//! Renders the hierarchical Terminal User Interface (TUI). +//! +//! This module uses `ratatui` to compose the visual layout of the application, +//! providing a navigable tree-view of configuration items. + use crate::app::{App, Mode}; use crate::config::Config; use ratatui::{ @@ -9,11 +14,16 @@ use ratatui::{ }; /// Renders the main application interface using ratatui. +/// +/// The interface is composed of: +/// 1. A hierarchical list of configuration items. +/// 2. An active input field for editing values/keys. +/// 3. A status bar showing the current mode and available keybinds. pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { let theme = &config.theme; let size = f.area(); - // Render the main background (optional based on transparency config). + // Render the main background. if !theme.transparent { f.render_widget( Block::default().style(Style::default().bg(theme.bg_normal())), @@ -21,7 +31,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { ); } - // Horizontal layout with 1-character side margins. + // Define outer margins. let outer_layout = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -31,19 +41,19 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { ]) .split(size); - // Vertical layout for the main UI components. + // Split the center area vertically. let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // Top margin - Constraint::Min(3), // Main list - Constraint::Length(3), // Focused input field + Constraint::Min(3), // Main tree list + Constraint::Length(3), // Focused input area Constraint::Length(1), // Spacer Constraint::Length(1), // Status bar ]) .split(outer_layout[1]); - // Build the interactive list of configuration variables. + // Construct the interactive hierarchical list. let matching_indices = app.matching_indices(); let items: Vec = app .vars @@ -53,11 +63,11 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { let is_selected = i == app.selected; let is_match = matching_indices.contains(&i); - // Indentation based on depth + // Indentation and tree-branch markers. let indent = " ".repeat(var.depth); let prefix = if var.is_group { "+ " } else { " " }; - // Determine colors based on depth + // Determine depth-based coloring for the key name. let depth_color = if is_selected { theme.bg_normal() } else { @@ -70,7 +80,6 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { } }; - // Determine colors based on status and selection let text_color = if is_selected { theme.fg_highlight() } else { @@ -103,7 +112,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { Span::styled(&var.key, key_style), ]; - // Add status indicator if not present + // Add semantic status labels (missing, modified). match var.status { crate::format::ItemStatus::MissingFromActive if !var.is_group => { let missing_style = if is_selected { @@ -138,7 +147,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { if var.is_group { ListItem::new(Line::from(key_spans)).style(item_style) } else { - // Show live input text for the selected item if in Insert mode. + // Determine which value to display (live input vs. stored value). let val = if is_selected && matches!(app.mode, Mode::Insert) { app.input.value() } else { @@ -156,6 +165,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { Span::styled(val, value_style), ]; + // Show default value if it differs from current. if let Some(t_val) = &var.template_value && Some(t_val) != var.value.as_ref() { let t_style = if is_selected { @@ -188,7 +198,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { state.select(Some(app.selected)); f.render_stateful_widget(list, chunks[1], &mut state); - // Render the focused input area. + // Compose the focused input area details. let current_var = app.vars.get(app.selected); let mut input_title = " Input ".to_string(); let mut extra_info = String::new(); @@ -208,13 +218,12 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { let input_border_color = match app.mode { Mode::Insert | Mode::InsertKey => theme.border_active(), - Mode::Normal | Mode::Search => theme.border_normal(), + _ => theme.border_normal(), }; let input_text = app.input.value(); let cursor_pos = app.input.visual_cursor(); - // Show template value in normal mode if it differs let display_text = if let Some(var) = current_var { if matches!(app.mode, Mode::InsertKey) { input_text.to_string() @@ -238,14 +247,14 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { .title(input_title) .title_style( Style::default() - .fg(theme.fg_accent()) // Make title pop + .fg(theme.fg_accent()) .add_modifier(Modifier::BOLD), ) .border_style(Style::default().fg(input_border_color)), ); f.render_widget(input, chunks[2]); - // Position the terminal cursor correctly when in Insert mode. + // Position terminal cursor during active input. if matches!(app.mode, Mode::Insert) || matches!(app.mode, Mode::InsertKey) { f.set_cursor_position(ratatui::layout::Position::new( chunks[2].x + 1 + cursor_pos as u16, @@ -253,7 +262,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { )); } - // Render the modern pill-style status bar. + // Render the status bar with mode-specific help hints. let (mode_str, mode_style) = match app.mode { Mode::Normal => ( " NORMAL ", @@ -302,7 +311,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { parts.push(format!("{} rename", kb.rename)); parts.push(format!("{} toggle", kb.toggle_group)); if app.selected_is_missing() { - parts.push(format!("{} add", "a")); // 'a' is currently hardcoded in runner + parts.push(format!("{} add", "a")); } if app.selected_is_array() { parts.push(format!("{}/{} array", kb.append_item, kb.prepend_item)); diff --git a/src/undo.rs b/src/undo.rs index 5980d7f..58de381 100644 --- a/src/undo.rs +++ b/src/undo.rs @@ -1,26 +1,44 @@ use crate::format::ConfigItem; use std::collections::HashMap; +/// Represents a single snapshot of the application state for undo/redo purposes. pub struct EditAction { + /// The complete list of configuration items at the time of the action. pub state: Vec, + /// The index of the item that was selected during this action. pub selected: usize, } +/// A node in the undo tree, representing a point in the application's history. pub struct UndoNode { + /// The state data captured at this history point. pub action: EditAction, + /// ID of the parent node (previous state). Root node has None. pub parent: Option, + /// IDs of all states that branched off from this one. pub children: Vec, } +/// A non-linear undo/redo system that tracks history as a branching tree. +/// +/// Unlike a simple stack, an UndoTree allows users to undo several steps, +/// make a new change (creating a branch), and still navigate through +/// the most recent history path. pub struct UndoTree { + /// Map of node IDs to their respective history nodes. nodes: HashMap, + /// The ID of the node representing the current application state. current_node: usize, + /// Counter for assigning unique IDs to new nodes. next_id: usize, - // Track the latest child added to a node to know which branch to follow on redo + /// Tracks the most recently active branch for each node. + /// This allows the 'redo' operation to follow the path the user + /// actually took when multiple branches exist. latest_branch: HashMap, } impl UndoTree { + /// Creates a new undo tree initialized with the starting application state. pub fn new(initial_state: Vec, initial_selected: usize) -> Self { let root_id = 0; let root_node = UndoNode { @@ -43,6 +61,10 @@ impl UndoTree { } } + /// Pushes a new state onto the tree, branching off from the current node. + /// + /// This creates a new child node for the current position and updates + /// the branch tracking to ensure this new path is preferred during redo. pub fn push(&mut self, state: Vec, selected: usize) { let new_id = self.next_id; self.next_id += 1; @@ -68,6 +90,8 @@ impl UndoTree { self.current_node = new_id; } + /// Moves the current pointer back to the parent node and returns the previous state. + /// Returns None if the current node is the root (no more history to undo). pub fn undo(&mut self) -> Option<&EditAction> { if let Some(current) = self.nodes.get(&self.current_node) && let Some(parent_id) = current.parent { @@ -77,6 +101,11 @@ impl UndoTree { None } + /// Moves the current pointer forward to the latest child branch and returns the state. + /// + /// Redo follows the `latest_branch` map to decide which path to take + /// if multiple branches exist. If no branch is recorded, it defaults to the + /// most recently created child. pub fn redo(&mut self) -> Option<&EditAction> { if let Some(next_id) = self.latest_branch.get(&self.current_node).copied() { self.current_node = next_id; From 311f51441419b987fee48c33fd0b9b8058a2fa67 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 20 Mar 2026 10:48:00 +0100 Subject: [PATCH 2/2] updated nvim plugin --- lua/mould/init.lua | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/lua/mould/init.lua b/lua/mould/init.lua index ab05545..103a91f 100644 --- a/lua/mould/init.lua +++ b/lua/mould/init.lua @@ -1,6 +1,17 @@ local M = {} -local function open_floating_terminal(cmd) +-- Helper function to get a set of files currently in a directory +local function get_dir_files(dir) + local files = {} + if vim.fn.isdirectory(dir) == 1 then + for _, f in ipairs(vim.fn.readdir(dir)) do + files[f] = true + end + end + return files +end + +local function open_floating_terminal(cmd, target_dir) local buf = vim.api.nvim_create_buf(false, true) local width = math.floor(vim.o.columns * 0.9) local height = math.floor(vim.o.lines * 0.9) @@ -23,8 +34,12 @@ local function open_floating_terminal(cmd) local original_buf = vim.api.nvim_get_current_buf() local original_file = vim.api.nvim_buf_get_name(original_buf) + -- Snapshot the directory contents BEFORE the command runs + local files_before = get_dir_files(target_dir) + vim.fn.termopen(cmd, { on_exit = function() + -- Close the floating window and delete the terminal buffer vim.api.nvim_win_close(win, true) vim.api.nvim_buf_delete(buf, { force = true }) @@ -34,6 +49,25 @@ local function open_floating_terminal(cmd) vim.cmd("checktime " .. vim.fn.fnameescape(original_file)) end) end + + -- Snapshot the directory AFTER the command finishes + local files_after = get_dir_files(target_dir) + + -- Compare to find the newly created file + for f, _ in pairs(files_after) do + if not files_before[f] then + local new_filepath = target_dir .. "/" .. f + + -- Open the new file (wrapped in schedule to ensure it runs safely after term closes) + vim.schedule(function() + vim.cmd("edit " .. vim.fn.fnameescape(new_filepath)) + -- Optional: Let the user know it worked + vim.notify("mould.nvim: Opened " .. f, vim.log.levels.INFO) + end) + + break -- Assuming only one file is generated; we can stop looking + end + end end, }) @@ -47,8 +81,12 @@ function M.open() return end + -- Get the directory of the current file + local target_dir = vim.fn.fnamemodify(filepath, ":p:h") local cmd = string.format("mould %s", vim.fn.shellescape(filepath)) - open_floating_terminal(cmd) + + -- Pass the target directory to our terminal function + open_floating_terminal(cmd, target_dir) end return M