papr-pdf 0.1.0

A fast, keyboard-first CLI tool to compile Markdown, LaTeX, and plain text files into publication-grade PDFs.
//! LaTeX document compilation with multi-file project resolution.

use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use which::which;

/// Represents an available LaTeX compiler found on the system.
pub enum LatexCompiler {
    /// Modern standalone Tectonic engine (recommended).
    Tectonic(PathBuf),
    /// Classic pdfLaTeX engine.
    PdfLatex(PathBuf),
    /// XeLaTeX engine.
    XeLatex(PathBuf),
    /// LuaLaTeX engine.
    LuaLatex(PathBuf),
}

impl LatexCompiler {
    /// Automatically detects an available LaTeX compiler on the host system.
    pub fn detect() -> Result<Self> {
        // First check tectonic (in PATH or in ~/.cargo/bin)
        if let Ok(path) = which("tectonic") {
            return Ok(LatexCompiler::Tectonic(path));
        }
        if let Some(home) = dirs::home_dir() {
            let cargo_tectonic = home.join(".cargo/bin/tectonic");
            if cargo_tectonic.is_file() {
                return Ok(LatexCompiler::Tectonic(cargo_tectonic));
            }
        }

        // Then check pdflatex, xelatex, lualatex
        if let Ok(path) = which("xelatex") {
            return Ok(LatexCompiler::XeLatex(path));
        }
        if let Ok(path) = which("pdflatex") {
            return Ok(LatexCompiler::PdfLatex(path));
        }
        if let Ok(path) = which("lualatex") {
            return Ok(LatexCompiler::LuaLatex(path));
        }

        anyhow::bail!(
            "No LaTeX engine found!\n\
             Recommended: Install Tectonic (modern standalone TeX engine):\n\
             curl --proto '=https' --tlsv1.2 -fsSL https://drop-sh.tectonic-typesetting.github.io | sh\n\
             Or install via package manager: 'sudo pacman -S tectonic' or 'sudo apt install tectonic'."
        )
    }

    /// User-friendly name of the compiler.
    pub fn name(&self) -> &'static str {
        match self {
            LatexCompiler::Tectonic(_) => "Tectonic",
            LatexCompiler::PdfLatex(_) => "pdflatex",
            LatexCompiler::XeLatex(_) => "xelatex",
            LatexCompiler::LuaLatex(_) => "lualatex",
        }
    }
}

/// Compiles a LaTeX `.tex` file into a PDF, resolving sibling files and subdirectories.
pub fn compile_latex(input_path: &Path, output_pdf: &Path) -> Result<()> {
    let compiler = LatexCompiler::detect()?;

    let abs_input = fs::canonicalize(input_path)
        .with_context(|| format!("Failed to canonicalize LaTeX path: {}", input_path.display()))?;

    let parent_dir = abs_input
        .parent()
        .ok_or_else(|| anyhow::anyhow!("Input file has no parent directory"))?;

    let file_stem = abs_input
        .file_stem()
        .ok_or_else(|| anyhow::anyhow!("Invalid file name"))?
        .to_string_lossy();

    let abs_output = if output_pdf.is_absolute() {
        output_pdf.to_path_buf()
    } else {
        std::env::current_dir()?.join(output_pdf)
    };

    if let Some(parent) = abs_output.parent() {
        fs::create_dir_all(parent)?;
    }

    match &compiler {
        LatexCompiler::Tectonic(tectonic_bin) => {
            // Tectonic direct execution in document parent dir for relative asset resolution
            let output = Command::new(tectonic_bin)
                .arg("-o")
                .arg(parent_dir)
                .arg(&abs_input)
                .current_dir(parent_dir)
                .stderr(Stdio::piped())
                .stdout(Stdio::piped())
                .output()
                .with_context(|| "Failed to run tectonic")?;

            if !output.status.success() {
                let stderr = String::from_utf8_lossy(&output.stderr);
                let relevant_errors: Vec<&str> = stderr
                    .lines()
                    .filter(|l| !l.starts_with("Fontconfig warning:"))
                    .collect();
                anyhow::bail!(
                    "Tectonic compilation failed:\n{}",
                    relevant_errors.join("\n")
                );
            }

            let generated_pdf = parent_dir.join(format!("{}.pdf", file_stem));
            if generated_pdf.exists() && generated_pdf != abs_output {
                fs::copy(&generated_pdf, &abs_output)?;
            }
        }
        LatexCompiler::PdfLatex(bin) | LatexCompiler::XeLatex(bin) | LatexCompiler::LuaLatex(bin) => {
            // Run twice for references/TOC resolution like Overleaf
            for pass in 1..=2 {
                let output = Command::new(bin)
                    .arg("-interaction=nonstopmode")
                    .arg("-file-line-error")
                    .arg(&abs_input)
                    .current_dir(parent_dir)
                    .output()
                    .with_context(|| format!("Failed to run {}", compiler.name()))?;

                if pass == 2 && !output.status.success() {
                    let log_path = parent_dir.join(format!("{}.log", file_stem));
                    let log_hint = if log_path.exists() {
                        format!("\nCheck log file: {}", log_path.display())
                    } else {
                        String::new()
                    };
                    anyhow::bail!("{} compilation failed.{}", compiler.name(), log_hint);
                }
            }

            let generated_pdf = parent_dir.join(format!("{}.pdf", file_stem));
            if generated_pdf.exists() && generated_pdf != abs_output {
                fs::copy(&generated_pdf, &abs_output)?;
            }
        }
    }

    if !abs_output.exists() {
        anyhow::bail!("Expected PDF was not found at {}", abs_output.display());
    }

    Ok(())
}