[feat](trx-configurator): add interactive configuration generator
New binary crate that generates trx-server.toml, trx-client.toml, or trx-rs.toml via interactive prompts or --defaults mode. Produces commented TOML using toml_edit with per-field descriptions. Supports server config (general, rig, listen, audio, behavior) and client config (general, remote, frontends). Hardware detection is stubbed for future iteration. 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:
Generated
+116
@@ -576,6 +576,19 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "console"
|
||||||
|
version = "0.15.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||||
|
dependencies = [
|
||||||
|
"encode_unicode",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"unicode-width",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -729,6 +742,19 @@ dependencies = [
|
|||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dialoguer"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
|
||||||
|
dependencies = [
|
||||||
|
"console",
|
||||||
|
"shell-words",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -777,6 +803,12 @@ version = "1.15.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encode_unicode"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -792,6 +824,22 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1312,6 +1360,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -1902,6 +1956,19 @@ dependencies = [
|
|||||||
"transpose",
|
"transpose",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -2037,6 +2104,12 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -2152,6 +2225,19 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -2553,6 +2639,15 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-configurator"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"dialoguer",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-core"
|
name = "trx-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2745,6 +2840,12 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -3073,6 +3174,15 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -3450,6 +3560,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ members = [
|
|||||||
"src/trx-client/trx-frontend/trx-frontend-http",
|
"src/trx-client/trx-frontend/trx-frontend-http",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http-json",
|
"src/trx-client/trx-frontend/trx-frontend-http-json",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-rigctl",
|
"src/trx-client/trx-frontend/trx-frontend-rigctl",
|
||||||
|
"src/trx-configurator",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "trx-configurator"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "trx-configurator"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { workspace = true, features = ["derive"] }
|
||||||
|
dialoguer = "0.11"
|
||||||
|
toml_edit = "0.22"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
/// Detect available serial ports on the system.
|
||||||
|
/// Returns a list of (path, description) pairs.
|
||||||
|
pub fn detect_serial_ports() -> Vec<(String, String)> {
|
||||||
|
// TODO: use serialport::available_ports() for real detection
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
mod detect;
|
||||||
|
mod prompts;
|
||||||
|
mod writer;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "trx-configurator", about = "Interactive configuration generator for trx-rs")]
|
||||||
|
struct Cli {
|
||||||
|
/// Generate a default config without interactive prompts
|
||||||
|
#[arg(long)]
|
||||||
|
defaults: bool,
|
||||||
|
|
||||||
|
/// Config type to generate (server, client, combined)
|
||||||
|
#[arg(long, value_name = "TYPE")]
|
||||||
|
r#type: Option<String>,
|
||||||
|
|
||||||
|
/// Output file path (default: based on config type)
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ConfigType {
|
||||||
|
Server,
|
||||||
|
Client,
|
||||||
|
Combined,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigType {
|
||||||
|
fn default_filename(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Server => "trx-server.toml",
|
||||||
|
Self::Client => "trx-client.toml",
|
||||||
|
Self::Combined => "trx-rs.toml",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let config_type = if let Some(t) = &cli.r#type {
|
||||||
|
match t.as_str() {
|
||||||
|
"server" => ConfigType::Server,
|
||||||
|
"client" => ConfigType::Client,
|
||||||
|
"combined" => ConfigType::Combined,
|
||||||
|
other => {
|
||||||
|
eprintln!("Unknown config type '{}'. Use: server, client, combined", other);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if cli.defaults {
|
||||||
|
eprintln!("--defaults requires --type (server, client, combined)");
|
||||||
|
std::process::exit(1);
|
||||||
|
} else {
|
||||||
|
prompts::prompt_config_type()
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = cli
|
||||||
|
.output
|
||||||
|
.unwrap_or_else(|| PathBuf::from(config_type.default_filename()));
|
||||||
|
|
||||||
|
let doc = if cli.defaults {
|
||||||
|
writer::build_default(config_type)
|
||||||
|
} else {
|
||||||
|
match config_type {
|
||||||
|
ConfigType::Server => {
|
||||||
|
let general = prompts::prompt_server_general();
|
||||||
|
let rig = prompts::prompt_rig();
|
||||||
|
let listen = prompts::prompt_listen();
|
||||||
|
writer::build_server(general, rig, listen)
|
||||||
|
}
|
||||||
|
ConfigType::Client => {
|
||||||
|
let general = prompts::prompt_client_general();
|
||||||
|
let remote = prompts::prompt_remote();
|
||||||
|
let frontends = prompts::prompt_frontends();
|
||||||
|
writer::build_client(general, remote, frontends)
|
||||||
|
}
|
||||||
|
ConfigType::Combined => {
|
||||||
|
println!("\n--- Server configuration ---\n");
|
||||||
|
let s_general = prompts::prompt_server_general();
|
||||||
|
let rig = prompts::prompt_rig();
|
||||||
|
let listen = prompts::prompt_listen();
|
||||||
|
|
||||||
|
println!("\n--- Client configuration ---\n");
|
||||||
|
let c_general = prompts::prompt_client_general();
|
||||||
|
let remote = prompts::prompt_remote();
|
||||||
|
let frontends = prompts::prompt_frontends();
|
||||||
|
|
||||||
|
writer::build_combined(s_general, rig, listen, c_general, remote, frontends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = writer::write_file(&doc, &output) {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
use dialoguer::{Confirm, Input, Select};
|
||||||
|
|
||||||
|
use crate::detect;
|
||||||
|
use crate::ConfigType;
|
||||||
|
|
||||||
|
// ── Data types returned by prompts ──────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct ServerGeneral {
|
||||||
|
pub callsign: String,
|
||||||
|
pub log_level: String,
|
||||||
|
pub latitude: Option<f64>,
|
||||||
|
pub longitude: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RigSetup {
|
||||||
|
pub model: String,
|
||||||
|
pub access_type: String,
|
||||||
|
pub serial_port: Option<String>,
|
||||||
|
pub serial_baud: Option<u32>,
|
||||||
|
pub tcp_host: Option<String>,
|
||||||
|
pub tcp_port: Option<u16>,
|
||||||
|
pub sdr_args: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListenSetup {
|
||||||
|
pub listen: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ClientGeneral {
|
||||||
|
pub callsign: String,
|
||||||
|
pub log_level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RemoteSetup {
|
||||||
|
pub url: String,
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FrontendsSetup {
|
||||||
|
pub http_enabled: bool,
|
||||||
|
pub http_port: u16,
|
||||||
|
pub rigctl_enabled: bool,
|
||||||
|
pub rigctl_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Prompt functions ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn prompt_config_type() -> ConfigType {
|
||||||
|
let items = &["Server (trx-server.toml)", "Client (trx-client.toml)", "Combined (trx-rs.toml)"];
|
||||||
|
let sel = Select::new()
|
||||||
|
.with_prompt("What configuration would you like to generate?")
|
||||||
|
.items(items)
|
||||||
|
.default(2)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
match sel {
|
||||||
|
0 => ConfigType::Server,
|
||||||
|
1 => ConfigType::Client,
|
||||||
|
_ => ConfigType::Combined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt_server_general() -> ServerGeneral {
|
||||||
|
let callsign: String = Input::new()
|
||||||
|
.with_prompt("Callsign")
|
||||||
|
.default("N0CALL".to_string())
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let log_levels = &["trace", "debug", "info", "warn", "error"];
|
||||||
|
let level_sel = Select::new()
|
||||||
|
.with_prompt("Log level")
|
||||||
|
.items(log_levels)
|
||||||
|
.default(2)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
let log_level = log_levels[level_sel].to_string();
|
||||||
|
|
||||||
|
let has_location = Confirm::new()
|
||||||
|
.with_prompt("Set station location (latitude/longitude)?")
|
||||||
|
.default(false)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (latitude, longitude) = if has_location {
|
||||||
|
let lat: f64 = Input::new()
|
||||||
|
.with_prompt("Latitude (decimal degrees, -90..90)")
|
||||||
|
.validate_with(|input: &f64| {
|
||||||
|
if *input >= -90.0 && *input <= 90.0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Must be between -90 and 90")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
let lon: f64 = Input::new()
|
||||||
|
.with_prompt("Longitude (decimal degrees, -180..180)")
|
||||||
|
.validate_with(|input: &f64| {
|
||||||
|
if *input >= -180.0 && *input <= 180.0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Must be between -180 and 180")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
(Some(lat), Some(lon))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
ServerGeneral {
|
||||||
|
callsign,
|
||||||
|
log_level,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt_rig() -> RigSetup {
|
||||||
|
let models = &["ft817", "ft450d", "soapysdr"];
|
||||||
|
let model_sel = Select::new()
|
||||||
|
.with_prompt("Rig model")
|
||||||
|
.items(models)
|
||||||
|
.default(0)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
let model = models[model_sel].to_string();
|
||||||
|
|
||||||
|
let (access_type, serial_port, serial_baud, tcp_host, tcp_port, sdr_args) = match model.as_str()
|
||||||
|
{
|
||||||
|
"soapysdr" => {
|
||||||
|
let args: String = Input::new()
|
||||||
|
.with_prompt("SoapySDR device args (e.g. driver=rtlsdr)")
|
||||||
|
.default("driver=rtlsdr".to_string())
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
("sdr".to_string(), None, None, None, None, Some(args))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let access_types = &["serial", "tcp"];
|
||||||
|
let access_sel = Select::new()
|
||||||
|
.with_prompt("Access type")
|
||||||
|
.items(access_types)
|
||||||
|
.default(0)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
let access_type = access_types[access_sel].to_string();
|
||||||
|
|
||||||
|
match access_type.as_str() {
|
||||||
|
"serial" => {
|
||||||
|
let port = prompt_serial_port();
|
||||||
|
let default_baud: u32 = match model.as_str() {
|
||||||
|
"ft450d" => 38400,
|
||||||
|
_ => 9600,
|
||||||
|
};
|
||||||
|
let baud: u32 = Input::new()
|
||||||
|
.with_prompt("Baud rate")
|
||||||
|
.default(default_baud)
|
||||||
|
.validate_with(|input: &u32| {
|
||||||
|
if [4800, 9600, 19200, 38400, 57600, 115200].contains(input) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Must be one of: 4800, 9600, 19200, 38400, 57600, 115200")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
(access_type, Some(port), Some(baud), None, None, None)
|
||||||
|
}
|
||||||
|
"tcp" => {
|
||||||
|
let host: String = Input::new()
|
||||||
|
.with_prompt("TCP host")
|
||||||
|
.default("127.0.0.1".to_string())
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
let port: u16 = Input::new()
|
||||||
|
.with_prompt("TCP port")
|
||||||
|
.default(4530u16)
|
||||||
|
.validate_with(|input: &u16| {
|
||||||
|
if *input > 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Port must be > 0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
(access_type, None, None, Some(host), Some(port), None)
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
RigSetup {
|
||||||
|
model,
|
||||||
|
access_type,
|
||||||
|
serial_port,
|
||||||
|
serial_baud,
|
||||||
|
tcp_host,
|
||||||
|
tcp_port,
|
||||||
|
sdr_args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_serial_port() -> String {
|
||||||
|
let ports = detect::detect_serial_ports();
|
||||||
|
if ports.is_empty() {
|
||||||
|
Input::new()
|
||||||
|
.with_prompt("Serial port path")
|
||||||
|
.default("/dev/ttyUSB0".to_string())
|
||||||
|
.interact_text()
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
let items: Vec<String> = ports
|
||||||
|
.iter()
|
||||||
|
.map(|(path, desc)| {
|
||||||
|
if desc.is_empty() {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} ({})", path, desc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sel = Select::new()
|
||||||
|
.with_prompt("Select serial port")
|
||||||
|
.items(&items)
|
||||||
|
.default(0)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
ports[sel].0.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt_listen() -> ListenSetup {
|
||||||
|
let listen: String = Input::new()
|
||||||
|
.with_prompt("Listen address")
|
||||||
|
.default("127.0.0.1".to_string())
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let port: u16 = Input::new()
|
||||||
|
.with_prompt("Listen port")
|
||||||
|
.default(4530u16)
|
||||||
|
.validate_with(|input: &u16| {
|
||||||
|
if *input > 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Port must be > 0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
ListenSetup { listen, port }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt_client_general() -> ClientGeneral {
|
||||||
|
let callsign: String = Input::new()
|
||||||
|
.with_prompt("Callsign")
|
||||||
|
.default("N0CALL".to_string())
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let log_levels = &["trace", "debug", "info", "warn", "error"];
|
||||||
|
let level_sel = Select::new()
|
||||||
|
.with_prompt("Log level")
|
||||||
|
.items(log_levels)
|
||||||
|
.default(2)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
let log_level = log_levels[level_sel].to_string();
|
||||||
|
|
||||||
|
ClientGeneral {
|
||||||
|
callsign,
|
||||||
|
log_level,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt_remote() -> RemoteSetup {
|
||||||
|
let url: String = Input::new()
|
||||||
|
.with_prompt("Server URL (host:port)")
|
||||||
|
.default("localhost:4530".to_string())
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let has_token = Confirm::new()
|
||||||
|
.with_prompt("Set auth token?")
|
||||||
|
.default(false)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let token = if has_token {
|
||||||
|
let t: String = Input::new()
|
||||||
|
.with_prompt("Auth token")
|
||||||
|
.interact_text()
|
||||||
|
.unwrap();
|
||||||
|
Some(t)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
RemoteSetup { url, token }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prompt_frontends() -> FrontendsSetup {
|
||||||
|
let http_enabled = Confirm::new()
|
||||||
|
.with_prompt("Enable HTTP web frontend?")
|
||||||
|
.default(true)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let http_port: u16 = if http_enabled {
|
||||||
|
Input::new()
|
||||||
|
.with_prompt("HTTP port")
|
||||||
|
.default(8080u16)
|
||||||
|
.validate_with(|input: &u16| {
|
||||||
|
if *input > 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Port must be > 0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
8080
|
||||||
|
};
|
||||||
|
|
||||||
|
let rigctl_enabled = Confirm::new()
|
||||||
|
.with_prompt("Enable rigctl frontend (Hamlib-compatible)?")
|
||||||
|
.default(false)
|
||||||
|
.interact()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rigctl_port: u16 = if rigctl_enabled {
|
||||||
|
Input::new()
|
||||||
|
.with_prompt("rigctl port")
|
||||||
|
.default(4532u16)
|
||||||
|
.validate_with(|input: &u16| {
|
||||||
|
if *input > 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Port must be > 0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.interact_text()
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
4532
|
||||||
|
};
|
||||||
|
|
||||||
|
FrontendsSetup {
|
||||||
|
http_enabled,
|
||||||
|
http_port,
|
||||||
|
rigctl_enabled,
|
||||||
|
rigctl_port,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
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");
|
||||||
|
let tables = build_server_tables(general, rig, listen);
|
||||||
|
for (key, item) in tables.iter() {
|
||||||
|
doc.insert(key, item.clone());
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
let tables = build_client_tables(general, remote, frontends);
|
||||||
|
for (key, item) in tables.iter() {
|
||||||
|
doc.insert(key, item.clone());
|
||||||
|
}
|
||||||
|
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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user