[feat] enhance CLI flags with --quiet and --no-interaction; update logging to respect verbosity and quiet modes; refactor log macros and add related tests

This commit is contained in:
2025-08-08 19:33:47 +02:00
parent a0216a0e18
commit cd076c5a91
8 changed files with 564 additions and 93 deletions

View File

@@ -1,38 +1,17 @@
use std::fs::{File, create_dir_all};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use clap_complete::Shell;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU8, Ordering};
// whisper-rs is used from the library crate
use polyscribe::backend::{BackendKind, select_backend};
static VERBOSE: AtomicU8 = AtomicU8::new(0);
macro_rules! vlog {
($lvl:expr, $($arg:tt)*) => {
let v = VERBOSE.load(Ordering::Relaxed);
let needed = match $lvl { 0u8 => true, 1u8 => v >= 1, 2u8 => v >= 2, _ => true };
if needed { eprintln!("INFO: {}", format!($($arg)*)); }
}
}
#[allow(unused_macros)]
macro_rules! warnlog {
($($arg:tt)*) => {
eprintln!("WARN: {}", format!($($arg)*));
}
}
macro_rules! errorlog {
($($arg:tt)*) => {
eprintln!("ERROR: {}", format!($($arg)*));
}
}
#[derive(Subcommand, Debug, Clone)]
enum AuxCommands {
@@ -64,10 +43,18 @@ enum GpuBackendCli {
about = "Merge JSON transcripts or transcribe audio using native whisper"
)]
struct Args {
/// Increase verbosity (-v, -vv). Logs go to stderr.
/// Increase verbosity (-v, -vv). Repeat to increase. Debug logs appear with -v; very verbose with -vv. Logs go to stderr.
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
verbose: u8,
/// Quiet mode: suppress non-error logging on stderr (overrides -v). Does not suppress interactive prompts or stdout output.
#[arg(short = 'q', long = "quiet", global = true)]
quiet: bool,
/// Non-interactive mode: never prompt; use defaults instead.
#[arg(long = "no-interaction", global = true)]
no_interaction: bool,
/// Optional auxiliary subcommands (completions, man)
#[command(subcommand)]
aux: Option<AuxCommands>,
@@ -146,6 +133,10 @@ fn prompt_speaker_name_for_path(path: &Path, default_name: &str, enabled: bool)
if !enabled {
return default_name.to_string();
}
if polyscribe::is_no_interaction() {
// Explicitly non-interactive: never prompt
return default_name.to_string();
}
let display_owned: String = path
.file_name()
.and_then(|s| s.to_str())
@@ -203,18 +194,37 @@ impl Drop for LastModelCleanup {
if let Err(e) = std::fs::remove_file(&self.path) {
// Best-effort cleanup; ignore missing file; warn for other errors
if e.kind() != std::io::ErrorKind::NotFound {
warnlog!("Failed to remove {}: {}", self.path.display(), e);
polyscribe::wlog!("Failed to remove {}: {}", self.path.display(), e);
}
}
}
}
#[cfg(unix)]
fn with_quiet_stdio_if_needed<F, R>(_quiet: bool, f: F) -> R
where
F: FnOnce() -> R,
{
// Quiet mode no longer redirects stdio globally; only logging is silenced.
f()
}
#[cfg(not(unix))]
fn with_quiet_stdio_if_needed<F, R>(_quiet: bool, f: F) -> R
where
F: FnOnce() -> R,
{
f()
}
fn run() -> Result<()> {
// Parse CLI
let args = Args::parse();
// Initialize verbosity
VERBOSE.store(args.verbose, Ordering::Relaxed);
// Initialize runtime flags
polyscribe::set_verbose(args.verbose);
polyscribe::set_quiet(args.quiet);
polyscribe::set_no_interaction(args.no_interaction);
// Handle auxiliary subcommands that write to stdout and exit early
if let Some(aux) = &args.aux {
@@ -254,12 +264,12 @@ fn run() -> Result<()> {
GpuBackendCli::Vulkan => BackendKind::Vulkan,
};
let sel = select_backend(requested, args.verbose > 0)?;
vlog!(0, "Using backend: {:?}", sel.chosen);
polyscribe::dlog!(1, "Using backend: {:?}", sel.chosen);
// If requested, run the interactive model downloader first. If no inputs were provided, exit after downloading.
if args.download_models {
if let Err(e) = polyscribe::models::run_interactive_model_downloader() {
errorlog!("Model downloader failed: {:#}", e);
polyscribe::elog!("Model downloader failed: {:#}", e);
}
if args.inputs.is_empty() {
return Ok(());
@@ -269,7 +279,7 @@ fn run() -> Result<()> {
// If requested, update local models and exit unless inputs provided to continue
if args.update_models {
if let Err(e) = polyscribe::models::update_local_models() {
errorlog!("Model update failed: {:#}", e);
polyscribe::elog!("Model update failed: {:#}", e);
return Err(e);
}
// if only updating models and no inputs, exit
@@ -279,7 +289,7 @@ fn run() -> Result<()> {
}
// Determine inputs and optional output path
vlog!(1, "Parsed {} input(s)", args.inputs.len());
polyscribe::dlog!(1, "Parsed {} input(s)", args.inputs.len());
let mut inputs = args.inputs;
let mut output_path = args.output;
if output_path.is_none() && inputs.len() >= 2 {
@@ -308,7 +318,7 @@ fn run() -> Result<()> {
}
if args.merge_and_separate {
vlog!(1, "Mode: merge-and-separate; output_dir={:?}", output_path);
polyscribe::dlog!(1, "Mode: merge-and-separate; output_dir={:?}", output_path);
// Combined mode: write separate outputs per input and also a merged output set
// Require an output directory
let out_dir = match output_path.as_ref() {
@@ -336,13 +346,28 @@ fn run() -> Result<()> {
// Collect entries per file and extend merged
let mut entries: Vec<OutputEntry> = Vec::new();
if is_audio_file(path) {
let items = sel.backend.transcribe(
path,
&speaker,
lang_hint.as_deref(),
args.gpu_layers,
)?;
entries.extend(items.into_iter());
// Progress log to stderr (suppressed by -q); avoid partial lines
polyscribe::ilog!("Processing file: {} ...", path.display());
let res = with_quiet_stdio_if_needed(args.quiet, || {
sel.backend.transcribe(
path,
&speaker,
lang_hint.as_deref(),
args.gpu_layers,
)
});
match res {
Ok(items) => {
polyscribe::ilog!("done");
entries.extend(items.into_iter());
}
Err(e) => {
if !(polyscribe::is_no_interaction() || !polyscribe::stdin_is_tty()) {
polyscribe::elog!("{:#}", e);
}
return Err(e);
}
}
} else if is_json_file(path) {
let mut buf = String::new();
File::open(path)
@@ -461,7 +486,7 @@ fn run() -> Result<()> {
.with_context(|| format!("Failed to create output file: {}", m_srt.display()))?;
ms.write_all(m_srt_str.as_bytes())?;
} else if args.merge {
vlog!(1, "Mode: merge; output_base={:?}", output_path);
polyscribe::dlog!(1, "Mode: merge; output_base={:?}", output_path);
// MERGED MODE (previous default)
let mut entries: Vec<OutputEntry> = Vec::new();
for input_path in &inputs {
@@ -476,16 +501,31 @@ fn run() -> Result<()> {
let mut buf = String::new();
if is_audio_file(path) {
let items = sel.backend.transcribe(
path,
&speaker,
lang_hint.as_deref(),
args.gpu_layers,
)?;
for e in items {
entries.push(e);
// Progress log to stderr (suppressed by -q)
polyscribe::ilog!("Processing file: {} ...", path.display());
let res = with_quiet_stdio_if_needed(args.quiet, || {
sel.backend.transcribe(
path,
&speaker,
lang_hint.as_deref(),
args.gpu_layers,
)
});
match res {
Ok(items) => {
polyscribe::ilog!("done");
for e in items {
entries.push(e);
}
continue;
}
Err(e) => {
if !(polyscribe::is_no_interaction() || !polyscribe::stdin_is_tty()) {
polyscribe::elog!("{:#}", e);
}
return Err(e);
}
}
continue;
} else if is_json_file(path) {
File::open(path)
.with_context(|| format!("Failed to open: {}", input_path))?
@@ -577,7 +617,7 @@ fn run() -> Result<()> {
writeln!(&mut handle)?;
}
} else {
vlog!(1, "Mode: separate; output_dir={:?}", output_path);
polyscribe::dlog!(1, "Mode: separate; output_dir={:?}", output_path);
// SEPARATE MODE (default now)
// If writing to stdout with multiple inputs, not supported
if output_path.is_none() && inputs.len() > 1 {
@@ -609,13 +649,28 @@ fn run() -> Result<()> {
// Collect entries per file
let mut entries: Vec<OutputEntry> = Vec::new();
if is_audio_file(path) {
let items = sel.backend.transcribe(
path,
&speaker,
lang_hint.as_deref(),
args.gpu_layers,
)?;
entries.extend(items);
// Progress log to stderr (suppressed by -q)
polyscribe::ilog!("Processing file: {} ...", path.display());
let res = with_quiet_stdio_if_needed(args.quiet, || {
sel.backend.transcribe(
path,
&speaker,
lang_hint.as_deref(),
args.gpu_layers,
)
});
match res {
Ok(items) => {
polyscribe::ilog!("done");
entries.extend(items);
}
Err(e) => {
if !(polyscribe::is_no_interaction() || !polyscribe::stdin_is_tty()) {
polyscribe::elog!("{:#}", e);
}
return Err(e);
}
}
} else if is_json_file(path) {
let mut buf = String::new();
File::open(path)
@@ -703,11 +758,11 @@ fn run() -> Result<()> {
fn main() {
if let Err(e) = run() {
errorlog!("{}", e);
if VERBOSE.load(Ordering::Relaxed) >= 1 {
polyscribe::elog!("{}", e);
if polyscribe::verbose_level() >= 1 {
let mut src = e.source();
while let Some(s) = src {
errorlog!("caused by: {}", s);
polyscribe::elog!("caused by: {}", s);
src = s.source();
}
}
@@ -897,6 +952,9 @@ mod tests {
#[test]
fn test_backend_auto_order_prefers_cuda_then_hip_then_vulkan_then_cpu() {
use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
// Clear overrides
unsafe {
std_env::remove_var("POLYSCRIBE_TEST_FORCE_CUDA");
@@ -935,6 +993,9 @@ mod tests {
#[test]
fn test_backend_explicit_missing_errors() {
use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
// Ensure all off
unsafe {
std_env::remove_var("POLYSCRIBE_TEST_FORCE_CUDA");