#![cfg(feature = "rebuild-internals")]
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::OnceLock;
use tempfile::TempDir;
use sqry_core::graph::GraphBuilderError;
use sqry_core::graph::unified::build::incremental::testing::{
Phase3bIterHookGuard, Phase3cHookGuard, Phase3cIterHookGuard, Phase3cReparseHookGuard,
};
use sqry_core::graph::unified::build::incremental::{
PostCommitDiagnostics, compute_reverse_dep_closure, incremental_rebuild,
};
use sqry_core::graph::unified::build::{BuildConfig, CancellationToken, build_unified_graph};
use sqry_core::graph::unified::concurrent::CodeGraph;
use sqry_core::graph::unified::file::FileId;
use sqry_core::plugin::PluginManager;
fn plugin_manager() -> &'static PluginManager {
static MANAGER: OnceLock<PluginManager> = OnceLock::new();
MANAGER.get_or_init(|| {
let mut manager = PluginManager::new();
manager.register_builtin(Box::new(sqry_lang_rust::RustPlugin::default()));
manager
})
}
fn write_fixture(workspace: &Path) -> PathBuf {
std::fs::write(
workspace.join("lib.rs"),
r#"mod a;
pub fn main_entry() -> &'static str {
let _g = a::greet();
helper()
}
fn helper() -> &'static str {
"helper"
}
"#,
)
.expect("write lib.rs");
std::fs::write(
workspace.join("a.rs"),
r#"pub fn greet() -> &'static str {
"hi"
}
"#,
)
.expect("write a.rs");
workspace.join("lib.rs")
}
fn edit_a_rs(workspace: &Path) -> PathBuf {
let a_path = workspace.join("a.rs");
std::fs::write(
&a_path,
r#"pub fn greet() -> &'static str {
"hi, world"
}
pub fn greet_also() -> &'static str {
"also"
}
"#,
)
.expect("rewrite a.rs");
a_path
}
fn build_config() -> BuildConfig {
BuildConfig::default()
}
fn canon(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
struct Fixture {
_tempdir: TempDir,
#[allow(dead_code)]
workspace: PathBuf,
current_graph: CodeGraph,
changed_paths: Vec<PathBuf>,
closure: std::collections::HashSet<FileId>,
}
fn build_fixture() -> Fixture {
let tempdir = TempDir::new().expect("make tempdir");
let workspace = canon(tempdir.path());
write_fixture(&workspace);
let current_graph =
build_unified_graph(&workspace, plugin_manager(), &build_config()).expect("initial build");
let edited_path = edit_a_rs(&workspace);
let changed_paths = vec![edited_path];
let changed_fids: Vec<FileId> = current_graph
.indexed_files()
.filter_map(|(fid, path)| {
if changed_paths.iter().any(|p| canon(p) == canon(path)) {
Some(fid)
} else {
None
}
})
.collect();
let closure = compute_reverse_dep_closure(&changed_fids, ¤t_graph);
assert!(
!closure.is_empty(),
"two-file Rust fixture must produce a non-empty closure when a.rs changes"
);
Fixture {
_tempdir: tempdir,
workspace,
current_graph,
changed_paths,
closure,
}
}
fn build_multi_file_fixture() -> Fixture {
let tempdir = TempDir::new().expect("make tempdir");
let workspace = canon(tempdir.path());
std::fs::write(
workspace.join("x.rs"),
"pub fn x_one() -> u32 { 1 }\npub fn x_two() -> u32 { 2 }\n",
)
.expect("write x.rs");
std::fs::write(
workspace.join("y.rs"),
"pub fn y_one() -> u32 { 10 }\npub fn y_two() -> u32 { 20 }\n",
)
.expect("write y.rs");
std::fs::write(
workspace.join("z.rs"),
"pub fn z_one() -> u32 { 100 }\npub fn z_two() -> u32 { 200 }\n",
)
.expect("write z.rs");
let current_graph =
build_unified_graph(&workspace, plugin_manager(), &build_config()).expect("initial build");
std::fs::write(
workspace.join("x.rs"),
"pub fn x_one() -> u32 { 3 }\npub fn x_two() -> u32 { 4 }\n",
)
.expect("rewrite x.rs");
std::fs::write(
workspace.join("y.rs"),
"pub fn y_one() -> u32 { 30 }\npub fn y_two() -> u32 { 40 }\n",
)
.expect("rewrite y.rs");
std::fs::write(
workspace.join("z.rs"),
"pub fn z_one() -> u32 { 300 }\npub fn z_two() -> u32 { 400 }\n",
)
.expect("rewrite z.rs");
let changed_paths = vec![
workspace.join("x.rs"),
workspace.join("y.rs"),
workspace.join("z.rs"),
];
let changed_fids: Vec<FileId> = current_graph
.indexed_files()
.filter_map(|(fid, path)| {
if changed_paths.iter().any(|p| canon(p) == canon(path)) {
Some(fid)
} else {
None
}
})
.collect();
assert_eq!(
changed_fids.len(),
3,
"the three standalone Rust files must all be indexed"
);
let closure = compute_reverse_dep_closure(&changed_fids, ¤t_graph);
assert!(
closure.len() >= 2,
"multi-file fixture must yield a closure of at least 2 files; got {}",
closure.len()
);
Fixture {
_tempdir: tempdir,
workspace,
current_graph,
changed_paths,
closure,
}
}
#[test]
fn incremental_rebuild_phase3c_reparses_closure_files_into_rebuild_graph() {
let fx = build_fixture();
let cancellation = CancellationToken::new();
let diagnostics_seen = Rc::new(RefCell::new(PostCommitDiagnostics::default()));
let diagnostics_hook = Rc::clone(&diagnostics_seen);
let _guard = Phase3cHookGuard::install(move |_rg, diag| {
*diagnostics_hook.borrow_mut() = diag.clone();
});
let _ = incremental_rebuild(
&fx.current_graph,
&fx.changed_paths,
&fx.closure,
plugin_manager(),
&build_config(),
&cancellation,
)
.expect("incremental_rebuild happy path");
let diag = diagnostics_seen.borrow();
assert!(
diag.files_committed > 0,
"Phase 3c sub-step 4 must successfully re-parse at least one closure file; \
diagnostics: {diag:?}"
);
assert!(
diag.nodes_committed > 0,
"Phase 3c sub-step 6 must commit non-zero nodes into the rebuild plane; \
diagnostics: {diag:?}"
);
}
#[test]
fn incremental_rebuild_phase3c_commits_intra_edges_to_rebuild_graph() {
let fx = build_fixture();
let cancellation = CancellationToken::new();
let diagnostics_seen = Rc::new(RefCell::new(PostCommitDiagnostics::default()));
let diagnostics_hook = Rc::clone(&diagnostics_seen);
let _guard = Phase3cHookGuard::install(move |_rg, diag| {
*diagnostics_hook.borrow_mut() = diag.clone();
});
let _ = incremental_rebuild(
&fx.current_graph,
&fx.changed_paths,
&fx.closure,
plugin_manager(),
&build_config(),
&cancellation,
)
.expect("incremental_rebuild happy path");
let diag = diagnostics_seen.borrow();
assert!(
diag.edges_collected > 0,
"Phase 3c must collect non-zero intra-file edges from the re-parsed closure; \
the lib.rs fixture has `let _g = a::greet();` plus `helper()`, which parse \
into two Calls edges minimum. Diagnostics: {diag:?}"
);
}
#[test]
fn incremental_rebuild_phase3c_polls_cancellation_between_parse_and_commit() {
let fx = build_fixture();
let cancellation = CancellationToken::new();
let reparse_fired = Rc::new(RefCell::new(0usize));
let reparse_fired_hook = Rc::clone(&reparse_fired);
let cancel_from_hook = cancellation.clone();
let _r_guard = Phase3cReparseHookGuard::install(move |parsed_count| {
*reparse_fired_hook.borrow_mut() = parsed_count;
cancel_from_hook.cancel();
});
let phase3c_post_substep6_fired = Rc::new(RefCell::new(false));
let phase3c_post_substep6_fired_hook = Rc::clone(&phase3c_post_substep6_fired);
let _c_guard = Phase3cHookGuard::install(move |_rg, _diag| {
*phase3c_post_substep6_fired_hook.borrow_mut() = true;
});
let err = incremental_rebuild(
&fx.current_graph,
&fx.changed_paths,
&fx.closure,
plugin_manager(),
&build_config(),
&cancellation,
)
.expect_err("post-reparse cancellation must short-circuit incremental_rebuild");
assert!(
matches!(err, GraphBuilderError::Cancelled),
"expected GraphBuilderError::Cancelled, got: {err:?}"
);
let reparsed_count = *reparse_fired.borrow();
assert!(
reparsed_count > 0,
"Phase 3c post-reparse hook MUST fire before cancellation is observed, and \
it MUST see a non-zero parsed_count (the fixture's closure always contains \
at least one re-parsable file). parsed_count={reparsed_count}"
);
assert!(
!*phase3c_post_substep6_fired.borrow(),
"Phase 3c post-substep6 hook must NOT fire when cancellation is flipped \
between sub-step 4 and sub-step 5 — sub-steps 5-6 must not run"
);
}
#[test]
fn incremental_rebuild_phase3c_does_not_fire_when_phase3b_loop_cancels() {
let fx = build_fixture();
let cancellation = CancellationToken::new();
let cancel_from_hook = cancellation.clone();
let _iter_guard = Phase3bIterHookGuard::install(move |idx, _fid, _rg| {
if idx == 0 {
cancel_from_hook.cancel();
}
});
let phase3c_reparse_fired = Rc::new(RefCell::new(false));
let phase3c_reparse_fired_hook = Rc::clone(&phase3c_reparse_fired);
let _r_guard = Phase3cReparseHookGuard::install(move |_parsed_count| {
*phase3c_reparse_fired_hook.borrow_mut() = true;
});
let phase3c_post_substep6_fired = Rc::new(RefCell::new(false));
let phase3c_post_substep6_fired_hook = Rc::clone(&phase3c_post_substep6_fired);
let _c_guard = Phase3cHookGuard::install(move |_rg, _diag| {
*phase3c_post_substep6_fired_hook.borrow_mut() = true;
});
let err = incremental_rebuild(
&fx.current_graph,
&fx.changed_paths,
&fx.closure,
plugin_manager(),
&build_config(),
&cancellation,
)
.expect_err("mid-loop cancellation must short-circuit incremental_rebuild");
assert!(
matches!(err, GraphBuilderError::Cancelled),
"expected GraphBuilderError::Cancelled, got: {err:?}"
);
assert!(
!*phase3c_reparse_fired.borrow(),
"Phase 3c post-reparse hook must NOT fire when Phase 3b's iteration-time \
cancellation short-circuits the pipeline — sub-step 4 must never run"
);
assert!(
!*phase3c_post_substep6_fired.borrow(),
"Phase 3c post-substep6 hook must NOT fire when Phase 3b's iteration-time \
cancellation short-circuits the pipeline"
);
}
#[test]
fn incremental_rebuild_phase3c_polls_cancellation_inside_reparse_loop() {
let fx = build_multi_file_fixture();
assert!(
fx.closure.len() >= 2,
"build_multi_file_fixture must produce a closure with >= 2 files; got {}",
fx.closure.len()
);
let cancellation = CancellationToken::new();
let iter_fired_for = Rc::new(RefCell::new(Vec::<usize>::new()));
let iter_fired_for_hook = Rc::clone(&iter_fired_for);
let cancel_from_hook = cancellation.clone();
let _iter_guard = Phase3cIterHookGuard::install(move |idx| {
iter_fired_for_hook.borrow_mut().push(idx);
if idx == 0 {
cancel_from_hook.cancel();
}
});
let phase3c_reparse_fired = Rc::new(RefCell::new(false));
let phase3c_reparse_fired_hook = Rc::clone(&phase3c_reparse_fired);
let _r_guard = Phase3cReparseHookGuard::install(move |_parsed_count| {
*phase3c_reparse_fired_hook.borrow_mut() = true;
});
let phase3c_post_substep6_fired = Rc::new(RefCell::new(false));
let phase3c_post_substep6_fired_hook = Rc::clone(&phase3c_post_substep6_fired);
let _c_guard = Phase3cHookGuard::install(move |_rg, _diag| {
*phase3c_post_substep6_fired_hook.borrow_mut() = true;
});
let err = incremental_rebuild(
&fx.current_graph,
&fx.changed_paths,
&fx.closure,
plugin_manager(),
&build_config(),
&cancellation,
)
.expect_err("mid-reparse-loop cancellation must short-circuit incremental_rebuild");
assert!(
matches!(err, GraphBuilderError::Cancelled),
"expected GraphBuilderError::Cancelled, got: {err:?}"
);
let iter_indices = iter_fired_for.borrow().clone();
assert_eq!(
iter_indices,
vec![0],
"the per-iteration hook must fire exactly once (iteration 0). iteration 1's \
loop-top `cancellation.check()?` must short-circuit before `parse_file` — \
and therefore before the iter hook — runs. observed: {iter_indices:?}"
);
assert!(
!*phase3c_reparse_fired.borrow(),
"Phase 3c post-reparse hook must NOT fire when `phase3c_reparse_closure` \
returns Err(Cancelled) — the outer caller site fires the post-reparse hook \
only after a successful return from sub-step 4"
);
assert!(
!*phase3c_post_substep6_fired.borrow(),
"Phase 3c post-substep6 hook must NOT fire when cancellation short-circuits \
inside the sub-step 4 re-parse loop — sub-steps 5-6 must not run"
);
}
#[test]
fn incremental_rebuild_phase3c_rebuild_graph_contains_parsed_symbols_before_discard() {
let fx = build_fixture();
let initial_slot_count = fx.current_graph.nodes().slot_count();
let cancellation = CancellationToken::new();
let post_slot = Rc::new(RefCell::new(0usize));
let post_slot_hook = Rc::clone(&post_slot);
let _guard = Phase3cHookGuard::install(move |rg, _diag| {
*post_slot_hook.borrow_mut() = rg.nodes().slot_count();
});
let _ = incremental_rebuild(
&fx.current_graph,
&fx.changed_paths,
&fx.closure,
plugin_manager(),
&build_config(),
&cancellation,
)
.expect("incremental_rebuild happy path");
let final_slot_count = *post_slot.borrow();
assert!(
final_slot_count > initial_slot_count,
"Phase 3c sub-step 6 must grow the rebuild plane's arena slot_count beyond \
the clone_for_rebuild starting value. initial={initial_slot_count}, \
final={final_slot_count}"
);
}
#[test]
fn incremental_rebuild_phase3c_still_delegates_to_full_build_fallback() {
let fx = build_fixture();
let cancellation = CancellationToken::new();
let phase3c_fired = Rc::new(RefCell::new(false));
let phase3c_fired_hook = Rc::clone(&phase3c_fired);
let _guard = Phase3cHookGuard::install(move |_rg, _diag| {
*phase3c_fired_hook.borrow_mut() = true;
});
let result = incremental_rebuild(
&fx.current_graph,
&fx.changed_paths,
&fx.closure,
plugin_manager(),
&build_config(),
&cancellation,
)
.expect("fallback full build must succeed");
assert!(
*phase3c_fired.borrow(),
"Phase 3c hook must fire before the fallback runs — otherwise sub-steps 4-6 \
were skipped entirely"
);
assert!(
result.node_count() > 0,
"the fallback full-build path must still produce a populated graph; the Phase \
3c boundary must not mask the result"
);
}