6 Commits

Author SHA1 Message Date
1d9342186a added undo + dynamic status bar
All checks were successful
Version Check / check-version (pull_request) Successful in 3s
2026-03-17 14:33:26 +01:00
f6a84416e6 updated insert modes + fixed status bar help
All checks were successful
Version Check / check-version (pull_request) Successful in 3s
2026-03-17 14:29:51 +01:00
01a7bd44b7 updated nvim readme desc
All checks were successful
Version Check / check-version (pull_request) Successful in 4s
2026-03-17 13:40:01 +01:00
f123f2d6df added dd remove
All checks were successful
Version Check / check-version (pull_request) Successful in 3s
2026-03-17 13:12:22 +01:00
94ff632b39 fixed string to int error 2026-03-17 13:09:22 +01:00
f09dbc8321 fixed tests case 2026-03-17 12:39:18 +01:00
11 changed files with 362 additions and 49 deletions

2
Cargo.lock generated
View File

@@ -853,7 +853,7 @@ dependencies = [
[[package]]
name = "mould"
version = "0.4.0"
version = "0.4.2"
dependencies = [
"anyhow",
"clap",

View File

@@ -1,9 +1,3 @@
[package]
edition = "2024"
name = "mould"
version = "0.4.0"
authors = ["Nils Pukropp <nils@narl.io>"]
[[bin]]
name = "mould"
path = "src/main.rs"
@@ -22,12 +16,18 @@ toml = "1.0.6"
tui-input = "0.15.0"
[dependencies.clap]
version = "4.6.0"
features = ["derive"]
version = "4.6.0"
[dependencies.serde]
version = "1.0.228"
features = ["derive"]
version = "1.0.228"
[dev-dependencies]
tempfile = "3.27.0"
[package]
authors = ["Nils Pukropp <nils@narl.io>"]
edition = "2024"
name = "mould"
version = "0.4.2"

View File

@@ -32,15 +32,13 @@ cargo build --release
```
### Neovim Plugin
If you use a plugin manager like `lazy.nvim`, you can add the local repository (or remote once published) directly:
If you use a plugin manager like `mini.deps`, you can add the repository directly:
```lua
{
"username/mould", -- replace with actual repo path or github url
config = function()
-- Provides the :Mould command
end
}
add({
source = 'https://git.narl.io/nvrl/mould-rs',
checkout = 'main',
})
```
## Usage
@@ -60,7 +58,13 @@ mould config.template.json -o config.json
- `k` / `Up`: Move selection up
- `gg`: Jump to the top
- `G`: Jump to the bottom
- `i`: Edit the value of the currently selected key (Enter Insert Mode)
- `i`: Edit value (cursor at start)
- `A`: Edit value (cursor at end)
- `S`: Substitute value (clear and edit)
- `o`: Append a new item to the current array
- `O`: Prepend a new item to the current array
- `dd`: Delete the currently selected variable or group
- `u`: Undo the last change
- `a`: Add missing value from template to active config
- `/`: Search for configuration keys (Jump to matches)
- `n`: Jump to the next search match

View File

@@ -9,15 +9,16 @@ transparent = false
# Colors are specified in hex format ("#RRGGBB").
# Default values follow the Semantic Catppuccin Mocha palette.
bg_normal = "#11111b"
bg_normal = "#1e1e2e"
bg_highlight = "#89b4fa"
bg_active = "#a6e3a1"
bg_search = "#cba6f7"
fg_normal = "#cdd6f4"
fg_dimmed = "#a6adc8"
fg_highlight = "#11111b"
fg_dimmed = "#6c7086"
fg_highlight = "#1e1e2e"
fg_warning = "#f38ba8"
fg_modified = "#fab387"
fg_accent = "#b4befe"
border_normal = "#45475a"
border_active = "#a6e3a1"
tree_depth_1 = "#b4befe"
@@ -30,6 +31,8 @@ tree_depth_4 = "#fab387"
down = "j"
up = "k"
edit = "i"
edit_append = "A"
edit_substitute = "S"
save = ":w"
quit = ":q"
normal_mode = "Esc"
@@ -38,3 +41,7 @@ next_match = "n"
previous_match = "N"
jump_top = "gg"
jump_bottom = "G"
append_item = "o"
prepend_item = "O"
delete_item = "dd"
undo = "u"

View File

@@ -11,6 +11,12 @@ pub enum Mode {
Search,
}
pub enum InsertVariant {
Start,
End,
Substitute,
}
/// The core application state, holding all configuration variables and UI status.
pub struct App {
/// The list of configuration variables being edited.
@@ -27,6 +33,8 @@ pub struct App {
pub input: Input,
/// The current search query for filtering keys.
pub search_query: String,
/// Stack of previous variable states for undo functionality.
pub undo_stack: Vec<Vec<ConfigItem>>,
}
impl App {
@@ -41,6 +49,7 @@ impl App {
status_message: None,
input: Input::new(initial_input),
search_query: String::new(),
undo_stack: Vec::new(),
}
}
@@ -149,12 +158,25 @@ impl App {
}
}
/// Transitions the application into Insert Mode.
pub fn enter_insert(&mut self) {
/// Transitions the application into Insert Mode with a specific variant.
pub fn enter_insert(&mut self, variant: InsertVariant) {
if let Some(var) = self.vars.get(self.selected) {
if !var.is_group {
self.save_undo_state();
self.mode = Mode::Insert;
self.status_message = None;
match variant {
InsertVariant::Start => {
use tui_input::InputRequest;
self.input.handle(InputRequest::GoToStart);
}
InsertVariant::End => {
use tui_input::InputRequest;
self.input.handle(InputRequest::GoToEnd);
}
InsertVariant::Substitute => {
self.input = Input::new(String::new());
}
}
}
}
}
@@ -164,4 +186,196 @@ impl App {
self.commit_input();
self.mode = Mode::Normal;
}
/// Deletes the currently selected item. If it's a group, deletes all children.
pub fn delete_selected(&mut self) {
if self.vars.is_empty() {
return;
}
self.save_undo_state();
let selected_path = self.vars[self.selected].path.clone();
let is_group = self.vars[self.selected].is_group;
// Identify if the item being removed is an array item
let array_info = parse_index(&selected_path);
// 1. Identify all items to remove
let mut to_remove = Vec::new();
to_remove.push(self.selected);
if is_group {
let prefix_dot = format!("{}.", selected_path);
let prefix_bracket = format!("{}[", selected_path);
for (i, var) in self.vars.iter().enumerate() {
if i == self.selected {
continue;
}
if var.path.starts_with(&prefix_dot) || var.path.starts_with(&prefix_bracket) {
to_remove.push(i);
}
}
}
// 2. Perform removal (reverse order to preserve indices)
to_remove.sort_unstable_by(|a, b| b.cmp(a));
for i in to_remove {
self.vars.remove(i);
}
// 3. Re-index subsequent array items if applicable
if let Some((base, removed_idx)) = array_info {
let base = base.to_string();
for var in self.vars.iter_mut() {
if var.path.starts_with(&base) {
// We need to find the index segment that matches this array
if let Some((b, i, suffix)) = find_array_segment(&var.path, &base) {
if b == base && i > removed_idx {
let new_idx = i - 1;
var.path = format!("{}[{}]{}", base, new_idx, suffix);
// Also update key if it matches the old index exactly
if var.key == format!("[{}]", i) {
var.key = format!("[{}]", new_idx);
}
}
}
}
}
}
// 4. Adjust selection
if self.selected >= self.vars.len() && !self.vars.is_empty() {
self.selected = self.vars.len() - 1;
}
self.sync_input_with_selected();
}
/// Adds a new item to an array if the selected item is part of one.
pub fn add_array_item(&mut self, after: bool) {
if self.vars.is_empty() {
return;
}
self.save_undo_state();
let (base, idx, depth) = {
let selected_item = &self.vars[self.selected];
if selected_item.is_group {
return;
}
let path = &selected_item.path;
if let Some((base, idx)) = parse_index(path) {
(base.to_string(), idx, selected_item.depth)
} else {
return;
}
};
let new_idx = if after { idx + 1 } else { idx };
let insert_pos = if after {
self.selected + 1
} else {
self.selected
};
// 1. Shift all items in this array that have index >= new_idx
for var in self.vars.iter_mut() {
if var.path.starts_with(&base) {
if let Some((b, i)) = parse_index(&var.path) {
if b == base && i >= new_idx {
var.path = format!("{}[{}]", base, i + 1);
// Also update key if it was just the index
if var.key == format!("[{}]", i) {
var.key = format!("[{}]", i + 1);
}
}
}
}
}
// 2. Insert new item
let new_item = ConfigItem {
key: format!("[{}]", new_idx),
path: format!("{}[{}]", base, new_idx),
value: Some("".to_string()),
template_value: None,
default_value: None,
depth,
is_group: false,
status: crate::format::ItemStatus::Modified,
value_type: crate::format::ValueType::String,
};
self.vars.insert(insert_pos, new_item);
self.selected = insert_pos;
self.sync_input_with_selected();
self.enter_insert(InsertVariant::Start);
self.status_message = None;
}
/// Status bar helpers
pub fn selected_is_group(&self) -> bool {
self.vars.get(self.selected).map(|v| v.is_group).unwrap_or(false)
}
pub fn selected_is_array(&self) -> bool {
self.vars.get(self.selected)
.map(|v| !v.is_group && v.path.contains('['))
.unwrap_or(false)
}
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 stack.
pub fn save_undo_state(&mut self) {
self.undo_stack.push(self.vars.clone());
if self.undo_stack.len() > 50 {
self.undo_stack.remove(0);
}
}
/// Reverts to the last saved state of variables.
pub fn undo(&mut self) {
if let Some(previous_vars) = self.undo_stack.pop() {
self.vars = previous_vars;
if self.selected >= self.vars.len() && !self.vars.is_empty() {
self.selected = self.vars.len() - 1;
}
self.sync_input_with_selected();
self.status_message = Some("Undo applied".to_string());
} else {
self.status_message = Some("Nothing to undo".to_string());
}
}
}
fn parse_index(path: &str) -> Option<(&str, usize)> {
if let Some(end) = path.rfind(']') {
let segment = &path[..=end];
if let Some(start) = segment.rfind('[') {
if let Ok(idx) = segment[start + 1..end].parse::<usize>() {
// Return the base and index
return Some((&path[..start], idx));
}
}
}
None
}
/// Helper to find an array segment in a path given a base prefix.
fn find_array_segment<'a>(path: &'a str, base: &str) -> Option<(&'a str, usize, &'a str)> {
if !path.starts_with(base) {
return None;
}
let remaining = &path[base.len()..];
if remaining.starts_with('[') {
if let Some(end) = remaining.find(']') {
if let Ok(idx) = remaining[1..end].parse::<usize>() {
return Some((&path[..base.len()], idx, &remaining[end + 1..]));
}
}
}
None
}

View File

@@ -106,6 +106,8 @@ pub struct KeybindsConfig {
pub down: String,
pub up: String,
pub edit: String,
pub edit_append: String,
pub edit_substitute: String,
pub save: String,
pub quit: String,
pub normal_mode: String,
@@ -114,6 +116,10 @@ pub struct KeybindsConfig {
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,
}
impl Default for KeybindsConfig {
@@ -122,6 +128,8 @@ impl Default for KeybindsConfig {
down: "j".to_string(),
up: "k".to_string(),
edit: "i".to_string(),
edit_append: "A".to_string(),
edit_substitute: "S".to_string(),
save: ":w".to_string(),
quit: ":q".to_string(),
normal_mode: "Esc".to_string(),
@@ -130,6 +138,10 @@ impl Default for KeybindsConfig {
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(),
}
}
}

View File

@@ -1,4 +1,4 @@
use super::{ConfigItem, FormatHandler, ItemStatus};
use super::{ConfigItem, FormatHandler, ItemStatus, ValueType};
use std::fs;
use std::io::{self, Write};
use std::path::Path;
@@ -27,6 +27,7 @@ impl FormatHandler for EnvHandler {
depth: 0,
is_group: false,
status: ItemStatus::Present,
value_type: ValueType::String,
});
}
}
@@ -65,6 +66,7 @@ impl FormatHandler for EnvHandler {
depth: 0,
is_group: false,
status: ItemStatus::MissingFromTemplate,
value_type: ValueType::String,
});
}
}
@@ -159,6 +161,7 @@ mod tests {
depth: 0,
is_group: false,
status: ItemStatus::Present,
value_type: ValueType::String,
}];
let handler = EnvHandler;

View File

@@ -1,4 +1,4 @@
use super::{ConfigItem, FormatHandler, FormatType, ItemStatus};
use super::{ConfigItem, FormatHandler, FormatType, ItemStatus, ValueType};
use serde_json::{Map, Value};
use std::fs;
use std::io;
@@ -58,6 +58,8 @@ fn flatten(value: &Value, prefix: &str, depth: usize, key_name: &str, vars: &mut
key_name.to_string()
} else if key_name.is_empty() {
prefix.to_string()
} else if key_name.starts_with('[') {
format!("{}{}", prefix, key_name)
} else {
format!("{}.{}", prefix, key_name)
};
@@ -74,6 +76,7 @@ fn flatten(value: &Value, prefix: &str, depth: usize, key_name: &str, vars: &mut
depth,
is_group: true,
status: ItemStatus::Present,
value_type: ValueType::Null,
});
}
let next_depth = if path.is_empty() { depth } else { depth + 1 };
@@ -92,6 +95,7 @@ fn flatten(value: &Value, prefix: &str, depth: usize, key_name: &str, vars: &mut
depth,
is_group: true,
status: ItemStatus::Present,
value_type: ValueType::Null,
});
}
let next_depth = if path.is_empty() { depth } else { depth + 1 };
@@ -110,6 +114,7 @@ fn flatten(value: &Value, prefix: &str, depth: usize, key_name: &str, vars: &mut
depth,
is_group: false,
status: ItemStatus::Present,
value_type: ValueType::String,
});
}
Value::Number(n) => {
@@ -123,6 +128,7 @@ fn flatten(value: &Value, prefix: &str, depth: usize, key_name: &str, vars: &mut
depth,
is_group: false,
status: ItemStatus::Present,
value_type: ValueType::Number,
});
}
Value::Bool(b) => {
@@ -136,6 +142,7 @@ fn flatten(value: &Value, prefix: &str, depth: usize, key_name: &str, vars: &mut
depth,
is_group: false,
status: ItemStatus::Present,
value_type: ValueType::Bool,
});
}
Value::Null => {
@@ -148,6 +155,7 @@ fn flatten(value: &Value, prefix: &str, depth: usize, key_name: &str, vars: &mut
depth,
is_group: false,
status: ItemStatus::Present,
value_type: ValueType::Null,
});
}
}
@@ -205,14 +213,14 @@ impl FormatHandler for HierarchicalHandler {
let val = var.value.as_deref()
.or(var.template_value.as_deref())
.unwrap_or("");
insert_into_value(&mut root, &var.path, val);
insert_into_value(&mut root, &var.path, val, var.value_type);
}
}
self.write_value(path, &root)
}
}
fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str) {
fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str, value_type: ValueType) {
let mut parts = path.split('.');
let last_part = match parts.next_back() {
Some(p) => p,
@@ -255,13 +263,30 @@ fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str) {
}
let map = current.as_object_mut().unwrap();
// Attempt basic type inference
let final_val = if let Ok(n) = new_val_str.parse::<i64>() {
Value::Number(n.into())
} else if let Ok(b) = new_val_str.parse::<bool>() {
Value::Bool(b)
} else {
Value::String(new_val_str.to_string())
// Use the preserved ValueType instead of aggressive inference
let final_val = match value_type {
ValueType::Number => {
if let Ok(n) = new_val_str.parse::<i64>() {
Value::Number(n.into())
} else if let Ok(f) = new_val_str.parse::<f64>() {
if let Some(n) = serde_json::Number::from_f64(f) {
Value::Number(n)
} else {
Value::String(new_val_str.to_string())
}
} else {
Value::String(new_val_str.to_string())
}
}
ValueType::Bool => {
if let Ok(b) = new_val_str.parse::<bool>() {
Value::Bool(b)
} else {
Value::String(new_val_str.to_string())
}
}
ValueType::Null if new_val_str.is_empty() => Value::Null,
_ => Value::String(new_val_str.to_string()),
};
if let Some(i) = final_idx {
@@ -310,12 +335,14 @@ mod tests {
}
});
flatten(&json, "", &mut vars);
assert_eq!(vars.len(), 2);
flatten(&json, "", 0, "", &mut vars);
assert_eq!(vars.len(), 6);
let mut root = Value::Object(Map::new());
for var in vars {
insert_into_value(&mut root, &var.key, &var.value);
if !var.is_group {
insert_into_value(&mut root, &var.path, var.value.as_deref().unwrap_or(""), var.value_type);
}
}
// When unflattening, it parses bool back

View File

@@ -12,6 +12,14 @@ pub enum ItemStatus {
Modified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValueType {
String,
Number,
Bool,
Null,
}
#[derive(Debug, Clone)]
pub struct ConfigItem {
pub key: String,
@@ -22,6 +30,7 @@ pub struct ConfigItem {
pub depth: usize,
pub is_group: bool,
pub status: ItemStatus,
pub value_type: ValueType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -1,4 +1,4 @@
use crate::app::{App, Mode};
use crate::app::{App, InsertVariant, Mode};
use crate::config::Config;
use crate::format::FormatHandler;
use crossterm::event::{self, Event, KeyCode, KeyEvent};
@@ -117,11 +117,17 @@ where
(&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"),
(&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"),
(&"a".to_string(), "add_missing"),
(&":".to_string(), "command"),
(&"q".to_string(), "quit"),
@@ -144,7 +150,9 @@ where
match action {
"down" => self.app.next(),
"up" => self.app.previous(),
"edit" => self.app.enter_insert(),
"edit" => self.app.enter_insert(InsertVariant::Start),
"edit_append" => self.app.enter_insert(InsertVariant::End),
"edit_substitute" => self.app.enter_insert(InsertVariant::Substitute),
"search" => {
self.app.mode = Mode::Search;
self.app.search_query.clear();
@@ -154,7 +162,14 @@ where
"previous_match" => self.app.jump_previous_match(),
"jump_top" => self.app.jump_top(),
"jump_bottom" => self.app.jump_bottom(),
"add_missing" => self.add_missing_item(),
"append_item" => self.app.add_array_item(true),
"prepend_item" => self.app.add_array_item(false),
"delete_item" => self.app.delete_selected(),
"undo" => self.app.undo(),
"add_missing" => {
self.app.save_undo_state();
self.add_missing_item();
}
"command" => {
self.command_buffer.push(':');
self.sync_command_status();

View File

@@ -269,14 +269,36 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
),
};
let status_msg = app
.status_message
.as_deref()
.unwrap_or_else(|| match app.mode {
Mode::Normal => " navigation | i: edit | /: search | :w: save | :q: quit ",
Mode::Insert => " Esc: back to normal | Enter: commit ",
Mode::Search => " Esc: back to normal | type to filter ",
});
let status_msg = if let Some(msg) = &app.status_message {
msg.clone()
} else {
let kb = &config.keybinds;
match app.mode {
Mode::Normal => {
let mut parts = vec![
format!("{}/{} move", kb.down, kb.up),
format!("{}/{} jump", kb.jump_top, kb.jump_bottom),
format!("{} search", kb.search),
];
if !app.selected_is_group() {
parts.push(format!("{}/{}/{} edit", kb.edit, kb.edit_append, kb.edit_substitute));
}
if app.selected_is_missing() {
parts.push(format!("{} add", "a")); // 'a' is currently hardcoded in runner
}
if app.selected_is_array() {
parts.push(format!("{}/{} array", kb.append_item, kb.prepend_item));
}
parts.push(format!("{} del", kb.delete_item));
parts.push(format!("{} undo", kb.undo));
parts.push(format!("{} save", kb.save));
parts.push(format!("{} quit", kb.quit));
parts.join(" · ")
}
Mode::Insert => "Esc normal · Enter commit".to_string(),
Mode::Search => "Esc normal · type to filter".to_string(),
}
};
let status_line = Line::from(vec![
Span::styled(mode_str, mode_style),