1 Commits

Author SHA1 Message Date
c270d37585 Merge pull request 'release/0.4.0' (#8) from release/0.4.0 into main
All checks were successful
Release / Build and Release (push) Successful in 57s
Reviewed-on: #8
2026-03-17 12:30:13 +01:00
11 changed files with 49 additions and 362 deletions

2
Cargo.lock generated
View File

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

View File

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

View File

@@ -32,13 +32,15 @@ cargo build --release
```
### Neovim Plugin
If you use a plugin manager like `mini.deps`, you can add the repository directly:
If you use a plugin manager like `lazy.nvim`, you can add the local repository (or remote once published) directly:
```lua
add({
source = 'https://git.narl.io/nvrl/mould-rs',
checkout = 'main',
})
{
"username/mould", -- replace with actual repo path or github url
config = function()
-- Provides the :Mould command
end
}
```
## Usage
@@ -58,13 +60,7 @@ mould config.template.json -o config.json
- `k` / `Up`: Move selection up
- `gg`: Jump to the top
- `G`: Jump to the bottom
- `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
- `i`: Edit the value of the currently selected key (Enter Insert Mode)
- `a`: Add missing value from template to active config
- `/`: Search for configuration keys (Jump to matches)
- `n`: Jump to the next search match
@@ -124,4 +120,4 @@ tree_depth_4 = "#fab387"
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

View File

@@ -9,16 +9,15 @@ transparent = false
# Colors are specified in hex format ("#RRGGBB").
# Default values follow the Semantic Catppuccin Mocha palette.
bg_normal = "#1e1e2e"
bg_normal = "#11111b"
bg_highlight = "#89b4fa"
bg_active = "#a6e3a1"
bg_search = "#cba6f7"
fg_normal = "#cdd6f4"
fg_dimmed = "#6c7086"
fg_highlight = "#1e1e2e"
fg_dimmed = "#a6adc8"
fg_highlight = "#11111b"
fg_warning = "#f38ba8"
fg_modified = "#fab387"
fg_accent = "#b4befe"
border_normal = "#45475a"
border_active = "#a6e3a1"
tree_depth_1 = "#b4befe"
@@ -31,8 +30,6 @@ tree_depth_4 = "#fab387"
down = "j"
up = "k"
edit = "i"
edit_append = "A"
edit_substitute = "S"
save = ":w"
quit = ":q"
normal_mode = "Esc"
@@ -41,7 +38,3 @@ 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,12 +11,6 @@ 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.
@@ -33,8 +27,6 @@ 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 {
@@ -49,7 +41,6 @@ impl App {
status_message: None,
input: Input::new(initial_input),
search_query: String::new(),
undo_stack: Vec::new(),
}
}
@@ -158,25 +149,12 @@ impl App {
}
}
/// Transitions the application into Insert Mode with a specific variant.
pub fn enter_insert(&mut self, variant: InsertVariant) {
/// Transitions the application into Insert Mode.
pub fn enter_insert(&mut self) {
if let Some(var) = self.vars.get(self.selected) {
if !var.is_group {
self.save_undo_state();
self.mode = Mode::Insert;
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());
}
}
self.status_message = None;
}
}
}
@@ -186,196 +164,4 @@ 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,8 +106,6 @@ 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,
@@ -116,10 +114,6 @@ 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 {
@@ -128,8 +122,6 @@ 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(),
@@ -138,10 +130,6 @@ 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, ValueType};
use super::{ConfigItem, FormatHandler, ItemStatus};
use std::fs;
use std::io::{self, Write};
use std::path::Path;
@@ -27,7 +27,6 @@ impl FormatHandler for EnvHandler {
depth: 0,
is_group: false,
status: ItemStatus::Present,
value_type: ValueType::String,
});
}
}
@@ -66,7 +65,6 @@ impl FormatHandler for EnvHandler {
depth: 0,
is_group: false,
status: ItemStatus::MissingFromTemplate,
value_type: ValueType::String,
});
}
}
@@ -161,7 +159,6 @@ 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, ValueType};
use super::{ConfigItem, FormatHandler, FormatType, ItemStatus};
use serde_json::{Map, Value};
use std::fs;
use std::io;
@@ -58,8 +58,6 @@ 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)
};
@@ -76,7 +74,6 @@ 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 };
@@ -95,7 +92,6 @@ 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 };
@@ -114,7 +110,6 @@ 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) => {
@@ -128,7 +123,6 @@ 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) => {
@@ -142,7 +136,6 @@ 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 => {
@@ -155,7 +148,6 @@ fn flatten(value: &Value, prefix: &str, depth: usize, key_name: &str, vars: &mut
depth,
is_group: false,
status: ItemStatus::Present,
value_type: ValueType::Null,
});
}
}
@@ -213,14 +205,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, var.value_type);
insert_into_value(&mut root, &var.path, val);
}
}
self.write_value(path, &root)
}
}
fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str, value_type: ValueType) {
fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str) {
let mut parts = path.split('.');
let last_part = match parts.next_back() {
Some(p) => p,
@@ -263,30 +255,13 @@ fn insert_into_value(root: &mut Value, path: &str, new_val_str: &str, value_type
}
let map = current.as_object_mut().unwrap();
// 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()),
// 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())
};
if let Some(i) = final_idx {
@@ -335,14 +310,12 @@ mod tests {
}
});
flatten(&json, "", 0, "", &mut vars);
assert_eq!(vars.len(), 6);
flatten(&json, "", &mut vars);
assert_eq!(vars.len(), 2);
let mut root = Value::Object(Map::new());
for var in vars {
if !var.is_group {
insert_into_value(&mut root, &var.path, var.value.as_deref().unwrap_or(""), var.value_type);
}
insert_into_value(&mut root, &var.key, &var.value);
}
// When unflattening, it parses bool back

View File

@@ -12,14 +12,6 @@ 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,
@@ -30,7 +22,6 @@ 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, InsertVariant, Mode};
use crate::app::{App, Mode};
use crate::config::Config;
use crate::format::FormatHandler;
use crossterm::event::{self, Event, KeyCode, KeyEvent};
@@ -117,17 +117,11 @@ 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"),
@@ -150,9 +144,7 @@ where
match action {
"down" => self.app.next(),
"up" => self.app.previous(),
"edit" => self.app.enter_insert(InsertVariant::Start),
"edit_append" => self.app.enter_insert(InsertVariant::End),
"edit_substitute" => self.app.enter_insert(InsertVariant::Substitute),
"edit" => self.app.enter_insert(),
"search" => {
self.app.mode = Mode::Search;
self.app.search_query.clear();
@@ -162,14 +154,7 @@ where
"previous_match" => self.app.jump_previous_match(),
"jump_top" => self.app.jump_top(),
"jump_bottom" => self.app.jump_bottom(),
"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();
}
"add_missing" => self.add_missing_item(),
"command" => {
self.command_buffer.push(':');
self.sync_command_status();

View File

@@ -269,36 +269,14 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
),
};
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_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_line = Line::from(vec![
Span::styled(mode_str, mode_style),