ggen-engine 26.10.11

SPARQL-in-Tera code generation engine (vendored, renamed from praxis/crates/ggen)
//! Shared Chicago-TDD scaffolding for `*_pack_e2e.rs` tests: real filesystem
//! (`TempDir`), real `sync()`, no mocks. Extracted from `pack_e2e.rs` and
//! `gh_terraform_pack_e2e.rs` (both pre-existing, independently-duplicated
//! copies of this exact boilerplate) so new pack e2e tests — including ones
//! generated by `packs/pack-authoring-pack` — have one authoritative
//! implementation instead of a third hand-copy.
//!
//! Not itself a test binary: `tests/support/mod.rs` is the directory-module
//! convention `cargo test` requires so `support/*.rs` isn't compiled as its
//! own `#[test]` crate. Each consuming file starts with `mod support; use
//! support::*;`.

#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)]

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

use ggen_engine::sync::{sync, SyncOptions};
use tempfile::TempDir;

/// Recursively copy `src` into `dst` (real fs, no symlink handling needed by
/// any current pack layout).
pub(crate) fn copy_tree(src: &Path, dst: &Path) {
    std::fs::create_dir_all(dst).expect("mkdir");
    for entry in std::fs::read_dir(src).expect("read_dir") {
        let entry = entry.expect("entry");
        let from = entry.path();
        let to = dst.join(entry.file_name());
        if from.is_dir() {
            copy_tree(&from, &to);
        } else {
            std::fs::copy(&from, &to).expect("copy");
        }
    }
}

/// Copy a real, on-disk pack directory (e.g. `packs/gh-terraform-pack`) into
/// a fresh `TempDir`, alongside a minimal consumer project (`consumer/`)
/// wired to it by relative path with an empty local ontology/templates dir.
/// Returns `(tempdir_guard, consumer_project_path)` — keep the guard alive
/// for the test's duration or the scratch dir is deleted.
///
/// Generalizes `gh_terraform_pack_e2e.rs`'s pack-specific `scaffold()`: any
/// pack directory works, not just `gh-terraform-pack`.
pub(crate) fn scaffold_pack(pack_src_dir: &Path) -> (TempDir, PathBuf) {
    let dir = TempDir::new().expect("tempdir");
    let pack_name = pack_src_dir
        .file_name()
        .and_then(|n| n.to_str())
        .expect("pack dir must have a valid utf8 name");
    copy_tree(pack_src_dir, &dir.path().join(pack_name));

    let project = dir.path().join("consumer");
    std::fs::create_dir_all(project.join("templates")).expect("mkdir templates");
    std::fs::write(project.join("ontology.ttl"), "").expect("write ontology.ttl");
    std::fs::write(
        project.join("ggen.toml"),
        format!(
            "[project]\nname = \"consumer\"\n\n\
             [ontology]\nsource = \"ontology.ttl\"\n\n\
             [packs]\n{pack_name} = {{ path = \"../{pack_name}\" }}\n\n\
             [templates]\ndir = \"templates\"\n"
        ),
    )
    .expect("write ggen.toml");
    (dir, project)
}

/// Read a project-relative file as a `String`, panicking with the relative
/// path on failure (so assertion failures point at the right file).
pub(crate) fn read(project: &Path, rel: &str) -> String {
    std::fs::read_to_string(project.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}"))
}

/// Read a project-relative file and parse it as JSON, panicking (with the
/// raw text included) on either a read or a parse failure.
pub(crate) fn read_json(project: &Path, rel: &str) -> serde_json::Value {
    let raw = read(project, rel);
    serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {rel} as JSON: {e}\n{raw}"))
}

/// Copy MULTIPLE real, on-disk pack directories into a fresh `TempDir`
/// (each as a sibling, by its own directory name) alongside a minimal
/// consumer project referencing all of them by relative path, in the same
/// order given, with an empty local ontology/templates dir — the
/// multi-pack generalization of `scaffold_pack`, using the identical
/// inline-table `name = { path = "../name" }` manifest shape.
///
/// Not a fit for every multi-pack test: `cross_pack_matrix.rs` asserts on
/// the literal `[packs.<name>]` per-table TOML syntax in its own generated
/// manifest (order-independence is exactly what it's proving), so it
/// keeps its own local scaffold rather than use this function's
/// inline-table style — switching its input format would require rewriting
/// the assertions that format exists to support, not just where the
/// scaffold code lives.
pub(crate) fn scaffold_multi_pack(pack_names: &[&str]) -> (TempDir, PathBuf) {
    use std::fmt::Write as _;

    let dir = TempDir::new().expect("tempdir");
    let packs_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packs");
    for name in pack_names {
        copy_tree(&packs_root.join(name), &dir.path().join(name));
    }

    let project = dir.path().join("consumer");
    std::fs::create_dir_all(project.join("templates")).expect("mkdir templates");
    std::fs::write(project.join("ontology.ttl"), "").expect("write ontology.ttl");

    let packs_table = pack_names.iter().fold(String::new(), |mut table, name| {
        let _ = writeln!(table, "{name} = {{ path = \"../{name}\" }}");
        table
    });
    std::fs::write(
        project.join("ggen.toml"),
        format!(
            "[project]\nname = \"consumer\"\n\n\
             [ontology]\nsource = \"ontology.ttl\"\n\n\
             [packs]\n{packs_table}\n\
             [templates]\ndir = \"templates\"\n"
        ),
    )
    .expect("write ggen.toml");
    (dir, project)
}

/// Like `scaffold_pack`, but the consumer's `ontology.ttl` is pre-populated
/// with `ontology` instead of left empty — for packs whose e2e tests build a
/// real conforming (or deliberately sabotaged) consumer ontology up front,
/// rather than syncing empty-then-overwriting via `assert_gate_refuses`.
/// Only a drop-in replacement for callers whose `ggen.toml` shape otherwise
/// matches `scaffold_pack`'s (sibling pack dir, no extra `[law]`/nested-path
/// requirements) — several packs need genuinely different manifests (extra
/// `[law]` sections, nested pack paths, `extra_ontologies`) and keep their
/// own local scaffold function for that reason; forcing every shape through
/// one wrapper would silently change what's being tested, not just where the
/// code lives.
pub(crate) fn scaffold_pack_with_ontology(
    pack_src_dir: &Path, ontology: &str,
) -> (TempDir, PathBuf) {
    let (dir, project) = scaffold_pack(pack_src_dir);
    std::fs::write(project.join("ontology.ttl"), ontology).expect("write ontology.ttl");
    (dir, project)
}

/// Count `*.md` files directly inside `dir` (non-recursive) — used for
/// fan-out doc assertions (`docs/.../resources/*.md`-style outputs).
pub(crate) fn count_md_files(dir: &Path) -> usize {
    std::fs::read_dir(dir)
        .unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display()))
        .filter_map(std::result::Result::ok)
        .filter(|e| e.path().extension().is_some_and(|x| x == "md"))
        .count()
}

/// Write a minimal, valid pack at `root/<name>`: `pack.toml`, `ontology.ttl`
/// (one `dom:<class_name> a dom:DomainClass` triple), and a single
/// `templates/<class_name lowercased>.rs.tmpl` whose frontmatter is just
/// `to: <to_path>` and whose body is `body` verbatim.
pub(crate) fn write_pack(root: &Path, name: &str, class_name: &str, to_path: &str, body: &str) {
    let pack_dir = root.join(name);
    std::fs::create_dir_all(pack_dir.join("templates")).expect("mkdir pack templates");

    std::fs::write(
        pack_dir.join("pack.toml"),
        format!(
            "[pack]\nname = \"{name}\"\nversion = \"0.1.0\"\ndescription = \"test pack {name}\"\n"
        ),
    )
    .expect("write pack.toml");

    std::fs::write(
        pack_dir.join("ontology.ttl"),
        format!(
            "@prefix dom: <http://example.com/ontology#> .\ndom:{class_name} a dom:DomainClass .\n"
        ),
    )
    .expect("write ontology.ttl");

    let tmpl_name = format!("{}.rs.tmpl", class_name.to_lowercase());
    std::fs::write(
        pack_dir.join("templates").join(tmpl_name),
        format!("---\nto: {to_path}\n---\n{body}\n"),
    )
    .expect("write template");
}

/// Write `root/<project_name>/` referencing a single pack by relative path
/// under the fixed alias `pack-a`, with no `lock` key declared (exercises
/// the `#[serde(default = "default_true")]` path, not an explicit `lock =
/// true`).
pub(crate) fn write_single_pack_project(
    root: &Path, project_name: &str, pack_name: &str,
) -> PathBuf {
    let project = root.join(project_name);
    std::fs::create_dir_all(project.join("templates")).expect("mkdir templates");
    std::fs::write(project.join("ontology.ttl"), "").expect("write ontology.ttl");
    std::fs::write(
        project.join("ggen.toml"),
        format!(
            "[project]\nname = \"{project_name}\"\n\n\
             [ontology]\nsource = \"ontology.ttl\"\n\n\
             [packs]\npack-a = {{ path = \"../{pack_name}\" }}\n\n\
             [templates]\ndir = \"templates\"\n"
        ),
    )
    .expect("write ggen.toml");
    project
}

/// Write `root/two-pack-project/` referencing `pack_a`/`pack_b` (fixed
/// aliases `pack-a`/`pack-b`) by relative path, plus an empty ontology and
/// templates dir of its own.
pub(crate) fn write_two_pack_project(root: &Path, pack_a: &str, pack_b: &str) -> PathBuf {
    let project = root.join("two-pack-project");
    std::fs::create_dir_all(project.join("templates")).expect("mkdir templates");
    std::fs::write(project.join("ontology.ttl"), "").expect("write ontology.ttl");
    std::fs::write(
        project.join("ggen.toml"),
        format!(
            "[project]\nname = \"two-pack-project\"\n\n\
             [ontology]\nsource = \"ontology.ttl\"\n\n\
             [packs]\npack-a = {{ path = \"../{pack_a}\" }}\npack-b = {{ path = \"../{pack_b}\" }}\n\n\
             [templates]\ndir = \"templates\"\n"
        ),
    )
    .expect("write ggen.toml");
    project
}

/// Run a second real `sync()` against an already-synced `project` and assert
/// it is a true no-op: nothing written, and `ggen.lock` is byte-identical to
/// its state before this call. Returns the (unchanged) lock content for
/// callers that want to chain further assertions.
pub(crate) fn assert_idempotent(project: &Path) -> String {
    let lock_before =
        std::fs::read_to_string(project.join("ggen.lock")).expect("ggen.lock must exist");
    let second = sync(
        project,
        SyncOptions {
            consumer_mode: Default::default(),
            dry_run: false,
            ..Default::default()
        },
    )
    .expect("second sync must succeed");
    assert!(
        second.written.is_empty(),
        "second sync wrote (not idempotent): {:?}",
        second.written
    );
    let lock_after =
        std::fs::read_to_string(project.join("ggen.lock")).expect("ggen.lock must exist");
    assert_eq!(
        lock_after, lock_before,
        "ggen.lock must be byte-identical across identical runs"
    );
    lock_after
}

/// Overwrite `project`'s `ontology.ttl` with `sabotage_ttl`, assert the real
/// `sync()` refuses (errs) and the error cites `gate_name_fragment` (e.g.
/// the gate's filename stem such as `"010_required"`) — the gate must
/// refuse before Extract/Render/Write, leaving no partial state.
///
/// Snapshots `ggen.lock` (if any) BEFORE the sabotage attempt and asserts it
/// is unchanged after: on a fresh, never-synced `project` that means
/// "still absent"; on a `project` that already had a successful sync
/// (e.g. a caller doing "baseline sync, then sabotage" in the same
/// project — the shape `packs/pack-authoring-pack`'s own generated
/// `<pack>_pack_e2e.rs` template uses) it means "byte-identical to the
/// legitimate prior lock", not "must not exist" — a real `ggen.lock` from
/// an earlier successful sync is not itself partial state.
pub(crate) fn assert_gate_refuses(project: &Path, sabotage_ttl: &str, gate_name_fragment: &str) {
    let lock_path = project.join("ggen.lock");
    let lock_before = std::fs::read_to_string(&lock_path).ok();

    std::fs::write(project.join("ontology.ttl"), sabotage_ttl).expect("write sabotage ontology");

    let err = sync(
        project,
        SyncOptions {
            consumer_mode: Default::default(),
            dry_run: false,
            ..Default::default()
        },
    )
    .expect_err("sabotage ontology must be refused by the gate");
    let msg = format!("{err}");
    assert!(
        msg.contains(gate_name_fragment),
        "refusal must cite gate {gate_name_fragment}: {msg}"
    );
    let lock_after = std::fs::read_to_string(&lock_path).ok();
    assert_eq!(
        lock_after, lock_before,
        "gate refusal must leave ggen.lock exactly as it was before the sabotage attempt \
         (no partial state) — absent stays absent, a prior legitimate lock stays unchanged"
    );
}