calepin 0.0.52

A Rust CLI for preprocessing Typst documents with executable code chunks
use std::ffi::OsString;
use std::path::Path;
use std::process::{Command, Output};

use anyhow::{anyhow, Context, Result};

use crate::typst::model::LayoutPaths;
use crate::typst::paths::slash_path;
use crate::utils::{process, tools};

pub const INPUT_MODE: &str = "calepin-mode";
pub const INPUT_RESULTS: &str = "calepin-results";
pub const INPUT_STORE: &str = "calepin-store";
pub const INPUT_TARGET: &str = "calepin-target";
pub const INPUT_ASSETS: &str = "calepin-assets";
pub const INPUT_PAGES: &str = "calepin-pages";
pub const INPUT_CURRENT_HREF: &str = "calepin-current-href";
pub const INPUT_IMAGE_META: &str = "calepin-image-meta";
pub const INPUT_SOURCE_DIR: &str = "calepin-source-dir";

pub const RESERVED_INPUT_KEYS: &[&str] = &[
    INPUT_MODE,
    INPUT_RESULTS,
    INPUT_STORE,
    INPUT_TARGET,
    INPUT_ASSETS,
    INPUT_PAGES,
    INPUT_CURRENT_HREF,
    INPUT_IMAGE_META,
    INPUT_SOURCE_DIR,
];

pub fn run_typst_capture(
    typst: &Path,
    action: &str,
    args: &[OsString],
    cwd: &Path,
    failure: impl FnOnce(&str) -> String,
    utf8_context: &'static str,
) -> Result<String> {
    let output = run_typst_output(typst, action, args, cwd, failure)?;
    String::from_utf8(output.stdout).context(utf8_context)
}

pub fn run_typst_status(
    typst: &Path,
    action: &str,
    args: &[OsString],
    cwd: &Path,
    failure: impl FnOnce(&str) -> String,
) -> Result<()> {
    run_typst_output(typst, action, args, cwd, failure)?;
    Ok(())
}

fn run_typst_output(
    typst: &Path,
    action: &str,
    args: &[OsString],
    cwd: &Path,
    failure: impl FnOnce(&str) -> String,
) -> Result<Output> {
    process::validate_executable(typst, action, Some(&tools::TYPST))?;
    let output = Command::new(typst)
        .args(args)
        .current_dir(cwd)
        .output()
        .map_err(|error| process::spawn_error(typst, action, error, Some(&tools::TYPST)))?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let mut message = failure(&stderr);
        if !message.ends_with('\n') {
            message.push('\n');
        }
        return Err(anyhow!("{}typst exited with {}", message, output.status));
    }
    Ok(output)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalepinMode {
    Query,
    Render,
}

impl CalepinMode {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Query => "query",
            Self::Render => "render",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalepinTarget {
    Paged,
    Html,
}

impl CalepinTarget {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Paged => "paged",
            Self::Html => "html",
        }
    }
}

pub fn push_input(args: &mut Vec<OsString>, key: &str, value: impl AsRef<str>) {
    args.push("--input".into());
    args.push(format!("{key}={}", value.as_ref()).into());
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypstInput {
    key: &'static str,
    value: String,
}

impl TypstInput {
    pub fn new(key: &'static str, value: impl Into<String>) -> Self {
        Self {
            key,
            value: value.into(),
        }
    }

    pub fn push_to(&self, args: &mut Vec<OsString>) {
        push_input(args, self.key, &self.value);
    }
}

pub fn source_dir_input(layout: &LayoutPaths) -> String {
    layout
        .input_rel
        .parent()
        .map(slash_path)
        .unwrap_or_default()
}

pub fn push_calepin_inputs(
    args: &mut Vec<OsString>,
    mode: CalepinMode,
    results: &str,
    target: CalepinTarget,
) {
    push_input(args, INPUT_MODE, mode.as_str());
    push_input(args, INPUT_RESULTS, results);
    push_input(args, INPUT_TARGET, target.as_str());
}

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

    #[cfg(unix)]
    #[test]
    fn run_typst_status_failure_includes_status_and_stderr() {
        let dir = tempfile::tempdir().unwrap();
        let typst = dir.path().join("typst");
        write_executable(
            &typst,
            "#!/bin/sh\nprintf 'simulated failure\\n' >&2\nexit 23\n",
        );

        let err = run_typst_status(&typst, "run typst", &[], dir.path(), |stderr| {
            format!("typst failed:\n{stderr}")
        })
        .unwrap_err()
        .to_string();

        assert!(err.contains("typst failed"));
        assert!(err.contains("simulated failure"));
        assert!(err.contains("exit status"));
        assert!(err.contains("23"));
    }

    #[test]
    fn calepin_input_construction_uses_typed_values() {
        let mut args = Vec::new();
        push_calepin_inputs(
            &mut args,
            CalepinMode::Render,
            "/.calepin/paper/results.json",
            CalepinTarget::Html,
        );

        let args = args_to_strings(args);
        assert_eq!(
            args,
            [
                "--input",
                "calepin-mode=render",
                "--input",
                "calepin-results=/.calepin/paper/results.json",
                "--input",
                "calepin-target=html",
            ]
        );

        let mut args = Vec::new();
        push_calepin_inputs(
            &mut args,
            CalepinMode::Query,
            "/.calepin/paper/results.json",
            CalepinTarget::Paged,
        );

        let args = args_to_strings(args);
        assert!(args
            .windows(2)
            .any(|pair| pair == ["--input", "calepin-mode=query"]));
        assert!(args
            .windows(2)
            .any(|pair| pair == ["--input", "calepin-target=paged"]));
    }

    #[test]
    fn reserved_input_keys_cover_all_calepin_input_constants() {
        for key in [
            INPUT_MODE,
            INPUT_RESULTS,
            INPUT_TARGET,
            INPUT_ASSETS,
            INPUT_PAGES,
            INPUT_CURRENT_HREF,
            INPUT_IMAGE_META,
            INPUT_SOURCE_DIR,
        ] {
            assert!(
                RESERVED_INPUT_KEYS.contains(&key),
                "{key} should be reserved"
            );
        }
    }

    fn args_to_strings(args: Vec<OsString>) -> Vec<String> {
        args.into_iter()
            .map(|arg| arg.to_string_lossy().to_string())
            .collect()
    }

    #[cfg(unix)]
    fn write_executable(path: &Path, contents: &str) {
        std::fs::write(path, contents).unwrap();
        make_executable(path);
    }

    #[cfg(unix)]
    fn make_executable(path: &Path) {
        use std::os::unix::fs::PermissionsExt;

        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
    }
}