use crate::registry::{FieldType, Kind};
use serde::{Serialize, de::DeserializeOwned};
pub use crate::schemadef::toned;
pub trait KyynType: Serialize + DeserializeOwned {
fn field_type() -> FieldType;
}
pub trait KyynKind: Serialize + DeserializeOwned {
fn kind() -> Kind;
}
#[derive(Debug)]
pub struct CustomRecord<'a, T> {
pub path: &'a str,
pub storage_id: Option<String>,
pub value: T,
}
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()))
}
}
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:?}"),
}
}
#[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)?
$(,)?
) => {
pub fn schema_hash() -> String {
$schema_hash.to_string()
}
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(®istry()).expect("registry serializes")
}
pub fn validate_entries_with(
entries: &[(String, String)],
systems: &[&str],
) -> Vec<$crate::violation::Violation> {
let mut out = $crate::validate::against_registry(®istry(), entries, systems);
$(out.extend($custom(entries, systems));)?
out
}
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");
}
}