use std::path::{Path, PathBuf};
use std::time::Duration;
const SOFFICE_TIMEOUT: Duration = Duration::from_secs(300);
pub(crate) async fn check_available() -> Result<(), String> {
let output = tokio::process::Command::new("soffice")
.arg("--version")
.output()
.await
.map_err(|_| {
"'soffice' was not found on PATH -- required for \
'pigeon job run transform' with an Apple Numbers or document input kind \
(doc, docx, pages, rtf, pptx, key)"
.to_string()
})?;
if !output.status.success() {
return Err(format!(
"'soffice --version' failed (exit {:?}) -- required for \
'pigeon job run transform' with an Apple Numbers or document input kind. stderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(())
}
pub(crate) async fn convert(
input: &Path,
work_dir: &Path,
target_ext: &str,
signature: &str,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(work_dir)
.map_err(|err| format!("failed to create {}: {err}", work_dir.display()))?;
let profile_dir = work_dir.join("lo-profile");
let mut command = tokio::process::Command::new("soffice");
command
.arg("--headless")
.arg(format!(
"-env:UserInstallation=file://{}",
profile_dir.display()
))
.args(["--convert-to", target_ext, "--outdir"])
.arg(work_dir)
.arg(input);
let output = match tokio::time::timeout(SOFFICE_TIMEOUT, command.output()).await {
Ok(Ok(output)) => output,
Ok(Err(err)) => return Err(format!("failed to run soffice: {err}")),
Err(_) => {
return Err(format!(
"soffice timed out after {}s converting {} -- {signature} this run can process",
SOFFICE_TIMEOUT.as_secs(),
input.display()
));
}
};
let stem = input
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| format!("{} has no usable file stem", input.display()))?;
let produced = work_dir.join(format!("{stem}.{target_ext}"));
let produced_len = std::fs::metadata(&produced).map(|meta| meta.len()).ok();
match produced_len {
Some(len) if len > 0 => Ok(produced),
_ => Err(format!(
"soffice produced no usable .{target_ext} for {} (exit {:?}) -- \
{signature}. stdout: {} stderr: {}",
input.display(),
output.status.code(),
String::from_utf8_lossy(&output.stdout).trim(),
String::from_utf8_lossy(&output.stderr).trim()
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn check_available_reports_a_missing_binary_clearly() {
match check_available().await {
Ok(()) => {}
Err(err) => assert!(err.contains("soffice"), "{err}"),
}
}
#[tokio::test]
async fn convert_fails_clearly_without_a_usable_output() {
if check_available().await.is_err() {
eprintln!("skipping: soffice not found on PATH");
return;
}
let temp = tempfile::tempdir().expect("tempdir");
let missing = temp.path().join("nope.docx");
let err = convert(
&missing,
&temp.path().join("work"),
"pdf",
"is not a readable document",
)
.await
.expect_err("a missing input cannot convert");
assert!(err.contains("is not a readable document"), "{err}");
}
#[tokio::test]
async fn convert_produces_the_requested_target_extension() {
if check_available().await.is_err() {
eprintln!("skipping: soffice not found on PATH");
return;
}
let temp = tempfile::tempdir().expect("tempdir");
let input = temp.path().join("fixture.rtf");
std::fs::write(
&input,
br"{\rtf1\ansi\deff0 Hello from a Rich Text Format fixture.\par}",
)
.expect("write rtf");
let produced = convert(
&input,
&temp.path().join("work"),
"pdf",
"is not a readable document",
)
.await
.expect("rtf converts to pdf");
assert_eq!(produced.extension().and_then(|e| e.to_str()), Some("pdf"));
let bytes = std::fs::read(&produced).expect("read pdf");
assert!(bytes.starts_with(b"%PDF-"), "not a PDF: {:?}", &bytes[..8]);
}
}