Files
mould-rs/src/main.rs
T
nvrlandClaude Opus 5 e8634c17a2
Version Check / check-version (pull_request) Successful in 10s
fix: make the template workflow real, and the UI legible
The headline flow — fill a config from its template — did not work. Three
bugs, each individually enough to make the tool feel unfinished:

- `add_missing` was unreachable. `find_binding` returned the first match in
  a linear array and `edit_append` was also bound to `a`, 17 entries earlier.
  Bindings now resolve through one table built from the config, and
  `KeybindsConfig::conflicts()` reports any key bound twice instead of
  letting one silently shadow the other. `add_missing` moves to `<Space>`,
  with `A` to take every missing default at once.
- Item status was ignored on write. Every writer fell back to
  `template_value`, so "(missing)" keys landed in the output regardless and
  saving was close to a copy. Unaccepted entries are now omitted.
- Parse errors were swallowed, then the good file was overwritten. A
  malformed target opened as an empty editor and `:w` truncated it. Parse
  failures are now fatal, reported with context, before the TUI starts.

Also:

- Split "differs from the template" (ItemStatus::Overridden) from "you
  edited this" (ConfigItem::dirty). A real config differs from its example
  on nearly every line, so the old Modified flag painted the whole list
  orange and carried no signal.
- Splice template-only entries in at their natural position instead of
  appending them, so a missing nested key no longer strands itself at the
  bottom of the tree at depth 3.
- Preserve .env comments and blank lines across a save.
- Header naming the output file and the template it is compared against,
  plus missing/changed counts. Previously nothing on screen said which
  files were in play or where `:w` would write.
- `?` opens a two-column keybinding reference built from the user's own
  config, scrollable on short terminals. The status bar drops to a few
  contextual hints; the old one joined 11+ hints into an unwrapped line
  that truncated at 80 columns.
- Foldable groups, which the `+ ` prefix had always implied.
- Search covers values as well as keys, reports `match n/m`, and `Esc`
  restores the pre-search cursor.
- Quitting with unsaved changes is refused; added `:q!`, `:x`, `:w <path>`.
- Status messages expire after 4s instead of permanently replacing hints.
- Cancelling a rename on a just-created item removes it again.
- Editing a Number field with text demotes the type instead of failing
  obscurely at write time.
- Undo-tree branches are reachable via `g-`/`g+` and reported on undo.
- Poll instead of blocking, so resizes repaint; drop the unused mouse
  capture that was costing terminal text selection for nothing.
- `--format` accepts xml, ini and properties, which were already supported.

36 tests, up from 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5mrbEFmwv2A4cdzbcSiS1
2026-09-05 05:01:39 +02:00

155 lines
5.1 KiB
Rust

mod app;
mod cli;
mod config;
mod error;
mod format;
mod runner;
mod ui;
mod resolver;
mod undo;
use app::App;
use config::load_config;
use error::MouldError;
use format::{detect_format, get_handler};
use log::{error, info, warn};
use runner::AppRunner;
use std::io;
use anyhow::Context;
use crossterm::{
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
fn main() -> anyhow::Result<()> {
let args = cli::parse();
// Initialize logger with verbosity from CLI
let log_level = match args.verbose {
0 => log::LevelFilter::Warn,
1 => log::LevelFilter::Info,
_ => log::LevelFilter::Debug,
};
env_logger::Builder::new()
.filter_level(log_level)
.format_timestamp(None)
.init();
let input_path = match args.input {
Some(path) => {
if !path.exists() {
error!("Input file not found: {}", path.display());
return Err(MouldError::FileNotFound(path.display().to_string()).into());
}
path
}
None => match resolver::find_input_file() {
Some(path) => {
info!("Discovered template: {}", path.display());
path
}
None => {
error!("No template file provided and none discovered in current directory.");
println!("Usage: mould <INPUT_FILE>");
println!("Supported defaults: .env.example, compose.yml, docker-compose.yml, etc.");
return Err(MouldError::FileNotFound("None".to_string()).into());
}
},
};
info!("Input: {}", input_path.display());
let format_type = detect_format(&input_path, args.format);
let handler = get_handler(format_type);
// Smart Comparison Logic
let (active_path, template_path) = resolver::resolve_paths(&input_path);
let output_path = args
.output
.unwrap_or_else(|| active_path.clone().unwrap_or_else(|| resolver::determine_output_path(&input_path)));
info!("Output: {}", output_path.display());
// 1. Load active config if it exists.
//
// A parse failure must be fatal: opening an editor over a file we could not
// read would present an empty document, and the next `:w` would truncate a
// perfectly good config.
let mut vars = if let Some(active) = &active_path {
handler
.parse(active)
.with_context(|| format!("could not parse {}", active.display()))?
} else {
Vec::new()
};
// 2. Load template config and merge
if let Some(template) = &template_path {
info!("Comparing with template: {}", template.display());
if vars.is_empty() {
vars = handler
.parse(template)
.with_context(|| format!("could not parse template {}", template.display()))?;
// Only the template exists, so nothing has been accepted into the
// active config yet. Keep each template value as the default on offer.
for v in vars.iter_mut() {
if !v.is_group {
v.status = crate::format::ItemStatus::MissingFromActive;
v.template_value = v.value.take();
v.default_value = v.template_value.clone();
}
}
} else {
handler
.merge(template, &mut vars)
.with_context(|| format!("could not parse template {}", template.display()))?;
}
} else if vars.is_empty() {
// Fallback if no template and active is empty
vars = handler
.parse(&input_path)
.with_context(|| format!("could not parse {}", input_path.display()))?;
}
if vars.is_empty() {
warn!("No variables found.");
}
let (config, config_error) = load_config();
let mut app = App::new(vars, output_path.clone(), template_path.clone());
if let Some(err) = config_error {
app.set_status(format!("config.toml ignored: {}", err));
}
// Terminal lifecycle
enable_raw_mode()
.map_err(|e| MouldError::Terminal(format!("Failed to enable raw mode: {}", e)))?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)
.map_err(|e| MouldError::Terminal(format!("Failed to enter alternate screen: {}", e)))?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)
.map_err(|e| MouldError::Terminal(format!("Failed to create terminal backend: {}", e)))?;
let mut runner = AppRunner::new(&mut terminal, &mut app, &config, handler.as_ref());
let res = runner.run();
// Restoration
disable_raw_mode().ok();
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
terminal.show_cursor().ok();
match res {
Ok(_) => {
info!("Successfully finished mould session.");
Ok(())
}
Err(e) => {
error!("Application error during run: {}", e);
Err(e.into())
}
}
}