kyyn-core 0.1.18

Core vocabulary for kyyn: registry, links, query AST, plugin and validation contracts for typed, git-backed knowledge bases.
Documentation
//! Compiler-backed schema authoring: small traits generated by the
//! `KyynType`/`KyynKind` derives, plus the assembly macro that turns an
//! explicit kind inventory into the existing [`crate::registry::Registry`].
//!
//! This is an authoring facade, not another schema representation. Every
//! generated value uses the same registry vocabulary handwritten schema
//! crates use, and manual trait implementations are the escape hatch.

use crate::registry::{FieldType, Kind};
use serde::{Serialize, de::DeserializeOwned};

pub use crate::schemadef::toned;

/// A Rust type that has one unambiguous representation in a registry field.
pub trait KyynType: Serialize + DeserializeOwned {
    fn field_type() -> FieldType;
}

/// A stored record kind. Implemented by `#[derive(KyynKind)]` or manually for
/// representations outside the derive's deliberately small surface.
pub trait KyynKind: Serialize + DeserializeOwned {
    fn kind() -> Kind;
}

/// One successfully decoded record for a kind's post-generic custom checks.
///
/// This value is intentionally produced only by [`custom_records`]. It is not
/// evidence that generic Registry validation passed for the complete entry
/// set; the schema assembly facade owns that mandatory phase.
#[derive(Debug)]
pub struct CustomRecord<'a, T> {
    /// Repo-relative path supplied to schema validation.
    pub path: &'a str,
    /// Storage placeholder bindings joined with `/`, or `None` for a
    /// singleton pattern.
    pub storage_id: Option<String>,
    /// The typed record used by the domain-specific invariant.
    pub value: T,
}

/// Iterate typed records of `T` during post-generic custom validation.
///
/// Routing uses the same storage-pattern implementation as Registry
/// validation. Entries for other kinds are ignored, as are matching entries
/// that do not deserialize: the mandatory generic phase has already emitted
/// the authoritative routing and parse findings before a `custom_validate`
/// hook runs. This lossy helper is therefore not a standalone validator or a
/// general RON loader.
pub fn custom_records<T: KyynKind>(
    entries: &[(String, String)],
) -> impl Iterator<Item = CustomRecord<'_, T>> {
    let storage = T::kind().storage;
    entries.iter().filter_map(move |(path, text)| {
        let bindings = crate::storage::match_storage(&storage, path)?;
        let value = ron::from_str(text).ok()?;
        let storage_id = (!bindings.is_empty()).then(|| bindings.join("/"));
        Some(CustomRecord {
            path,
            storage_id,
            value,
        })
    })
}

impl KyynType for String {
    fn field_type() -> FieldType {
        FieldType::Str
    }
}

impl KyynType for i64 {
    fn field_type() -> FieldType {
        FieldType::Int
    }
}

impl KyynType for bool {
    fn field_type() -> FieldType {
        FieldType::Bool
    }
}

impl KyynType for chrono::NaiveDate {
    fn field_type() -> FieldType {
        FieldType::Date
    }
}

impl<T: KyynType> KyynType for Option<T> {
    fn field_type() -> FieldType {
        FieldType::Option(Box::new(T::field_type()))
    }
}

impl<T: KyynType> KyynType for Vec<T> {
    fn field_type() -> FieldType {
        FieldType::List(Box::new(T::field_type()))
    }
}

/// Variants from a derived enum, for a badge role declaration. Panics only
/// when a schema author asks a non-enum type for variants.
pub fn enum_variants<T: KyynType>() -> Vec<crate::registry::Variant> {
    match T::field_type() {
        FieldType::Enum(variants) => variants,
        other => panic!("expected enum KyynType, got {other:?}"),
    }
}

/// Assemble a schema crate around an explicit kind inventory.
///
/// The generated `validate_entries_with` ALWAYS runs registry validation;
/// `custom_validate` can only append findings. Omitting the optional hook is
/// the ordinary schema with no bespoke invariants.
#[macro_export]
macro_rules! kyyn_schema {
    (
        kinds: [$($kind:ty),* $(,)?],
        roles: $roles:expr,
        queries: $queries:expr
        $(, custom_validate: $custom:path)?
        $(,)?
    ) => {
        $crate::kyyn_schema! {
            @impl
            schema_hash: env!("SCHEMA_SRC_HASH"),
            kinds: [$($kind),*],
            roles: $roles,
            queries: $queries
            $(, custom_validate: $custom)?
        }
    };
    (
        #[doc(hidden)] schema_hash: $schema_hash:expr,
        kinds: [$($kind:ty),* $(,)?],
        roles: $roles:expr,
        queries: $queries:expr
        $(, custom_validate: $custom:path)?
        $(,)?
    ) => {
        $crate::kyyn_schema! {
            @impl
            schema_hash: $schema_hash,
            kinds: [$($kind),*],
            roles: $roles,
            queries: $queries
            $(, custom_validate: $custom)?
        }
    };
    (
        @impl
        schema_hash: $schema_hash:expr,
        kinds: [$($kind:ty),* $(,)?],
        roles: $roles:expr,
        queries: $queries:expr
        $(, custom_validate: $custom:path)?
        $(,)?
    ) => {
        /// Hash of this crate's schema-bearing sources, supplied by its build
        /// script and checked against committed `registry.ron` at accept.
        pub fn schema_hash() -> String {
            $schema_hash.to_string()
        }

        /// The generated engine-facing registry projection.
        pub fn registry() -> $crate::registry::Registry {
            $crate::registry::Registry {
                schema_hash: schema_hash(),
                kinds: vec![$(<$kind as $crate::schema::KyynKind>::kind()),*],
                roles: $roles,
                queries: $queries,
            }
        }

        pub fn registry_ron() -> String {
            $crate::ronfmt::to_ron(&registry()).expect("registry serializes")
        }

        /// Generic structural validation is non-optional. A custom hook may
        /// add domain findings but cannot replace or suppress this baseline.
        pub fn validate_entries_with(
            entries: &[(String, String)],
            systems: &[&str],
        ) -> Vec<$crate::violation::Violation> {
            let mut out = $crate::validate::against_registry(&registry(), entries, systems);
            $(out.extend($custom(entries, systems));)?
            out
        }

        /// Serve the standard schema subprocess protocol.
        pub fn serve() {
            $crate::protocol::serve_schema(registry, validate_entries_with);
        }
    };
}

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

    #[derive(Debug, Deserialize, Serialize)]
    struct Todo {
        id: String,
    }

    #[derive(Debug, Deserialize, Serialize)]
    struct Checkin {
        person: String,
    }

    #[derive(Debug, Deserialize, Serialize)]
    struct SelfContext {
        name: String,
    }

    fn kind(name: &str, storage: &str) -> Kind {
        Kind {
            name: name.into(),
            doc: String::new(),
            storage: storage.into(),
            fields: vec![],
        }
    }

    impl KyynKind for Todo {
        fn kind() -> Kind {
            kind("todo", "facts/todos/{id}.ron")
        }
    }

    impl KyynKind for Checkin {
        fn kind() -> Kind {
            kind("checkin", "facts/checkins/{person}/{date}.ron")
        }
    }

    impl KyynKind for SelfContext {
        fn kind() -> Kind {
            kind("self", "facts/self.ron")
        }
    }

    fn entry(path: &str, text: &str) -> (String, String) {
        (path.into(), text.into())
    }

    #[test]
    fn custom_records_routes_id_multi_and_singleton_storage() {
        let entries = vec![
            entry("facts/todos/ship.ron", r#"(id: "ship")"#),
            entry("facts/checkins/tom/2026-07-17.ron", r#"(person: "tom")"#),
            entry("facts/self.ron", r#"(name: "Tom")"#),
        ];

        let todo = custom_records::<Todo>(&entries).next().unwrap();
        assert_eq!(todo.path, "facts/todos/ship.ron");
        assert_eq!(todo.storage_id.as_deref(), Some("ship"));
        assert_eq!(todo.value.id, "ship");

        let checkin = custom_records::<Checkin>(&entries).next().unwrap();
        assert_eq!(checkin.storage_id.as_deref(), Some("tom/2026-07-17"));
        assert_eq!(checkin.value.person, "tom");

        let context = custom_records::<SelfContext>(&entries).next().unwrap();
        assert_eq!(context.storage_id, None);
        assert_eq!(context.value.name, "Tom");
    }

    #[test]
    fn custom_records_ignores_other_kinds_and_malformed_matches() {
        let entries = vec![
            entry("facts/self.ron", r#"(name: "Tom")"#),
            entry("facts/todos/broken.ron", "(id: "),
            entry("facts/todos/good.ron", r#"(id: "good")"#),
        ];
        let records = custom_records::<Todo>(&entries).collect::<Vec<_>>();
        assert_eq!(records.len(), 1);
        assert_eq!(records[0].path, "facts/todos/good.ron");
    }
}