use ulid::Ulid;
use crate::scope::Scope;
#[inline]
pub fn scope_prefix(scope: &Scope) -> String {
format!("lunaris:{}:", scope.as_str())
}
#[inline]
pub fn episode_key(scope: &Scope, id: Ulid) -> Vec<u8> {
format!("{}episode:{id}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn chunk_key(scope: &Scope, id: Ulid) -> Vec<u8> {
format!("{}chunk:{id}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn entity_key(scope: &Scope, id: Ulid) -> Vec<u8> {
format!("{}entity:{id}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn relation_key(scope: &Scope, id: Ulid) -> Vec<u8> {
format!("{}relation:{id}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn fact_key(scope: &Scope, id: Ulid) -> Vec<u8> {
format!("{}fact:{id}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn fact_spo_key(scope: &Scope, subject_id: &[u8; 16], predicate: &str) -> Vec<u8> {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut hexed = String::with_capacity(32);
for &b in subject_id {
hexed.push(HEX[(b >> 4) as usize] as char);
hexed.push(HEX[(b & 0x0f) as usize] as char);
}
format!("{}factspo:{hexed}:{predicate}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn community_key(scope: &Scope, id: Ulid) -> Vec<u8> {
format!("{}community:{id}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn doctree_key(scope: &Scope, id: Ulid) -> Vec<u8> {
format!("{}doctree:{id}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn activation_key(scope: &Scope, id: Ulid) -> Vec<u8> {
format!("{}activation:{id}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn activation_prefix(scope: &Scope) -> Vec<u8> {
format!("{}activation:", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn dedupe_key(scope: &Scope, raw: &str) -> Vec<u8> {
let hash = blake3::hash(raw.as_bytes());
format!("{}dedupe:{}", scope_prefix(scope), hash.to_hex()).into_bytes()
}
#[inline]
pub fn source_index_key(scope: &Scope, source: &str) -> Vec<u8> {
let hash = blake3::hash(source.as_bytes());
format!("{}src:{}", scope_prefix(scope), hash.to_hex()).into_bytes()
}
#[inline]
pub fn episode_prefix(scope: &Scope) -> Vec<u8> {
format!("{}episode:", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn chunk_prefix(scope: &Scope) -> Vec<u8> {
format!("{}chunk:", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn entity_prefix(scope: &Scope) -> Vec<u8> {
format!("{}entity:", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn relation_prefix(scope: &Scope) -> Vec<u8> {
format!("{}relation:", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn fact_prefix(scope: &Scope) -> Vec<u8> {
format!("{}fact:", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn community_prefix(scope: &Scope) -> Vec<u8> {
format!("{}community:", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn doctree_prefix(scope: &Scope) -> Vec<u8> {
format!("{}doctree:", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn verify_agenda_key(scope: &Scope, id: Ulid) -> Vec<u8> {
format!("{}verify_agenda:{id}", scope_prefix(scope)).into_bytes()
}
#[inline]
pub fn verify_agenda_prefix(scope: &Scope) -> Vec<u8> {
format!("{}verify_agenda:", scope_prefix(scope)).into_bytes()
}
pub fn parse_scope_from_key(raw: &[u8]) -> Option<Scope> {
let s = std::str::from_utf8(raw).ok()?;
let rest = s.strip_prefix("lunaris:")?;
let colon = rest.find(':')?;
let scope_str = &rest[..colon];
Scope::new(scope_str).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn scope_a() -> Scope {
Scope::new("acme.agent-1").unwrap()
}
fn scope_b() -> Scope {
Scope::new("acme.agent-2").unwrap()
}
#[test]
fn scope_prefix_format() {
let s = scope_a();
assert_eq!(scope_prefix(&s), "lunaris:acme.agent-1:");
}
#[test]
fn scoped_key_stable_format() {
let scope = Scope::new("_dev_").unwrap();
let id = Ulid::from_string("01HZZZZZZZZZZZZZZZZZZZZZZZ").unwrap();
assert_eq!(
episode_key(&scope, id),
b"lunaris:_dev_:episode:01HZZZZZZZZZZZZZZZZZZZZZZZ".to_vec()
);
assert_eq!(
chunk_key(&scope, id),
b"lunaris:_dev_:chunk:01HZZZZZZZZZZZZZZZZZZZZZZZ".to_vec()
);
assert_eq!(
entity_key(&scope, id),
b"lunaris:_dev_:entity:01HZZZZZZZZZZZZZZZZZZZZZZZ".to_vec()
);
assert_eq!(
relation_key(&scope, id),
b"lunaris:_dev_:relation:01HZZZZZZZZZZZZZZZZZZZZZZZ".to_vec()
);
assert_eq!(fact_key(&scope, id), b"lunaris:_dev_:fact:01HZZZZZZZZZZZZZZZZZZZZZZZ".to_vec());
assert_eq!(
community_key(&scope, id),
b"lunaris:_dev_:community:01HZZZZZZZZZZZZZZZZZZZZZZZ".to_vec()
);
}
#[test]
fn scoped_keys_differ_across_scopes() {
let id = Ulid::from_string("01HZZZZZZZZZZZZZZZZZZZZZZZ").unwrap();
let ka = episode_key(&scope_a(), id);
let kb = episode_key(&scope_b(), id);
assert_ne!(ka, kb, "same ULID in different scopes must produce different keys");
assert!(ka.starts_with(b"lunaris:acme.agent-1:episode:"));
assert!(kb.starts_with(b"lunaris:acme.agent-2:episode:"));
}
#[test]
fn scan_prefix_does_not_alias_across_kinds() {
assert!(Scope::new("a:episode").is_err(), "colon-containing scope must be rejected");
assert!(Scope::new("a:chunk").is_err());
assert!(Scope::new("a:fact").is_err());
let a = Scope::new("a").unwrap();
let sp = scope_prefix(&a).into_bytes();
let ep = episode_prefix(&a);
assert_ne!(sp, ep, "scope_prefix and episode_prefix must differ for any scope");
assert!(ep.starts_with(&sp));
assert!(ep.ends_with(b"episode:"));
}
#[test]
fn prefix_matches_key_starts() {
let scope = Scope::new("org.team").unwrap();
let id = Ulid::new();
assert!(episode_key(&scope, id).starts_with(&episode_prefix(&scope)));
assert!(chunk_key(&scope, id).starts_with(&chunk_prefix(&scope)));
assert!(entity_key(&scope, id).starts_with(&entity_prefix(&scope)));
assert!(relation_key(&scope, id).starts_with(&relation_prefix(&scope)));
assert!(fact_key(&scope, id).starts_with(&fact_prefix(&scope)));
assert!(community_key(&scope, id).starts_with(&community_prefix(&scope)));
}
#[test]
fn verify_agenda_key_format() {
let scope = Scope::new("_dev_").unwrap();
let id = Ulid::from_string("01HZZZZZZZZZZZZZZZZZZZZZZZ").unwrap();
assert_eq!(
verify_agenda_key(&scope, id),
b"lunaris:_dev_:verify_agenda:01HZZZZZZZZZZZZZZZZZZZZZZZ".to_vec()
);
}
#[test]
fn verify_agenda_prefix_format() {
let scope = Scope::new("acme.agent-1").unwrap();
assert_eq!(verify_agenda_prefix(&scope), b"lunaris:acme.agent-1:verify_agenda:".to_vec());
}
#[test]
fn verify_agenda_key_starts_with_prefix() {
let scope = Scope::new("org.team").unwrap();
let id = Ulid::new();
assert!(verify_agenda_key(&scope, id).starts_with(&verify_agenda_prefix(&scope)));
}
#[test]
fn verify_agenda_keys_differ_across_scopes() {
let id = Ulid::from_string("01HZZZZZZZZZZZZZZZZZZZZZZZ").unwrap();
let ka = verify_agenda_key(&scope_a(), id);
let kb = verify_agenda_key(&scope_b(), id);
assert_ne!(ka, kb, "same ULID in different scopes must produce different keys");
}
#[test]
fn parse_scope_roundtrips_every_primitive_kind() {
let scope = Scope::new("acme.agent-42").unwrap();
let id = Ulid::new();
for k in [
episode_key(&scope, id),
chunk_key(&scope, id),
entity_key(&scope, id),
relation_key(&scope, id),
fact_key(&scope, id),
community_key(&scope, id),
] {
let parsed = parse_scope_from_key(&k).expect("valid Lunaris key must parse");
assert_eq!(parsed.as_str(), scope.as_str(), "key {k:?} parsed to wrong scope");
}
}
#[test]
fn parse_scope_rejects_non_lunaris_prefix() {
assert!(parse_scope_from_key(b"other:scope:kind:id").is_none());
assert!(parse_scope_from_key(b"").is_none());
assert!(parse_scope_from_key(b"lunaris:no-trailing-colon").is_none());
assert!(
parse_scope_from_key(b"lunaris:scope:").map(|s| s.as_str().to_string())
== Some("scope".to_string())
);
}
#[test]
fn parse_scope_rejects_empty_segment() {
assert!(parse_scope_from_key(b"lunaris::episode:01HZZZ").is_none());
}
#[test]
fn parse_scope_rejects_invalid_utf8() {
assert!(parse_scope_from_key(&[0xff, 0xfe, b':']).is_none());
}
#[test]
fn parse_scope_rejects_scope_with_invalid_chars() {
assert!(parse_scope_from_key(b"lunaris:bad/scope:episode:01HZZZ").is_none());
}
#[test]
fn source_index_key_partitions_by_scope_and_by_source() {
let a = Scope::new("tenant-a").unwrap();
let b = Scope::new("tenant-b").unwrap();
assert_ne!(source_index_key(&a, "notes/x.md"), source_index_key(&b, "notes/x.md"));
assert_ne!(source_index_key(&a, "notes/x.md"), source_index_key(&a, "notes/y.md"));
assert_eq!(source_index_key(&a, "notes/x.md"), source_index_key(&a, "notes/x.md"));
}
#[test]
fn a_source_index_key_cannot_be_confused_for_a_primitive_key() {
let scope = Scope::new("t").unwrap();
let key = source_index_key(&scope, "episode:01HZZZZZZZZZZZZZZZZZZZZZZZ");
let rendered = String::from_utf8(key).unwrap();
assert!(
rendered.starts_with("lunaris:t:src:"),
"expected a src-namespaced key, got {rendered}"
);
assert!(!rendered.contains("episode:"), "the raw source leaked into the key: {rendered}");
}
}