diff --git a/swift-shifter/src/converter/mod.rs b/swift-shifter/src/converter/mod.rs index 77760ba..198c029 100644 --- a/swift-shifter/src/converter/mod.rs +++ b/swift-shifter/src/converter/mod.rs @@ -26,6 +26,19 @@ pub fn detect_output_formats(path: &str) -> Result, String> { Ok(targets) } +/// Directory the final hop of a multi-hop chain writes to: the configured +/// output dir, else the ORIGINAL input's directory. Falling back to the last +/// hop's own input would resolve to the temp dir, which is deleted afterward. +fn chain_output_dir(original: &str, configured: Option<&str>) -> String { + if let Some(dir) = configured { + return dir.to_string(); + } + match Path::new(original).parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent.to_string_lossy().into_owned(), + _ => ".".to_string(), + } +} + /// Execute exactly one conversion hop. `from`/`to` MUST be normalized. /// `out_dir` is where THIS hop writes (a temp dir for intermediate hops). async fn run_single_hop( @@ -81,7 +94,8 @@ fn pdf_llm_cfg(config: &Config) -> document::LlmCfg { } /// Convert `path` to `target_format`, chaining hops through a temp dir when no -/// direct conversion exists. Only the final artifact lands in `config.output_dir`. +/// direct conversion exists. Only the final artifact lands in the output dir +/// (`config.output_dir`, or the input's own directory when unset). pub async fn convert_file( app: &tauri::AppHandle, path: &str, @@ -105,12 +119,13 @@ pub async fn convert_file( .await; } - // Multi-hop: run through a temp dir; only the last hop writes to output_dir. + // Multi-hop: run through a temp dir; only the last hop writes to `final_dir`. let tmp = tempfile::Builder::new() .prefix("swift-shifter-chain-") .tempdir() .map_err(|e| format!("Failed to create temp dir: {e}"))?; let tmp_str = tmp.path().to_str().ok_or("Temp path is not valid UTF-8")?; + let final_dir = chain_output_dir(path, config.output_dir.as_deref()); let total = seq.len() - 1; let mut current = path.to_string(); @@ -124,12 +139,8 @@ pub async fn convert_file( .ok(); let is_last = i == total - 1; - let out_dir = if is_last { - config.output_dir.as_deref() - } else { - Some(tmp_str) - }; - current = run_single_hop(app, ¤t, &win[0], &win[1], out_dir, config).await?; + let out_dir = if is_last { final_dir.as_str() } else { tmp_str }; + current = run_single_hop(app, ¤t, &win[0], &win[1], Some(out_dir), config).await?; } app.emit( @@ -138,7 +149,7 @@ pub async fn convert_file( ) .ok(); - // `tmp` drops here, removing all intermediates; the final file is in output_dir. + // `tmp` drops here, removing all intermediates; the final file is in `final_dir`. Ok(current) } mod tests; diff --git a/swift-shifter/src/converter/tests.rs b/swift-shifter/src/converter/tests.rs index 217cc39..d63583a 100644 --- a/swift-shifter/src/converter/tests.rs +++ b/swift-shifter/src/converter/tests.rs @@ -221,6 +221,24 @@ mod tests { } } + #[test] + fn test_chain_output_dir_prefers_configured_dir() { + let dir = crate::converter::chain_output_dir("/in/doc.md", Some("/out")); + assert_eq!(dir, "/out"); + } + + #[test] + fn test_chain_output_dir_defaults_to_original_input_dir() { + let dir = crate::converter::chain_output_dir("/in/doc.md", None); + assert_eq!(dir, "/in"); + } + + #[test] + fn test_chain_output_dir_bare_filename_uses_cwd() { + let dir = crate::converter::chain_output_dir("doc.md", None); + assert_eq!(dir, "."); + } + #[test] fn test_detect_with_chains_unknown_errors() { assert!(crate::converter::graph::detect_with_chains("/x.xyz").is_err()); diff --git a/swift-shifter/tests/chain_cli.rs b/swift-shifter/tests/chain_cli.rs index 76cdc89..a8ef1b6 100644 --- a/swift-shifter/tests/chain_cli.rs +++ b/swift-shifter/tests/chain_cli.rs @@ -13,6 +13,23 @@ fn tool_present(doctor_stdout: &str, name: &str) -> bool { #[test] fn png_to_html_chain_produces_file() { + run_png_to_html_chain(OutputDirFlag::Passed); +} + +/// Regression for #27: without `--output-dir`, the final artifact must land +/// next to the original input, not in the (deleted) chain temp dir. +#[test] +fn png_to_html_chain_without_output_dir_writes_next_to_input() { + run_png_to_html_chain(OutputDirFlag::Omitted); +} + +/// Whether the CLI run gets `--output-dir ` or falls back to the default. +enum OutputDirFlag { + Passed, + Omitted, +} + +fn run_png_to_html_chain(output_dir_flag: OutputDirFlag) { // 1. Ask the CLI which tools exist. let doctor = Command::new(env!("CARGO_BIN_EXE_swift-shifter")) .arg("doctor") @@ -39,15 +56,14 @@ fn png_to_html_chain_produces_file() { let png = dir.path().join("tiny.png"); write_tiny_png(&png); - // 3. Run the chain via the CLI: `convert html tiny.png`, output into the temp dir. - let out = Command::new(env!("CARGO_BIN_EXE_swift-shifter")) - .args([ - "--output-dir", - dir.path().to_str().unwrap(), - "convert", - "html", - png.to_str().unwrap(), - ]) + // 3. Run the chain via the CLI: `convert html tiny.png`. Either way the + // output must land in the temp dir (explicitly, or as the input's dir). + let mut cmd = Command::new(env!("CARGO_BIN_EXE_swift-shifter")); + if let OutputDirFlag::Passed = output_dir_flag { + cmd.args(["--output-dir", dir.path().to_str().unwrap()]); + } + let out = cmd + .args(["convert", "html", png.to_str().unwrap()]) .output() .expect("failed to run convert"); @@ -65,6 +81,13 @@ fn png_to_html_chain_produces_file() { assert!(produced.ends_with(".html"), "expected .html output, got {produced}"); let meta = std::fs::metadata(produced).expect("output file missing"); assert!(meta.len() > 0, "output file is empty"); + // Canonicalize both sides: on macOS the temp dir sits behind a /var symlink. + let produced_dir = std::path::Path::new(produced).parent().unwrap(); + assert_eq!( + produced_dir.canonicalize().unwrap(), + dir.path().canonicalize().unwrap(), + "output not written to the expected dir" + ); // 5. The intermediate PDF must NOT leak into the output dir — only the final // artifact lands there; intermediates live in (and die with) a temp dir.