calepin 0.0.2

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

use super::{path_arg, run_tool, tool_error, DiagramRun};
use crate::engines::EngineResult;
use crate::utils::tools;

pub(super) const INPUT_EXT: &str = "mmd";

const SANDBOX_ERROR: &str = "No usable sandbox";
const MISSING_CHROME_ERROR: &str = "Could not find Chrome";

pub(super) fn render(run: &DiagramRun<'_>, results: &mut Vec<EngineResult>) -> Result<bool> {
    let Some(mut output) = run_mmdc(run, args(run), results)? else {
        return Ok(false);
    };

    if !output.status.success() {
        let chrome_path = if is_missing_chrome_failure(&output.stderr) {
            chrome_executable_path(run.executables.chrome.as_deref())
        } else {
            None
        };
        if let Some(chrome_path) = chrome_path.as_deref() {
            let Some(retry) =
                run_mmdc(run, puppeteer_args(run, false, Some(chrome_path))?, results)?
            else {
                return Ok(false);
            };
            output = retry;
        }

        if !output.status.success() && is_sandbox_failure(&output.stderr) {
            let Some(retry) = run_mmdc(
                run,
                puppeteer_args(run, true, chrome_path.as_deref())?,
                results,
            )?
            else {
                return Ok(false);
            };
            output = retry;
        }
    }

    if !output.status.success() {
        results.push(tool_error(&run.executables.mmdc, output.stderr));
        return Ok(false);
    }

    Ok(true)
}

fn run_mmdc(
    run: &DiagramRun<'_>,
    args: Vec<OsString>,
    results: &mut Vec<EngineResult>,
) -> Result<Option<Output>> {
    run_tool(&tools::MMDC, &run.executables.mmdc, &args, results)
}

fn args(run: &DiagramRun<'_>) -> Vec<OsString> {
    vec![
        "-i".into(),
        path_arg(run.input_path),
        "-o".into(),
        path_arg(run.fig_path),
        "-b".into(),
        "transparent".into(),
    ]
}

fn puppeteer_args(
    run: &DiagramRun<'_>,
    no_sandbox: bool,
    executable_path: Option<&Path>,
) -> Result<Vec<OsString>> {
    let config_path = run.work_dir.join("puppeteer-config.json");
    let mut config = serde_json::Map::new();
    if no_sandbox {
        config.insert("args".to_string(), serde_json::json!(["--no-sandbox"]));
    }
    if let Some(executable_path) = executable_path {
        config.insert(
            "executablePath".to_string(),
            serde_json::json!(executable_path.to_string_lossy()),
        );
    }
    let json = serde_json::to_string(&config).context("failed to serialize puppeteer config")?;
    std::fs::write(&config_path, format!("{json}\n"))
        .with_context(|| format!("failed to write {}", config_path.display()))?;

    let mut args = args(run);
    args.push("-p".into());
    args.push(path_arg(&config_path));
    Ok(args)
}

fn is_sandbox_failure(stderr: &[u8]) -> bool {
    String::from_utf8_lossy(stderr).contains(SANDBOX_ERROR)
}

fn is_missing_chrome_failure(stderr: &[u8]) -> bool {
    String::from_utf8_lossy(stderr).contains(MISSING_CHROME_ERROR)
}

fn chrome_executable_path(configured: Option<&Path>) -> Option<PathBuf> {
    let mut candidates = Vec::new();
    if let Some(path) = configured {
        candidates.push(path.to_path_buf());
    }
    if let Some(path) = std::env::var_os("PUPPETEER_EXECUTABLE_PATH") {
        candidates.push(PathBuf::from(path));
    }

    candidates.extend([
        PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
        PathBuf::from("/usr/bin/google-chrome"),
        PathBuf::from("/usr/bin/google-chrome-stable"),
        PathBuf::from("/usr/bin/chromium"),
        PathBuf::from("/usr/bin/chromium-browser"),
        PathBuf::from("/snap/bin/chromium"),
    ]);

    for env_var in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] {
        if let Some(base) = std::env::var_os(env_var) {
            candidates.push(PathBuf::from(base).join(r"Google\Chrome\Application\chrome.exe"));
        }
    }

    candidates.into_iter().find(|path| path.is_file())
}

#[cfg(test)]
mod tests {
    use super::super::execute_diagram;
    use super::super::test_support::{
        assert_successful_plot, env_lock, write_executable, EnvVarGuard,
    };
    use crate::config::ExecutablePaths;
    use crate::typst::model::EngineName;

    #[test]
    fn retries_with_no_sandbox_config_when_chromium_requires_it() {
        let _guard = env_lock();
        let temp_dir = tempfile::tempdir().unwrap();
        let bin_dir = temp_dir.path().join("bin");
        std::fs::create_dir(&bin_dir).unwrap();
        write_executable(
            &bin_dir.join("mmdc"),
            r#"#!/bin/sh
out=""
config=""
while [ "$#" -gt 0 ]; do
  case "$1" in
    -o) shift; out="$1" ;;
    -p|--puppeteerConfigFile) shift; config="$1" ;;
  esac
  shift
done
if [ -z "$config" ]; then
  echo "No usable sandbox!" >&2
  exit 1
fi
if ! grep -q -- "--no-sandbox" "$config"; then
  echo "missing no-sandbox" >&2
  exit 2
fi
printf "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>" > "$out"
"#,
        );
        let _path = EnvVarGuard::prepend_path(bin_dir);

        let fig_path = temp_dir.path().join("figure.svg");
        let source = vec!["flowchart LR".to_string(), "  A --> B".to_string()];
        let results = execute_diagram(
            "flowchart LR\n  A --> B",
            EngineName::Mermaid,
            &fig_path,
            &source,
            &ExecutablePaths::defaults(),
        )
        .unwrap();

        assert_successful_plot(&results, &fig_path);
    }

    #[test]
    fn retries_with_detected_chrome_when_puppeteer_cache_is_missing() {
        let _guard = env_lock();
        let temp_dir = tempfile::tempdir().unwrap();
        let chrome_path = temp_dir.path().join("chrome");
        write_executable(&chrome_path, "#!/bin/sh\n");

        let bin_dir = temp_dir.path().join("bin");
        std::fs::create_dir(&bin_dir).unwrap();
        write_executable(
            &bin_dir.join("mmdc"),
            format!(
                r#"#!/bin/sh
out=""
config=""
while [ "$#" -gt 0 ]; do
  case "$1" in
    -o) shift; out="$1" ;;
    -p|--puppeteerConfigFile) shift; config="$1" ;;
  esac
  shift
done
if [ -z "$config" ]; then
  echo "Could not find Chrome (ver. 148.0.7778.97)." >&2
  exit 1
fi
if ! grep -q -- "{}" "$config"; then
  echo "missing executablePath" >&2
  exit 2
fi
printf "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>" > "$out"
"#,
                chrome_path.display()
            ),
        );
        let _path = EnvVarGuard::prepend_path(bin_dir);
        let _chrome = EnvVarGuard::set("PUPPETEER_EXECUTABLE_PATH", &chrome_path);

        let fig_path = temp_dir.path().join("figure.svg");
        let source = vec!["flowchart LR".to_string(), "  A --> B".to_string()];
        let results = execute_diagram(
            "flowchart LR\n  A --> B",
            EngineName::Mermaid,
            &fig_path,
            &source,
            &ExecutablePaths::defaults(),
        )
        .unwrap();

        assert_successful_plot(&results, &fig_path);
    }
}