use crate::error::{Error, Result};
use crate::resource::{DocId, ResourceKind};
use crate::url_key::UrlKey;
use compact_str::CompactString;
use graph_r::bytesio::{put_len_prefixed, put_u32, put_u64, put_u8, Reader};
use roaring::RoaringBitmap;
use smallvec::SmallVec;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocMeta {
pub url: String,
pub url_key: UrlKey,
pub kind: ResourceKind,
pub content_hash: u64,
pub doc_len: u32,
pub title: Option<CompactString>,
pub snippet: CompactString,
pub lang: Option<CompactString>,
pub tags: SmallVec<[CompactString; 4]>,
pub fetched_at_ms: u64,
pub pinned: bool,
pub etag: Option<CompactString>,
}
impl DocMeta {
#[must_use]
pub fn path(&self) -> &str {
let after_host = match self.url.split_once("://") {
Some((_, rest)) => rest.find('/').map_or("", |i| &rest[i..]),
None => return &self.url,
};
if after_host.is_empty() {
return "/";
}
let end = after_host.find(['?', '#']).unwrap_or(after_host.len());
&after_host[..end]
}
}
#[must_use]
pub fn encode(docs: &[DocMeta]) -> Vec<u8> {
let mut buf = Vec::new();
put_u32(&mut buf, docs.len() as u32);
for d in docs {
put_len_prefixed(&mut buf, d.url.as_bytes());
put_u64(&mut buf, d.url_key.raw());
put_u8(&mut buf, d.kind.as_tag());
put_u64(&mut buf, d.content_hash);
put_u32(&mut buf, d.doc_len);
put_opt_str(&mut buf, d.title.as_deref());
put_len_prefixed(&mut buf, d.snippet.as_bytes());
put_opt_str(&mut buf, d.lang.as_deref());
put_u32(&mut buf, d.tags.len() as u32);
for tag in &d.tags {
put_len_prefixed(&mut buf, tag.as_bytes());
}
put_u64(&mut buf, d.fetched_at_ms);
put_u8(&mut buf, u8::from(d.pinned));
put_opt_str(&mut buf, d.etag.as_deref());
}
buf
}
pub fn decode(bytes: &[u8], has_freshness: bool) -> Result<Vec<DocMeta>> {
let mut r = Reader::new(bytes);
let count = r.u32()? as usize;
let mut docs = Vec::with_capacity(count.min(r.remaining()));
for _ in 0..count {
let url = r.blob()?.to_owned();
let url_key = UrlKey(r.u64()?);
let kind = ResourceKind::from_tag(r.u8()?);
let content_hash = r.u64()?;
let doc_len = r.u32()?;
let title = get_opt_str(&mut r)?;
let snippet = CompactString::from(r.blob()?);
let lang = get_opt_str(&mut r)?;
let tag_count = r.u32()? as usize;
let mut tags = SmallVec::new();
for _ in 0..tag_count {
tags.push(CompactString::from(r.blob()?));
}
let (fetched_at_ms, pinned, etag) = if has_freshness {
let fetched_at_ms = r.u64()?;
let pinned = match r.u8()? {
0 => false,
1 => true,
_ => return Err(Error::format("invalid pinned flag")),
};
let etag = get_opt_str(&mut r)?;
(fetched_at_ms, pinned, etag)
} else {
(0, false, None)
};
docs.push(DocMeta {
url,
url_key,
kind,
content_hash,
doc_len,
title,
snippet,
lang,
tags,
fetched_at_ms,
pinned,
etag,
});
}
if r.remaining() != 0 {
return Err(Error::format("trailing bytes in DocMeta section"));
}
Ok(docs)
}
fn put_opt_str(buf: &mut Vec<u8>, s: Option<&str>) {
match s {
Some(v) => {
put_u8(buf, 1);
put_len_prefixed(buf, v.as_bytes());
}
None => put_u8(buf, 0),
}
}
fn get_opt_str(r: &mut Reader<'_>) -> Result<Option<CompactString>> {
match r.u8()? {
0 => Ok(None),
1 => Ok(Some(CompactString::from(r.blob()?))),
_ => Err(Error::format("invalid optional-string flag")),
}
}
#[derive(Clone, Debug, Default)]
pub enum Filter {
#[default]
All,
Tag(CompactString),
Lang(CompactString),
PathPrefix(CompactString),
Kind(ResourceKind),
FetchedBefore(u64),
FetchedAfter(u64),
And(Box<Filter>, Box<Filter>),
Or(Box<Filter>, Box<Filter>),
Not(Box<Filter>),
}
impl Filter {
#[must_use]
pub fn is_all(&self) -> bool {
matches!(self, Filter::All)
}
fn matches(&self, doc: &DocMeta) -> bool {
match self {
Filter::All => true,
Filter::Tag(t) => doc.tags.iter().any(|x| x == t),
Filter::Lang(l) => doc.lang.as_deref() == Some(l.as_str()),
Filter::PathPrefix(p) => doc.path().starts_with(p.as_str()),
Filter::Kind(k) => doc.kind == *k,
Filter::FetchedBefore(t) => doc.fetched_at_ms < *t,
Filter::FetchedAfter(t) => doc.fetched_at_ms >= *t,
Filter::And(a, b) => a.matches(doc) && b.matches(doc),
Filter::Or(a, b) => a.matches(doc) || b.matches(doc),
Filter::Not(a) => !a.matches(doc),
}
}
#[must_use]
pub fn evaluate(&self, docs: &[DocMeta]) -> Option<RoaringBitmap> {
if self.is_all() {
return None;
}
let mut allowed = RoaringBitmap::new();
for (i, doc) in docs.iter().enumerate() {
if self.matches(doc) {
allowed.insert(i as DocId);
}
}
Some(allowed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::url_key::UrlKeyExt as _;
use url::Url;
fn doc(url: &str, kind: ResourceKind, lang: Option<&str>, tags: &[&str]) -> DocMeta {
let parsed = Url::parse(url).unwrap();
DocMeta {
url: url.to_owned(),
url_key: UrlKey::from_url(&parsed),
kind,
content_hash: 0xABCD,
doc_len: 10,
title: Some(CompactString::from("Title")),
snippet: CompactString::from("snippet text"),
lang: lang.map(CompactString::from),
tags: tags.iter().map(|t| CompactString::from(*t)).collect(),
fetched_at_ms: 1000,
pinned: false,
etag: Some(CompactString::from("W/\"abc\"")),
}
}
fn corpus() -> Vec<DocMeta> {
vec![
doc(
"https://x.dev/docs/a",
ResourceKind::Markdown,
Some("en"),
&["guide"],
),
doc(
"https://x.dev/blog/b",
ResourceKind::Html,
Some("fr"),
&["news"],
),
doc(
"https://x.dev/docs/c",
ResourceKind::Html,
Some("en"),
&["guide", "ref"],
),
]
}
#[test]
fn encode_decode_roundtrips() {
let docs = corpus();
let bytes = encode(&docs);
let decoded = decode(&bytes, true).unwrap();
assert_eq!(decoded, docs);
}
#[test]
fn legacy_decode_defaults_freshness() {
let mut buf = Vec::new();
put_u32(&mut buf, 1);
put_len_prefixed(&mut buf, b"https://x.dev/a");
put_u64(&mut buf, 0);
put_u8(&mut buf, ResourceKind::Html.as_tag());
put_u64(&mut buf, 0xABCD);
put_u32(&mut buf, 10);
put_opt_str(&mut buf, None); put_len_prefixed(&mut buf, b"snip"); put_opt_str(&mut buf, None); put_u32(&mut buf, 0); let decoded = decode(&buf, false).unwrap();
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].fetched_at_ms, 0);
assert!(!decoded[0].pinned);
assert_eq!(decoded[0].etag, None);
}
#[test]
fn decode_rejects_truncation() {
let bytes = encode(&corpus());
assert!(decode(&bytes[..bytes.len() - 3], true).is_err());
}
#[test]
fn decode_rejects_huge_count_without_oom() {
let bytes = [0xff, 0xff, 0x0a, 0x10];
assert!(decode(&bytes, true).is_err());
}
#[test]
fn fetched_range_filters() {
let mut docs = corpus();
docs[0].fetched_at_ms = 100;
docs[1].fetched_at_ms = 500;
docs[2].fetched_at_ms = 900;
let before = Filter::FetchedBefore(500).evaluate(&docs).unwrap();
assert!(before.contains(0) && !before.contains(1) && !before.contains(2));
let after = Filter::FetchedAfter(500).evaluate(&docs).unwrap();
assert!(!after.contains(0) && after.contains(1) && after.contains(2));
}
#[test]
fn path_extraction() {
let d = doc("https://x.dev/docs/a?q=1", ResourceKind::Html, None, &[]);
assert_eq!(d.path(), "/docs/a");
let root = doc("https://x.dev", ResourceKind::Html, None, &[]);
assert_eq!(root.path(), "/");
}
#[test]
fn filter_all_is_unrestricted() {
assert!(Filter::All.evaluate(&corpus()).is_none());
}
#[test]
fn tag_and_kind_filters() {
let docs = corpus();
let guide = Filter::Tag(CompactString::from("guide"))
.evaluate(&docs)
.unwrap();
assert_eq!(guide.len(), 2); let html = Filter::Kind(ResourceKind::Html).evaluate(&docs).unwrap();
assert_eq!(html.len(), 2); }
#[test]
fn path_prefix_filter() {
let docs = corpus();
let f = Filter::PathPrefix(CompactString::from("/docs/"));
let allowed = f.evaluate(&docs).unwrap();
assert!(allowed.contains(0));
assert!(!allowed.contains(1));
assert!(allowed.contains(2));
}
#[test]
fn boolean_combinators() {
let docs = corpus();
let f = Filter::And(
Box::new(Filter::Lang(CompactString::from("en"))),
Box::new(Filter::Tag(CompactString::from("guide"))),
);
let allowed = f.evaluate(&docs).unwrap();
assert_eq!(allowed.len(), 2);
let not_html = Filter::Not(Box::new(Filter::Kind(ResourceKind::Html)));
assert_eq!(not_html.evaluate(&docs).unwrap().len(), 1); }
}