diff --git a/src/lib.rs b/src/lib.rs index 0234d74..814ccb1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,7 +36,17 @@ pub fn set_no_interaction(b: bool) { } /// Return current non-interactive state. pub fn is_no_interaction() -> bool { - NO_INTERACTION.load(Ordering::Relaxed) + if NO_INTERACTION.load(Ordering::Relaxed) { + return true; + } + // Also honor NO_INTERACTION=1/true environment variable for convenience/testing + match std::env::var("NO_INTERACTION") { + Ok(v) => { + let v = v.trim(); + v == "1" || v.eq_ignore_ascii_case("true") + } + Err(_) => false, + } } /// Set verbose level (0 = normal, 1 = verbose, 2 = super-verbose) @@ -638,8 +648,13 @@ where } // Print a blank line before the selection prompt to keep output synchronized. printer(""); - let idx = crate::ui::prompt_select_index("Select a Whisper model", &display_names) - .context("Failed to read selection")?; + let idx = if crate::is_no_interaction() || !crate::stdin_is_tty() { + // Non-interactive: auto-select the first candidate deterministically (as listed) + 0 + } else { + crate::ui::prompt_select_index("Select a Whisper model", &display_names) + .context("Failed to read selection")? + }; let chosen = candidates.swap_remove(idx); let _ = std::fs::write(models_dir.join(".last_model"), chosen.display().to_string()); // Print an empty line after selection input diff --git a/src/main.rs b/src/main.rs index 7241eed..33fd3fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,7 +67,8 @@ struct Args { quiet: bool, /// Non-interactive mode: never prompt; use defaults instead. - #[arg(long = "no-interaction", global = true)] + /// Deprecated alias supported: --no-interation (typo) + #[arg(long = "no-interaction", alias = "no-interation", global = true)] no_interaction: bool, /// Disable progress bars (also respects NO_PROGRESS=1). Progress bars render on stderr only when attached to a TTY. @@ -503,14 +504,17 @@ fn run() -> Result<()> { if let Some(out) = &args.output { // Merge target: either only merged, or merged plus separate let outp = PathBuf::from(out); - if let Some(parent) = outp.parent() { create_dir_all(parent).ok(); } - // Name: _out or _merged depending on flag + // Ensure target directory exists appropriately for the chosen mode if args.merge_and_separate { + // When writing inside an output directory, create it directly + create_dir_all(&outp).ok(); // In merge+separate mode, always write merged output inside the provided directory - let base = PathBuf::from(out).join(format!("{}_merged", polyscribe::date_prefix())); + let base = outp.join(format!("{}_merged", polyscribe::date_prefix())); let root = OutputRoot { items: merged_items.clone() }; write_outputs(&base, &root, &out_formats)?; } else { + // For single merged file, ensure the parent dir exists + if let Some(parent) = outp.parent() { create_dir_all(parent).ok(); } let base = outp.with_file_name(format!("{}_{}", polyscribe::date_prefix(), outp.file_name().and_then(|s| s.to_str()).unwrap_or("out"))); let root = OutputRoot { items: merged_items.clone() }; write_outputs(&base, &root, &out_formats)?; @@ -915,4 +919,26 @@ mod tests { std_env::remove_var("POLYSCRIBE_TEST_FORCE_VULKAN"); } } + + #[test] + fn test_no_interaction_disables_speaker_prompt() { + use polyscribe::ui; + // Ensure non-interactive via env and global flag + unsafe { + std_env::set_var("NO_INTERACTION", "1"); + } + polyscribe::set_no_interaction(true); + ui::testing_reset_prompt_call_counters(); + // Build a minimal progress manager + let pf = polyscribe::progress::ProgressFactory::from_config(&polyscribe::Config::default()); + let pm = pf.make_manager(polyscribe::progress::ProgressMode::Single); + let dummy = std::path::PathBuf::from("example.wav"); + let got = super::prompt_speaker_name_for_path(&dummy, "DefaultSpeaker", /*enabled:*/ true, &pm); + assert_eq!(got, "DefaultSpeaker"); + assert_eq!(ui::testing_prompt_call_count(), 0, "no prompt functions should be called when NO_INTERACTION=1"); + // Cleanup + unsafe { + std_env::remove_var("NO_INTERACTION"); + } + } } diff --git a/src/models.rs b/src/models.rs index 9b26704..77eda43 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1135,6 +1135,7 @@ mod tests { #[test] fn test_format_model_list_spacing_and_structure() { + use std::env as std_env; let models = vec![ ModelEntry { name: "tiny.en-q5_1".to_string(), @@ -1375,6 +1376,22 @@ mod tests { } } + #[test] + fn test_no_interaction_models_downloader_skips_prompts() { + // Force non-interactive; verify that no UI prompt functions are invoked + unsafe { std::env::set_var("NO_INTERACTION", "1"); } + crate::set_no_interaction(true); + crate::ui::testing_reset_prompt_call_counters(); + let models = vec![ + ModelEntry { name: "tiny.en-q5_1".to_string(), base: "tiny".to_string(), subtype: "en-q5_1".to_string(), size: 1024, sha256: None, repo: "ggerganov/whisper.cpp".to_string() }, + ModelEntry { name: "tiny-q5_1".to_string(), base: "tiny".to_string(), subtype: "q5_1".to_string(), size: 2048, sha256: None, repo: "ggerganov/whisper.cpp".to_string() }, + ]; + let picked = super::prompt_select_models_two_stage(&models).unwrap(); + assert!(picked.is_empty(), "non-interactive should not select any models by default"); + assert_eq!(crate::ui::testing_prompt_call_count(), 0, "no prompt functions should be called in non-interactive mode"); + unsafe { std::env::remove_var("NO_INTERACTION"); } + } + #[test] fn test_wrong_hash_deletes_temp_and_errors() { use std::sync::{Mutex, OnceLock}; diff --git a/src/ui.rs b/src/ui.rs index 04618e4..0d33476 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -4,6 +4,24 @@ // If you need a new prompt type, add it here so callers don't depend on a specific library. use anyhow::{anyhow, Result}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +// Test-visible counter to detect accidental prompt calls in non-interactive/CI contexts. +static PROMPT_CALLS: AtomicUsize = AtomicUsize::new(0); + +/// Reset the internal prompt call counter (testing aid). +pub fn testing_reset_prompt_call_counters() { + PROMPT_CALLS.store(0, Ordering::Relaxed); +} + +/// Get current prompt call count (testing aid). +pub fn testing_prompt_call_count() -> usize { + PROMPT_CALLS.load(Ordering::Relaxed) +} + +fn note_prompt_call() { + PROMPT_CALLS.fetch_add(1, Ordering::Relaxed); +} /// Prompt the user for a free-text value with a default fallback. /// @@ -12,6 +30,7 @@ use anyhow::{anyhow, Result}; /// - On any prompt error (e.g., non-TTY, read error), returns an error; callers should /// handle it and typically fall back to `default` in non-interactive contexts. pub fn prompt_text(prompt: &str, default: &str) -> Result { + note_prompt_call(); let res: Result = cliclack::input(prompt) .default_input(default) .interact(); @@ -29,6 +48,7 @@ pub fn prompt_text(prompt: &str, default: &str) -> Result { /// /// Returns the selected boolean. Any underlying prompt error is returned as an error. pub fn prompt_confirm(prompt: &str, default: bool) -> Result { + note_prompt_call(); let res: Result = cliclack::confirm(prompt) .initial_value(default) .interact(); @@ -43,6 +63,7 @@ pub fn prompt_select_index(prompt: &str, items: &[T]) -> R if items.is_empty() { return Err(anyhow!("prompt_select_index called with empty items")); } + note_prompt_call(); let mut sel = cliclack::select(prompt); for (i, it) in items.iter().enumerate() { sel = sel.item(i, format!("{}", it), ""); @@ -74,6 +95,7 @@ pub fn prompt_multiselect_indices( for (i, it) in items.iter().enumerate() { ms = ms.item(i, format!("{}", it), ""); } + note_prompt_call(); let indices: Vec = ms .initial_values(defaults.to_vec()) .required(false) diff --git a/tests/integration_cli.rs b/tests/integration_cli.rs index cf98d89..1bb58ca 100644 --- a/tests/integration_cli.rs +++ b/tests/integration_cli.rs @@ -950,3 +950,30 @@ fn out_format_multiple_json_and_srt() { } */ + + +#[test] +fn cli_no_interation_alias_skips_speaker_prompts_and_uses_defaults() { + let exe = env!("CARGO_BIN_EXE_polyscribe"); + + let input1 = manifest_path("input/1-s0wlz.json"); + let input2 = manifest_path("input/2-vikingowl.json"); + + let output = Command::new(exe) + .arg(input1.as_os_str()) + .arg(input2.as_os_str()) + .arg("-m") + .arg("--set-speaker-names") + .arg("--no-interation") + .output() + .expect("failed to spawn polyscribe"); + + assert!(output.status.success(), "CLI did not exit successfully"); + + let stdout = String::from_utf8(output.stdout).expect("stdout not UTF-8"); + let root: OutputRoot = serde_json::from_str(&stdout).unwrap(); + let speakers: std::collections::HashSet = + root.items.into_iter().map(|e| e.speaker).collect(); + assert!(speakers.contains("s0wlz"), "default s0wlz not used (alias)"); + assert!(speakers.contains("vikingowl"), "default vikingowl not used (alias)"); +}