1use std::fmt;
20use std::str::FromStr;
21
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct Link {
27 raw: String,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Parts<'a> {
33 pub system: Option<&'a str>,
35 pub kind: &'a str,
36 pub id: Option<&'a str>,
38}
39
40impl<'a> Parts<'a> {
41 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 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 pub fn kb(kind: &str, id: impl fmt::Display) -> Link {
69 Link {
70 raw: format!("{kind}:{id}"),
71 }
72 }
73
74 pub fn singleton(kind: &str) -> Link {
76 Link {
77 raw: kind.to_string(),
78 }
79 }
80
81 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 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 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, }
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 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 ("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}