kyyn-core 0.1.10

Core vocabulary for kyyn: registry, links, query AST, plugin and validation contracts for typed, git-backed knowledge bases.
Documentation
//! Storage patterns — the bridge between a kind's declared `storage`
//! (`facts/todos/{id}.ron`) and repo-relative paths. Routing, its inverse,
//! and the placeholder grammar live HERE so the engine and registry-driven
//! validation share one implementation.

use crate::registry::{Kind, Registry};

/// Which kind stores at this path, and the record id its placeholders bind.
pub fn route<'r>(registry: &'r Registry, path: &str) -> Option<(&'r Kind, Option<String>)> {
    registry.kinds.iter().find_map(|k| {
        let bindings = match_storage(&k.storage, path)?;
        let id = if bindings.is_empty() {
            None
        } else {
            Some(bindings.join("/"))
        };
        Some((k, id))
    })
}

/// Match a storage pattern (`facts/checkins/{person}/{date}.ron`) against
/// a repo-relative path; returns the placeholder values in pattern order.
pub fn match_storage(pattern: &str, path: &str) -> Option<Vec<String>> {
    let pat_segs: Vec<&str> = pattern.split('/').collect();
    let path_segs: Vec<&str> = path.split('/').collect();
    if pat_segs.len() != path_segs.len() {
        return None;
    }
    let mut bindings = Vec::new();
    for (pat, seg) in pat_segs.iter().zip(&path_segs) {
        match placeholder(pat) {
            Some((prefix, suffix)) => {
                let bound = seg.strip_prefix(prefix)?.strip_suffix(suffix)?;
                if bound.is_empty() {
                    return None;
                }
                bindings.push(bound.to_string());
            }
            None => {
                if pat != seg {
                    return None;
                }
            }
        }
    }
    Some(bindings)
}

/// The storage path for a record id — the inverse of [`route`]: placeholders
/// consume the id's `/`-separated segments in order; a singleton pattern
/// (no placeholders) takes no id.
pub fn storage_path(kind: &Kind, id: Option<&str>) -> Option<String> {
    let n_placeholders = kind
        .storage
        .split('/')
        .filter(|s| placeholder(s).is_some())
        .count();
    let segments: Vec<&str> = match id {
        Some(id) => id.split('/').collect(),
        None => Vec::new(),
    };
    if segments.len() != n_placeholders {
        return None;
    }
    let mut next = segments.into_iter();
    let out: Vec<String> = kind
        .storage
        .split('/')
        .map(|seg| match placeholder(seg) {
            Some((prefix, suffix)) => format!("{prefix}{}{suffix}", next.next().unwrap()),
            None => seg.to_string(),
        })
        .collect();
    Some(out.join("/"))
}

/// `{name}` with optional literal prefix/suffix (`{id}.ron`) → (prefix, suffix).
pub fn placeholder(segment: &str) -> Option<(&str, &str)> {
    let open = segment.find('{')?;
    let close = segment.find('}')?;
    (open < close).then(|| (&segment[..open], &segment[close + 1..]))
}

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

    fn kind(name: &str, storage: &str) -> Kind {
        let _ = Field {
            name: String::new(),
            doc: String::new(),
            ty: crate::registry::FieldType::Str,
            role: None,
            refers_to: None,
        };
        Kind {
            name: name.into(),
            doc: String::new(),
            storage: storage.into(),
            fields: vec![],
        }
    }

    #[test]
    fn routes_and_inverts_single_multi_and_singleton_patterns() {
        let r = Registry {
            schema_hash: String::new(),
            kinds: vec![
                kind("todo", "facts/todos/{id}.ron"),
                kind("checkin", "facts/checkins/{person}/{date}.ron"),
                kind("self", "facts/self.ron"),
            ],
            queries: vec![],
            roles: vec![],
        };
        for (path, kname, id) in [
            ("facts/todos/ship-it.ron", "todo", Some("ship-it")),
            (
                "facts/checkins/jane/2026-07-06.ron",
                "checkin",
                Some("jane/2026-07-06"),
            ),
            ("facts/self.ron", "self", None),
        ] {
            let (k, got) = route(&r, path).unwrap();
            assert_eq!((k.name.as_str(), got.as_deref()), (kname, id));
            assert_eq!(storage_path(k, id).as_deref(), Some(path));
        }
        assert!(route(&r, "facts/unknown/x.ron").is_none());
        assert!(route(&r, "README.md").is_none());
        assert!(route(&r, "facts/todos/.ron").is_none(), "empty binding");
        let todo = &r.kinds[0];
        assert!(storage_path(todo, None).is_none(), "id-kind needs an id");
        let checkin = &r.kinds[1];
        assert!(storage_path(checkin, Some("only-one-segment")).is_none());
    }
}