obs-build 0.2.1

Build-time codegen helpers for the obs SDK; reads .proto via buffa-build + buffa-reflect.
Documentation
//! Integration tests for the multi-file output of `obs_build::Config::compile`.
//!
//! Verifies the four-file contract from spec 12 § 3.1:
//!
//! - `schemas.rs`: `EventSchemaErased` impls + linkme registrations
//! - `builders.rs`: fluent setter + `.emit()`
//! - `lints.rs`: const-eval lint asserts
//! - `arrow_schema.rs`: dispatch table
//!
//! Plus the byte-identical-output property (spec 12 § 1.2): two runs of
//! the codegen on the same input produce identical bytes.

#![allow(
    clippy::unwrap_used,
    clippy::indexing_slicing,
    clippy::expect_used,
    clippy::disallowed_methods,
    clippy::disallowed_types,
    missing_docs
)]

use std::{
    path::PathBuf,
    sync::atomic::{AtomicU64, Ordering},
};

use obs_build::{Config, DescriptorSource};

fn fixture_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
}

fn workspace_proto_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("obs-proto")
        .join("proto")
}

/// Run protoc against the fixture proto and return the FDS bytes path.
fn run_protoc_for_fixture(out_root: &std::path::Path) -> PathBuf {
    let fds_path = out_root.join("fds.bin");
    let protoc = std::env::var("PROTOC").unwrap_or_else(|_| "protoc".to_string());
    let status = std::process::Command::new(&protoc)
        .arg(format!("--proto_path={}", fixture_dir().display()))
        .arg(format!("--proto_path={}", workspace_proto_dir().display()))
        .arg("--include_imports")
        .arg(format!("--descriptor_set_out={}", fds_path.display()))
        .arg("myapp/v1/events.proto")
        .status()
        .expect("invoke protoc");
    assert!(status.success(), "protoc failed for fixture");
    fds_path
}

#[test]
fn test_should_emit_four_files_per_compile() {
    let tmp = tempdir();
    let fds = run_protoc_for_fixture(tmp.path());
    Config::new()
        .descriptor_source(DescriptorSource::Precompiled(fds))
        .out_dir(tmp.path())
        .compile()
        .expect("codegen succeeds");

    for fname in ["schemas.rs", "builders.rs", "lints.rs", "arrow_schema.rs"] {
        let path = tmp.path().join("obs").join(fname);
        assert!(path.exists(), "expected {fname} to be written: {path:?}");
        let body = std::fs::read_to_string(&path).unwrap();
        assert!(!body.is_empty(), "{fname} should not be empty");
        assert!(
            body.contains("@generated by obs-build"),
            "{fname} missing generation marker"
        );
    }
}

#[test]
fn test_codegen_output_should_be_byte_identical_across_runs() {
    let tmp_a = tempdir();
    let tmp_b = tempdir();
    let fds_a = run_protoc_for_fixture(tmp_a.path());
    let fds_b = run_protoc_for_fixture(tmp_b.path());
    Config::new()
        .descriptor_source(DescriptorSource::Precompiled(fds_a))
        .out_dir(tmp_a.path())
        .compile()
        .unwrap();
    Config::new()
        .descriptor_source(DescriptorSource::Precompiled(fds_b))
        .out_dir(tmp_b.path())
        .compile()
        .unwrap();

    for fname in ["schemas.rs", "builders.rs", "lints.rs", "arrow_schema.rs"] {
        let body_a = std::fs::read(tmp_a.path().join("obs").join(fname)).unwrap();
        let body_b = std::fs::read(tmp_b.path().join("obs").join(fname)).unwrap();
        assert_eq!(body_a, body_b, "non-deterministic output for {fname}");
    }
}

#[test]
fn test_schemas_rs_should_emit_one_static_per_event() {
    let tmp = tempdir();
    let fds = run_protoc_for_fixture(tmp.path());
    Config::new()
        .descriptor_source(DescriptorSource::Precompiled(fds))
        .out_dir(tmp.path())
        .compile()
        .unwrap();
    let body = std::fs::read_to_string(tmp.path().join("obs").join("schemas.rs")).unwrap();
    // ObsRequestCompleted and ObsHelloEmitted both register.
    assert!(body.contains("ObsRequestCompletedSchema"));
    assert!(body.contains("ObsHelloEmittedSchema"));
    assert!(body.contains("__OBS_SCHEMA_OBS_REQUEST_COMPLETED"));
    assert!(body.contains("__OBS_SCHEMA_OBS_HELLO_EMITTED"));
    assert!(body.contains("\"myapp.v1.ObsRequestCompleted\""));
    assert!(body.contains("\"myapp.v1.ObsHelloEmitted\""));
    // Schema hash is baked as `u64`.
    assert!(body.contains("u64"));
}

#[test]
fn test_builders_rs_should_emit_one_builder_per_event() {
    let tmp = tempdir();
    let fds = run_protoc_for_fixture(tmp.path());
    Config::new()
        .descriptor_source(DescriptorSource::Precompiled(fds))
        .out_dir(tmp.path())
        .compile()
        .unwrap();
    let body = std::fs::read_to_string(tmp.path().join("obs").join("builders.rs")).unwrap();
    assert!(body.contains("pub struct ObsRequestCompletedBuilder"));
    assert!(body.contains("pub struct ObsHelloEmittedBuilder"));
    // Setters (one per field).
    assert!(body.contains("pub fn route("));
    assert!(body.contains("pub fn latency_ms("));
    assert!(body.contains("pub fn who("));
    // Hot-path emit goes through `static __CALLSITE`.
    assert!(body.contains("static __CALLSITE"));
}

#[test]
fn test_lints_rs_should_assert_l001_l011_per_event() {
    let tmp = tempdir();
    let fds = run_protoc_for_fixture(tmp.path());
    Config::new()
        .descriptor_source(DescriptorSource::Precompiled(fds))
        .out_dir(tmp.path())
        .compile()
        .unwrap();
    let body = std::fs::read_to_string(tmp.path().join("obs").join("lints.rs")).unwrap();
    // Lints all flow through const-eval `panic!`s emitted from the
    // shared `obs-build::lints` module (D8-1). Banner is always
    // present.
    assert!(body.contains("@generated by obs-build"));
    // The fixture's `latency_ms: MEASUREMENT` carries no metric block
    // → fires L004. The shared module renders the same message bytes
    // as the derive path. Spec 95 § 2.1.
    assert!(
        body.contains("obs L004"),
        "expected L004 panic for fixture's MEASUREMENT-without-metric field, got:\n{body}"
    );
    assert!(body.contains("latency_ms"));
}

#[test]
fn test_generated_project_metrics_should_use_field_relative_instruments() {
    let tmp = tempdir();
    let fds = run_protoc_for_fixture(tmp.path());
    Config::new()
        .descriptor_source(DescriptorSource::Precompiled(fds))
        .out_dir(tmp.path())
        .compile()
        .unwrap();
    let body = std::fs::read_to_string(tmp.path().join("obs").join("schemas.rs")).unwrap();
    assert!(
        body.contains("record_counter(\"latency_ms\"")
            || body.contains("record_gauge_u64(\"latency_ms\"")
            || body.contains("record_histogram(\"latency_ms\""),
        "expected generated metric instrument to be field-relative, got:\n{body}"
    );
    assert!(
        !body.contains("record_counter(\"myapp.v1.ObsRequestCompleted.latency_ms\"")
            && !body.contains("record_gauge_u64(\"myapp.v1.ObsRequestCompleted.latency_ms\"")
            && !body.contains("record_histogram(\"myapp.v1.ObsRequestCompleted.latency_ms\""),
        "generated metric instrument should not be event-qualified"
    );
}

#[test]
fn test_arrow_schema_rs_should_list_every_event_full_name() {
    let tmp = tempdir();
    let fds = run_protoc_for_fixture(tmp.path());
    Config::new()
        .descriptor_source(DescriptorSource::Precompiled(fds))
        .out_dir(tmp.path())
        .compile()
        .unwrap();
    let body = std::fs::read_to_string(tmp.path().join("obs").join("arrow_schema.rs")).unwrap();
    assert!(body.contains("payload_struct_for"));
    assert!(body.contains("all_payload_full_names"));
    assert!(body.contains("\"myapp.v1.ObsRequestCompleted\""));
    assert!(body.contains("\"myapp.v1.ObsHelloEmitted\""));
}

// ─── tiny tempdir helper to avoid pulling `tempfile` into deps ────────

struct TempDir(PathBuf);
impl TempDir {
    fn path(&self) -> &std::path::Path {
        &self.0
    }
}
impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}
static TEMPDIR_SEQ: AtomicU64 = AtomicU64::new(0);

fn tempdir() -> TempDir {
    let mut path = std::env::temp_dir();
    let seq = TEMPDIR_SEQ.fetch_add(1, Ordering::Relaxed);
    path.push(format!("obs_build_test_{}_{}", std::process::id(), seq));
    std::fs::create_dir_all(&path).unwrap();
    TempDir(path)
}