From e703c9376cd000161672ff225ff8e0f3c167ed9f Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Sun, 21 Jun 2026 23:12:02 +0800 Subject: feat: add TOML config file support with CLI fallback --- Cargo.lock | 2 + Cargo.toml | 2 + help.txt | 1 + src/args.rs | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 8 ++- 5 files changed, 169 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7bba13..26f6e6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -577,7 +577,9 @@ version = "0.1.0" dependencies = [ "clap", "pinyin", + "serde", "tokio", + "toml", "tracing-subscriber", "vtx-engine", ] diff --git a/Cargo.toml b/Cargo.toml index 7821ca1..d8fc385 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ path = "src/main.rs" [dependencies] clap = { version = "4.6.1", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" tokio = { version = "1.52.3", features = ["net", "rt", "rt-multi-thread", "macros"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } pinyin = { version = "0.11.0", features = ["with_tone", "with_tone_num", "with_tone_num_end"] } diff --git a/help.txt b/help.txt index c318505..5c8e537 100644 --- a/help.txt +++ b/help.txt @@ -17,6 +17,7 @@ Options [default: 255.255.255.0] -L, --list-devices List microphones and exit. --list-models List Whisper models and exit. + --config= Config file (TOML). Falls back to ./dmvop.toml. --download-model= Download a model and exit. --instant Aggressive mode — shorter segments for near-real-time output. Speak slowly. diff --git a/src/args.rs b/src/args.rs index 9fe76d6..9a4bd0d 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; /// Whether verbose debug output is enabled. -/// Set by `DMVOPArguments::verbose`. pub static VERBOSE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Print a debug message only when `--verbose` is set. @@ -17,6 +16,10 @@ macro_rules! debug_log { /// The full help text embedded at compile time. pub static HELP_TEXT: &str = include_str!("../help.txt"); +// =========================================================================== +// CLI arguments (clap) +// =========================================================================== + #[derive(clap::Parser)] #[command(name = "dmvop", disable_help_flag = true, disable_version_flag = true)] pub struct DMVOPArguments { @@ -32,6 +35,10 @@ pub struct DMVOPArguments { #[arg(long = "verbose", short = 'V')] pub verbose: bool, + // Config file path + #[arg(long = "config", require_equals = true)] + pub config: Option, + // List all available input devices and exit #[arg(long = "list-devices", alias = "list", short = 'L')] pub list_devices: bool, @@ -127,6 +134,151 @@ pub struct DMVOPArguments { pub post: Option, } +// =========================================================================== +// Configuration file (serde) — mirrors DMVOPArguments with all-Option fields +// =========================================================================== + +/// Mirrors [`DMVOPArguments`] as a TOML-serialisable config. +/// Every field is `Option` so the merge logic can tell what was explicitly set. +#[derive(serde::Deserialize, Default)] +#[serde(default)] +pub struct DMVOPConfig { + pub instant: Option, + pub lang: Option, + pub model: Option, + pub device: Option, + pub format: Option, + pub format_file: Option, + pub output: Option>, + pub port: Option, + pub socket_file: Option, + pub models_dir: Option, + pub subnet_mask: Option, + pub post: Option, +} + +/// Resolve a path in the config file relative to the config file's directory. +fn resolve_config_path(config_file: &PathBuf, path: &PathBuf) -> PathBuf { + if path.is_absolute() { + path.clone() + } else if let Some(parent) = config_file.parent() { + parent.join(path) + } else { + path.clone() + } +} + +/// Load config file, then merge CLI args on top. +/// Config paths are resolved relative to the config file's directory. +pub fn load_and_merge_config(config: Option<&PathBuf>) -> Option { + let config_path = config.cloned().or_else(|| { + let fallback = PathBuf::from("./dmvop.toml"); + if fallback.exists() { + Some(fallback) + } else { + None + } + }); + + let config_path = config_path?; + let content = match std::fs::read_to_string(&config_path) { + Ok(c) => c, + Err(e) => { + eprintln!( + "[dmvop] Failed to read config '{}': {}", + config_path.display(), + e + ); + std::process::exit(1); + } + }; + + let mut cfg: DMVOPConfig = match toml::from_str(&content) { + Ok(c) => c, + Err(e) => { + eprintln!( + "[dmvop] Failed to parse config '{}': {}", + config_path.display(), + e + ); + std::process::exit(1); + } + }; + + // Resolve relative paths + if let Some(ref mut f) = cfg.format_file { + *f = resolve_config_path(&config_path, f); + } + if let Some(ref mut d) = cfg.models_dir { + *d = resolve_config_path(&config_path, d); + } + if let Some(ref mut s) = cfg.socket_file { + *s = resolve_config_path(&config_path, s); + } + + Some(cfg) +} + +/// Merge config file values into CLI args (CLI wins). +pub fn apply_config(args: &mut DMVOPArguments, cfg: &DMVOPConfig) { + if let Some(v) = cfg.instant { + if !args.instant { + args.instant = v; + } + } + if let Some(ref v) = cfg.lang { + if args.lang.is_none() { + args.lang = Some(v.clone()); + } + } + if let Some(ref v) = cfg.model { + args.model = v.clone(); + } + if let Some(ref v) = cfg.device { + if args.device_name.is_none() { + args.device_name = Some(v.clone()); + } + } + if let Some(ref v) = cfg.format { + if args.format_pattern.is_none() { + args.format_pattern = Some(v.clone()); + } + } + if let Some(ref v) = cfg.format_file { + if args.format_file.is_none() { + args.format_file = Some(v.clone()); + } + } + if let Some(ref v) = cfg.output { + if args.output.is_empty() { + args.output = v.iter().filter_map(|s| s.parse().ok()).collect(); + } + } + if let Some(v) = cfg.port { + args.port = v; + } + if let Some(ref v) = cfg.socket_file { + args.socket_file = v.clone(); + } + if let Some(ref v) = cfg.models_dir { + if args.models_dir.is_none() { + args.models_dir = Some(v.clone()); + } + } + if let Some(ref v) = cfg.subnet_mask { + args.subnet_mask = v.clone(); + } + if let Some(ref v) = cfg.post { + if args.post.is_none() { + args.post = Some(v.clone()); + } + } +} + +// =========================================================================== +// Output mode enum +// =========================================================================== + #[derive(Clone, Debug)] #[allow(non_camel_case_types)] pub enum OutputMode { @@ -163,13 +315,10 @@ impl std::str::FromStr for OutputMode { } } -/// Parse a format pattern string and produce a formatted output string -/// from the provided values. -/// -/// Supported placeholders: -/// - `%{vol}` — volume 0–100 -/// - `%{word}` — transcribed word/text -/// - `%{confid}` / `%{confidence}` — confidence score +// =========================================================================== +// Format helpers +// =========================================================================== + pub fn format_output(pattern: &str, word: &str, confidence: f32, volume: f32) -> String { let mut result = pattern.to_string(); diff --git a/src/main.rs b/src/main.rs index 66af45f..2aa68ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,7 +58,7 @@ async fn main() { .with_target(false) .init(); - let args = match DMVOPArguments::try_parse() { + let mut args = match DMVOPArguments::try_parse() { Ok(a) => a, Err(_) => { eprintln!("error: invalid arguments. Use --help for usage."); @@ -72,6 +72,12 @@ async fn main() { return; } + // Load config (--config or ./dmvop.toml) and merge (CLI wins) + if let Some(cfg) = load_and_merge_config(args.config.as_ref()) { + apply_config(&mut args, &cfg); + debug_log!("[dmvop] Loaded config"); + } + // Set global verbose flag VERBOSE.store(args.verbose, std::sync::atomic::Ordering::Relaxed); -- cgit