Skip to main content

kyyn_core/
link.rs

1//! The universal `Link` type — one grammar to point at anything from anywhere.
2//!
3//! A link is stored, compared and serialized as its **raw string**, with the
4//! grammar **`[system:]kind[:id]`**. The parser knows only the grammar; *which*
5//! kinds exist, whether an id resolves, and which systems are declared is
6//! judged by the registry and the validation fn — never here. That keeps this
7//! crate free of any KB's shape:
8//!
9//! - KB-native links have no system prefix: `person:jane-doe`,
10//!   `checkin:jane-doe/2026-07-06`, and bare `self` (a **singleton** kind — a
11//!   kind whose storage binds no id links as just its name).
12//! - External systems carry a prefix: `graph:email:<id>`,
13//!   `sharepoint:file:<id>`. The system set is *declared* (source manifest /
14//!   built-ins) and supplied to [`Link::parts`] at interpretation time.
15//! - Ids are taken verbatim after the kind and may contain `:` and `/`
16//!   (Graph ids do). Nothing ever fails to parse — an unknown head is just a
17//!   kind the validator will flag as unrecognized.
18
19use std::fmt;
20use std::str::FromStr;
21
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24/// A typed reference with the string grammar `[system:]kind[:id]`.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct Link {
27    raw: String,
28}
29
30/// A link decomposed against a declared system set.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Parts<'a> {
33    /// The external system, if the head names a declared one.
34    pub system: Option<&'a str>,
35    pub kind: &'a str,
36    /// Verbatim remainder; `None` for singleton links (`self`).
37    pub id: Option<&'a str>,
38}
39
40impl<'a> Parts<'a> {
41    /// What allowed-kind policy matches on: the system for external links
42    /// (`graph`), the kind for KB-native ones (`person`).
43    pub fn namespace(&self) -> &'a str {
44        self.system.unwrap_or(self.kind)
45    }
46
47    pub fn is_external(&self) -> bool {
48        self.system.is_some()
49    }
50
51    /// Whether this link's head names something the caller knows: a declared
52    /// system (already encoded — `parts` only sets `system` for declared
53    /// ones), an id-carrying kind, or a singleton kind. The ONE predicate
54    /// both validation (prose leniency, unrecognized warnings) and
55    /// navigation (which prose brackets become graph edges) must share —
56    /// two implementations here means backlinks and validation disagree.
57    pub fn recognized(&self, kinds: &[&str], singletons: &[&str]) -> bool {
58        self.is_external() || kinds.contains(&self.kind) || singletons.contains(&self.kind)
59    }
60}
61
62impl Link {
63    pub fn new(raw: impl Into<String>) -> Link {
64        Link { raw: raw.into() }
65    }
66
67    /// A KB-native link: `kind:id`.
68    pub fn kb(kind: &str, id: impl fmt::Display) -> Link {
69        Link {
70            raw: format!("{kind}:{id}"),
71        }
72    }
73
74    /// A link to a singleton kind: just the kind name (`self`).
75    pub fn singleton(kind: &str) -> Link {
76        Link {
77            raw: kind.to_string(),
78        }
79    }
80
81    /// An external link: `system:kind:id`.
82    pub fn external(system: &str, kind: &str, id: impl fmt::Display) -> Link {
83        Link {
84            raw: format!("{system}:{kind}:{id}"),
85        }
86    }
87
88    pub fn raw(&self) -> &str {
89        &self.raw
90    }
91
92    /// Decompose against the declared system set. Grammar only — no judgment:
93    /// the head is a system iff declared, else it is the kind and the
94    /// remainder (which may contain `:` and `/`) is the id, verbatim.
95    pub fn parts<'a>(&'a self, systems: &[&str]) -> Parts<'a> {
96        match self.raw.split_once(':') {
97            None => Parts {
98                system: None,
99                kind: &self.raw,
100                id: None,
101            },
102            Some((head, rest)) => {
103                if systems.contains(&head) {
104                    match rest.split_once(':') {
105                        Some((kind, id)) => Parts {
106                            system: Some(head),
107                            kind,
108                            id: Some(id),
109                        },
110                        None => Parts {
111                            system: Some(head),
112                            kind: rest,
113                            id: None,
114                        },
115                    }
116                } else {
117                    Parts {
118                        system: None,
119                        kind: head,
120                        id: Some(rest),
121                    }
122                }
123            }
124        }
125    }
126
127    /// Every `[[…]]` occurrence in prose. Malformed brackets (an unclosed
128    /// `[[`) are ignored; whatever sits inside a well-formed pair becomes a
129    /// link (interpretation happens later, like every other link).
130    pub fn find_inline(text: &str) -> Vec<Link> {
131        let mut out = Vec::new();
132        let mut rest = text;
133        while let Some(start) = rest.find("[[") {
134            let after = &rest[start + 2..];
135            match after.find("]]") {
136                Some(end) => {
137                    out.push(Link::new(&after[..end]));
138                    rest = &after[end + 2..];
139                }
140                None => break, // unclosed — ignore the tail
141            }
142        }
143        out
144    }
145}
146
147impl fmt::Display for Link {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.write_str(&self.raw)
150    }
151}
152
153impl FromStr for Link {
154    type Err = std::convert::Infallible;
155
156    /// Infallible: every string is a link; validity is judged elsewhere.
157    fn from_str(s: &str) -> Result<Self, Self::Err> {
158        Ok(Link::new(s))
159    }
160}
161
162impl Serialize for Link {
163    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
164        serializer.serialize_str(&self.raw)
165    }
166}
167
168impl<'de> Deserialize<'de> for Link {
169    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
170        Ok(Link::new(String::deserialize(deserializer)?))
171    }
172}
173
174impl schemars::JsonSchema for Link {
175    fn schema_name() -> std::borrow::Cow<'static, str> {
176        "Link".into()
177    }
178
179    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
180        schemars::json_schema!({
181            "type": "string",
182            "description":
183                "A typed link with the string grammar `[system:]kind[:id]`. Bare kinds \
184                 point at KB records and must resolve (`person:jane-doe`, \
185                 `checkin:jane-doe/2026-07-06`); a singleton kind links as just its \
186                 name (`self`). External systems carry a declared prefix \
187                 (`graph:event:<id>`, `sharepoint:file:<id>`) — cite these as \
188                 provenance. Ids are verbatim after the kind and may contain ':' \
189                 and '/'."
190        })
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    const SYSTEMS: &[&str] = &["graph", "sharepoint"];
199
200    #[test]
201    fn grammar_decomposition() {
202        let cases: Vec<(&str, Option<&str>, &str, Option<&str>)> = vec![
203            ("person:jane-doe", None, "person", Some("jane-doe")),
204            (
205                "meeting:2026-06-26-project-spoon",
206                None,
207                "meeting",
208                Some("2026-06-26-project-spoon"),
209            ),
210            (
211                "checkin:jane-doe/2026-07-06",
212                None,
213                "checkin",
214                Some("jane-doe/2026-07-06"),
215            ),
216            ("self", None, "self", None),
217            (
218                "graph:email:AAMkAD=:with/odd:chars==",
219                Some("graph"),
220                "email",
221                Some("AAMkAD=:with/odd:chars=="),
222            ),
223            (
224                "graph:chat:19:meeting@thread.v2",
225                Some("graph"),
226                "chat",
227                Some("19:meeting@thread.v2"),
228            ),
229            (
230                "sharepoint:file:01ABC!123",
231                Some("sharepoint"),
232                "file",
233                Some("01ABC!123"),
234            ),
235            // Undeclared system → its head reads as a kind; the validator warns.
236            ("jira:issue:EV-12", None, "jira", Some("issue:EV-12")),
237            ("just some text", None, "just some text", None),
238        ];
239        for (raw, system, kind, id) in cases {
240            let link: Link = raw.parse().unwrap();
241            let p = link.parts(SYSTEMS);
242            assert_eq!((p.system, p.kind, p.id), (system, kind, id), "{raw}");
243            assert_eq!(link.to_string(), raw, "display is verbatim");
244        }
245    }
246
247    #[test]
248    fn constructors_produce_the_canonical_strings() {
249        assert_eq!(Link::kb("person", "jane-doe").raw(), "person:jane-doe");
250        assert_eq!(Link::singleton("self").raw(), "self");
251        assert_eq!(
252            Link::external("graph", "event", "ev1").raw(),
253            "graph:event:ev1"
254        );
255    }
256
257    #[test]
258    fn namespace_is_system_for_external_kind_for_native() {
259        let l = Link::kb("person", "x");
260        assert_eq!(l.parts(SYSTEMS).namespace(), "person");
261        assert!(!l.parts(SYSTEMS).is_external());
262        let g = Link::external("graph", "event", "e");
263        assert_eq!(g.parts(SYSTEMS).namespace(), "graph");
264        assert!(g.parts(SYSTEMS).is_external());
265        assert_eq!(Link::singleton("self").parts(SYSTEMS).namespace(), "self");
266    }
267
268    #[test]
269    fn serde_round_trips_as_a_plain_string() {
270        for raw in ["person:jane-doe", "self", "graph:email:a=:b/c=="] {
271            let link: Link = raw.parse().unwrap();
272            let ron_str = ron::to_string(&link).unwrap();
273            assert_eq!(ron_str, format!("\"{raw}\""));
274            assert_eq!(ron::from_str::<Link>(&ron_str).unwrap(), link);
275            let json = serde_json::to_string(&link).unwrap();
276            assert_eq!(serde_json::from_str::<Link>(&json).unwrap(), link);
277        }
278    }
279
280    #[test]
281    fn find_inline_extracts_links_and_ignores_malformed_brackets() {
282        let text = "See [[person:jane-doe]] and [[meeting:2026-07-06-jane-alex-catchup]].\n\
283                    An [array[0]] is not a link; an unclosed [[person:ghost stays out.";
284        let links = Link::find_inline(text);
285        assert_eq!(
286            links,
287            vec![
288                Link::kb("person", "jane-doe"),
289                Link::kb("meeting", "2026-07-06-jane-alex-catchup"),
290            ]
291        );
292        assert!(Link::find_inline("no links here").is_empty());
293        assert_eq!(Link::find_inline("[[??]]"), vec![Link::new("??")]);
294    }
295}