use jacquard_common::Data;
use jacquard_common::types::string::AtprotoStr;
use jacquard_common::types::uri::UriValue;
use smol_str::{SmolStr, format_smolstr};
pub struct Link {
/// dotted field path within the record, e.g. `.subject.uri` or `.facets[].features[app.bsky.richtext.facet#link].uri`
pub path: SmolStr,
pub target: SmolStr,
}
/// extract all link targets from a record, with their field paths.
///
/// paths follow the same convention as constellation:
/// - start with `.` (root-level field `subject` -> `.subject`)
/// - object keys: `{parent}.{key}`
/// - plain array elements: `{parent}[]`
/// - typed array elements (objects with `$type`): `{parent}[{$type}]`
///
/// results are sorted by (path, target) and deduplicated.
pub fn extract_links(value: &Data) -> Vec {
let mut links = Vec::new();
walk(value, "", &mut links);
links.sort_unstable_by(|a, b| a.path.cmp(&b.path).then(a.target.cmp(&b.target)));
links.dedup_by(|a, b| a.path == b.path && a.target == b.target);
links
}
fn walk(value: &Data<'_>, path: &str, links: &mut Vec) {
match value {
Data::String(s) => {
let target = match s {
AtprotoStr::AtUri(uri) => uri.as_str(),
AtprotoStr::Did(did) => did.as_str(),
AtprotoStr::Uri(UriValue::At(uri)) => uri.as_str(),
AtprotoStr::Uri(UriValue::Did(did)) => did.as_str(),
_ => return,
};
links.push(Link {
path: SmolStr::new(path),
target: SmolStr::new(target),
});
}
Data::Array(arr) => {
for item in &arr.0 {
let child_path = match item {
Data::Object(obj) => {
let type_tag = obj.0.get("$type").and_then(|v| match v {
Data::String(s) => Some(s.as_str()),
_ => None,
});
match type_tag {
Some(t) => format_smolstr!("{path}[{t}]"),
None => format_smolstr!("{path}[]"),
}
}
_ => format_smolstr!("{path}[]"),
};
walk(item, &child_path, links);
}
}
Data::Object(obj) => {
for (k, v) in &obj.0 {
walk(v, &format_smolstr!("{path}.{k}"), links);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make(s: &str) -> Data<'static> {
Data::from_json_owned(serde_json::Value::String(s.to_string())).unwrap()
}
fn targets(links: &[Link]) -> Vec<&str> {
links.iter().map(|l| l.target.as_str()).collect()
}
fn paths(links: &[Link]) -> Vec<&str> {
links.iter().map(|l| l.path.as_str()).collect()
}
#[test]
fn extracts_at_uri() {
let val = make("at://did:plc:abc123/app.bsky.feed.post/tid123");
let links = extract_links(&val);
assert_eq!(
targets(&links),
vec!["at://did:plc:abc123/app.bsky.feed.post/tid123"]
);
assert_eq!(paths(&links), vec![""]);
}
#[test]
fn extracts_did() {
let val = make("did:plc:abc123");
let links = extract_links(&val);
assert_eq!(targets(&links), vec!["did:plc:abc123"]);
}
#[test]
fn skips_plain_string() {
assert!(extract_links(&make("hello world")).is_empty());
}
#[test]
fn skips_handle() {
assert!(extract_links(&make("alice.bsky.social")).is_empty());
}
#[test]
fn object_paths() {
let json = serde_json::json!({
"subject": {
"uri": "at://did:plc:abc/app.bsky.feed.post/tid",
"cid": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq"
}
});
let val = Data::from_json_owned(json).unwrap();
let links = extract_links(&val);
assert_eq!(links.len(), 1);
assert_eq!(
links[0].target.as_str(),
"at://did:plc:abc/app.bsky.feed.post/tid"
);
assert_eq!(links[0].path.as_str(), ".subject.uri");
}
#[test]
fn array_paths_plain() {
let json = serde_json::json!([
"at://did:plc:a/app.bsky.feed.post/tid1",
"at://did:plc:b/app.bsky.feed.post/tid2"
]);
let val = Data::from_json_owned(json).unwrap();
let links = extract_links(&val);
assert_eq!(links.len(), 2);
assert!(links.iter().all(|l| l.path.as_str() == "[]"));
}
#[test]
fn array_paths_typed_object() {
let json = serde_json::json!({
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "at://did:plc:abc/app.bsky.feed.post/tid"
}
]
});
let val = Data::from_json_owned(json).unwrap();
let links = extract_links(&val);
assert_eq!(links.len(), 1);
assert_eq!(
links[0].path.as_str(),
".features[app.bsky.richtext.facet#link].uri"
);
}
#[test]
fn array_paths_untyped_object() {
let json = serde_json::json!({
"items": [
{ "subject": "did:plc:abc" }
]
});
let val = Data::from_json_owned(json).unwrap();
let links = extract_links(&val);
assert_eq!(links.len(), 1);
assert_eq!(links[0].path.as_str(), ".items[].subject");
}
#[test]
fn deduplicates_same_path_and_target() {
let uri = "at://did:plc:abc123/app.bsky.feed.post/tid123";
let json = serde_json::json!([uri, uri]);
let val = Data::from_json_owned(json).unwrap();
assert_eq!(extract_links(&val).len(), 1);
}
#[test]
fn keeps_same_target_at_different_paths() {
let uri = "at://did:plc:abc/app.bsky.feed.post/tid";
let json = serde_json::json!({
"root": { "uri": uri },
"parent": { "uri": uri }
});
let val = Data::from_json_owned(json).unwrap();
let links = extract_links(&val);
assert_eq!(links.len(), 2);
let ps: Vec<_> = links.iter().map(|l| l.path.as_str()).collect();
assert!(ps.contains(&".parent.uri"));
assert!(ps.contains(&".root.uri"));
}
#[test]
fn skips_cid_link() {
let json = serde_json::json!({"$link": "bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq"});
let val = Data::from_json_owned(json).unwrap();
assert!(extract_links(&val).is_empty());
}
}