use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
pub fn compile(root: &Path, entry: &str) -> Result<()> {
let tectonic = find_tectonic()?;
let entry_path = root.join(entry);
let output = Command::new(&tectonic)
.arg(&entry_path)
.arg("--outdir")
.arg(root)
.arg("--keep-logs")
.current_dir(root)
.output()
.with_context(|| format!("Failed to run tectonic at {}", tectonic.display()))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let raw = format!("{}{}", stdout, stderr);
let errors = parse_errors(&raw);
if errors.is_empty() {
anyhow::bail!("Compilation failed:\n{}", raw.trim());
}
let mut msg = String::from("Compilation failed:\n\n");
for e in &errors {
msg.push_str(&format!(
"ERROR [{}:{}]\n {}\n\n",
e.file, e.line, e.message
));
}
anyhow::bail!("{}", msg.trim());
}
struct CompileError {
file: String,
line: usize,
message: String,
}
fn parse_errors(raw: &str) -> Vec<CompileError> {
let mut errors = Vec::new();
for line in raw.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("error:") {
let rest = rest.trim();
if let Some((loc, msg)) = rest.split_once(": ") {
if let Some((file, line_str)) = loc.rsplit_once(':') {
if let Ok(line_num) = line_str.parse::<usize>() {
errors.push(CompileError {
file: file.trim().to_string(),
line: line_num,
message: msg.trim().to_string(),
});
continue;
}
}
}
errors.push(CompileError {
file: String::new(),
line: 0,
message: rest.to_string(),
});
}
if let Some(msg) = trimmed.strip_prefix("! ") {
errors.push(CompileError {
file: String::new(),
line: 0,
message: msg.to_string(),
});
}
if let Some(num_str) = trimmed.strip_prefix("l.") {
let num_part: String = num_str.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(n) = num_part.parse::<usize>() {
if let Some(last) = errors.last_mut() {
last.line = n;
}
}
}
}
errors
}
fn find_tectonic() -> Result<std::path::PathBuf> {
if let Some(path) = locate_tectonic() {
return Ok(path);
}
eprintln!("Tectonic not found. Installing automatically...");
let dest = tectonic_managed_path()?;
install_tectonic(&dest)?;
Ok(dest)
}
fn locate_tectonic() -> Option<std::path::PathBuf> {
#[cfg(unix)]
let which_cmd = "which";
#[cfg(not(unix))]
let which_cmd = "where";
if let Ok(output) = Command::new(which_cmd).arg("tectonic").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !path.is_empty() {
return Some(path.into());
}
}
}
[
dirs::home_dir().map(|h| h.join(".texforge/bin/tectonic")),
dirs::home_dir().map(|h| h.join(".cargo/bin/tectonic")),
Some("/usr/local/bin/tectonic".into()),
Some("/opt/homebrew/bin/tectonic".into()),
]
.into_iter()
.flatten()
.find(|p| p.exists())
}
fn tectonic_managed_path() -> Result<std::path::PathBuf> {
dirs::home_dir()
.map(|h| h.join(".texforge/bin/tectonic"))
.ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
}
fn install_tectonic(dest: &std::path::Path) -> Result<()> {
let target = current_target()?;
let version = "0.15.0";
let (filename, is_zip) = if target.contains("windows") {
(format!("tectonic-{}-{}.zip", version, target), true)
} else {
(format!("tectonic-{}-{}.tar.gz", version, target), false)
};
let url = format!(
"https://github.com/tectonic-typesetting/tectonic/releases/download/tectonic%40{}/{}",
version, filename
);
eprintln!("Downloading tectonic {}...", version);
let response = reqwest::blocking::Client::new()
.get(&url)
.header("User-Agent", "texforge")
.send()
.context("Failed to download tectonic")?;
if !response.status().is_success() {
anyhow::bail!(
"Failed to download tectonic: HTTP {}\nURL: {}",
response.status(),
url
);
}
let bytes = response.bytes()?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
if is_zip {
install_from_zip(&bytes, dest)?;
} else {
install_from_targz(&bytes, dest)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?;
}
eprintln!(" ◇ Tectonic installed to {}", dest.display());
Ok(())
}
fn install_from_targz(bytes: &[u8], dest: &std::path::Path) -> Result<()> {
let decoder = flate2::read::GzDecoder::new(bytes);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_string_lossy().to_string();
if path.ends_with("tectonic") || path == "tectonic" {
std::io::copy(&mut entry, &mut std::fs::File::create(dest)?)?;
return Ok(());
}
}
anyhow::bail!("tectonic binary not found in archive")
}
fn install_from_zip(bytes: &[u8], dest: &std::path::Path) -> Result<()> {
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
if file.name().ends_with("tectonic.exe") || file.name() == "tectonic.exe" {
std::io::copy(&mut file, &mut std::fs::File::create(dest)?)?;
return Ok(());
}
}
anyhow::bail!("tectonic.exe not found in archive")
}
fn current_target() -> Result<&'static str> {
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
return Ok("x86_64-unknown-linux-musl");
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
return Ok("aarch64-unknown-linux-musl");
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
return Ok("x86_64-apple-darwin");
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
return Ok("aarch64-apple-darwin");
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
return Ok("x86_64-pc-windows-msvc");
#[cfg(not(any(
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
anyhow::bail!("Unsupported platform for automatic tectonic installation")
}