zim-studio 1.5.0

A Terminal-Based Audio Project Scaffold and Metadata System
Documentation
//! Invoke `xelatex` on a generated `.tex` source.
//!
//! Two passes so the table of contents resolves page numbers. If `xelatex` is
//! not on `PATH` we surface a clear error pointing at MacTeX.

use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const ENGINE: &str = "xelatex";
const TEX_NAME: &str = "doc.tex";
const PDF_NAME: &str = "doc.pdf";

/// Write `latex_source` to `work_dir/doc.tex` and compile it twice.
/// Returns the absolute path to the resulting PDF inside `work_dir`.
pub fn compile(latex_source: &str, work_dir: &Path) -> Result<PathBuf, Box<dyn Error>> {
    ensure_engine_available()?;

    fs::create_dir_all(work_dir)?;
    let tex_path = work_dir.join(TEX_NAME);
    fs::write(&tex_path, latex_source)?;

    for pass in 1..=2 {
        run_pass(work_dir, pass)?;
    }

    let pdf = work_dir.join(PDF_NAME);
    if !pdf.exists() {
        return Err(format!(
            "{ENGINE} ran without error but produced no PDF at {}",
            pdf.display()
        )
        .into());
    }
    Ok(pdf)
}

/// Path of the intermediate `.tex` written by [`compile`].
pub fn tex_path(work_dir: &Path) -> PathBuf {
    work_dir.join(TEX_NAME)
}

fn ensure_engine_available() -> Result<(), Box<dyn Error>> {
    let probe = Command::new(ENGINE).arg("--version").output();
    match probe {
        Ok(o) if o.status.success() => Ok(()),
        Ok(_) => Err(format!("{ENGINE} is installed but failed its --version probe").into()),
        Err(_) => Err(format!(
            "{ENGINE} not found on PATH. Install MacTeX (https://tug.org/mactex/) or another TeX Live distribution."
        )
        .into()),
    }
}

fn run_pass(work_dir: &Path, pass: u32) -> Result<(), Box<dyn Error>> {
    let output = Command::new(ENGINE)
        .arg("-interaction=nonstopmode")
        .arg("-halt-on-error")
        .arg("-output-directory")
        .arg(work_dir)
        .arg(TEX_NAME)
        .current_dir(work_dir)
        .output()?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let stdout = String::from_utf8_lossy(&output.stdout);
        let log = work_dir.join("doc.log");
        let log_tail = fs::read_to_string(&log)
            .map(|s| tail(&s, 60))
            .unwrap_or_else(|_| String::from("(no log file produced)"));
        return Err(format!(
            "{ENGINE} pass {pass} failed.\n--- stderr ---\n{stderr}\n--- stdout tail ---\n{}\n--- log tail ---\n{log_tail}",
            tail(&stdout, 40)
        )
        .into());
    }
    Ok(())
}

fn tail(text: &str, n: usize) -> String {
    let lines: Vec<&str> = text.lines().collect();
    let start = lines.len().saturating_sub(n);
    lines[start..].join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tail_returns_last_n_lines() {
        let s = (1..=10)
            .map(|i| i.to_string())
            .collect::<Vec<_>>()
            .join("\n");
        assert_eq!(tail(&s, 3), "8\n9\n10");
        assert_eq!(tail(&s, 100), s);
    }

    #[test]
    fn tex_path_is_inside_work_dir() {
        let dir = Path::new("/tmp/zim-pdf-test");
        assert_eq!(tex_path(dir), dir.join("doc.tex"));
    }
}