use std::path::{Path, PathBuf};
use std::time::Duration;
use super::media::CONVERTER_CRASHED_SIGNATURE;
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 profile_url = directory_url(&profile_dir)?;
let outdir_url = directory_url(work_dir)?;
let mut command = tokio::process::Command::new("soffice");
command
.arg("--headless")
.arg(format!("-env:UserInstallation={profile_url}"))
.args(["--convert-to", target_ext, "--outdir"])
.arg(&outdir_url)
.arg(input)
.kill_on_drop(true);
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();
if let Some(len) = produced_len
&& len > 0
{
return Ok(produced);
}
if output.status.code().is_none() {
return Err(format!(
"soffice crashed ({}) converting {} -- {CONVERTER_CRASHED_SIGNATURE}. stderr: {}",
termination_signal(&output.status),
input.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
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()
))
}
fn directory_url(path: &Path) -> Result<String, String> {
let absolute = std::path::absolute(path)
.map_err(|err| format!("failed to resolve {}: {err}", path.display()))?;
url::Url::from_directory_path(&absolute)
.map(String::from)
.map_err(|()| format!("{} cannot be expressed as a file URL", absolute.display()))
}
#[cfg(unix)]
fn termination_signal(status: &std::process::ExitStatus) -> String {
use std::os::unix::process::ExitStatusExt;
match status.signal() {
Some(signal) => format!("signal {signal}"),
None => "no exit code".to_string(),
}
}
#[cfg(not(unix))]
fn termination_signal(_status: &std::process::ExitStatus) -> String {
"no exit code".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn directory_url_percent_encodes_reserved_characters() {
let url = directory_url(Path::new("/tmp/a b#1 (c)%41/lo-profile")).unwrap();
assert_eq!(url, "file:///tmp/a%20b%231%20(c)%2541/lo-profile/");
assert!(!url.contains(' ') && !url.contains('#'), "{url}");
}
#[test]
fn directory_url_resolves_a_relative_path() {
let url = directory_url(Path::new("relative/dir")).unwrap();
assert!(url.starts_with("file:///"), "{url}");
assert!(url.ends_with("/relative/dir/"), "{url}");
}
#[test]
fn a_crash_is_non_retryable_but_not_wrong_input_kind() {
let err = format!(
"soffice crashed (signal 6) converting /x.docx -- {CONVERTER_CRASHED_SIGNATURE}. stderr: boom"
);
assert!(super::super::media::is_non_retryable_transcode_error(&err));
assert!(!super::super::media::is_wrong_input_kind_error(&err));
}
#[tokio::test]
async fn convert_succeeds_in_a_work_dir_with_url_reserved_characters() {
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 Reserved characters.\par}").unwrap();
let work_dir = temp.path().join("a b#1 (c)%41");
let produced = convert(&input, &work_dir, "pdf", "is not a readable document")
.await
.expect("converts despite the directory name");
assert_eq!(produced, work_dir.join("fixture.pdf"));
assert!(
work_dir.join("lo-profile").is_dir(),
"profile went elsewhere"
);
assert!(!temp.path().join("a b#1 (c)A").exists());
}
#[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]);
}
}