kyyn-core 0.1.13

Core vocabulary for kyyn: registry, links, query AST, plugin and validation contracts for typed, git-backed knowledge bases.
Documentation
//! The universal `Link` type — one grammar to point at anything from anywhere.
//!
//! A link is stored, compared and serialized as its **raw string**, with the
//! grammar **`[system:]kind[:id]`**. The parser knows only the grammar; *which*
//! kinds exist, whether an id resolves, and which systems are declared is
//! judged by the registry and the validation fn — never here. That keeps this
//! crate free of any KB's shape:
//!
//! - KB-native links have no system prefix: `person:jane-doe`,
//!   `checkin:jane-doe/2026-07-06`, and bare `self` (a **singleton** kind — a
//!   kind whose storage binds no id links as just its name).
//! - External systems carry a prefix: `graph:email:<id>`,
//!   `sharepoint:file:<id>`. The system set is *declared* (source manifest /
//!   built-ins) and supplied to [`Link::parts`] at interpretation time.
//! - Ids are taken verbatim after the kind and may contain `:` and `/`
//!   (Graph ids do). Nothing ever fails to parse — an unknown head is just a
//!   kind the validator will flag as unrecognized.

use std::fmt;
use std::str::FromStr;

use serde::{Deserialize, Deserializer, Serialize, Serializer};

/// A typed reference with the string grammar `[system:]kind[:id]`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Link {
    raw: String,
}

/// A link decomposed against a declared system set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Parts<'a> {
    /// The external system, if the head names a declared one.
    pub system: Option<&'a str>,
    pub kind: &'a str,
    /// Verbatim remainder; `None` for singleton links (`self`).
    pub id: Option<&'a str>,
}

impl<'a> Parts<'a> {
    /// What allowed-kind policy matches on: the system for external links
    /// (`graph`), the kind for KB-native ones (`person`).
    pub fn namespace(&self) -> &'a str {
        self.system.unwrap_or(self.kind)
    }

    pub fn is_external(&self) -> bool {
        self.system.is_some()
    }

    /// Whether this link's head names something the caller knows: a declared
    /// system (already encoded — `parts` only sets `system` for declared
    /// ones), an id-carrying kind, or a singleton kind. The ONE predicate
    /// both validation (prose leniency, unrecognized warnings) and
    /// navigation (which prose brackets become graph edges) must share —
    /// two implementations here means backlinks and validation disagree.
    pub fn recognized(&self, kinds: &[&str], singletons: &[&str]) -> bool {
        self.is_external() || kinds.contains(&self.kind) || singletons.contains(&self.kind)
    }
}

impl Link {
    pub fn new(raw: impl Into<String>) -> Link {
        Link { raw: raw.into() }
    }

    /// A KB-native link: `kind:id`.
    pub fn kb(kind: &str, id: impl fmt::Display) -> Link {
        Link {
            raw: format!("{kind}:{id}"),
        }
    }

    /// A link to a singleton kind: just the kind name (`self`).
    pub fn singleton(kind: &str) -> Link {
        Link {
            raw: kind.to_string(),
        }
    }

    /// An external link: `system:kind:id`.
    pub fn external(system: &str, kind: &str, id: impl fmt::Display) -> Link {
        Link {
            raw: format!("{system}:{kind}:{id}"),
        }
    }

    pub fn raw(&self) -> &str {
        &self.raw
    }

    /// Decompose against the declared system set. Grammar only — no judgment:
    /// the head is a system iff declared, else it is the kind and the
    /// remainder (which may contain `:` and `/`) is the id, verbatim.
    pub fn parts<'a>(&'a self, systems: &[&str]) -> Parts<'a> {
        match self.raw.split_once(':') {
            None => Parts {
                system: None,
                kind: &self.raw,
                id: None,
            },
            Some((head, rest)) => {
                if systems.contains(&head) {
                    match rest.split_once(':') {
                        Some((kind, id)) => Parts {
                            system: Some(head),
                            kind,
                            id: Some(id),
                        },
                        None => Parts {
                            system: Some(head),
                            kind: rest,
                            id: None,
                        },
                    }
                } else {
                    Parts {
                        system: None,
                        kind: head,
                        id: Some(rest),
                    }
                }
            }
        }
    }

    /// Every `[[…]]` occurrence in prose. Malformed brackets (an unclosed
    /// `[[`) are ignored; whatever sits inside a well-formed pair becomes a
    /// link (interpretation happens later, like every other link).
    pub fn find_inline(text: &str) -> Vec<Link> {
        let mut out = Vec::new();
        let mut rest = text;
        while let Some(start) = rest.find("[[") {
            let after = &rest[start + 2..];
            match after.find("]]") {
                Some(end) => {
                    out.push(Link::new(&after[..end]));
                    rest = &after[end + 2..];
                }
                None => break, // unclosed — ignore the tail
            }
        }
        out
    }
}

impl fmt::Display for Link {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.raw)
    }
}

impl FromStr for Link {
    type Err = std::convert::Infallible;

    /// Infallible: every string is a link; validity is judged elsewhere.
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(Link::new(s))
    }
}

impl Serialize for Link {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&self.raw)
    }
}

impl<'de> Deserialize<'de> for Link {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        Ok(Link::new(String::deserialize(deserializer)?))
    }
}

impl schemars::JsonSchema for Link {
    fn schema_name() -> std::borrow::Cow<'static, str> {
        "Link".into()
    }

    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
        schemars::json_schema!({
            "type": "string",
            "description":
                "A typed link with the string grammar `[system:]kind[:id]`. Bare kinds \
                 point at KB records and must resolve (`person:jane-doe`, \
                 `checkin:jane-doe/2026-07-06`); a singleton kind links as just its \
                 name (`self`). External systems carry a declared prefix \
                 (`graph:event:<id>`, `sharepoint:file:<id>`) — cite these as \
                 provenance. Ids are verbatim after the kind and may contain ':' \
                 and '/'."
        })
    }
}

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

    const SYSTEMS: &[&str] = &["graph", "sharepoint"];

    #[test]
    fn grammar_decomposition() {
        let cases: Vec<(&str, Option<&str>, &str, Option<&str>)> = vec![
            ("person:jane-doe", None, "person", Some("jane-doe")),
            (
                "meeting:2026-06-26-project-spoon",
                None,
                "meeting",
                Some("2026-06-26-project-spoon"),
            ),
            (
                "checkin:jane-doe/2026-07-06",
                None,
                "checkin",
                Some("jane-doe/2026-07-06"),
            ),
            ("self", None, "self", None),
            (
                "graph:email:AAMkAD=:with/odd:chars==",
                Some("graph"),
                "email",
                Some("AAMkAD=:with/odd:chars=="),
            ),
            (
                "graph:chat:19:meeting@thread.v2",
                Some("graph"),
                "chat",
                Some("19:meeting@thread.v2"),
            ),
            (
                "sharepoint:file:01ABC!123",
                Some("sharepoint"),
                "file",
                Some("01ABC!123"),
            ),
            // Undeclared system → its head reads as a kind; the validator warns.
            ("jira:issue:EV-12", None, "jira", Some("issue:EV-12")),
            ("just some text", None, "just some text", None),
        ];
        for (raw, system, kind, id) in cases {
            let link: Link = raw.parse().unwrap();
            let p = link.parts(SYSTEMS);
            assert_eq!((p.system, p.kind, p.id), (system, kind, id), "{raw}");
            assert_eq!(link.to_string(), raw, "display is verbatim");
        }
    }

    #[test]
    fn constructors_produce_the_canonical_strings() {
        assert_eq!(Link::kb("person", "jane-doe").raw(), "person:jane-doe");
        assert_eq!(Link::singleton("self").raw(), "self");
        assert_eq!(
            Link::external("graph", "event", "ev1").raw(),
            "graph:event:ev1"
        );
    }

    #[test]
    fn namespace_is_system_for_external_kind_for_native() {
        let l = Link::kb("person", "x");
        assert_eq!(l.parts(SYSTEMS).namespace(), "person");
        assert!(!l.parts(SYSTEMS).is_external());
        let g = Link::external("graph", "event", "e");
        assert_eq!(g.parts(SYSTEMS).namespace(), "graph");
        assert!(g.parts(SYSTEMS).is_external());
        assert_eq!(Link::singleton("self").parts(SYSTEMS).namespace(), "self");
    }

    #[test]
    fn serde_round_trips_as_a_plain_string() {
        for raw in ["person:jane-doe", "self", "graph:email:a=:b/c=="] {
            let link: Link = raw.parse().unwrap();
            let ron_str = ron::to_string(&link).unwrap();
            assert_eq!(ron_str, format!("\"{raw}\""));
            assert_eq!(ron::from_str::<Link>(&ron_str).unwrap(), link);
            let json = serde_json::to_string(&link).unwrap();
            assert_eq!(serde_json::from_str::<Link>(&json).unwrap(), link);
        }
    }

    #[test]
    fn find_inline_extracts_links_and_ignores_malformed_brackets() {
        let text = "See [[person:jane-doe]] and [[meeting:2026-07-06-jane-alex-catchup]].\n\
                    An [array[0]] is not a link; an unclosed [[person:ghost stays out.";
        let links = Link::find_inline(text);
        assert_eq!(
            links,
            vec![
                Link::kb("person", "jane-doe"),
                Link::kb("meeting", "2026-07-06-jane-alex-catchup"),
            ]
        );
        assert!(Link::find_inline("no links here").is_empty());
        assert_eq!(Link::find_inline("[[??]]"), vec![Link::new("??")]);
    }
}