use atproto_extras::{FacetLimits, parse_mentions, parse_tags, parse_urls};
use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver};
use atproto_record::lexicon::app::bsky::richtext::facet::{
ByteSlice, Facet, FacetFeature, Mention,
};
use clap::Parser;
use regex::bytes::Regex;
use std::sync::Arc;
#[derive(Parser)]
#[command(
name = "atproto-extras-parse-facets",
version,
about = "Parse text and output AT Protocol facets as JSON",
long_about = "This tool parses a string for mentions, URLs, and hashtags,\n\
then outputs the corresponding AT Protocol facet array in JSON format.\n\n\
By default, mentions are detected but output with placeholder DIDs.\n\
Use --resolve-mentions to resolve handles to actual DIDs (requires network)."
)]
struct Args {
text: String,
#[arg(long)]
resolve_mentions: bool,
#[arg(long, short = 'd')]
debug: bool,
}
fn parse_mention_spans(text: &str) -> Vec<Facet> {
let mut facets = Vec::new();
let url_facets = parse_urls(text);
let mention_regex = Regex::new(
r"(?:^|[^\w])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)",
)
.expect("Invalid mention regex");
let text_bytes = text.as_bytes();
for capture in mention_regex.captures_iter(text_bytes) {
if let Some(mention_match) = capture.get(1) {
let start = mention_match.start();
let end = mention_match.end();
let overlaps_url = url_facets.iter().any(|facet| {
(start >= facet.index.byte_start && start < facet.index.byte_end)
|| (end > facet.index.byte_start && end <= facet.index.byte_end)
});
if !overlaps_url {
let handle = std::str::from_utf8(&mention_match.as_bytes()[1..])
.unwrap_or_default()
.to_string();
facets.push(Facet {
index: ByteSlice {
byte_start: start,
byte_end: end,
},
features: vec![FacetFeature::Mention(Mention {
did: format!("did:plc:<unresolved:{}>", handle),
})],
});
}
}
}
facets
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let text = &args.text;
let mut facets: Vec<Facet> = Vec::new();
let limits = FacetLimits::default();
if args.resolve_mentions {
let http_client = reqwest::Client::new();
let dns_resolver = HickoryDnsResolver::create_resolver(&[]);
let resolver = InnerIdentityResolver {
http_client,
dns_resolver: Arc::new(dns_resolver),
plc_hostname: "plc.directory".to_string(),
};
let mention_facets = parse_mentions(text, &resolver, &limits).await;
facets.extend(mention_facets);
} else {
let mention_facets = parse_mention_spans(text);
facets.extend(mention_facets);
}
let url_facets = parse_urls(text);
facets.extend(url_facets);
let tag_facets = parse_tags(text);
facets.extend(tag_facets);
facets.sort_by_key(|f| f.index.byte_start);
if facets.is_empty() {
println!("null");
} else {
match serde_json::to_string_pretty(&facets) {
Ok(json) => println!("{}", json),
Err(e) => {
eprintln!(
"error-atproto-extras-parse-facets-1 Error serializing facets: {}",
e
);
std::process::exit(1);
}
}
}
if args.debug {
eprintln!();
eprintln!("--- Debug Info ---");
eprintln!("Input text: {:?}", text);
eprintln!("Text length: {} bytes", text.len());
eprintln!("Facets found: {}", facets.len());
eprintln!("Mentions resolved: {}", args.resolve_mentions);
let text_bytes = text.as_bytes();
for (i, facet) in facets.iter().enumerate() {
let start = facet.index.byte_start;
let end = facet.index.byte_end;
let slice_text =
std::str::from_utf8(&text_bytes[start..end]).unwrap_or("<invalid utf8>");
let feature_type = match &facet.features[0] {
FacetFeature::Mention(_) => "mention",
FacetFeature::Link(_) => "link",
FacetFeature::Tag(_) => "tag",
};
eprintln!(
" [{}] {} @ bytes {}..{}: {:?}",
i, feature_type, start, end, slice_text
);
}
}
}