use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Link {
raw: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Parts<'a> {
pub system: Option<&'a str>,
pub kind: &'a str,
pub id: Option<&'a str>,
}
impl<'a> Parts<'a> {
pub fn namespace(&self) -> &'a str {
self.system.unwrap_or(self.kind)
}
pub fn is_external(&self) -> bool {
self.system.is_some()
}
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() }
}
pub fn kb(kind: &str, id: impl fmt::Display) -> Link {
Link {
raw: format!("{kind}:{id}"),
}
}
pub fn singleton(kind: &str) -> Link {
Link {
raw: kind.to_string(),
}
}
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
}
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),
}
}
}
}
}
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, }
}
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;
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"),
),
("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("??")]);
}
}