#![allow(clippy::too_many_lines)]
use std::collections::{HashMap, HashSet};
use crate::confidence::ConfidenceMetadata;
use crate::graph::error::GraphResult;
use crate::graph::unified::bind::alias::AliasTable;
use crate::graph::unified::bind::scope::ScopeArena;
use crate::graph::unified::bind::scope::provenance::ScopeProvenanceStore;
use crate::graph::unified::bind::shadow::ShadowTable;
use crate::graph::unified::concurrent::CodeGraph;
use crate::graph::unified::edge::bidirectional::BidirectionalEdgeStore;
use crate::graph::unified::node::NodeId;
use crate::graph::unified::storage::arena::NodeArena;
use crate::graph::unified::storage::edge_provenance::EdgeProvenanceStore;
use crate::graph::unified::storage::indices::AuxiliaryIndices;
use crate::graph::unified::storage::interner::StringInterner;
use crate::graph::unified::storage::metadata::NodeMetadataStore;
use crate::graph::unified::storage::node_provenance::NodeProvenanceStore;
use crate::graph::unified::storage::registry::FileRegistry;
use crate::graph::unified::storage::segment::FileSegmentTable;
use super::coverage::NodeIdBearing;
#[macro_export]
macro_rules! sqry_graph_fields {
(@decl_rebuild) => {
pub struct RebuildGraph {
pub(crate) nodes: NodeArena,
pub(crate) edges: BidirectionalEdgeStore,
pub(crate) strings: StringInterner,
pub(crate) files: FileRegistry,
pub(crate) indices: AuxiliaryIndices,
pub(crate) macro_metadata: NodeMetadataStore,
pub(crate) node_provenance: NodeProvenanceStore,
pub(crate) edge_provenance: EdgeProvenanceStore,
pub(crate) fact_epoch: u64,
pub(crate) epoch: u64,
pub(crate) confidence: HashMap<String, ConfidenceMetadata>,
pub(crate) scope_arena: ScopeArena,
pub(crate) alias_table: AliasTable,
pub(crate) shadow_table: ShadowTable,
pub(crate) scope_provenance_store: ScopeProvenanceStore,
pub(crate) file_segments: FileSegmentTable,
pub(crate) tombstones: HashSet<NodeId>,
pub(crate) drained_tombstones: HashSet<NodeId>,
}
};
(@clone_inner from $this:expr) => {{
let CodeGraph {
nodes,
edges,
strings,
files,
indices,
macro_metadata,
node_provenance,
edge_provenance,
fact_epoch,
epoch,
confidence,
scope_arena,
alias_table,
shadow_table,
scope_provenance_store,
file_segments,
} = $this;
RebuildGraph {
nodes: (**nodes).clone(),
edges: (**edges).clone(),
strings: (**strings).clone(),
files: (**files).clone(),
indices: (**indices).clone(),
macro_metadata: (**macro_metadata).clone(),
node_provenance: (**node_provenance).clone(),
edge_provenance: (**edge_provenance).clone(),
fact_epoch: *fact_epoch,
epoch: *epoch,
confidence: confidence.clone(),
scope_arena: (**scope_arena).clone(),
alias_table: (**alias_table).clone(),
shadow_table: (**shadow_table).clone(),
scope_provenance_store: (**scope_provenance_store).clone(),
file_segments: (**file_segments).clone(),
tombstones: ::std::collections::HashSet::new(),
drained_tombstones: ::std::collections::HashSet::new(),
}
}};
(@field_names) => {
&[
"nodes",
"edges",
"strings",
"files",
"indices",
"macro_metadata",
"node_provenance",
"edge_provenance",
"fact_epoch",
"epoch",
"confidence",
"scope_arena",
"alias_table",
"shadow_table",
"scope_provenance_store",
"file_segments",
]
};
}
pub(crate) use sqry_graph_fields;
sqry_graph_fields!(@decl_rebuild);
impl CodeGraph {
#[must_use]
pub fn clone_for_rebuild(&self) -> RebuildGraph {
Self::clone_for_rebuild_inner(self)
}
fn clone_for_rebuild_inner(&self) -> RebuildGraph {
sqry_graph_fields!(@clone_inner from self)
}
}
impl RebuildGraph {
pub fn finalize(mut self) -> GraphResult<CodeGraph> {
let string_remap = self.strings.build_dedup_table();
if !string_remap.is_empty() {
rewrite_node_entries_through_remap(&mut self.nodes, &string_remap);
self.indices.rewrite_string_ids_through_remap(&string_remap);
self.files.rewrite_string_ids_through_remap(&string_remap);
self.alias_table
.rewrite_string_ids_through_remap(&string_remap);
self.shadow_table
.rewrite_string_ids_through_remap(&string_remap);
self.edges
.rewrite_edge_kind_string_ids_through_remap(&string_remap);
}
self.strings.recycle_unreferenced();
{
let tombstones = &self.tombstones;
let predicate: Box<dyn Fn(NodeId) -> bool + '_> =
Box::new(move |nid| !tombstones.contains(&nid));
self.nodes.retain_nodes(&*predicate);
}
{
let tombstones = &self.tombstones;
let predicate: Box<dyn Fn(NodeId) -> bool + '_> =
Box::new(move |nid| !tombstones.contains(&nid));
self.edges.retain_nodes(&*predicate);
}
{
let tombstones = &self.tombstones;
let predicate: Box<dyn Fn(NodeId) -> bool + '_> =
Box::new(move |nid| !tombstones.contains(&nid));
self.indices.retain_nodes(&*predicate);
}
{
let tombstones = &self.tombstones;
let predicate: Box<dyn Fn(NodeId) -> bool + '_> =
Box::new(move |nid| !tombstones.contains(&nid));
self.macro_metadata.retain_nodes(&*predicate);
}
{
let tombstones = &self.tombstones;
let predicate: Box<dyn Fn(NodeId) -> bool + '_> =
Box::new(move |nid| !tombstones.contains(&nid));
self.files.retain_nodes(&*predicate);
}
{
let tombstones = &self.tombstones;
let predicate: Box<dyn Fn(NodeId) -> bool + '_> =
Box::new(move |nid| !tombstones.contains(&nid));
self.node_provenance.retain_nodes(&*predicate);
self.scope_arena.retain_nodes(&*predicate);
self.alias_table.retain_nodes(&*predicate);
self.shadow_table.retain_nodes(&*predicate);
}
self.drained_tombstones = std::mem::take(&mut self.tombstones);
{
use crate::graph::unified::compaction::{
Direction, build_compacted_csr, snapshot_edges,
};
let node_count = self.nodes.slot_count();
let forward_snapshot = {
let forward = self.edges.forward();
snapshot_edges(&forward, node_count)
};
let reverse_snapshot = {
let reverse = self.edges.reverse();
snapshot_edges(&reverse, node_count)
};
let (forward_csr, reverse_csr) = rayon::join(
|| build_compacted_csr(&forward_snapshot, Direction::Forward),
|| build_compacted_csr(&reverse_snapshot, Direction::Reverse),
);
let (forward_csr, _) =
forward_csr.map_err(|e| crate::graph::error::GraphBuilderError::Internal {
reason: format!("rebuild finalize step 9 (forward CSR build): {e}"),
})?;
let (reverse_csr, _) =
reverse_csr.map_err(|e| crate::graph::error::GraphBuilderError::Internal {
reason: format!("rebuild finalize step 9 (reverse CSR build): {e}"),
})?;
self.edges
.swap_csrs_and_clear_deltas(forward_csr, reverse_csr);
}
{
use std::collections::BTreeSet;
let mut active_languages: BTreeSet<String> = BTreeSet::new();
for (_file_id, _path, maybe_language) in self.files.iter_with_language() {
if let Some(language) = maybe_language {
active_languages.insert(language.to_string());
}
}
self.confidence.retain(|language_key, meta| {
if active_languages.contains(language_key) {
true
} else {
!meta.limitations.is_empty() || !meta.unavailable_features.is_empty()
}
});
}
let new_epoch = self.epoch.wrapping_add(1);
let graph = CodeGraph::__assemble_from_rebuild_parts_internal(
self.nodes,
self.edges,
self.strings,
self.files,
self.indices,
self.macro_metadata,
self.node_provenance,
self.edge_provenance,
self.fact_epoch,
new_epoch,
self.confidence,
self.scope_arena,
self.alias_table,
self.shadow_table,
self.scope_provenance_store,
self.file_segments,
);
#[cfg(any(debug_assertions, test))]
crate::graph::unified::publish::assert_publish_invariants(&graph, &self.drained_tombstones);
Ok(graph)
}
#[must_use]
pub fn pending_tombstone_count(&self) -> usize {
self.tombstones.len()
}
#[must_use]
pub fn nodes(&self) -> &NodeArena {
&self.nodes
}
#[must_use]
pub fn files(&self) -> &FileRegistry {
&self.files
}
#[must_use]
pub fn file_segments(&self) -> &FileSegmentTable {
&self.file_segments
}
pub fn tombstone(&mut self, id: NodeId) {
self.tombstones.insert(id);
}
pub(crate) fn tombstone_many<I: IntoIterator<Item = NodeId>>(&mut self, ids: I) {
self.tombstones.extend(ids);
}
#[allow(dead_code)] pub fn remove_file(&mut self, file_id: super::super::file::FileId) -> Vec<NodeId> {
let tombstoned: Vec<NodeId> = self.files.take_nodes(file_id);
self.files.unregister(file_id);
self.file_segments.remove(file_id);
if tombstoned.is_empty() {
return tombstoned;
}
let dead: std::collections::HashSet<NodeId> = tombstoned.iter().copied().collect();
for &nid in &tombstoned {
let _ = self.nodes.remove(nid);
}
self.edges.tombstone_edges_for_nodes(&dead);
self.tombstone_many(tombstoned.iter().copied());
tombstoned
}
#[must_use]
pub fn prior_epoch(&self) -> u64 {
self.epoch
}
#[cfg(any(debug_assertions, test))]
pub fn assert_no_tombstone_residue(&self) {
use super::coverage::NodeIdBearing;
let dead = &self.tombstones;
if dead.is_empty() {
return;
}
for nid in self.nodes.all_node_ids() {
assert!(
!dead.contains(&nid),
"RebuildGraph::assert_no_tombstone_residue: tombstone {nid:?} still in NodeArena"
);
}
for nid in self.edges.all_node_ids() {
assert!(
!dead.contains(&nid),
"RebuildGraph::assert_no_tombstone_residue: tombstone {nid:?} still in edge store"
);
}
for nid in self.indices.all_node_ids() {
assert!(
!dead.contains(&nid),
"RebuildGraph::assert_no_tombstone_residue: tombstone {nid:?} still in auxiliary indices"
);
}
for nid in self.macro_metadata.all_node_ids() {
assert!(
!dead.contains(&nid),
"RebuildGraph::assert_no_tombstone_residue: tombstone {nid:?} still in macro metadata"
);
}
for nid in self.node_provenance.all_node_ids() {
assert!(
!dead.contains(&nid),
"RebuildGraph::assert_no_tombstone_residue: tombstone {nid:?} still in node provenance"
);
}
for nid in self.scope_arena.all_node_ids() {
assert!(
!dead.contains(&nid),
"RebuildGraph::assert_no_tombstone_residue: tombstone {nid:?} still in scope arena"
);
}
for nid in self.alias_table.all_node_ids() {
assert!(
!dead.contains(&nid),
"RebuildGraph::assert_no_tombstone_residue: tombstone {nid:?} still in alias table"
);
}
for nid in self.shadow_table.all_node_ids() {
assert!(
!dead.contains(&nid),
"RebuildGraph::assert_no_tombstone_residue: tombstone {nid:?} still in shadow table"
);
}
for nid in self.files.all_node_ids() {
assert!(
!dead.contains(&nid),
"RebuildGraph::assert_no_tombstone_residue: tombstone {nid:?} still in per-file bucket"
);
}
}
}
fn rewrite_node_entries_through_remap(
nodes: &mut NodeArena,
remap: &HashMap<
crate::graph::unified::string::id::StringId,
crate::graph::unified::string::id::StringId,
>,
) {
if remap.is_empty() {
return;
}
for (_id, entry) in nodes.iter_mut() {
if let Some(&canon) = remap.get(&entry.name) {
entry.name = canon;
}
if let Some(sid) = entry.signature
&& let Some(&canon) = remap.get(&sid)
{
entry.signature = Some(canon);
}
if let Some(sid) = entry.doc
&& let Some(&canon) = remap.get(&sid)
{
entry.doc = Some(canon);
}
if let Some(sid) = entry.qualified_name
&& let Some(&canon) = remap.get(&sid)
{
entry.qualified_name = Some(canon);
}
if let Some(sid) = entry.visibility
&& let Some(&canon) = remap.get(&sid)
{
entry.visibility = Some(canon);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::unified::file::FileId;
use crate::graph::unified::node::NodeKind;
use crate::graph::unified::storage::arena::NodeEntry;
use crate::graph::unified::storage::metadata::MacroNodeMetadata;
fn seeded_graph() -> (CodeGraph, NodeId, NodeId, NodeId) {
let mut graph = CodeGraph::new();
let file_a = FileId::new(1);
let file_b = FileId::new(2);
let sym = graph.strings_mut().intern("symbol_a").expect("intern a");
let node_a;
let node_b;
let node_c;
{
let arena = graph.nodes_mut();
node_a = arena
.alloc(NodeEntry::new(NodeKind::Function, sym, file_a))
.expect("alloc a");
node_b = arena
.alloc(NodeEntry::new(NodeKind::Method, sym, file_a))
.expect("alloc b");
node_c = arena
.alloc(NodeEntry::new(NodeKind::Struct, sym, file_b))
.expect("alloc c");
}
graph
.indices_mut()
.add(node_a, NodeKind::Function, sym, Some(sym), file_a);
graph
.indices_mut()
.add(node_b, NodeKind::Method, sym, Some(sym), file_a);
graph
.indices_mut()
.add(node_c, NodeKind::Struct, sym, Some(sym), file_b);
graph
.macro_metadata_mut()
.insert(node_a, MacroNodeMetadata::default());
(graph, node_a, node_b, node_c)
}
#[test]
fn clone_for_rebuild_copies_every_field_without_arc_sharing() {
let (graph, _a, _b, _c) = seeded_graph();
let rebuild = graph.clone_for_rebuild();
assert_eq!(rebuild.nodes.len(), graph.nodes().len());
assert_eq!(rebuild.macro_metadata.len(), graph.macro_metadata().len());
let mut rebuild = rebuild;
let ids: Vec<NodeId> = rebuild.nodes.all_node_ids().collect();
let victim = *ids.first().expect("at least one node");
rebuild.tombstone(victim);
assert_eq!(rebuild.pending_tombstone_count(), 1);
assert_eq!(graph.nodes().len(), ids.len());
}
#[test]
fn clone_for_rebuild_preserves_epoch_and_fact_epoch() {
let (mut graph, _, _, _) = seeded_graph();
graph.set_epoch(7);
let rebuild = graph.clone_for_rebuild();
assert_eq!(rebuild.prior_epoch(), 7);
assert_eq!(rebuild.fact_epoch, graph.fact_epoch());
}
#[test]
fn finalize_step11_bumps_epoch_by_one() {
let (mut graph, _, _, _) = seeded_graph();
graph.set_epoch(41);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
assert_eq!(finalized.epoch(), 42);
}
#[test]
fn finalize_step8_drains_tombstones_into_drained_set() {
let (graph, a, _b, _c) = seeded_graph();
let mut rebuild = graph.clone_for_rebuild();
rebuild.tombstone(a);
assert_eq!(rebuild.pending_tombstone_count(), 1);
let finalized = rebuild.finalize().expect("finalize ok");
assert!(
finalized.nodes().get(a).is_none(),
"tombstoned node must be gone from arena"
);
}
#[test]
fn finalize_steps_2_and_4_compact_arena_and_indices_consistently() {
let (graph, a, _b, c) = seeded_graph();
let mut rebuild = graph.clone_for_rebuild();
rebuild.tombstone(a);
rebuild.tombstone(c);
let finalized = rebuild.finalize().expect("finalize ok");
assert!(finalized.nodes().get(a).is_none());
assert!(finalized.nodes().get(c).is_none());
use crate::graph::unified::storage::metadata::NodeMetadata;
let _ = NodeMetadata::Macro(MacroNodeMetadata::default()); assert!(!finalized.indices().by_kind(NodeKind::Function).contains(&a));
assert!(!finalized.indices().by_kind(NodeKind::Struct).contains(&c));
}
#[test]
fn finalize_step5_compacts_macro_metadata() {
let (graph, a, _b, _c) = seeded_graph();
let mut rebuild = graph.clone_for_rebuild();
rebuild.tombstone(a);
let finalized = rebuild.finalize().expect("finalize ok");
assert!(finalized.macro_metadata().get(a).is_none());
}
#[test]
fn finalize_step9_installs_rebuilt_csr() {
let (graph, _, _, _) = seeded_graph();
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
assert!(
finalized.edges().forward().csr().is_some(),
"step 9 must install forward CSR"
);
assert!(
finalized.edges().reverse().csr().is_some(),
"step 9 must install reverse CSR"
);
assert_eq!(finalized.edges().forward().delta_count(), 0);
assert_eq!(finalized.edges().reverse().delta_count(), 0);
}
#[test]
fn finalize_step10_drops_confidence_for_removed_languages() {
let (mut graph, _, _, _) = seeded_graph();
graph.merge_confidence(
"rust",
crate::confidence::ConfidenceMetadata {
level: crate::confidence::ConfidenceLevel::Verified,
..Default::default()
},
);
graph.merge_confidence(
"python",
crate::confidence::ConfidenceMetadata {
level: crate::confidence::ConfidenceLevel::Partial,
..Default::default()
},
);
assert_eq!(graph.confidence().len(), 2);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
assert!(
finalized.confidence().is_empty(),
"step 10 must drop confidence entries for languages that \
have no live files and no recorded limitations"
);
}
#[test]
fn finalize_step10_preserves_confidence_with_limitations() {
let (mut graph, _, _, _) = seeded_graph();
graph.merge_confidence(
"rust",
crate::confidence::ConfidenceMetadata {
level: crate::confidence::ConfidenceLevel::AstOnly,
limitations: vec!["no rust-analyzer".to_string()],
unavailable_features: vec!["type inference".to_string()],
},
);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
assert_eq!(finalized.confidence().len(), 1);
assert!(finalized.confidence().contains_key("rust"));
}
#[test]
fn finalize_step1_canonicalises_interner_via_dedup() {
let (graph, _, _, _) = seeded_graph();
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
assert!(
!finalized.strings().is_lookup_stale(),
"step 1 freeze must leave lookup_stale == false"
);
}
#[test]
fn finalize_is_infallible_on_empty_tombstone_set() {
let (graph, _, _, _) = seeded_graph();
let rebuild = graph.clone_for_rebuild();
let result = rebuild.finalize();
assert!(result.is_ok(), "empty-tombstone finalize must succeed");
let finalized = result.unwrap();
assert_eq!(finalized.nodes().len(), 3);
}
#[test]
fn finalize_survives_interner_snapshot_unchanged_when_no_edits() {
let (graph, _, _, _) = seeded_graph();
let prior_string_count = graph.strings().len();
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
assert_eq!(
finalized.strings().len(),
prior_string_count,
"freeze step must preserve string count across a no-op rebuild"
);
}
#[test]
fn rebuild_graph_pending_tombstone_count_is_accurate() {
let (graph, a, b, c) = seeded_graph();
let mut rebuild = graph.clone_for_rebuild();
assert_eq!(rebuild.pending_tombstone_count(), 0);
rebuild.tombstone(a);
rebuild.tombstone(b);
rebuild.tombstone(c);
rebuild.tombstone(a);
assert_eq!(rebuild.pending_tombstone_count(), 3);
}
#[test]
fn step1_remaps_auxiliary_indices_keys_through_dedup_table() {
use crate::graph::unified::storage::interner::StringInterner;
let mut graph = CodeGraph::new();
let interner: &mut StringInterner = graph.strings_mut();
let start = interner.alloc_range(2).expect("alloc range");
{
let (s_slots, rc_slots) = interner.bulk_slices_mut(start, 2);
s_slots[0] = Some(std::sync::Arc::from("alpha"));
s_slots[1] = Some(std::sync::Arc::from("alpha"));
rc_slots[0] = 1;
rc_slots[1] = 1;
}
let id_dup = crate::graph::unified::string::id::StringId::new(start + 1);
let id_canon = crate::graph::unified::string::id::StringId::new(start);
let file = FileId::new(1);
let mut entry = NodeEntry::new(NodeKind::Function, id_dup, file);
entry.qualified_name = Some(id_dup);
let node = graph.nodes_mut().alloc(entry).expect("alloc");
graph
.indices_mut()
.add(node, NodeKind::Function, id_dup, Some(id_dup), file);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
let entry = finalized.nodes().get(node).expect("node survives");
assert_eq!(entry.name, id_canon, "NodeEntry.name must be canonicalised");
assert_eq!(
entry.qualified_name,
Some(id_canon),
"NodeEntry.qualified_name must be canonicalised"
);
assert!(
finalized.indices().by_name(id_canon).contains(&node),
"indices.by_name under canonical StringId must contain the node"
);
assert!(
finalized.indices().by_name(id_dup).is_empty(),
"duplicate StringId bucket must be empty after remap"
);
assert!(
finalized.indices().by_qualified_name(id_dup).is_empty(),
"duplicate StringId qname bucket must be empty after remap"
);
assert!(!finalized.strings().is_lookup_stale());
}
#[test]
fn step1_merges_aux_indices_buckets_when_keys_collapse() {
use crate::graph::unified::storage::interner::StringInterner;
let mut graph = CodeGraph::new();
let interner: &mut StringInterner = graph.strings_mut();
let start = interner.alloc_range(2).expect("alloc range");
{
let (s_slots, rc_slots) = interner.bulk_slices_mut(start, 2);
s_slots[0] = Some(std::sync::Arc::from("beta"));
s_slots[1] = Some(std::sync::Arc::from("beta"));
rc_slots[0] = 1;
rc_slots[1] = 1;
}
let id_canon = crate::graph::unified::string::id::StringId::new(start);
let id_dup = crate::graph::unified::string::id::StringId::new(start + 1);
let file = FileId::new(1);
let node_canon = graph
.nodes_mut()
.alloc(NodeEntry::new(NodeKind::Function, id_canon, file))
.expect("alloc canon");
let node_dup = graph
.nodes_mut()
.alloc(NodeEntry::new(NodeKind::Function, id_dup, file))
.expect("alloc dup");
graph
.indices_mut()
.add(node_canon, NodeKind::Function, id_canon, None, file);
graph
.indices_mut()
.add(node_dup, NodeKind::Function, id_dup, None, file);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
let canonical_bucket = finalized.indices().by_name(id_canon);
assert!(canonical_bucket.contains(&node_canon));
assert!(canonical_bucket.contains(&node_dup));
let mut seen = std::collections::HashSet::new();
for id in canonical_bucket {
assert!(seen.insert(*id), "bucket must be dedup'd by NodeId");
}
assert!(
finalized.indices().by_name(id_dup).is_empty(),
"collapsed duplicate key bucket must be removed"
);
}
#[test]
fn step1_remaps_file_registry_source_uri() {
use crate::graph::unified::storage::interner::StringInterner;
let mut graph = CodeGraph::new();
let interner: &mut StringInterner = graph.strings_mut();
let start = interner.alloc_range(2).expect("alloc range");
{
let (s, rc) = interner.bulk_slices_mut(start, 2);
s[0] = Some(std::sync::Arc::from("file:///foo"));
s[1] = Some(std::sync::Arc::from("file:///foo"));
rc[0] = 1;
rc[1] = 1;
}
let id_canon = crate::graph::unified::string::id::StringId::new(start);
let id_dup = crate::graph::unified::string::id::StringId::new(start + 1);
let fid = graph
.files_mut()
.register_external_with_uri(
"/virtual/Foo.class",
Some(crate::graph::node::Language::Java),
Some(id_dup),
)
.expect("register external");
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
let view = finalized
.files()
.file_provenance(fid)
.expect("provenance present");
assert_eq!(
view.source_uri,
Some(id_canon),
"FileRegistry source_uri must be rewritten to canonical StringId"
);
}
#[test]
fn step1_remaps_edge_kind_string_ids_through_dedup_table() {
use crate::graph::unified::edge::EdgeKind;
use crate::graph::unified::storage::interner::StringInterner;
let mut graph = CodeGraph::new();
let file_a = FileId::new(1);
let interner: &mut StringInterner = graph.strings_mut();
let start = interner.alloc_range(3).expect("alloc range");
{
let (s, rc) = interner.bulk_slices_mut(start, 3);
s[0] = Some(std::sync::Arc::from("symbol_edge_dup"));
s[1] = Some(std::sync::Arc::from("symbol_edge_dup"));
s[2] = Some(std::sync::Arc::from("symbol_edge_dup"));
rc[0] = 1;
rc[1] = 1;
rc[2] = 1;
}
let id_canon = crate::graph::unified::string::id::StringId::new(start);
let id_dup_1 = crate::graph::unified::string::id::StringId::new(start + 1);
let id_dup_2 = crate::graph::unified::string::id::StringId::new(start + 2);
let src;
let tgt;
{
let arena = graph.nodes_mut();
src = arena
.alloc(NodeEntry::new(NodeKind::Function, id_canon, file_a))
.expect("alloc src");
tgt = arena
.alloc(NodeEntry::new(NodeKind::Function, id_canon, file_a))
.expect("alloc tgt");
}
graph
.indices_mut()
.add(src, NodeKind::Function, id_canon, None, file_a);
graph
.indices_mut()
.add(tgt, NodeKind::Function, id_canon, None, file_a);
graph.edges_mut().add_edge(
src,
tgt,
EdgeKind::Imports {
alias: Some(id_dup_1),
is_wildcard: false,
},
file_a,
);
graph.edges_mut().add_edge(
tgt,
src,
EdgeKind::Imports {
alias: Some(id_dup_2),
is_wildcard: true,
},
file_a,
);
assert!(
graph.edges().forward().delta_count() >= 2,
"pre-finalize: forward delta must hold both Imports edges"
);
assert!(
graph.edges().reverse().delta_count() >= 2,
"pre-finalize: reverse delta must hold the mirror of both Imports edges"
);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
let forward = finalized.edges().forward();
let fwd_csr = forward
.csr()
.expect("forward CSR must be populated after step 9");
let mut fwd_imports_seen = 0usize;
for kind in fwd_csr.edge_kind() {
if let EdgeKind::Imports { alias, .. } = kind {
fwd_imports_seen += 1;
assert_ne!(
*alias,
Some(id_dup_1),
"forward CSR Imports alias must not reference pre-dedup slot id_dup_1"
);
assert_ne!(
*alias,
Some(id_dup_2),
"forward CSR Imports alias must not reference pre-dedup slot id_dup_2"
);
assert_eq!(
*alias,
Some(id_canon),
"forward CSR Imports alias must be canonicalised"
);
}
}
drop(forward);
assert!(
fwd_imports_seen >= 2,
"forward CSR must hold both Imports edges after finalize (saw {fwd_imports_seen})"
);
let reverse = finalized.edges().reverse();
let rev_csr = reverse
.csr()
.expect("reverse CSR must be populated after step 9");
let mut rev_imports_seen = 0usize;
for kind in rev_csr.edge_kind() {
if let EdgeKind::Imports { alias, .. } = kind {
rev_imports_seen += 1;
assert_ne!(*alias, Some(id_dup_1));
assert_ne!(*alias, Some(id_dup_2));
assert_eq!(*alias, Some(id_canon));
}
}
drop(reverse);
assert!(
rev_imports_seen >= 2,
"reverse CSR must mirror the forward Imports edges (saw {rev_imports_seen})"
);
assert_eq!(
finalized.strings().ref_count(id_dup_1),
0,
"duplicate slot id_dup_1 must be recycled (ref_count == 0) by step 1"
);
assert_eq!(
finalized.strings().ref_count(id_dup_2),
0,
"duplicate slot id_dup_2 must be recycled (ref_count == 0) by step 1"
);
assert!(
finalized.strings().ref_count(id_canon) > 0,
"canonical slot must retain references from the two Imports edges (got {})",
finalized.strings().ref_count(id_canon)
);
}
#[test]
fn step1_remaps_edge_kind_string_ids_in_csr_tier() {
use crate::graph::unified::compaction::{Direction, build_compacted_csr, snapshot_edges};
use crate::graph::unified::edge::EdgeKind;
use crate::graph::unified::storage::interner::StringInterner;
let mut graph = CodeGraph::new();
let file_a = FileId::new(1);
let interner: &mut StringInterner = graph.strings_mut();
let start = interner.alloc_range(2).expect("alloc range");
{
let (s, rc) = interner.bulk_slices_mut(start, 2);
s[0] = Some(std::sync::Arc::from("csr_alias_dup"));
s[1] = Some(std::sync::Arc::from("csr_alias_dup"));
rc[0] = 1;
rc[1] = 1;
}
let id_canon = crate::graph::unified::string::id::StringId::new(start);
let id_dup = crate::graph::unified::string::id::StringId::new(start + 1);
let src;
let tgt;
{
let arena = graph.nodes_mut();
src = arena
.alloc(NodeEntry::new(NodeKind::Function, id_canon, file_a))
.expect("alloc src");
tgt = arena
.alloc(NodeEntry::new(NodeKind::Function, id_canon, file_a))
.expect("alloc tgt");
}
graph
.indices_mut()
.add(src, NodeKind::Function, id_canon, None, file_a);
graph
.indices_mut()
.add(tgt, NodeKind::Function, id_canon, None, file_a);
graph.edges_mut().add_edge(
src,
tgt,
EdgeKind::Imports {
alias: Some(id_dup),
is_wildcard: false,
},
file_a,
);
let node_count = 2;
let edges = graph.edges_mut();
let fwd_snap = snapshot_edges(&edges.forward(), node_count);
let (forward_csr, _) =
build_compacted_csr(&fwd_snap, Direction::Forward).expect("forward csr");
let rev_snap = snapshot_edges(&edges.reverse(), node_count);
let (reverse_csr, _) =
build_compacted_csr(&rev_snap, Direction::Reverse).expect("reverse csr");
edges.swap_csrs_and_clear_deltas(forward_csr, reverse_csr);
assert!(
edges.forward().csr().is_some(),
"forward CSR must be present"
);
assert!(
edges.reverse().csr().is_some(),
"reverse CSR must be present"
);
assert_eq!(edges.forward().delta_count(), 0, "delta must be empty");
assert_eq!(edges.reverse().delta_count(), 0, "delta must be empty");
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
let forward = finalized.edges().forward();
let csr = forward
.csr()
.expect("forward CSR must survive finalize step 1");
let mut imports_seen = 0usize;
for kind in csr.edge_kind() {
if let EdgeKind::Imports { alias, .. } = kind {
imports_seen += 1;
assert_ne!(
*alias,
Some(id_dup),
"CSR Imports alias must not reference pre-dedup duplicate"
);
assert_eq!(
*alias,
Some(id_canon),
"CSR Imports alias must be canonicalised"
);
}
}
assert!(
imports_seen > 0,
"forward CSR must hold at least one Imports edge"
);
drop(forward);
let reverse = finalized.edges().reverse();
let rev_csr = reverse
.csr()
.expect("reverse CSR must survive finalize step 1");
let mut rev_imports_seen = 0usize;
for kind in rev_csr.edge_kind() {
if let EdgeKind::Imports { alias, .. } = kind {
rev_imports_seen += 1;
assert_ne!(*alias, Some(id_dup));
assert_eq!(*alias, Some(id_canon));
}
}
assert!(rev_imports_seen > 0, "reverse CSR must hold Imports edges");
drop(reverse);
assert_eq!(
finalized.strings().ref_count(id_dup),
0,
"duplicate slot must be recycled (ref_count == 0) by step 1"
);
}
#[test]
fn step1_remaps_all_stringid_holders_exhaustively() {
use crate::graph::unified::edge::EdgeKind;
use crate::graph::unified::storage::interner::StringInterner;
let mut graph = CodeGraph::new();
let file_a = FileId::new(1);
let interner: &mut StringInterner = graph.strings_mut();
let start = interner.alloc_range(10).expect("alloc range");
{
let (s, rc) = interner.bulk_slices_mut(start, 10);
for (i, label) in [
"arena_name",
"arena_name",
"arena_qname",
"arena_qname",
"idx_key",
"idx_key",
"edge_alias",
"edge_alias",
"file_uri",
"file_uri",
]
.iter()
.enumerate()
{
s[i] = Some(std::sync::Arc::from(*label));
rc[i] = 1;
}
}
let sid = |off: u32| crate::graph::unified::string::id::StringId::new(start + off);
let arena_name_canon = sid(0);
let arena_name_dup = sid(1);
let arena_qname_canon = sid(2);
let arena_qname_dup = sid(3);
let idx_key_canon = sid(4);
let idx_key_dup = sid(5);
let edge_alias_canon = sid(6);
let edge_alias_dup = sid(7);
let file_uri_canon = sid(8);
let file_uri_dup = sid(9);
let node;
let node2;
{
let arena = graph.nodes_mut();
let mut entry = NodeEntry::new(NodeKind::Function, arena_name_dup, file_a);
entry.qualified_name = Some(arena_qname_dup);
node = arena.alloc(entry).expect("alloc arena node");
node2 = arena
.alloc(NodeEntry::new(NodeKind::Function, arena_name_dup, file_a))
.expect("alloc arena node2");
}
graph
.indices_mut()
.add(node, NodeKind::Function, idx_key_dup, None, file_a);
graph
.indices_mut()
.add(node2, NodeKind::Function, idx_key_dup, None, file_a);
let fid = graph
.files_mut()
.register_external_with_uri(
"/virtual/Exhaustive.class",
Some(crate::graph::node::Language::Java),
Some(file_uri_dup),
)
.expect("register external");
graph.edges_mut().add_edge(
node,
node2,
EdgeKind::Imports {
alias: Some(edge_alias_dup),
is_wildcard: false,
},
file_a,
);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
for nid in [node, node2] {
let entry = finalized.nodes().get(nid).expect("node survives");
assert_eq!(entry.name, arena_name_canon, "arena name canonicalised");
if let Some(q) = entry.qualified_name {
assert_eq!(q, arena_qname_canon, "arena qname canonicalised");
}
}
assert!(finalized.indices().by_name(idx_key_dup).is_empty());
assert!(!finalized.indices().by_name(idx_key_canon).is_empty());
let view = finalized
.files()
.file_provenance(fid)
.expect("provenance present");
assert_eq!(view.source_uri, Some(file_uri_canon));
let fwd = finalized.edges().forward();
let fwd_csr = fwd
.csr()
.expect("forward CSR must be populated after step 9");
let mut fwd_imports = 0usize;
for kind in fwd_csr.edge_kind() {
if let EdgeKind::Imports { alias, .. } = kind {
fwd_imports += 1;
assert_ne!(*alias, Some(edge_alias_dup));
assert_eq!(*alias, Some(edge_alias_canon));
}
}
drop(fwd);
assert!(fwd_imports > 0, "forward Imports must be present in CSR");
let rev = finalized.edges().reverse();
let rev_csr = rev
.csr()
.expect("reverse CSR must be populated after step 9");
let mut rev_imports = 0usize;
for kind in rev_csr.edge_kind() {
if let EdgeKind::Imports { alias, .. } = kind {
rev_imports += 1;
assert_ne!(*alias, Some(edge_alias_dup));
assert_eq!(*alias, Some(edge_alias_canon));
}
}
drop(rev);
assert!(rev_imports > 0, "reverse Imports must be present in CSR");
for (dup, canon, label) in [
(arena_name_dup, arena_name_canon, "arena_name"),
(arena_qname_dup, arena_qname_canon, "arena_qname"),
(idx_key_dup, idx_key_canon, "idx_key"),
(edge_alias_dup, edge_alias_canon, "edge_alias"),
(file_uri_dup, file_uri_canon, "file_uri"),
] {
assert_eq!(
finalized.strings().ref_count(dup),
0,
"{label}: duplicate slot must be recycled (ref_count == 0)"
);
assert!(
finalized.strings().ref_count(canon) > 0,
"{label}: canonical slot must retain references (got {})",
finalized.strings().ref_count(canon)
);
}
}
#[test]
fn finalize_step6_drops_tombstoned_nodes_from_buckets() {
let (graph, a, b, c) = seeded_graph();
let file_a = FileId::new(1);
let file_b = FileId::new(2);
{
}
{
let files = graph.files();
let _ = files; }
let mut graph = graph;
graph.files_mut().record_node(file_a, a);
graph.files_mut().record_node(file_a, b);
graph.files_mut().record_node(file_b, c);
let mut rebuild = graph.clone_for_rebuild();
rebuild.tombstone(a);
rebuild.tombstone(c);
let finalized = rebuild.finalize().expect("finalize ok");
let buckets: std::collections::BTreeMap<FileId, Vec<crate::graph::unified::node::NodeId>> =
finalized.files().per_file_nodes_for_gate0d().collect();
assert_eq!(buckets.len(), 1, "empty bucket must be dropped");
assert_eq!(buckets.get(&file_a).cloned().unwrap_or_default(), vec![b]);
assert!(!buckets.contains_key(&file_b));
}
#[test]
fn finalize_step6_dedups_within_bucket() {
let (mut graph, a, b, c) = seeded_graph();
let file_a = FileId::new(1);
let file_b = FileId::new(2);
graph.files_mut().record_node(file_a, a);
graph.files_mut().record_node(file_a, a); graph.files_mut().record_node(file_a, b);
graph.files_mut().record_node(file_b, c);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
let buckets: std::collections::BTreeMap<FileId, Vec<crate::graph::unified::node::NodeId>> =
finalized.files().per_file_nodes_for_gate0d().collect();
let bucket_a = buckets.get(&file_a).expect("bucket for file_a");
assert_eq!(
bucket_a.len(),
2,
"duplicates within bucket must be dedup'd"
);
assert!(bucket_a.contains(&a));
assert!(bucket_a.contains(&b));
}
#[test]
fn finalize_step6_drops_empty_buckets() {
let (mut graph, a, _b, _c) = seeded_graph();
let file = FileId::new(1);
graph.files_mut().record_node(file, a);
let mut rebuild = graph.clone_for_rebuild();
rebuild.tombstone(a); let finalized = rebuild.finalize().expect("finalize ok");
assert_eq!(
finalized.files().per_file_bucket_count(),
0,
"empty bucket must be dropped by step 6"
);
}
#[test]
fn bucket_bijection_passes_when_every_live_node_is_bucketed() {
let (mut graph, a, b, c) = seeded_graph();
let file_a = FileId::new(1);
let file_b = FileId::new(2);
graph.files_mut().record_node(file_a, a);
graph.files_mut().record_node(file_a, b);
graph.files_mut().record_node(file_b, c);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
finalized.assert_bucket_bijection();
}
#[test]
#[should_panic(expected = "duplicate node")]
fn bucket_bijection_detects_duplicate_within_bucket() {
let (mut graph, a, _b, _c) = seeded_graph();
let file_a = FileId::new(1);
graph.files_mut().record_node(file_a, a);
graph.files_mut().record_node(file_a, a);
graph.assert_bucket_bijection();
}
#[test]
#[should_panic(expected = "misfiled")]
fn bucket_bijection_detects_misfiled_node() {
let (mut graph, a, _b, _c) = seeded_graph();
graph.files_mut().record_node(FileId::new(99), a);
graph.assert_bucket_bijection();
}
#[test]
#[should_panic(expected = "absent from all buckets")]
fn bucket_bijection_detects_missing_live_node() {
let (mut graph, a, _b, c) = seeded_graph();
let file_a = FileId::new(1);
let _ = c;
graph.files_mut().record_node(file_a, a);
graph.assert_bucket_bijection();
}
#[test]
#[should_panic(expected = "dead node")]
fn bucket_bijection_detects_dead_node_in_bucket() {
let (mut graph, a, _b, _c) = seeded_graph();
let file_a = FileId::new(1);
graph.files_mut().record_node(file_a, a);
graph.nodes_mut().remove(a);
graph.assert_bucket_bijection();
}
#[test]
#[should_panic(expected = "still in edge store")]
fn rebuild_graph_residue_detects_tombstone_still_in_edge_store() {
use crate::graph::unified::edge::kind::EdgeKind;
let (graph, a, b, _c) = seeded_graph();
let mut rebuild = graph.clone_for_rebuild();
rebuild.edges.add_edge(
a,
b,
EdgeKind::Calls {
argument_count: 0,
is_async: false,
},
FileId::new(1),
);
rebuild.nodes.remove(a);
rebuild.tombstone(a);
rebuild.assert_no_tombstone_residue();
}
#[test]
#[should_panic(expected = "still in auxiliary indices")]
fn rebuild_graph_residue_detects_tombstone_still_in_auxiliary_indices() {
let (graph, a, _b, _c) = seeded_graph();
let mut rebuild = graph.clone_for_rebuild();
rebuild.nodes.remove(a);
rebuild.tombstone(a);
rebuild.assert_no_tombstone_residue();
}
#[test]
#[should_panic(expected = "still in NodeArena")]
fn rebuild_graph_residue_detects_tombstone_still_in_node_arena() {
let (graph, a, _b, _c) = seeded_graph();
let mut rebuild = graph.clone_for_rebuild();
assert!(
rebuild.nodes.get(a).is_some(),
"pre-condition: arena must still contain the staged tombstone"
);
rebuild.tombstone(a);
rebuild.assert_no_tombstone_residue();
}
#[test]
fn rebuild_graph_residue_is_noop_on_empty_tombstone_set() {
let (graph, _a, _b, _c) = seeded_graph();
let rebuild = graph.clone_for_rebuild();
assert_eq!(rebuild.pending_tombstone_count(), 0);
rebuild.assert_no_tombstone_residue();
}
#[test]
#[should_panic(expected = "still in NodeArena")]
fn finalize_step14_residue_detects_live_reference_to_drained_node() {
let (graph, a, _b, _c) = seeded_graph();
let mut drained: ::std::collections::HashSet<NodeId> = ::std::collections::HashSet::new();
drained.insert(a);
crate::graph::unified::publish::assert_publish_invariants(&graph, &drained);
}
#[test]
fn finalize_with_empty_drained_set_passes_publish_invariants() {
let (graph, _a, _b, _c) = seeded_graph();
let file_a = FileId::new(1);
let file_b = FileId::new(2);
let mut graph = graph;
graph.files_mut().record_node(file_a, _a);
graph.files_mut().record_node(file_a, _b);
graph.files_mut().record_node(file_b, _c);
let rebuild = graph.clone_for_rebuild();
let finalized = rebuild.finalize().expect("finalize ok");
crate::graph::unified::publish::assert_publish_invariants(
&finalized,
&::std::collections::HashSet::new(),
);
}
fn seed_two_file_rebuild(
per_file: usize,
) -> (
RebuildGraph,
crate::graph::unified::file::FileId,
crate::graph::unified::file::FileId,
Vec<NodeId>,
Vec<NodeId>,
) {
use crate::graph::unified::edge::EdgeKind;
use crate::graph::unified::node::NodeKind;
use crate::graph::unified::storage::arena::NodeEntry;
use std::path::Path;
let mut graph = CodeGraph::new();
let sym = graph.strings_mut().intern("sym").expect("intern");
let file_a = graph
.files_mut()
.register(Path::new("/tmp/rebuild_remove_file_test/a.rs"))
.expect("register a");
let file_b = graph
.files_mut()
.register(Path::new("/tmp/rebuild_remove_file_test/b.rs"))
.expect("register b");
let mut file_a_nodes = Vec::with_capacity(per_file);
let mut file_b_nodes = Vec::with_capacity(per_file);
for _ in 0..per_file {
let n = graph
.nodes_mut()
.alloc(NodeEntry::new(NodeKind::Function, sym, file_a))
.expect("alloc a");
file_a_nodes.push(n);
graph.files_mut().record_node(file_a, n);
graph
.indices_mut()
.add(n, NodeKind::Function, sym, None, file_a);
}
for _ in 0..per_file {
let n = graph
.nodes_mut()
.alloc(NodeEntry::new(NodeKind::Function, sym, file_b))
.expect("alloc b");
file_b_nodes.push(n);
graph.files_mut().record_node(file_b, n);
graph
.indices_mut()
.add(n, NodeKind::Function, sym, None, file_b);
}
for i in 0..per_file.saturating_sub(1) {
graph.edges_mut().add_edge(
file_a_nodes[i],
file_a_nodes[i + 1],
EdgeKind::Calls {
argument_count: 0,
is_async: false,
},
file_a,
);
graph.edges_mut().add_edge(
file_b_nodes[i],
file_b_nodes[i + 1],
EdgeKind::Calls {
argument_count: 0,
is_async: false,
},
file_b,
);
}
graph.edges_mut().add_edge(
file_a_nodes[0],
file_b_nodes[0],
EdgeKind::Calls {
argument_count: 0,
is_async: false,
},
file_a,
);
graph.edges_mut().add_edge(
file_b_nodes[0],
file_a_nodes[0],
EdgeKind::Calls {
argument_count: 0,
is_async: false,
},
file_b,
);
let rebuild = graph.clone_for_rebuild();
(rebuild, file_a, file_b, file_a_nodes, file_b_nodes)
}
#[test]
fn rebuild_remove_file_tombstones_all_per_file_nodes() {
let (mut rebuild, file_a, _file_b, file_a_nodes, _) = seed_two_file_rebuild(3);
let returned = rebuild.remove_file(file_a);
let returned_set: std::collections::HashSet<NodeId> = returned.iter().copied().collect();
let expected_set: std::collections::HashSet<NodeId> =
file_a_nodes.iter().copied().collect();
assert_eq!(
returned_set, expected_set,
"remove_file must return exactly the file_a nodes drained from the bucket"
);
for nid in &file_a_nodes {
assert!(
rebuild.nodes.get(*nid).is_none(),
"node {nid:?} from removed file must be tombstoned on rebuild arena"
);
}
assert_eq!(rebuild.pending_tombstone_count(), file_a_nodes.len());
}
#[test]
fn rebuild_remove_file_invalidates_all_edges_sourced_or_targeted_at_removed_nodes() {
let (mut rebuild, file_a, _file_b, file_a_nodes, file_b_nodes) = seed_two_file_rebuild(3);
assert_eq!(rebuild.edges.forward().delta().len(), 6);
let _ = rebuild.remove_file(file_a);
assert_eq!(rebuild.edges.forward().delta().len(), 2);
assert_eq!(rebuild.edges.reverse().delta().len(), 2);
let b0 = file_b_nodes[0];
let a0 = file_a_nodes[0];
let from_b0: Vec<_> = rebuild
.edges
.edges_from(b0)
.into_iter()
.filter(|e| e.target == a0)
.collect();
assert!(
from_b0.is_empty(),
"edge b0 -> a0 must be gone after rebuild.remove_file(file_a)"
);
}
#[test]
fn rebuild_remove_file_drops_file_registry_entry() {
let (mut rebuild, file_a, _file_b, _, _) = seed_two_file_rebuild(2);
assert!(rebuild.files.resolve(file_a).is_some());
assert!(!rebuild.files.nodes_for_file(file_a).is_empty());
let _ = rebuild.remove_file(file_a);
assert!(
rebuild.files.resolve(file_a).is_none(),
"rebuild FileRegistry entry must be gone"
);
assert!(
rebuild.files.nodes_for_file(file_a).is_empty(),
"rebuild per-file bucket for file_a must be drained"
);
}
#[test]
fn rebuild_remove_file_is_idempotent_on_unknown_file() {
use crate::graph::unified::file::FileId;
let (mut rebuild, _file_a, _file_b, _, _) = seed_two_file_rebuild(2);
let nodes_before = rebuild.nodes.len();
let delta_fwd_before = rebuild.edges.forward().delta().len();
let delta_rev_before = rebuild.edges.reverse().delta().len();
let tombstones_before = rebuild.pending_tombstone_count();
let bogus = FileId::new(9999);
let returned = rebuild.remove_file(bogus);
assert!(returned.is_empty());
assert_eq!(rebuild.nodes.len(), nodes_before);
assert_eq!(rebuild.edges.forward().delta().len(), delta_fwd_before);
assert_eq!(rebuild.edges.reverse().delta().len(), delta_rev_before);
assert_eq!(rebuild.pending_tombstone_count(), tombstones_before);
}
#[test]
fn rebuild_remove_file_stages_tombstones_for_finalize_sweep() {
let (mut rebuild, file_a, file_b, file_a_nodes, file_b_nodes) = seed_two_file_rebuild(2);
let _ = rebuild.remove_file(file_a);
let _ = rebuild.remove_file(file_b);
assert_eq!(
rebuild.pending_tombstone_count(),
file_a_nodes.len() + file_b_nodes.len()
);
let finalized = rebuild.finalize().expect("finalize must succeed");
crate::graph::unified::publish::assert_publish_bijection(&finalized);
assert_eq!(finalized.nodes().len(), 0);
assert!(finalized.files().resolve(file_a).is_none());
assert!(finalized.files().resolve(file_b).is_none());
}
#[test]
fn rebuild_remove_file_repeated_calls_are_idempotent() {
let (mut rebuild, file_a, _file_b, file_a_nodes, _) = seed_two_file_rebuild(3);
let first = rebuild.remove_file(file_a);
assert_eq!(first.len(), file_a_nodes.len());
let staged = rebuild.pending_tombstone_count();
let second = rebuild.remove_file(file_a);
assert!(second.is_empty());
assert_eq!(rebuild.pending_tombstone_count(), staged);
}
#[test]
fn rebuild_remove_file_clears_file_segments_entry() {
use std::path::Path;
let (mut rebuild, file_a, _file_b, file_a_nodes, _) = seed_two_file_rebuild(3);
let first_index = file_a_nodes
.iter()
.map(|n| n.index())
.min()
.expect("per_file = 3");
let last_index = file_a_nodes
.iter()
.map(|n| n.index())
.max()
.expect("per_file = 3");
let slot_count = last_index - first_index + 1;
rebuild
.file_segments
.record_range(file_a, first_index, slot_count);
assert!(
rebuild.file_segments().get(file_a).is_some(),
"seeded segment for file_a must be present before remove_file"
);
let _ = rebuild.remove_file(file_a);
assert!(
rebuild.file_segments().get(file_a).is_none(),
"RebuildGraph::remove_file must clear the file_segments entry"
);
let finalized = rebuild.finalize().expect("finalize must succeed");
assert!(
finalized.file_segments().get(file_a).is_none(),
"finalize must publish a CodeGraph with no stale file_segments for file_a"
);
let _ = Path::new("");
}
}