calepin 0.0.16

A Rust CLI for preprocessing Typst documents with executable code chunks
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{Context, Result};
use serde::Serialize;
use xxhash_rust::xxh3::xxh3_64;

use crate::config::ExecutablePaths;
use crate::typst::io::write_if_changed;
use crate::typst::model::{ChunkSpec, EngineName, ExecOptions, LayoutPaths};

const PREPROCESS_FINGERPRINT_FILE: &str = "fingerprint.xxh3";

pub(super) fn preprocess_fingerprint(
    layout: &LayoutPaths,
    executables: &ExecutablePaths,
    chunks: &[ChunkSpec],
    cwd: &Path,
    timeout: Option<Duration>,
    params: &serde_json::Value,
    theme: &crate::theme::ThemeSelection,
    image_meta_signature: u64,
) -> Result<u64> {
    let payload = PreprocessFingerprint {
        schema: crate::typst::model::RESULT_SCHEMA_VERSION,
        calepin_version: env!("CARGO_PKG_VERSION"),
        input_rel: path_fingerprint(&layout.input_rel),
        figures_dir: path_fingerprint(&layout.figures_dir),
        cwd: path_fingerprint(cwd),
        timeout_secs: timeout.map(|duration| duration.as_secs()),
        executables: ExecutableFingerprint::from(executables),
        chunks: chunks
            .iter()
            .map(ChunkFingerprint::from)
            .collect::<Vec<_>>(),
        params: params.clone(),
        theme: theme_fingerprint(theme),
        image_meta: format!("{image_meta_signature:016x}"),
    };
    let bytes = serde_json::to_vec(&payload)?;
    Ok(xxh3_64(&bytes))
}

pub(super) fn preprocess_cache_hit(layout: &LayoutPaths, fingerprint: u64) -> Result<bool> {
    if !layout.results_path.is_file() {
        return Ok(false);
    }
    Ok(read_preprocess_fingerprint(layout)? == Some(fingerprint))
}

fn preprocess_fingerprint_path(layout: &LayoutPaths) -> PathBuf {
    layout.sibling_path(PREPROCESS_FINGERPRINT_FILE)
}

fn read_preprocess_fingerprint(layout: &LayoutPaths) -> Result<Option<u64>> {
    let path = preprocess_fingerprint_path(layout);
    let text = match fs::read_to_string(&path) {
        Ok(text) => text,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => {
            return Err(error).with_context(|| format!("failed to read {}", path.display()))
        }
    };
    let text = text.trim();
    if text.is_empty() {
        return Ok(None);
    }
    Ok(u64::from_str_radix(text, 16).ok())
}

pub(super) fn write_preprocess_fingerprint(layout: &LayoutPaths, fingerprint: u64) -> Result<()> {
    let path = preprocess_fingerprint_path(layout);
    let text = format!("{fingerprint:016x}\n");
    write_if_changed(&path, text)
}

#[derive(Serialize)]
struct PreprocessFingerprint {
    schema: u8,
    calepin_version: &'static str,
    input_rel: String,
    figures_dir: String,
    cwd: String,
    timeout_secs: Option<u64>,
    executables: ExecutableFingerprint,
    chunks: Vec<ChunkFingerprint>,
    params: serde_json::Value,
    theme: String,
    image_meta: String,
}

#[derive(Serialize)]
struct ChunkFingerprint {
    label: String,
    ordinal: usize,
    engine: EngineName,
    code: String,
    exec_options: ExecOptions,
}

impl From<&ChunkSpec> for ChunkFingerprint {
    fn from(chunk: &ChunkSpec) -> Self {
        Self {
            label: chunk.label.clone(),
            ordinal: chunk.ordinal,
            engine: chunk.engine.clone(),
            code: chunk.code.clone(),
            exec_options: chunk.exec_options.clone(),
        }
    }
}

#[derive(Serialize)]
struct ExecutableFingerprint {
    typst: String,
    rscript: String,
    python: String,
    mmdc: String,
    dot: String,
    tectonic: String,
    dvisvgm: String,
    pdf2svg: String,
    d2: String,
    chrome: Option<String>,
}

impl From<&ExecutablePaths> for ExecutableFingerprint {
    fn from(paths: &ExecutablePaths) -> Self {
        Self {
            typst: path_fingerprint(&paths.typst),
            rscript: path_fingerprint(&paths.rscript),
            python: path_fingerprint(&paths.python),
            mmdc: path_fingerprint(&paths.mmdc),
            dot: path_fingerprint(&paths.dot),
            tectonic: path_fingerprint(&paths.tectonic),
            dvisvgm: path_fingerprint(&paths.dvisvgm),
            pdf2svg: path_fingerprint(&paths.pdf2svg),
            d2: path_fingerprint(&paths.d2),
            chrome: paths.chrome.as_deref().map(path_fingerprint),
        }
    }
}

fn path_fingerprint(path: &Path) -> String {
    path.to_string_lossy().into_owned()
}

fn theme_fingerprint(theme: &crate::theme::ThemeSelection) -> String {
    match theme {
        crate::theme::ThemeSelection::Default => "default".to_string(),
        crate::theme::ThemeSelection::Disabled => "disabled".to_string(),
        crate::theme::ThemeSelection::Builtin(name) => format!("builtin:{name}"),
        crate::theme::ThemeSelection::Dir(path) => format!("dir:{}", path.display()),
    }
}