use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::arena::{ScopeArena, ScopeId, ScopeKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FileStableId([u8; 16]);
impl FileStableId {
#[must_use]
pub fn from_registry_path(path: &Path) -> Self {
let identity = path.as_os_str().as_encoded_bytes();
let hash = blake3::hash(identity);
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&hash.as_bytes()[..16]);
Self(bytes)
}
#[inline]
#[must_use]
pub fn as_bytes(&self) -> &[u8; 16] {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ScopeStableId(pub [u8; 16]);
#[must_use]
pub fn compute_scope_stable_id(
file_stable_id: FileStableId,
file_content_hash: [u8; 32],
kind: ScopeKind,
byte_span: (u32, u32),
) -> ScopeStableId {
let mut hasher = blake3::Hasher::new();
hasher.update(file_stable_id.as_bytes());
hasher.update(&file_content_hash);
hasher.update(&[kind.discriminant()]);
hasher.update(&byte_span.0.to_le_bytes());
hasher.update(&byte_span.1.to_le_bytes());
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
ScopeStableId(bytes)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScopeProvenance {
pub first_seen_epoch: u64,
pub last_seen_epoch: u64,
pub file_stable_id: FileStableId,
pub stable_id: ScopeStableId,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScopeProvenanceStore {
slots: Vec<Option<(u64, ScopeProvenance)>>,
#[serde(skip)]
reverse_index: HashMap<ScopeStableId, ScopeId>,
}
impl ScopeProvenanceStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[inline]
#[must_use]
pub fn slot_count(&self) -> usize {
self.slots.len()
}
#[must_use]
pub fn len(&self) -> usize {
self.slots.iter().filter(|s| s.is_some()).count()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn resize_to(&mut self, slot_count: usize) {
if self.slots.len() < slot_count {
self.slots.resize(slot_count, None);
}
}
pub fn insert(&mut self, id: ScopeId, provenance: ScopeProvenance) {
let idx = id.index() as usize;
if self.slots.len() <= idx {
self.resize_to(idx + 1);
}
let stable = provenance.stable_id;
self.slots[idx] = Some((id.generation(), provenance));
self.reverse_index.insert(stable, id);
}
#[must_use]
pub fn lookup(&self, id: ScopeId) -> Option<&ScopeProvenance> {
let slot = self.slots.get(id.index() as usize)?.as_ref()?;
if slot.0 != id.generation() {
return None;
}
Some(&slot.1)
}
pub fn entries(&self) -> impl Iterator<Item = (ScopeId, &ScopeProvenance)> {
self.slots.iter().enumerate().filter_map(|(idx, slot)| {
let (slot_gen, prov) = slot.as_ref()?;
#[allow(clippy::cast_possible_truncation)]
let id = ScopeId::new(idx as u32, *slot_gen);
Some((id, prov))
})
}
#[must_use]
pub fn scope_by_stable_id(&self, stable: ScopeStableId) -> Option<ScopeId> {
self.reverse_index.get(&stable).copied()
}
pub fn rebuild_reverse_index(&mut self, arena: &ScopeArena) {
self.reverse_index.clear();
for (idx, slot) in self.slots.iter().enumerate() {
let Some((slot_gen, prov)) = slot.as_ref() else {
continue;
};
#[allow(clippy::cast_possible_truncation)]
let candidate = ScopeId::new(idx as u32, *slot_gen);
if arena.get(candidate).is_some() {
self.reverse_index.insert(prov.stable_id, candidate);
}
}
debug_assert!(
self.reverse_index.len() <= self.len(),
"reverse_index must not exceed occupied slot count"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::unified::bind::scope::arena::{Scope, ScopeArena};
use crate::graph::unified::file::id::FileId;
use crate::graph::unified::node::id::NodeId;
#[test]
fn t21_file_stable_id_is_deterministic_and_path_distinct() {
let id_a = FileStableId::from_registry_path(Path::new("/a/b/file.rs"));
let id_b = FileStableId::from_registry_path(Path::new("/a/b/file.rs"));
let id_c = FileStableId::from_registry_path(Path::new("/c/d/file.rs"));
assert_eq!(id_a, id_b, "deterministic for same path");
assert_ne!(id_a, id_c, "distinct for distinct paths");
}
#[test]
fn t22_file_stable_id_ignores_source_uri_and_is_external() {
let id = FileStableId::from_registry_path(Path::new("/workspace/src/main.rs"));
assert_eq!(
id,
FileStableId::from_registry_path(Path::new("/workspace/src/main.rs"))
);
}
#[test]
fn t23_compute_scope_stable_id_is_input_sensitive() {
let file_id = FileStableId::from_registry_path(Path::new("/a.rs"));
let hash = [0u8; 32];
let base = compute_scope_stable_id(file_id, hash, ScopeKind::Module, (0, 100));
assert_eq!(
base,
compute_scope_stable_id(file_id, hash, ScopeKind::Module, (0, 100))
);
assert_ne!(
base,
compute_scope_stable_id(file_id, hash, ScopeKind::Module, (0, 200))
);
assert_ne!(
base,
compute_scope_stable_id(file_id, hash, ScopeKind::Function, (0, 100))
);
let mut alt_hash = hash;
alt_hash[0] = 1;
assert_ne!(
base,
compute_scope_stable_id(file_id, alt_hash, ScopeKind::Module, (0, 100))
);
let other_file = FileStableId::from_registry_path(Path::new("/b.rs"));
assert_ne!(
base,
compute_scope_stable_id(other_file, hash, ScopeKind::Module, (0, 100))
);
}
#[test]
fn t24_scope_provenance_store_insert_lookup_and_stale_handle() {
let mut store = ScopeProvenanceStore::new();
store.resize_to(4);
let id = ScopeId::new(0, 1);
let prov = ScopeProvenance {
first_seen_epoch: 10,
last_seen_epoch: 20,
file_stable_id: FileStableId::from_registry_path(Path::new("/a.rs")),
stable_id: ScopeStableId([0u8; 16]),
};
store.insert(id, prov.clone());
assert_eq!(store.lookup(id), Some(&prov));
assert_eq!(store.lookup(ScopeId::new(0, 999)), None);
}
#[test]
fn t38_scope_by_stable_id_reverse_lookup() {
let mut arena = ScopeArena::new();
let scope_id = arena.allocate(Scope {
kind: ScopeKind::Module,
parent: ScopeId::INVALID,
node: NodeId::new(0, 1),
byte_span: (0, 100),
file: FileId::new(0),
});
let stable = ScopeStableId([7u8; 16]);
let mut store = ScopeProvenanceStore::new();
store.resize_to(arena.slot_count());
store.insert(
scope_id,
ScopeProvenance {
first_seen_epoch: 1,
last_seen_epoch: 1,
file_stable_id: FileStableId::from_registry_path(Path::new("/x.rs")),
stable_id: stable,
},
);
assert_eq!(store.scope_by_stable_id(stable), Some(scope_id));
assert_eq!(store.scope_by_stable_id(ScopeStableId([0u8; 16])), None);
store.rebuild_reverse_index(&arena);
assert_eq!(store.scope_by_stable_id(stable), Some(scope_id));
}
#[test]
fn t_postcard_round_trip_and_stable_id_lookup_restored() {
let mut arena = ScopeArena::new();
let scope_id = arena.allocate(Scope {
kind: ScopeKind::Function,
parent: ScopeId::INVALID,
node: NodeId::new(1, 1),
byte_span: (10, 80),
file: FileId::new(0),
});
let stable = ScopeStableId([42u8; 16]);
let mut store = ScopeProvenanceStore::new();
store.resize_to(arena.slot_count());
store.insert(
scope_id,
ScopeProvenance {
first_seen_epoch: 5,
last_seen_epoch: 5,
file_stable_id: FileStableId::from_registry_path(Path::new("/lib.rs")),
stable_id: stable,
},
);
let bytes = postcard::to_allocvec(&store).expect("serialize");
let mut restored: ScopeProvenanceStore = postcard::from_bytes(&bytes).expect("deserialize");
assert_eq!(
restored.lookup(scope_id).map(|p| p.stable_id),
Some(stable),
"lookup by ScopeId must survive round-trip"
);
assert_eq!(
restored.scope_by_stable_id(stable),
None,
"reverse_index is serde(skip) — must be None before rebuild"
);
restored.rebuild_reverse_index(&arena);
assert_eq!(
restored.scope_by_stable_id(stable),
Some(scope_id),
"scope_by_stable_id must work after rebuild_reverse_index"
);
}
}