diff --git a/src/main.rs b/src/main.rs index 33fd3fa..5d34b91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,6 +129,10 @@ struct Args { /// Continue processing other inputs even if some fail; exit non-zero if any failed #[arg(long = "continue-on-error")] continue_on_error: bool, + + /// Overwrite existing output files instead of appending a numeric suffix + #[arg(long = "force")] + force: bool, } #[derive(Debug, Deserialize)] @@ -511,13 +515,13 @@ fn run() -> Result<()> { // In merge+separate mode, always write merged output inside the provided directory let base = outp.join(format!("{}_merged", polyscribe::date_prefix())); let root = OutputRoot { items: merged_items.clone() }; - write_outputs(&base, &root, &out_formats)?; + write_outputs(&base, &root, &out_formats, args.force)?; } 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)?; + write_outputs(&base, &root, &out_formats, args.force)?; } } else { // Print JSON to stdout @@ -547,7 +551,7 @@ fn run() -> Result<()> { let root = OutputRoot { items }; let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("output"); let base = out_dir.join(format!("{}_{}", polyscribe::date_prefix(), stem)); - write_outputs(&base, &root, &out_formats)?; + write_outputs(&base, &root, &out_formats, args.force)?; } else if is_audio_file(path) { // Skip in tests } diff --git a/src/output.rs b/src/output.rs index 3f4420f..770cd44 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,6 +1,6 @@ use std::fs::File; use std::io::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::Context; @@ -20,10 +20,41 @@ impl OutputFormats { } } +fn any_target_exists(base: &Path, formats: &OutputFormats) -> bool { + (formats.json && base.with_extension("json").exists()) + || (formats.toml && base.with_extension("toml").exists()) + || (formats.srt && base.with_extension("srt").exists()) +} + +fn with_suffix(base: &Path, n: usize) -> PathBuf { + let parent = base.parent().unwrap_or_else(|| Path::new("")); + let name = base.file_name().and_then(|s| s.to_str()).unwrap_or("out"); + parent.join(format!("{}_{}", name, n)) +} + +fn resolve_base(base: &Path, formats: &OutputFormats, force: bool) -> PathBuf { + if force { + return base.to_path_buf(); + } + if !any_target_exists(base, formats) { + return base.to_path_buf(); + } + let mut n = 1usize; + loop { + let candidate = with_suffix(base, n); + if !any_target_exists(&candidate, formats) { + return candidate; + } + n += 1; + } +} + /// Write outputs for the given base path (without extension). /// This will create files named `base.json`, `base.toml`, and `base.srt` /// according to the `formats` flags. JSON and TOML will always end with a trailing newline. -pub fn write_outputs(base: &Path, root: &OutputRoot, formats: &OutputFormats) -> anyhow::Result<()> { +pub fn write_outputs(base: &Path, root: &OutputRoot, formats: &OutputFormats, force: bool) -> anyhow::Result<()> { + let base = resolve_base(base, formats, force); + if formats.json { let json_path = base.with_extension("json"); let mut json_file = File::create(&json_path).with_context(|| { @@ -70,7 +101,7 @@ mod tests { let items = vec![OutputEntry { id: 0, speaker: "Alice".to_string(), start: 0.0, end: 1.23, text: "Hello".to_string() }]; let root = OutputRoot { items }; - write_outputs(&base, &root, &OutputFormats::all()).unwrap(); + write_outputs(&base, &root, &OutputFormats::all(), false).unwrap(); let json_path = base.with_extension("json"); let toml_path = base.with_extension("toml"); @@ -86,4 +117,33 @@ mod tests { assert!(json.ends_with('\n'), "json should end with newline"); assert!(toml.ends_with('\n'), "toml should end with newline"); } + + #[test] + fn suffix_is_added_when_file_exists_unless_forced() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path().join("run"); + + // Precreate a toml file for base to simulate existing output + let pre_path = base.with_extension("toml"); + std::fs::create_dir_all(dir.path()).unwrap(); + std::fs::write(&pre_path, b"existing\n").unwrap(); + + let items = vec![OutputEntry { id: 0, speaker: "A".to_string(), start: 0.0, end: 1.0, text: "Hi".to_string() }]; + let root = OutputRoot { items }; + let fmts = OutputFormats { json: false, toml: true, srt: false }; + + // Without force, should write to run_1.toml + write_outputs(&base, &root, &fmts, false).unwrap(); + assert!(base.with_file_name("run_1").with_extension("toml").exists()); + + // If run_1.toml also exists, next should be run_2.toml + std::fs::write(base.with_file_name("run_1").with_extension("toml"), b"x\n").unwrap(); + write_outputs(&base, &root, &fmts, false).unwrap(); + assert!(base.with_file_name("run_2").with_extension("toml").exists()); + + // With force, should overwrite the base.toml + write_outputs(&base, &root, &fmts, true).unwrap(); + let content = std::fs::read_to_string(pre_path).unwrap(); + assert!(content.ends_with('\n')); + } }