Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions swift-shifter/src/converter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ pub fn detect_output_formats(path: &str) -> Result<Vec<String>, 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(
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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, &current, &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, &current, &win[0], &win[1], Some(out_dir), config).await?;
}

app.emit(
Expand All @@ -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;
18 changes: 18 additions & 0 deletions swift-shifter/src/converter/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
41 changes: 32 additions & 9 deletions swift-shifter/tests/chain_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <input's 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")
Expand All @@ -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");

Expand All @@ -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.
Expand Down
Loading