Files
trx-rs/src/trx-configurator/src/writer.rs
T
sjgandClaude Opus 5 335922fecc [feat](trx-config): report unknown configuration keys
Every config struct is #[serde(default)], so a misspelled key was dropped in
silence and the setting kept its default.  Writing `prot = 9999` under
[listen] started the server on 4530 without a word.

Collect the ignored key paths with serde_ignored and pair each with the
closest known key at the same level:

    WARN unknown config key 'listen.prot' (did you mean 'listen.port'?)

Warnings by default, so a config written for a newer version still runs on an
older binary; --strict-config makes them fatal for CI.  Logging now starts
before validation so these warnings are actually visible.

trx-configurator --check drops its hand-maintained key lists and re-implemented
range checks in favour of the real loader and validators, so it no longer
passes configs the binaries reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-06 20:55:01 +02:00

516 lines
16 KiB
Rust

// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use std::path::Path;
use toml_edit::{value, DocumentMut, Item, Table};
use crate::prompts::{
ClientGeneral, FrontendsSetup, ListenSetup, RemoteSetup, RigSetup, ServerGeneral,
};
use crate::ConfigType;
// ── Helpers ─────────────────────────────────────────────────────────────
fn commented(table: &mut Table, key: &str, val: Item, comment: &str) {
table.insert(key, val);
if let Some(mut kv) = table.key_mut(key) {
kv.leaf_decor_mut().set_prefix(format!("# {}\n", comment));
}
}
fn header_comment(table: &mut Table, comment: &str) {
table.decor_mut().set_prefix(format!("\n# {}\n", comment));
}
// ── Server document builder ─────────────────────────────────────────────
fn build_server_tables(general: ServerGeneral, rig: RigSetup, listen: ListenSetup) -> Table {
let mut root = Table::new();
// [general]
{
let mut t = Table::new();
header_comment(&mut t, "General settings");
commented(
&mut t,
"callsign",
value(&general.callsign),
"Station callsign",
);
commented(
&mut t,
"log_level",
value(&general.log_level),
"Log level (trace, debug, info, warn, error)",
);
if let Some(lat) = general.latitude {
commented(
&mut t,
"latitude",
value(lat),
"Station latitude (decimal degrees, WGS84)",
);
}
if let Some(lon) = general.longitude {
commented(
&mut t,
"longitude",
value(lon),
"Station longitude (decimal degrees, WGS84)",
);
}
root.insert("general", Item::Table(t));
}
// [rig]
{
let mut t = Table::new();
header_comment(&mut t, "Rig backend configuration");
commented(
&mut t,
"model",
value(&rig.model),
"Rig model (ft817, ft450d, soapysdr)",
);
commented(
&mut t,
"initial_freq_hz",
value(144_300_000i64),
"Initial frequency in Hz",
);
commented(&mut t, "initial_mode", value("USB"), "Initial mode");
// [rig.access]
let mut access = Table::new();
header_comment(&mut access, "Rig access method");
commented(
&mut access,
"type",
value(&rig.access_type),
"Access type: serial, tcp, or sdr",
);
match rig.access_type.as_str() {
"serial" => {
if let Some(port) = &rig.serial_port {
commented(
&mut access,
"port",
value(port.as_str()),
"Serial port path",
);
}
if let Some(baud) = rig.serial_baud {
commented(&mut access, "baud", value(baud as i64), "Baud rate");
}
}
"tcp" => {
if let Some(host) = &rig.tcp_host {
commented(&mut access, "host", value(host.as_str()), "Remote host");
}
if let Some(port) = rig.tcp_port {
commented(&mut access, "tcp_port", value(port as i64), "Remote port");
}
}
"sdr" => {
if let Some(args) = &rig.sdr_args {
commented(
&mut access,
"args",
value(args.as_str()),
"SoapySDR device args string",
);
}
}
_ => {}
}
t.insert("access", Item::Table(access));
root.insert("rig", Item::Table(t));
}
// [behavior]
{
let mut t = Table::new();
header_comment(&mut t, "Polling and retry behavior");
commented(
&mut t,
"poll_interval_ms",
value(500i64),
"Rig polling interval (ms)",
);
commented(
&mut t,
"poll_interval_tx_ms",
value(100i64),
"Polling interval during TX (ms)",
);
commented(&mut t, "max_retries", value(3i64), "Maximum retry attempts");
commented(
&mut t,
"retry_base_delay_ms",
value(100i64),
"Base retry delay (ms)",
);
root.insert("behavior", Item::Table(t));
}
// [listen]
{
let mut t = Table::new();
header_comment(&mut t, "JSON TCP listener for client connections");
commented(&mut t, "enabled", value(true), "Enable the TCP listener");
commented(
&mut t,
"listen",
value(&listen.listen),
"IP address to listen on",
);
commented(
&mut t,
"port",
value(listen.port as i64),
"TCP port for client connections",
);
root.insert("listen", Item::Table(t));
}
// [audio]
{
let mut t = Table::new();
header_comment(&mut t, "Audio streaming");
commented(&mut t, "enabled", value(true), "Enable audio streaming");
commented(&mut t, "listen", value("127.0.0.1"), "Audio listen address");
commented(&mut t, "port", value(4531i64), "Audio TCP port");
commented(&mut t, "sample_rate", value(48000i64), "Sample rate in Hz");
commented(
&mut t,
"channels",
value(2i64),
"Channel count (1 = mono, 2 = stereo)",
);
commented(
&mut t,
"frame_duration_ms",
value(20i64),
"Opus frame duration (ms)",
);
commented(
&mut t,
"bitrate_bps",
value(256000i64),
"Opus bitrate (bps)",
);
root.insert("audio", Item::Table(t));
}
root
}
pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup) -> DocumentMut {
let mut doc = DocumentMut::new();
doc.decor_mut()
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
// Emit the sectioned shape (`[trx-server]`) that trx-server writes with
// --print-config, so a generated file can be dropped into a combined
// trx-rs.toml unchanged.
doc.insert(
"trx-server",
Item::Table(build_server_tables(general, rig, listen)),
);
doc
}
// ── Client document builder ─────────────────────────────────────────────
fn build_client_tables(
general: ClientGeneral,
remote: RemoteSetup,
frontends: FrontendsSetup,
) -> Table {
let mut root = Table::new();
// [general]
{
let mut t = Table::new();
header_comment(&mut t, "General settings");
commented(
&mut t,
"callsign",
value(&general.callsign),
"Station callsign",
);
commented(
&mut t,
"log_level",
value(&general.log_level),
"Log level (trace, debug, info, warn, error)",
);
root.insert("general", Item::Table(t));
}
// [remote]
{
let mut t = Table::new();
header_comment(&mut t, "Remote server connection");
commented(
&mut t,
"url",
value(&remote.url),
"Server address (host:port)",
);
commented(
&mut t,
"poll_interval_ms",
value(750i64),
"State poll interval (ms)",
);
let mut auth = Table::new();
if let Some(token) = &remote.token {
commented(&mut auth, "token", value(token.as_str()), "Auth token");
}
if !auth.is_empty() {
t.insert("auth", Item::Table(auth));
}
root.insert("remote", Item::Table(t));
}
// [frontends.http]
{
let mut frontends_table = Table::new();
header_comment(&mut frontends_table, "Frontend configuration");
let mut http = Table::new();
commented(
&mut http,
"enabled",
value(frontends.http_enabled),
"Enable HTTP web frontend",
);
commented(&mut http, "listen", value("127.0.0.1"), "Listen address");
commented(
&mut http,
"port",
value(frontends.http_port as i64),
"HTTP port",
);
frontends_table.insert("http", Item::Table(http));
let mut rigctl = Table::new();
commented(
&mut rigctl,
"enabled",
value(frontends.rigctl_enabled),
"Enable Hamlib rigctl frontend",
);
commented(&mut rigctl, "listen", value("127.0.0.1"), "Listen address");
commented(
&mut rigctl,
"port",
value(frontends.rigctl_port as i64),
"rigctl port",
);
frontends_table.insert("rigctl", Item::Table(rigctl));
let mut http_json = Table::new();
commented(
&mut http_json,
"enabled",
value(true),
"Enable JSON-over-TCP frontend",
);
commented(
&mut http_json,
"listen",
value("127.0.0.1"),
"Listen address",
);
commented(&mut http_json, "port", value(0i64), "Port (0 = ephemeral)");
frontends_table.insert("http_json", Item::Table(http_json));
let mut audio = Table::new();
commented(&mut audio, "enabled", value(true), "Enable audio client");
commented(
&mut audio,
"server_port",
value(4531i64),
"Server audio port",
);
frontends_table.insert("audio", Item::Table(audio));
root.insert("frontends", Item::Table(frontends_table));
}
root
}
pub fn build_client(
general: ClientGeneral,
remote: RemoteSetup,
frontends: FrontendsSetup,
) -> DocumentMut {
let mut doc = DocumentMut::new();
doc.decor_mut()
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
doc.insert(
"trx-client",
Item::Table(build_client_tables(general, remote, frontends)),
);
doc
}
// ── Combined document builder ───────────────────────────────────────────
pub fn build_combined(
s_general: ServerGeneral,
rig: RigSetup,
listen: ListenSetup,
c_general: ClientGeneral,
remote: RemoteSetup,
frontends: FrontendsSetup,
) -> DocumentMut {
let mut doc = DocumentMut::new();
doc.decor_mut()
.set_prefix("# trx-rs combined configuration\n# Generated by trx-configurator\n");
let server = build_server_tables(s_general, rig, listen);
let mut server_item = Item::Table(server);
if let Some(t) = server_item.as_table_mut() {
header_comment(t, "Server configuration");
}
doc.insert("trx-server", server_item);
let client = build_client_tables(c_general, remote, frontends);
let mut client_item = Item::Table(client);
if let Some(t) = client_item.as_table_mut() {
header_comment(t, "Client configuration");
}
doc.insert("trx-client", client_item);
doc
}
// ── Default builder ─────────────────────────────────────────────────────
pub fn build_default(config_type: ConfigType) -> DocumentMut {
let s_general = ServerGeneral {
callsign: "N0CALL".to_string(),
log_level: "info".to_string(),
latitude: None,
longitude: None,
};
let rig = RigSetup {
model: "ft817".to_string(),
access_type: "serial".to_string(),
serial_port: Some("/dev/ttyUSB0".to_string()),
serial_baud: Some(9600),
tcp_host: None,
tcp_port: None,
sdr_args: None,
};
let listen = ListenSetup {
listen: "127.0.0.1".to_string(),
port: 4530,
};
let c_general = ClientGeneral {
callsign: "N0CALL".to_string(),
log_level: "info".to_string(),
};
let remote = RemoteSetup {
url: "localhost:4530".to_string(),
token: None,
};
let frontends = FrontendsSetup {
http_enabled: true,
http_port: 8080,
rigctl_enabled: false,
rigctl_port: 4532,
};
match config_type {
ConfigType::Server => build_server(s_general, rig, listen),
ConfigType::Client => build_client(c_general, remote, frontends),
ConfigType::Combined => {
build_combined(s_general, rig, listen, c_general, remote, frontends)
}
}
}
// ── File writer ─────────────────────────────────────────────────────────
pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> {
if path.exists() {
let overwrite = dialoguer::Confirm::new()
.with_prompt(format!("{} already exists. Overwrite?", path.display()))
.default(false)
.interact()
.map_err(|e| e.to_string())?;
if !overwrite {
return Err("Aborted.".to_string());
}
}
std::fs::write(path, doc.to_string())
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
println!("Wrote {}", path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use trx_config::{ClientConfig, ServerConfig};
fn write_temp(doc: &DocumentMut) -> tempfile::NamedTempFile {
let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
std::fs::write(file.path(), doc.to_string()).unwrap();
file
}
/// The wizard used to emit root-level `[general]` / `[rig]` tables while the
/// loader demanded a `[trx-server]` section, so every generated standalone
/// config was rejected by the binary it was generated for.
#[test]
fn test_generated_server_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Server));
let cfg = ServerConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.rig.model.as_deref(), Some("ft817"));
assert_eq!(cfg.listen.port, 4530);
}
#[test]
fn test_generated_client_config_loads_and_validates() {
let file = write_temp(&build_default(ConfigType::Client));
let cfg = ClientConfig::load_from_file(file.path())
.expect("generated config must load")
.config;
cfg.validate().expect("generated config must validate");
assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530"));
}
#[test]
fn test_generated_combined_config_loads_both_sections() {
let file = write_temp(&build_default(ConfigType::Combined));
let server = ServerConfig::load_from_file(file.path())
.expect("server section must load")
.config;
server.validate().expect("server section must validate");
let client = ClientConfig::load_from_file(file.path())
.expect("client section must load")
.config;
client.validate().expect("client section must validate");
}
#[test]
fn test_generated_docs_are_sectioned() {
let doc = build_default(ConfigType::Server);
assert!(doc.as_table().contains_key("trx-server"));
let doc = build_default(ConfigType::Client);
assert!(doc.as_table().contains_key("trx-client"));
}
}