[feat](trx-configurator): add --check flag for config validation

Validates existing TOML config files for syntax correctness, unknown
keys, and structural issues. Auto-detects config type (server, client,
combined) and checks known sections against expected schema.

Validates: log levels, coordinate ranges, port ranges, access types,
lat/lon pairing, and unknown key warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-25 21:53:10 +01:00
parent 81486fa147
commit 892533bdc2
3 changed files with 533 additions and 0 deletions
+3
View File
@@ -15,3 +15,6 @@ path = "src/main.rs"
clap = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] }
dialoguer = "0.11" dialoguer = "0.11"
toml_edit = "0.22" toml_edit = "0.22"
[dev-dependencies]
tempfile = "3"
+512
View File
@@ -0,0 +1,512 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
use std::fmt::Write as _;
use std::path::Path;
use toml_edit::DocumentMut;
/// Known top-level keys for a standalone server config.
const SERVER_KEYS: &[&str] = &[
"general",
"rig",
"rigs",
"behavior",
"listen",
"audio",
"sdr",
"pskreporter",
"aprsfi",
"decode_logs",
];
/// Known top-level keys for a standalone client config.
const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"];
/// Known top-level keys for a combined trx-rs.toml.
const COMBINED_KEYS: &[&str] = &["trx-server", "trx-client"];
/// Known sub-keys within [general] (server).
const SERVER_GENERAL_KEYS: &[&str] = &["callsign", "log_level", "latitude", "longitude"];
/// Known sub-keys within [general] (client).
const CLIENT_GENERAL_KEYS: &[&str] = &[
"callsign",
"log_level",
"website_url",
"website_name",
"ais_vessel_url_base",
];
/// Known sub-keys within [rig].
const RIG_KEYS: &[&str] = &["model", "initial_freq_hz", "initial_mode", "access"];
/// Known sub-keys within [rig.access].
const ACCESS_KEYS: &[&str] = &["type", "port", "baud", "host", "tcp_port", "args"];
/// Known sub-keys within [listen].
const LISTEN_KEYS: &[&str] = &["enabled", "listen", "port", "auth"];
/// Known sub-keys within [audio] (server).
const AUDIO_KEYS: &[&str] = &[
"enabled",
"listen",
"port",
"rx_enabled",
"tx_enabled",
"device",
"sample_rate",
"channels",
"frame_duration_ms",
"bitrate_bps",
];
/// Known sub-keys within [behavior].
const BEHAVIOR_KEYS: &[&str] = &[
"poll_interval_ms",
"poll_interval_tx_ms",
"max_retries",
"retry_base_delay_ms",
"vfo_prime",
];
/// Known sub-keys within [remote].
const REMOTE_KEYS: &[&str] = &["url", "rig_id", "auth", "poll_interval_ms"];
/// Known sub-keys within [frontends].
const FRONTENDS_KEYS: &[&str] = &["http", "rigctl", "http_json", "audio"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetectedType {
Server,
Client,
Combined,
Unknown,
}
impl std::fmt::Display for DetectedType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Server => write!(f, "server"),
Self::Client => write!(f, "client"),
Self::Combined => write!(f, "combined"),
Self::Unknown => write!(f, "unknown"),
}
}
}
pub fn check_file(path: &Path) -> Result<String, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
// Step 1: TOML syntax check
let doc: DocumentMut = content
.parse()
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
let mut report = String::new();
let mut warnings: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let table = doc.as_table();
// Step 2: Detect config type
let detected = detect_type(table);
writeln!(report, "{}: valid TOML", path.display()).unwrap();
writeln!(report, " Detected type: {}", detected).unwrap();
// Step 3: Structural validation
match detected {
DetectedType::Server => {
check_unknown_keys(table, SERVER_KEYS, "", &mut warnings);
check_server_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Client => {
check_unknown_keys(table, CLIENT_KEYS, "", &mut warnings);
check_client_sections(table, "", &mut warnings, &mut errors);
}
DetectedType::Combined => {
check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings);
if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) {
check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings);
check_server_sections(server, "[trx-server].", &mut warnings, &mut errors);
}
if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) {
check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings);
check_client_sections(client, "[trx-client].", &mut warnings, &mut errors);
}
}
DetectedType::Unknown => {
warnings.push("Could not detect config type. Expected server, client, or combined (trx-rs.toml) layout.".to_string());
}
}
// Step 4: Format report
for w in &warnings {
writeln!(report, " warning: {}", w).unwrap();
}
for e in &errors {
writeln!(report, " error: {}", e).unwrap();
}
if errors.is_empty() && warnings.is_empty() {
writeln!(report, " No issues found.").unwrap();
} else {
writeln!(
report,
" {} warning(s), {} error(s)",
warnings.len(),
errors.len()
)
.unwrap();
}
if errors.is_empty() {
Ok(report)
} else {
Err(report)
}
}
fn detect_type(table: &toml_edit::Table) -> DetectedType {
if table.contains_key("trx-server") || table.contains_key("trx-client") {
return DetectedType::Combined;
}
let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect();
let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count();
let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count();
// Use distinguishing keys to break ties
if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") {
return DetectedType::Server;
}
if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") {
return DetectedType::Client;
}
if server_score > client_score {
DetectedType::Server
} else if client_score > server_score {
DetectedType::Client
} else if server_score > 0 {
DetectedType::Server
} else {
DetectedType::Unknown
}
}
fn check_unknown_keys(
table: &toml_edit::Table,
known: &[&str],
prefix: &str,
warnings: &mut Vec<String>,
) {
for (key, _) in table.iter() {
if !known.contains(&key) {
warnings.push(format!("{}unknown key '{}'", prefix, key));
}
}
}
fn check_server_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(general, SERVER_GENERAL_KEYS, &format!("{}[general].", prefix), warnings);
validate_log_level(general, &format!("{}[general]", prefix), errors);
validate_coordinates(general, &format!("{}[general]", prefix), errors);
}
if let Some(rig) = table.get("rig").and_then(|v| v.as_table()) {
check_unknown_keys(rig, RIG_KEYS, &format!("{}[rig].", prefix), warnings);
if let Some(access) = rig.get("access").and_then(|v| v.as_table()) {
check_unknown_keys(access, ACCESS_KEYS, &format!("{}[rig.access].", prefix), warnings);
validate_access(access, &format!("{}[rig.access]", prefix), errors);
}
}
if let Some(listen) = table.get("listen").and_then(|v| v.as_table()) {
check_unknown_keys(listen, LISTEN_KEYS, &format!("{}[listen].", prefix), warnings);
validate_port(listen, "port", &format!("{}[listen]", prefix), errors);
}
if let Some(audio) = table.get("audio").and_then(|v| v.as_table()) {
check_unknown_keys(audio, AUDIO_KEYS, &format!("{}[audio].", prefix), warnings);
validate_port(audio, "port", &format!("{}[audio]", prefix), errors);
}
if let Some(behavior) = table.get("behavior").and_then(|v| v.as_table()) {
check_unknown_keys(behavior, BEHAVIOR_KEYS, &format!("{}[behavior].", prefix), warnings);
}
}
fn check_client_sections(
table: &toml_edit::Table,
prefix: &str,
warnings: &mut Vec<String>,
errors: &mut Vec<String>,
) {
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
check_unknown_keys(general, CLIENT_GENERAL_KEYS, &format!("{}[general].", prefix), warnings);
validate_log_level(general, &format!("{}[general]", prefix), errors);
}
if let Some(remote) = table.get("remote").and_then(|v| v.as_table()) {
check_unknown_keys(remote, REMOTE_KEYS, &format!("{}[remote].", prefix), warnings);
}
if let Some(frontends) = table.get("frontends").and_then(|v| v.as_table()) {
check_unknown_keys(frontends, FRONTENDS_KEYS, &format!("{}[frontends].", prefix), warnings);
if let Some(http) = frontends.get("http").and_then(|v| v.as_table()) {
validate_port(http, "port", &format!("{}[frontends.http]", prefix), errors);
}
if let Some(rigctl) = frontends.get("rigctl").and_then(|v| v.as_table()) {
validate_port(rigctl, "port", &format!("{}[frontends.rigctl]", prefix), errors);
}
}
}
// ── Value validators ────────────────────────────────────────────────────
fn validate_log_level(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(level) = table.get("log_level").and_then(|v| v.as_str()) {
if !["trace", "debug", "info", "warn", "error"].contains(&level) {
errors.push(format!(
"{}.log_level '{}' is invalid (expected: trace, debug, info, warn, error)",
context, level
));
}
}
}
fn validate_coordinates(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(lat) = table.get("latitude").and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) {
if !(-90.0..=90.0).contains(&lat) {
errors.push(format!("{}.latitude {} is out of range (-90..90)", context, lat));
}
}
if let Some(lon) = table.get("longitude").and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) {
if !(-180.0..=180.0).contains(&lon) {
errors.push(format!(
"{}.longitude {} is out of range (-180..180)",
context, lon
));
}
}
let has_lat = table.contains_key("latitude");
let has_lon = table.contains_key("longitude");
if has_lat != has_lon {
errors.push(format!(
"{}: latitude and longitude must be set together or both omitted",
context
));
}
}
fn validate_port(
table: &toml_edit::Table,
key: &str,
context: &str,
errors: &mut Vec<String>,
) {
if let Some(port) = table.get(key).and_then(|v| v.as_integer()) {
if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) {
if enabled && port <= 0 {
errors.push(format!("{}.{} must be > 0 when enabled", context, key));
}
}
if !(0..=65535).contains(&port) {
errors.push(format!(
"{}.{} {} is out of range (0..65535)",
context, key, port
));
}
}
}
fn validate_access(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
if let Some(access_type) = table.get("type").and_then(|v| v.as_str()) {
if !["serial", "tcp", "sdr"].contains(&access_type) {
errors.push(format!(
"{}.type '{}' is invalid (expected: serial, tcp, sdr)",
context, access_type
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn check_toml(content: &str) -> Result<String, String> {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
check_file(f.path())
}
#[test]
fn test_valid_server_config() {
let result = check_toml(
r#"
[general]
callsign = "W1AW"
log_level = "info"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[listen]
enabled = true
port = 4530
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("Detected type: server"));
assert!(report.contains("No issues found"));
}
#[test]
fn test_valid_client_config() {
let result = check_toml(
r#"
[general]
callsign = "W1AW"
[remote]
url = "localhost:4530"
[frontends.http]
enabled = true
port = 8080
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("Detected type: client"));
}
#[test]
fn test_valid_combined_config() {
let result = check_toml(
r#"
[trx-server.general]
callsign = "W1AW"
[trx-client.general]
callsign = "W1AW"
[trx-client.remote]
url = "localhost:4530"
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("Detected type: combined"));
}
#[test]
fn test_invalid_toml_syntax() {
let result = check_toml("this is not [valid toml");
assert!(result.is_err());
assert!(result.unwrap_err().contains("TOML syntax error"));
}
#[test]
fn test_unknown_key_warning() {
let result = check_toml(
r#"
[general]
callsign = "W1AW"
[rig]
model = "ft817"
[bogus_section]
foo = "bar"
"#,
);
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.contains("unknown key 'bogus_section'"));
}
#[test]
fn test_invalid_log_level() {
let result = check_toml(
r#"
[general]
log_level = "verbose"
[rig]
model = "ft817"
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("log_level 'verbose' is invalid"));
}
#[test]
fn test_latitude_without_longitude() {
let result = check_toml(
r#"
[general]
latitude = 45.0
[rig]
model = "ft817"
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude and longitude must be set together"));
}
#[test]
fn test_latitude_out_of_range() {
let result = check_toml(
r#"
[general]
latitude = 95.0
longitude = 10.0
[rig]
model = "ft817"
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("latitude 95 is out of range"));
}
#[test]
fn test_invalid_access_type() {
let result = check_toml(
r#"
[rig]
model = "ft817"
[rig.access]
type = "usb"
"#,
);
assert!(result.is_err());
let report = result.unwrap_err();
assert!(report.contains("type 'usb' is invalid"));
}
}
+18
View File
@@ -2,6 +2,7 @@
// //
// SPDX-License-Identifier: BSD-2-Clause // SPDX-License-Identifier: BSD-2-Clause
mod check;
mod detect; mod detect;
mod prompts; mod prompts;
mod writer; mod writer;
@@ -24,6 +25,10 @@ struct Cli {
/// Output file path (default: based on config type) /// Output file path (default: based on config type)
#[arg(short, long)] #[arg(short, long)]
output: Option<PathBuf>, output: Option<PathBuf>,
/// Check an existing config file for syntax and structure errors
#[arg(long, value_name = "FILE")]
check: Option<PathBuf>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -46,6 +51,19 @@ impl ConfigType {
fn main() { fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
if let Some(path) = &cli.check {
match check::check_file(path) {
Ok(report) => {
println!("{}", report);
std::process::exit(0);
}
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
let config_type = if let Some(t) = &cli.r#type { let config_type = if let Some(t) = &cli.r#type {
match t.as_str() { match t.as_str() {
"server" => ConfigType::Server, "server" => ConfigType::Server,