rustyfi 0.1.4

SATySFi command line interface: compile .saty documents to PDF
//! THE CROSS-VERSION IMPORT CAPSTONE: a real SATySFi 0.1 document
//! (`tests/fixtures/xver-capstone.saty`) `@require:`-ing a REAL, unmodified
//! upstream SATySFi 0.0.6 package (`lib-rustyfi/dist/packages/list.satyg`,
//! which itself `@require:`s `option.satyg`) rendered to an actual PDF —
//! the cross-version analogue of `e2e.rs`'s
//! `v01_stdja_capstone_renders_to_extractable_text` (the 0.1-only marquee
//! capstone) and `tier4_stdjabook_capstone_renders_to_extractable_text`
//! (the 0.0.6-only one).
//!
//! ## The lib-root question
//!
//! The entry is `V0_1`, so its own 0.1 scaffolding (`document`/`+p`/`\math`)
//! would normally come from a `dist-v01/packages/` package — but the
//! `@require:` target this capstone needs (`list`/`option`) is a REAL 0.0.6
//! package under `dist/packages/`, and
//! `rustyfi_loader::v006::resolve::resolve_require`'s candidate list never
//! reaches both `dist/packages/` and `dist-v01/packages/` from one
//! `lib_root` (they are siblings, not nested).
//!
//! No loader changes needed: this capstone's own 0.1 scaffolding
//! (`tests/fixtures/xver-capstone-helper.satyh`, a trimmed `v01-mini.satyh`)
//! is reached via `@import:` — resolved independently of `lib_root`
//! (`resolve_import`, not `resolve_require`), like `xver_import.rs`'s own
//! `XVER_HELPER_SRC`/`XVER_HELPER` sibling file. That leaves `lib_root` free
//! to be a 0.0.6-ONLY root (`v006_only_lib_root`, a symlink to the real
//! frozen corpus), which is what forces the crossing — see the `saw_v006`
//! assertion in the test.

use std::path::{Path, PathBuf};
use std::process::Command;

/// This repo's `lib-rustyfi/` directory — resolved relative to this crate's
/// own manifest directory, same as `e2e.rs`'s `lib_root()`.
fn lib_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lib-rustyfi")
}

/// `annot`/`list`/`option`'s combined parse depth mirrors the other e2e
/// capstones' stack needs — same big-stack helper as `e2e.rs`.
fn run_with_big_stack(f: impl FnOnce() + Send + 'static) {
    std::thread::Builder::new()
        .stack_size(64 * 1024 * 1024)
        .spawn(f)
        .expect("spawn big-stack thread")
        .join()
        .expect("big-stack thread panicked (see assertion above)");
}

/// Locate a real DejaVu TrueType face for `TtfFontStore`, exactly
/// `e2e.rs`'s `find_regular_ttf` (duplicated here since integration test
/// binaries share no code across files).
fn find_regular_ttf() -> Option<PathBuf> {
    for family in ["DejaVuSerif", "DejaVuSans"] {
        if let Ok(output) = Command::new("fc-match")
            .args(["--format=%{file}", family])
            .output()
        {
            if output.status.success() {
                let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
                if !path.is_empty() && Path::new(&path).is_file() && path.ends_with(".ttf") {
                    return Some(PathBuf::from(path));
                }
            }
        }
    }
    for candidate in [
        "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
        "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
        "/usr/share/fonts/dejavu/DejaVuSans.ttf",
        "/run/current-system/sw/share/fonts/truetype/DejaVuSans.ttf",
    ] {
        if Path::new(candidate).is_file() {
            return Some(PathBuf::from(candidate));
        }
    }
    None
}

/// A lib root exposing ONLY the 0.0.6 corpus, so a `V0_1` entry's `@require:`
/// must fall back across the version boundary instead of finding a
/// same-generation package. `dist` is symlinked rather than copied — the
/// capstone's point is that it reads the REAL, unmodified corpus files.
fn v006_only_lib_root() -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!(
        "rustyfi-xver-capstone-v006-root-{}",
        std::process::id()
    ));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).expect("create the 0.0.6-only lib root");
    #[cfg(unix)]
    std::os::unix::fs::symlink(lib_root().join("dist"), dir.join("dist"))
        .expect("symlink the real dist/ into the 0.0.6-only lib root");
    dir
}

/// THE CAPSTONE: `xver-capstone.saty` `@require:`s the real
/// `lib-rustyfi/dist/packages/list.satyg` (which itself `@require:`s
/// `option.satyg`) and actually calls `List.map`/`List.fold-left`/
/// `Option.from` to compute `36` (`List.map (+10) [1,2,3] = [11,12,13]`,
/// `List.fold-left (+) 0 .. = 36`, `Option.from 0 (Some 36) = 36`), then
/// renders that number as digits in the body text — `pdftotext` must find
/// "36" in the output, proving the 0.0.6 package's computation, not just
/// its type, crossed the version boundary into the rendered PDF.
#[test]
fn xver_capstone_renders_to_extractable_text() {
    let font = match find_regular_ttf() {
        Some(p) => p,
        None => {
            eprintln!("skipping xver capstone: no DejaVu TrueType font found");
            return;
        }
    };
    run_with_big_stack(move || {
        let entry = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/xver-capstone.saty");
        let program = rustyfi_loader::load(
            &entry,
            &rustyfi_loader::LoadOptions {
                lib_root: Some(v006_only_lib_root()),
                version: rustyfi_syntax::RustyfiVersion::V0_1,
                ..Default::default()
            },
        )
        .expect(
            "xver-capstone.saty + its real 0.0.6 list/option @require: targets + the local \
             0.1 @import: helper must all load through one LoadOptions",
        );

        // Sanity: `list`/`option` were actually detected as `V0_0` corpus
        // targets (the loader's per-file version-detection rule) — pin the provenance explicitly so
        // a loader regression here fails with a clear message.
        //
        // THE TRAP, and why the load above uses a 0.0.6-ONLY lib root rather
        // than the repo's: `@require:` prefers the requesting file's own
        // generation, and the bundled `dist-v01/packages/` has its own
        // `list`/`option`, so against the full root a 0.1 entry would
        // (correctly) get the 0.1 pair and this capstone would quietly stop
        // crossing versions at all — while still passing. Hiding `dist-v01`
        // keeps it a real 0.1-consumes-0.0.6 proof, the only thing it exists
        // to show.
        let saw_v006 = program.files[..program.files.len() - 1]
            .iter()
            .filter(|f| matches!(f.version, rustyfi_syntax::RustyfiVersion::V0_0))
            .count();
        assert_eq!(
            saw_v006,
            2,
            "list.satyg + option.satyg should both be V0_0-tagged deps: {:?}",
            program
                .files
                .iter()
                .map(|f| (&f.path, f.version))
                .collect::<Vec<_>>()
        );

        let store =
            rustyfi_pdf::TtfFontStore::load(&font, None, None).expect("load DejaVu regular face");
        let doc = rustyfi_lang::compile_document_v1(&program.files, &store).expect(
            "the xver capstone must compile end-to-end: a real 0.0.6 list/option dependency \
             spliced into a 0.1 whole-program compile, through real elaborate/typecheck/eval",
        );
        assert!(!doc.pages.is_empty(), "expected at least one page");
        assert!(
            doc.pages.iter().any(|p| !p.lines.is_empty()),
            "expected at least one non-empty page"
        );

        let bytes = rustyfi_pdf::render_pdf_ttf(&doc.geometry, &doc.pages, &store, &doc.images)
            .expect("PDF rendering must succeed");
        assert!(bytes.starts_with(b"%PDF-"), "not a PDF header");
        assert!(
            bytes.windows(9).any(|w| w == b"FontFile2"),
            "expected an embedded TrueType font (FontFile2) in the capstone PDF"
        );

        let tmp = std::env::temp_dir().join(format!(
            "rustyfi-e2e-xver-capstone-{}.pdf",
            std::process::id()
        ));
        std::fs::write(&tmp, &bytes).unwrap();
        let pdftotext = Command::new("pdftotext").arg(&tmp).arg("-").output();
        match pdftotext {
            Ok(out) if out.status.success() => {
                let text = String::from_utf8_lossy(&out.stdout);
                // The load-bearing assertion: `36` is the result of
                // `List.map`/`List.fold-left`/`Option.from`, all real
                // bindings from the REAL upstream 0.0.6 `list.satyg`/
                // `option.satyg` — this can only appear if the cross-
                // version splice actually evaluated those bindings.
                assert!(
                    text.contains("36"),
                    "pdftotext output missing \"36\" — the cross-version List/Option \
                     computation must have reached the rendered PDF:\n{text}"
                );
                for word in ["quick", "brown", "fox"] {
                    assert!(
                        text.contains(word),
                        "pdftotext output missing {word:?} — the capstone must render \
                         extractable Latin body text:\n{text}"
                    );
                }
            }
            _ => eprintln!(
                "pdftotext unavailable; the PDF-header + FontFile2-embed checks already passed"
            ),
        }
        let _ = std::fs::remove_file(&tmp);
    });
}