openlatch-client 0.1.7

The open-source security layer for AI agents — client forwarder
// Build script: generates Rust types from JSON Schema files in schemas/
//
// Pipeline: schemas/*.schema.json → merge into combined schema → typify → src/generated/types.rs
// Also writes src/generated/known_values.rs containing x-known-values
// vocabularies; src/core/envelope/known_types.rs cross-checks its variant
// list against these at test time (fail-loud on schema drift).

use std::path::Path;

fn main() {
    // Re-run if any schema file changes
    println!("cargo:rerun-if-changed=schemas/");

    // PostHog project key baking. Reads OPENLATCH_POSTHOG_KEY at build time
    // and re-emits it as a compile-time constant via env!(). When unset, the
    // baked constant is empty and the telemetry subsystem refuses to start
    // (preserves invariant I1 — zero network before consent).
    //
    // Production: set in CI via `env: { OPENLATCH_POSTHOG_KEY: secrets.POSTHOG_PROJECT_KEY }`.
    // Developers: leave unset (telemetry off) or export locally for testing.
    println!("cargo:rerun-if-env-changed=OPENLATCH_POSTHOG_KEY");
    let key = std::env::var("OPENLATCH_POSTHOG_KEY").unwrap_or_default();
    println!("cargo:rustc-env=OPENLATCH_POSTHOG_KEY={key}");
    println!("cargo:rerun-if-env-changed=OPENLATCH_POSTHOG_HOST");
    let host = std::env::var("OPENLATCH_POSTHOG_HOST")
        .unwrap_or_else(|_| "https://eu.i.posthog.com".to_string());
    println!("cargo:rustc-env=OPENLATCH_POSTHOG_HOST={host}");

    // Sentry DSN baking (crash reporting). Mirrors the PostHog key pattern —
    // unset at build time → empty string → runtime short-circuits to a no-op
    // guard. See src/core/crash_report/ and
    // .brainstorms/2026-04-13-sentry-integration.md Decision 3.
    println!("cargo:rerun-if-env-changed=OPENLATCH_SENTRY_DSN");
    let dsn = std::env::var("OPENLATCH_SENTRY_DSN").unwrap_or_default();
    println!("cargo:rustc-env=OPENLATCH_SENTRY_DSN={dsn}");

    // Release SHA baking for Sentry `release` field. Priority:
    //   GITHUB_SHA → OPENLATCH_RELEASE_SHA → `git rev-parse HEAD` → semver fallback.
    println!("cargo:rerun-if-env-changed=GITHUB_SHA");
    println!("cargo:rerun-if-env-changed=OPENLATCH_RELEASE_SHA");
    let sha = resolve_release_sha();
    println!("cargo:rustc-env=OPENLATCH_RELEASE_SHA={sha}");

    let schemas_dir = Path::new("schemas");
    let out_path = std::path::PathBuf::from("src/generated/types.rs");
    let known_values_path = std::path::PathBuf::from("src/generated/known_values.rs");

    // Read all schema files
    let enums_raw: serde_json::Value = read_schema(schemas_dir, "enums.schema.json");
    let struct_files = [
        "event-envelope.schema.json",
        "verdict-response.schema.json",
        "cloud-ingestion-request.schema.json",
        "cloud-ingestion-response.schema.json",
        "auth-me-response.schema.json",
    ];

    // Extract x-known-values vocabularies from the enums schema and emit them
    // as Rust constants. This is the schema side of the
    // schema-vs-known_types.rs cross-check enforced in known_types.rs tests.
    write_known_values(&enums_raw, &known_values_path);

    // Build a single combined schema with ALL definitions in $defs.
    // This avoids duplicate type generation when processing multiple schemas.
    let combined = build_combined_schema(&enums_raw, schemas_dir, &struct_files);

    // Configure typify
    let mut settings = typify::TypeSpaceSettings::default();
    settings.with_struct_builder(false);
    settings.with_derive("PartialEq".parse().unwrap());

    // Register the hand-written CloudEvents lens enums. Typify emits no code
    // for these $defs; references from event-envelope.schema.json resolve to
    // the hand-written paths. See src/core/envelope/known_types.rs.
    settings.with_replacement(
        "HookEventType",
        "crate::core::envelope::known_types::HookEventType",
        [
            typify::TypeSpaceImpl::FromStr,
            typify::TypeSpaceImpl::Display,
        ]
        .into_iter(),
    );
    settings.with_replacement(
        "AgentType",
        "crate::core::envelope::known_types::AgentType",
        [
            typify::TypeSpaceImpl::FromStr,
            typify::TypeSpaceImpl::Display,
        ]
        .into_iter(),
    );

    let mut type_space = typify::TypeSpace::new(&settings);

    let root_schema: schemars::schema::RootSchema =
        serde_json::from_value(combined).expect("failed to parse combined schema");
    type_space
        .add_root_schema(root_schema)
        .expect("failed to process combined schema");

    // Generate and format the output
    let tokens = type_space.to_stream();
    let ast = syn::parse2::<syn::File>(tokens).expect("failed to parse generated tokens");
    let formatted = prettyplease::unparse(&ast);

    let output = format!(
        "// AUTO-GENERATED by build.rs from schemas/*.schema.json\n\
         // DO NOT EDIT — changes will be overwritten on next build.\n\
         // To modify types, edit the JSON Schema files in schemas/ and rebuild.\n\n\
         {formatted}\n"
    );

    // Write to temp file, run rustfmt, then compare with existing to avoid rebuild loops
    let tmp_path = out_path.with_extension("rs.tmp");
    std::fs::write(&tmp_path, &output).unwrap();
    let _ = std::process::Command::new("rustfmt")
        .arg(tmp_path.as_os_str())
        .status();
    let final_output = std::fs::read_to_string(&tmp_path).unwrap_or(output);
    let _ = std::fs::remove_file(&tmp_path);

    // Only write if content actually changed
    let needs_write = match std::fs::read_to_string(&out_path) {
        Ok(existing) => existing != final_output,
        Err(_) => true,
    };
    if needs_write {
        std::fs::write(&out_path, final_output).unwrap();
    }
}

/// Resolve the release SHA that Sentry will use as the `release` field.
/// Priority: CI-provided `GITHUB_SHA` → manual `OPENLATCH_RELEASE_SHA`
/// override → local `git rev-parse HEAD` → `CARGO_PKG_VERSION` fallback
/// (the last covers `cargo install` from crates.io where there's no .git).
fn resolve_release_sha() -> String {
    if let Ok(sha) = std::env::var("GITHUB_SHA") {
        if !sha.is_empty() {
            return sha;
        }
    }
    if let Ok(sha) = std::env::var("OPENLATCH_RELEASE_SHA") {
        if !sha.is_empty() {
            return sha;
        }
    }
    if let Ok(output) = std::process::Command::new("git")
        .args(["rev-parse", "HEAD"])
        .output()
    {
        if output.status.success() {
            if let Ok(s) = String::from_utf8(output.stdout) {
                let trimmed = s.trim();
                if !trimmed.is_empty() {
                    return trimmed.to_string();
                }
            }
        }
    }
    format!("v{}", env!("CARGO_PKG_VERSION"))
}

fn read_schema(dir: &Path, filename: &str) -> serde_json::Value {
    let path = dir.join(filename);
    let content = std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
    serde_json::from_str(&content)
        .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display()))
}

/// Build a single combined JSON Schema with all types as named $defs.
///
/// Strategy:
/// 1. Collect enum $defs from enums.schema.json
/// 2. For each struct schema, add the struct as a named $def (using its title)
/// 3. Also merge any local $defs from the struct schema (e.g., CloudEventPayload)
/// 4. Rewrite all cross-file $ref paths to local $defs references
/// 5. Return a single schema with all $defs — typify generates one type per named $def
fn build_combined_schema(
    enums: &serde_json::Value,
    schemas_dir: &Path,
    struct_files: &[&str],
) -> serde_json::Value {
    let mut all_defs = serde_json::Map::new();

    // 1. Add enum definitions
    if let Some(defs) = enums.get("$defs").and_then(|d| d.as_object()) {
        for (key, value) in defs {
            all_defs.insert(key.clone(), value.clone());
        }
    }

    // 2. Add struct schemas as named definitions (keyed by title)
    for filename in struct_files {
        let path = schemas_dir.join(filename);
        if !path.exists() {
            continue;
        }

        let mut schema = read_schema(schemas_dir, filename);

        // Extract and merge any local $defs (e.g., CloudEventPayload in cloud-ingestion-request)
        if let Some(local_defs) = schema.as_object_mut().and_then(|obj| obj.remove("$defs")) {
            if let Some(local_defs_map) = local_defs.as_object() {
                for (key, value) in local_defs_map {
                    let mut resolved = value.clone();
                    rewrite_refs(&mut resolved);
                    all_defs.insert(key.clone(), resolved);
                }
            }
        }

        // Get the title to use as the $def name
        let title = schema
            .get("title")
            .and_then(|t| t.as_str())
            .unwrap_or_else(|| panic!("{filename} must have a title"))
            .to_string();

        // Remove metadata fields that don't belong in a $def
        if let Some(obj) = schema.as_object_mut() {
            obj.remove("$schema");
            obj.remove("$id");
            obj.remove("title");
        }

        // Rewrite cross-file $ref to local references
        rewrite_refs(&mut schema);

        all_defs.insert(title, schema);
    }

    // 3. Build the combined root schema
    serde_json::json!({
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "$defs": all_defs
    })
}

/// Rewrite relative $ref paths (e.g., "enums.schema.json#/$defs/AgentType")
/// to local $defs references (e.g., "#/$defs/AgentType").
/// Also rewrites internal $ref like "#/$defs/X" (which are already local).
fn rewrite_refs(value: &mut serde_json::Value) {
    match value {
        serde_json::Value::Object(map) => {
            if let Some(ref_val) = map.get_mut("$ref") {
                if let Some(ref_str) = ref_val.as_str() {
                    // Rewrite "filename.schema.json#/$defs/X" → "#/$defs/X"
                    if let Some(fragment) = ref_str.find("#/") {
                        let local_ref = &ref_str[fragment..];
                        *ref_val = serde_json::Value::String(local_ref.to_string());
                    }
                }
            }
            for v in map.values_mut() {
                rewrite_refs(v);
            }
        }
        serde_json::Value::Array(arr) => {
            for v in arr {
                rewrite_refs(v);
            }
        }
        _ => {}
    }
}

/// Extract `x-known-values` arrays from HookEventType and AgentType in
/// `schemas/enums.schema.json` and emit them as Rust `&[&str]` constants
/// into `src/generated/known_values.rs`. The hand-written
/// `src/core/envelope/known_types.rs` tests compare its canonical variant
/// list against these constants — mismatch fails the tests at CI time
/// rather than letting client and schema drift silently.
fn write_known_values(enums: &serde_json::Value, out_path: &Path) {
    let hook = extract_known_values(enums, "HookEventType");
    let agent = extract_known_values(enums, "AgentType");

    let body = format!(
        "// AUTO-GENERATED by build.rs from schemas/enums.schema.json x-known-values.\n\
         // DO NOT EDIT — changes will be overwritten on next build.\n\n\
         pub const SCHEMA_HOOK_EVENT_TYPES: &[&str] = &[\n{}];\n\n\
         pub const SCHEMA_AGENT_TYPES: &[&str] = &[\n{}];\n",
        hook.iter()
            .map(|v| format!("    {},\n", rust_str_literal(v)))
            .collect::<String>(),
        agent
            .iter()
            .map(|v| format!("    {},\n", rust_str_literal(v)))
            .collect::<String>(),
    );

    let needs_write = match std::fs::read_to_string(out_path) {
        Ok(existing) => existing != body,
        Err(_) => true,
    };
    if needs_write {
        std::fs::write(out_path, body).expect("failed to write known_values.rs");
    }
}

fn extract_known_values(enums: &serde_json::Value, def_name: &str) -> Vec<String> {
    let arr = enums
        .get("$defs")
        .and_then(|d| d.get(def_name))
        .and_then(|d| d.get("x-known-values"))
        .and_then(|v| v.as_array())
        .unwrap_or_else(|| panic!("{def_name} must declare x-known-values as an array"));
    arr.iter()
        .map(|v| {
            v.as_str()
                .unwrap_or_else(|| panic!("{def_name} x-known-values entries must be strings"))
                .to_string()
        })
        .collect()
}

fn rust_str_literal(s: &str) -> String {
    // Simple escape: wire values are ASCII alphanumeric + _ + - so no
    // non-trivial escaping needed. Quote-wrap and let rustfmt handle layout.
    let escaped: String = s
        .chars()
        .flat_map(|c| match c {
            '"' => vec!['\\', '"'],
            '\\' => vec!['\\', '\\'],
            _ => vec![c],
        })
        .collect();
    format!("\"{escaped}\"")
}