Version Check / check-version (pull_request) Successful in 10s
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
155 lines
5.1 KiB
Rust
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())
|
|
}
|
|
}
|
|
} |