openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Runtime JSON Schema validation. The schema bundle is loaded from
//! `schemas/manifest.schema.json` at first use and reused for the process
//! lifetime. Errors are reported with a JSON-Pointer field path and the
//! offending value (with a `<redacted>` placeholder for any field whose name
//! contains `secret`/`token`/`key`).

use std::sync::OnceLock;

use crate::error::{OlError, OL_4210_SCHEMA_MISMATCH};

/// Embedded copies of the manifest schemas. These are vendored into the
/// binary via `include_str!` so validation works without filesystem access
/// at runtime — important for `cargo install` users who don't ship the
/// `schemas/` directory.
const MANIFEST_SCHEMA: &str = include_str!("../../schemas/manifest.schema.json");
const MANIFEST_EDITOR: &str = include_str!("../../schemas/manifest-editor.schema.json");
const MANIFEST_TOOL: &str = include_str!("../../schemas/manifest-tool.schema.json");
const MANIFEST_PROVIDER: &str = include_str!("../../schemas/manifest-provider.schema.json");
const MANIFEST_BINDING: &str = include_str!("../../schemas/manifest-binding.schema.json");
const MANIFEST_CAPABILITY: &str = include_str!("../../schemas/manifest-capability.schema.json");
const ENUMS: &str = include_str!("../../schemas/enums.schema.json");

/// Compile a Draft-2020-12 validator from the bundled schemas. Cached for
/// the process lifetime so repeated `parse` calls don't re-compile.
fn validator() -> &'static jsonschema::Validator {
    static V: OnceLock<jsonschema::Validator> = OnceLock::new();
    V.get_or_init(|| build_validator().expect("manifest schema must compile"))
}

fn build_validator() -> Result<jsonschema::Validator, jsonschema::ValidationError<'static>> {
    let resources = [
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-editor.schema.json",
            MANIFEST_EDITOR,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-tool.schema.json",
            MANIFEST_TOOL,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-provider.schema.json",
            MANIFEST_PROVIDER,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-binding.schema.json",
            MANIFEST_BINDING,
        ),
        (
            "https://schemas.openlatch.ai/provider/v1/manifest-capability.schema.json",
            MANIFEST_CAPABILITY,
        ),
        (
            "https://schemas.openlatch.ai/client/v1/enums.schema.json",
            ENUMS,
        ),
    ];

    // Resolve cross-file refs by serving each related schema from a static map.
    let parsed: serde_json::Value =
        serde_json::from_str(MANIFEST_SCHEMA).expect("manifest.schema.json must parse");
    let resource_pairs: Vec<(String, serde_json::Value)> = resources
        .iter()
        .map(|(uri, raw)| {
            let value = serde_json::from_str(raw).expect("vendored manifest sub-schema must parse");
            ((*uri).to_string(), value)
        })
        .collect();

    jsonschema::options()
        .with_resources(
            resource_pairs
                .into_iter()
                .map(|(uri, value)| (uri, jsonschema::Resource::from_contents(value).unwrap())),
        )
        .build(&parsed)
}

/// Validate a parsed YAML/JSON value against the bundled schema. Returns
/// the first validation error with its JSON-Pointer path attached.
pub fn validate(value: &serde_json::Value) -> Result<(), OlError> {
    if let Err(err) = validator().validate(value) {
        return Err(OlError::new(
            OL_4210_SCHEMA_MISMATCH,
            format!("schema mismatch at `{}`: {}", err.instance_path, err),
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_object_fails_required_fields() {
        let v = serde_json::json!({});
        let err = validate(&v).unwrap_err();
        assert_eq!(err.code.code, "OL-4210");
    }

    #[test]
    fn minimal_valid_manifest_accepted() {
        let v = serde_json::json!({
            "schema_version": 1,
            "editor": {
                "slug": "acme",
                "display_name": "Acme Security",
                "description": "x"
            }
        });
        validate(&v).unwrap();
    }
}