1use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result};
11use ignore::WalkBuilder;
12use rayon::prelude::*;
13
14use crate::graph::GraphBuilderError;
15use crate::graph::error::GraphResult;
16use crate::graph::unified::analysis::LabelBudgetConfig;
17use crate::graph::unified::analysis::ReachabilityStrategy;
18use crate::graph::unified::build::StagingGraph;
19use crate::graph::unified::build::cancellation::CancellationToken;
20use crate::graph::unified::build::parallel_commit::{
21 GlobalOffsets, PhaseCIndirectDrain, phase2_assign_ranges, phase3_parallel_commit,
22 phase4_apply_global_remap, phase4c_prime_unify_cross_file_nodes, phase4d_bulk_insert_edges,
23};
24use crate::graph::unified::build::pass3_intra::PendingEdge;
25use crate::graph::unified::build::progress::GraphBuildProgressTracker;
26use crate::graph::unified::concurrent::CodeGraph;
27use crate::graph::unified::node::{NodeId, NodeKind};
28use crate::graph::unified::storage::c_indirect::{
29 BindingEntry, CIndirectSideTables, IndirectCallsite,
30};
31use crate::graph::unified::string::StringId;
32use crate::io::FileReader;
33use crate::plugin::PluginManager;
34use crate::plugin::error::ParseError;
35use crate::plugin::{SafeParser, SafeParserConfig};
36use crate::progress::{SharedReporter, no_op_reporter};
37use crate::project::path_utils::normalize_path_components;
38
39#[derive(Debug, Clone)]
45pub struct BuildResult {
46 pub node_count: usize,
48 pub edge_count: usize,
51 pub raw_edge_count: usize,
54 pub file_count: std::collections::HashMap<String, usize>,
60 pub total_files: usize,
62 pub built_at: String,
64 pub root_path: String,
66 pub thread_count: usize,
71
72 pub active_plugin_ids: Vec<String>,
74
75 pub analysis_strategies: Vec<AnalysisStrategySummary>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct AnalysisStrategySummary {
82 pub edge_kind: &'static str,
84 pub strategy: ReachabilityStrategy,
86}
87
88const DEFAULT_STAGING_MEMORY_LIMIT: usize = 512 * 1024 * 1024;
94
95const BYTES_PER_MIB: usize = 1024 * 1024;
96const INDEX_WORKER_STACK_SIZE_ENV: &str = "SQRY_INDEX_WORKER_STACK_MB";
97const DEFAULT_INDEX_WORKER_STACK_SIZE_MB: usize = 32;
98const MIN_INDEX_WORKER_STACK_SIZE_MB: usize = 8;
99const MAX_INDEX_WORKER_STACK_SIZE_MB: usize = 256;
100
101const DEFAULT_EXCLUDED_SOURCE_DIRS: &[&str] = &[
111 ".git",
112 ".hg",
113 ".svn",
114 ".cache",
115 ".next",
116 ".nuxt",
117 ".sqry",
118 ".turbo",
119 ".venv",
120 "__pycache__",
121 "_actions",
122 "_update",
123 "_work",
124 "build",
125 "dist",
126 "node_modules",
127 "target",
128 "vendor",
129 "venv",
130];
131
132const DEFAULT_EXCLUDED_SOURCE_DIR_PREFIXES: &[&str] = &["externals."];
133
134#[derive(Debug, Clone)]
136pub struct BuildConfig {
137 pub max_depth: Option<usize>,
139
140 pub follow_links: bool,
142
143 pub include_hidden: bool,
145
146 pub num_threads: Option<usize>,
148
149 pub staging_memory_limit: usize,
158
159 pub label_budget: LabelBudgetConfig,
164}
165
166impl Default for BuildConfig {
167 fn default() -> Self {
168 let limit = std::env::var("SQRY_STAGING_MEMORY_LIMIT_MB")
169 .ok()
170 .and_then(|v| v.parse::<usize>().ok())
171 .map_or(DEFAULT_STAGING_MEMORY_LIMIT, |mb| mb * 1024 * 1024);
172
173 let label_budget = LabelBudgetConfig {
174 budget_per_kind: 15_000_000,
175 on_exceeded: crate::graph::unified::analysis::BudgetExceededPolicy::Degrade,
176 density_gate_threshold: 64,
177 skip_labels: false,
178 };
179
180 Self {
181 max_depth: None,
182 follow_links: false,
183 include_hidden: false,
184 num_threads: None,
185 staging_memory_limit: limit,
186 label_budget,
187 }
188 }
189}
190
191fn create_thread_pool(config: &BuildConfig) -> Result<rayon::ThreadPool> {
193 let mut builder = rayon::ThreadPoolBuilder::new();
194 if let Some(n) = config.num_threads {
195 builder = builder.num_threads(n);
196 }
197 builder
198 .stack_size(index_worker_stack_size_bytes())
199 .build()
200 .context("Failed to create rayon thread pool for graph indexing")
201}
202
203#[must_use]
204fn index_worker_stack_size_bytes() -> usize {
205 let env_value = std::env::var(INDEX_WORKER_STACK_SIZE_ENV).ok();
206 index_worker_stack_size_bytes_from_value(env_value.as_deref())
207}
208
209#[must_use]
210fn index_worker_stack_size_bytes_from_value(value: Option<&str>) -> usize {
211 let size_mb = value
212 .and_then(|raw| raw.trim().parse::<usize>().ok())
213 .unwrap_or(DEFAULT_INDEX_WORKER_STACK_SIZE_MB)
214 .clamp(
215 MIN_INDEX_WORKER_STACK_SIZE_MB,
216 MAX_INDEX_WORKER_STACK_SIZE_MB,
217 );
218 size_mb * BYTES_PER_MIB
219}
220
221fn compute_parse_chunks(
230 files: &[PathBuf],
231 _pool: &rayon::ThreadPool,
232 _plugins: &PluginManager,
233 memory_limit: usize,
234) -> Vec<std::ops::Range<usize>> {
235 const EXPANSION_FACTOR: usize = 4;
239
240 let mut chunks = Vec::new();
241 let mut chunk_start = 0;
242 let mut chunk_estimate = 0usize;
243
244 for (i, path) in files.iter().enumerate() {
245 #[allow(clippy::cast_possible_truncation)] let file_size = std::fs::metadata(path)
247 .map(|m| m.len() as usize)
248 .unwrap_or(0);
249 let estimated_staging = file_size * EXPANSION_FACTOR;
250
251 if chunk_estimate + estimated_staging > memory_limit && i > chunk_start {
254 chunks.push(chunk_start..i);
255 chunk_start = i;
256 chunk_estimate = 0;
257 }
258 chunk_estimate += estimated_staging;
259 }
260
261 if chunk_start < files.len() {
263 chunks.push(chunk_start..files.len());
264 }
265
266 if chunks.len() > 1 {
267 log::info!(
268 "Memory-bounded chunking: {} batches for {} files (limit: {} MB)",
269 chunks.len(),
270 files.len(),
271 memory_limit / (1024 * 1024),
272 );
273 }
274
275 chunks
276}
277
278pub const GRAPH_FILE_PROCESSING_PHASE: &str = "File processing";
280
281pub fn build_unified_graph(
319 root: &Path,
320 plugins: &PluginManager,
321 config: &BuildConfig,
322) -> Result<CodeGraph> {
323 build_unified_graph_cancellable(root, plugins, config, &CancellationToken::default())
324 .map_err(anyhow::Error::from)
325}
326
327pub fn build_unified_graph_with_progress(
348 root: &Path,
349 plugins: &PluginManager,
350 config: &BuildConfig,
351 progress: SharedReporter,
352) -> Result<(CodeGraph, usize)> {
353 build_unified_graph_with_progress_cancellable(
354 root,
355 plugins,
356 config,
357 progress,
358 &CancellationToken::default(),
359 )
360 .map_err(anyhow::Error::from)
361}
362
363pub fn build_unified_graph_cancellable(
380 root: &Path,
381 plugins: &PluginManager,
382 config: &BuildConfig,
383 cancellation: &CancellationToken,
384) -> GraphResult<CodeGraph> {
385 let (graph, _effective_threads) =
386 build_unified_graph_inner(root, plugins, config, no_op_reporter(), cancellation)?;
387 Ok(graph)
388}
389
390pub fn build_unified_graph_with_progress_cancellable(
400 root: &Path,
401 plugins: &PluginManager,
402 config: &BuildConfig,
403 progress: SharedReporter,
404 cancellation: &CancellationToken,
405) -> GraphResult<(CodeGraph, usize)> {
406 build_unified_graph_inner(root, plugins, config, progress, cancellation)
407}
408
409#[allow(clippy::too_many_lines)] fn build_unified_graph_inner(
420 root: &Path,
421 plugins: &PluginManager,
422 config: &BuildConfig,
423 progress: SharedReporter,
424 cancellation: &CancellationToken,
425) -> GraphResult<(CodeGraph, usize)> {
426 if !root.exists() {
427 return Err(GraphBuilderError::Internal {
428 reason: format!("Path {} does not exist", root.display()),
429 });
430 }
431
432 log::info!(
433 "Building unified graph from source files in {}",
434 root.display()
435 );
436
437 cancellation.check()?;
439
440 let has_graph_builders = plugins
441 .plugins()
442 .iter()
443 .any(|plugin| plugin.graph_builder().is_some());
444 if !has_graph_builders {
445 return Err(GraphBuilderError::Internal {
446 reason: "No graph builders registered – cannot build code graph".to_string(),
447 });
448 }
449
450 let tracker = GraphBuildProgressTracker::new(progress);
452
453 let mut files = find_source_files(root, config);
455 sort_files_for_build(root, &mut files);
456
457 cancellation.check()?;
460
461 let mut graph = CodeGraph::new();
463
464 let pool = create_thread_pool(config).map_err(|e| GraphBuilderError::Internal {
466 reason: format!("thread pool: {e}"),
467 })?;
468 let effective_threads = pool.current_num_threads();
469 log::info!("Parallel indexing: using {effective_threads} threads");
470
471 let total_files = files.len();
485 tracker.start_phase(
486 1,
487 "Chunked structural indexing (parse -> range-plan -> semantic commit)",
488 total_files,
489 );
490
491 let (mut succeeded, mut parse_errors, mut skipped, mut timed_out) =
492 (0usize, 0usize, 0usize, 0usize);
493 let mut total_staging_bytes = 0usize;
494 let mut peak_chunk_staging_bytes = 0usize;
495 let mut max_file_staging_bytes = 0usize;
496
497 let initial_string_offset = graph.strings_mut().alloc_range(0).unwrap_or(1);
500 let mut offsets = GlobalOffsets {
501 node_offset: u32::try_from(graph.nodes().slot_count()).unwrap_or(0),
502 string_offset: initial_string_offset,
503 };
504 let mut all_edges: Vec<Vec<PendingEdge>> = Vec::new();
506 let mut all_staged_metadata: Vec<(
512 crate::graph::unified::file::id::FileId,
513 crate::graph::unified::storage::metadata::NodeMetadataStore,
514 )> = Vec::new();
515 let mut c_indirect_pending: PhaseCIndirectDrain = PhaseCIndirectDrain::default();
522
523 let chunks = compute_parse_chunks(&files, &pool, plugins, config.staging_memory_limit);
524 for chunk_range in chunks {
525 cancellation.check()?;
527
528 let chunk_files = &files[chunk_range];
529
530 #[cfg(any(test, feature = "rebuild-internals"))]
535 testing::fire_after_chunk_hook(cancellation);
536
537 let staged_results: Vec<(PathBuf, Result<ParsedFileOutcome>)> = pool.install(|| {
539 chunk_files
540 .par_iter()
541 .map(|path| {
542 let result = parse_file(path.as_path(), plugins);
543 tracker.increment_progress();
544 (path.clone(), result)
545 })
546 .collect()
547 });
548
549 let mut chunk_parsed: Vec<(PathBuf, ParsedFile)> = Vec::new();
551 let mut chunk_staging_bytes = 0usize;
552 for (path, result) in staged_results {
553 match result {
554 Ok(ParsedFileOutcome::Parsed(parsed)) => {
555 let file_bytes = parsed.staging.estimated_byte_size();
556 total_staging_bytes += file_bytes;
557 chunk_staging_bytes += file_bytes;
558 if file_bytes > max_file_staging_bytes {
559 max_file_staging_bytes = file_bytes;
560 }
561 chunk_parsed.push((path, parsed));
562 }
563 Ok(ParsedFileOutcome::Skipped) => skipped += 1,
564 Ok(ParsedFileOutcome::TimedOut {
565 file,
566 phase,
567 timeout_ms,
568 }) => {
569 timed_out += 1;
570 log::warn!(
571 "Timed out building graph for {} during {} after {} ms",
572 file.display(),
573 phase,
574 timeout_ms,
575 );
576 }
577 Err(e) => {
578 parse_errors += 1;
579 log::warn!("Failed to parse {}: {e}", path.display());
580 }
581 }
582 }
583 if chunk_staging_bytes > peak_chunk_staging_bytes {
584 peak_chunk_staging_bytes = chunk_staging_bytes;
585 }
586
587 if chunk_parsed.is_empty() {
588 continue;
589 }
590
591 let file_info: Vec<_> = chunk_parsed
593 .iter()
594 .map(|(path, parsed)| (path.clone(), Some(parsed.language)))
595 .collect();
596 let file_ids = graph.files_mut().register_batch(&file_info).map_err(|e| {
597 GraphBuilderError::Internal {
598 reason: format!("Failed to register files: {e}"),
599 }
600 })?;
601
602 let staging_refs: Vec<_> = chunk_parsed.iter().map(|(_, p)| &p.staging).collect();
604 let plan = phase2_assign_ranges(&staging_refs, &file_ids, &offsets);
605
606 let placeholder = crate::graph::unified::storage::NodeEntry::new(
608 crate::graph::unified::node::NodeKind::Other,
609 crate::graph::unified::string::StringId::new(0),
610 crate::graph::unified::file::FileId::new(0),
611 );
612 graph
613 .nodes_mut()
614 .alloc_range(plan.total_nodes, &placeholder)
615 .map_err(|e| GraphBuilderError::Internal {
616 reason: format!("Failed to alloc node range: {e:?}"),
617 })?;
618 graph
619 .strings_mut()
620 .alloc_range(plan.total_strings)
621 .map_err(|e| GraphBuilderError::Internal {
622 reason: format!("Failed to alloc string range: {e}"),
623 })?;
624
625 let phase3 = pool.install(|| phase3_parallel_commit(&plan, &staging_refs, &mut graph));
634
635 let expected_nodes = plan.total_nodes as usize;
639 let expected_strings = plan.total_strings as usize;
640 let expected_edges = usize::try_from(plan.total_edges)
641 .unwrap_or_else(|_| unreachable!("edge count does not fit usize"));
642 if phase3.total_nodes_written != expected_nodes
643 || phase3.total_strings_written != expected_strings
644 || phase3.total_edges_collected != expected_edges
645 {
646 return Err(GraphBuilderError::Internal {
647 reason: format!(
648 "Phase 3 count mismatch: nodes {}/{expected_nodes}, strings {}/{expected_strings}, edges {}/{expected_edges}. This indicates a bug in StagingGraph counting.",
649 phase3.total_nodes_written,
650 phase3.total_strings_written,
651 phase3.total_edges_collected,
652 ),
653 });
654 }
655
656 for fp in &plan.file_plans {
658 let start = fp.node_range.start;
659 let count = fp.node_range.end.saturating_sub(start);
660 graph
661 .file_segments_mut()
662 .record_range(fp.file_id, start, count);
663 }
664
665 debug_assert_eq!(
678 phase3.per_file_node_ids.len(),
679 plan.file_plans.len(),
680 "phase3 per-file node ID vector length must match plan length"
681 );
682 for (fp, node_ids) in plan.file_plans.iter().zip(phase3.per_file_node_ids.iter()) {
683 for nid in node_ids {
684 graph.files_mut().record_node(fp.file_id, *nid);
685 }
686 }
687
688 succeeded += chunk_parsed.len();
689
690 for (_path, parsed) in &mut chunk_parsed {
692 if let Some(confidence) = parsed.staging.take_confidence() {
693 let language_name = parsed.language.to_string();
694 graph.merge_confidence(&language_name, confidence);
695 }
696 }
697
698 debug_assert_eq!(
710 plan.file_plans.len(),
711 chunk_parsed.len(),
712 "per-chunk file plan / parsed-file vectors must align so Phase 4d-prime sees \
713 the FileId Phase 3 assigned"
714 );
715 debug_assert_eq!(
716 phase3.per_file_node_ids.len(),
717 plan.file_plans.len(),
718 "Phase 3 per-file node ID vector length must match plan length for metadata rekey"
719 );
720 for ((fp, (_path, parsed)), arena_ids) in plan
721 .file_plans
722 .iter()
723 .zip(chunk_parsed.iter_mut())
724 .zip(phase3.per_file_node_ids.iter())
725 {
726 let metadata = parsed.staging.take_macro_metadata();
727 if metadata.is_empty() {
728 continue;
729 }
730 let rekeyed =
731 super::parallel_commit::rekey_staging_metadata_to_arena(&metadata, arena_ids);
732 if !rekeyed.is_empty() {
733 all_staged_metadata.push((fp.file_id, rekeyed));
734 }
735 }
736
737 offsets.node_offset += plan.total_nodes;
739 offsets.string_offset += plan.total_strings;
740
741 cancellation.check()?;
744
745 all_edges.extend(phase3.per_file_edges);
747
748 if let Some(drain) = phase3.c_indirect_drain {
752 c_indirect_pending.merge(drain);
753 }
754 }
755 tracker.complete_phase();
756
757 #[cfg(any(test, feature = "rebuild-internals"))]
761 testing::fire_before_phase4_hook(cancellation);
762
763 tracker.start_phase(4, "Finalizing graph", 5);
765
766 cancellation.check()?;
768
769 let string_remap = graph.strings_mut().build_dedup_table();
771 if !string_remap.is_empty() {
772 log::debug!(
773 "Phase 4a: dedup removed {} duplicate string(s)",
774 string_remap.len()
775 );
776
777 phase4_apply_global_remap(graph.nodes_mut(), &mut all_edges, &string_remap);
779 }
780 tracker.increment_progress(); cancellation.check()?;
784
785 graph.rebuild_indices();
788 tracker.increment_progress(); cancellation.check()?;
793
794 let (unification_stats, unification_remap) =
800 phase4c_prime_unify_cross_file_nodes(&mut graph, &mut all_edges);
801 if unification_stats.nodes_merged > 0 {
802 log::info!(
803 "Phase 4c-prime: unified {} duplicate nodes ({} candidate groups examined, \
804 {} edges rewritten, {} ms)",
805 unification_stats.nodes_merged,
806 unification_stats.candidate_pairs_examined,
807 unification_stats.edges_rewritten,
808 unification_stats.elapsed_ms,
809 );
810 cancellation.check()?;
815 graph.rebuild_indices();
817 }
818 tracker.increment_progress(); if !c_indirect_pending.is_empty() {
841 let stats = apply_deferred_address_taken_marks(&mut graph, c_indirect_pending);
842 log::info!(
843 target: "sqry_core::build",
844 "Phase 4c-prime-post: applied C indirect side-tables — {} address-taken marks, \
845 {} struct field signatures, {} bindings (resolved {} entries), \
846 {} pending callsites, {} local-scope indices, {} unresolved names",
847 stats.address_taken_marks_applied,
848 stats.struct_field_signatures_inserted,
849 stats.binding_entries_inserted,
850 stats.bindings_resolved,
851 stats.indirect_callsites_inserted,
852 stats.local_scope_indices_inserted,
853 stats.unresolved_names,
854 );
855 }
856
857 cancellation.check()?;
859
860 let _final_edge_seq = phase4d_bulk_insert_edges(&mut graph, &all_edges);
867 tracker.increment_progress(); let _staged_metadata_merged = super::parallel_commit::phase4d_prime_propagate_staging_metadata(
877 &mut graph,
878 std::mem::take(&mut all_staged_metadata),
879 &unification_remap,
880 );
881 tracker.increment_progress(); tracker.complete_phase();
883
884 log::info!(
885 "Parallel indexing complete: {succeeded} committed, {skipped} skipped, \
886 {timed_out} timed out, {parse_errors} parse errors, \
887 ~{} MB total staged, ~{} MB peak chunk (max single file: ~{} KB)",
888 total_staging_bytes / (1024 * 1024),
889 peak_chunk_staging_bytes / (1024 * 1024),
890 max_file_staging_bytes / 1024,
891 );
892
893 let attempted = succeeded + parse_errors + timed_out;
894
895 if attempted == 0 {
896 log::warn!(
897 "No eligible source files found for graph build in {}",
898 root.display()
899 );
900 }
901
902 if attempted > 0 && succeeded == 0 {
903 return Err(GraphBuilderError::Internal {
904 reason: "All graph builds failed".to_string(),
905 });
906 }
907
908 cancellation.check()?;
910
911 tracker.start_phase(5, "Binding plane derivation", 1);
921 let binding_stats = super::phase4e_binding::derive_binding_plane(&mut graph);
922 log::info!(
923 target: "sqry_core::build",
924 "Phase 4e: {} scopes, {} aliases, {} shadows derived",
925 binding_stats.scopes,
926 binding_stats.aliases,
927 binding_stats.shadows,
928 );
929 tracker.increment_progress();
930 tracker.complete_phase();
931
932 cancellation.check()?;
949 tracker.start_phase(6, "C indirect-call resolution", 1);
950 let c_indirect_stats = super::pass5b_c_indirect::resolve_c_indirect_calls(&mut graph);
951 log::info!(
952 target: "sqry_core::build",
953 "Pass 5b: binding={}, typematch={}, cap_exceeded={}, fallback={}",
954 c_indirect_stats.binding_resolved,
955 c_indirect_stats.typematch_resolved,
956 c_indirect_stats.cap_exceeded,
957 c_indirect_stats.stub_fallback,
958 );
959 tracker.increment_progress();
960 tracker.complete_phase();
961
962 cancellation.check()?;
976 tracker.start_phase(7, "Go method-set satisfaction", 1);
977 let go_method_set_stats =
978 super::pass_go_method_set::run_go_method_set_satisfaction(&mut graph, None);
979 log::info!(
980 target: "sqry_core::build",
981 "Go method-set: {} value-form Implements, {} pointer-form Implements, \
982 {} signature Implements, {} promoted methods, {} shadow Calls/References, \
983 elapsed_ms={}",
984 go_method_set_stats.implements_edges_value,
985 go_method_set_stats.implements_edges_pointer,
986 go_method_set_stats.signature_implements_edges,
987 go_method_set_stats.promoted_method_nodes,
988 go_method_set_stats.promoted_back_reference_edges,
989 go_method_set_stats.elapsed_ms,
990 );
991 tracker.increment_progress();
992 tracker.complete_phase();
993
994 #[cfg(any(test, feature = "rebuild-internals"))]
999 testing::fire_before_pass5_hook(cancellation);
1000
1001 cancellation.check()?;
1003
1004 tracker.start_phase(8, "Cross-language linking", 1);
1006 let cross_language_stats = super::pass5_cross_language::link_cross_language_edges(&mut graph);
1007 if cross_language_stats.total_edges_created > 0 {
1008 log::info!(
1009 "Pass 5: {} cross-language edges created ({} FFI, {} HTTP)",
1010 cross_language_stats.total_edges_created,
1011 cross_language_stats.ffi_edges_created,
1012 cross_language_stats.http_endpoints_matched,
1013 );
1014 }
1015 tracker.increment_progress(); tracker.complete_phase();
1017
1018 log::info!("Built unified graph with {} nodes", graph.node_count());
1019
1020 super::super::publish::assert_publish_bijection(&graph);
1035
1036 Ok((graph, effective_threads))
1037}
1038
1039#[derive(Debug, Default, Clone)]
1051pub(crate) struct DeferredCIndirectStats {
1052 pub address_taken_marks_applied: usize,
1056 pub struct_field_signatures_inserted: usize,
1059 pub binding_entries_inserted: usize,
1062 pub bindings_resolved: usize,
1067 pub indirect_callsites_inserted: usize,
1073 pub local_scope_indices_inserted: usize,
1078 pub unresolved_names: usize,
1081}
1082
1083type DeferredCNodeCache = std::collections::HashMap<String, Vec<NodeId>>;
1084type StructFieldSignature = ((StringId, StringId), StringId);
1085type BindingSideTableEntry = ((StringId, StringId), BindingEntry);
1086
1087fn apply_local_scope_indices(
1088 graph: &mut CodeGraph,
1089 local_scope_indices: Vec<(
1090 crate::graph::unified::file::FileId,
1091 crate::graph::unified::storage::c_indirect::LocalScopeIndex,
1092 )>,
1093 stats: &mut DeferredCIndirectStats,
1094) {
1095 if local_scope_indices.is_empty() {
1096 return;
1097 }
1098
1099 let tables = graph
1100 .c_indirect_tables_mut()
1101 .get_or_insert_with(CIndirectSideTables::new);
1102 for (file_id, scope_index) in local_scope_indices {
1103 tables.local_scope_indices.insert(file_id, scope_index);
1107 stats.local_scope_indices_inserted += 1;
1108 }
1109}
1110
1111fn resolve_c_callable_names<'a, I>(
1112 graph: &mut CodeGraph,
1113 names: I,
1114 stats: &mut DeferredCIndirectStats,
1115) -> DeferredCNodeCache
1116where
1117 I: Iterator<Item = &'a str>,
1118{
1119 resolve_c_names(graph, names, stats, |kind| {
1120 super::helper::CALL_COMPATIBLE_KINDS.contains(kind)
1121 })
1122}
1123
1124fn resolve_c_variable_names<'a, I>(
1125 graph: &mut CodeGraph,
1126 names: I,
1127 stats: &mut DeferredCIndirectStats,
1128) -> DeferredCNodeCache
1129where
1130 I: Iterator<Item = &'a str>,
1131{
1132 resolve_c_names(graph, names, stats, |kind| {
1133 matches!(kind, NodeKind::Variable)
1134 })
1135}
1136
1137fn resolve_c_names<'a, I, F>(
1138 graph: &mut CodeGraph,
1139 names: I,
1140 stats: &mut DeferredCIndirectStats,
1141 is_eligible_kind: F,
1142) -> DeferredCNodeCache
1143where
1144 I: Iterator<Item = &'a str>,
1145 F: Fn(&NodeKind) -> bool,
1146{
1147 let mut resolved = DeferredCNodeCache::new();
1148 for name in names {
1149 if resolved.contains_key(name) {
1150 continue;
1151 }
1152 let str_id = match graph.strings_mut().intern(name) {
1153 Ok(id) => id,
1154 Err(err) => {
1155 log::warn!(
1156 "Phase 4c-prime-post: failed to intern C indirect name {name:?}: {err:?} - \
1157 skipping every entry referencing this name"
1158 );
1159 resolved.insert(name.to_owned(), Vec::new());
1160 continue;
1161 }
1162 };
1163
1164 let matches = collect_c_nodes_by_string_id(graph, str_id, &is_eligible_kind);
1165 if matches.is_empty() {
1166 stats.unresolved_names += 1;
1167 }
1168 resolved.insert(name.to_owned(), matches);
1169 }
1170 resolved
1171}
1172
1173fn collect_c_nodes_by_string_id<F>(
1174 graph: &CodeGraph,
1175 str_id: StringId,
1176 is_eligible_kind: &F,
1177) -> Vec<NodeId>
1178where
1179 F: Fn(&NodeKind) -> bool,
1180{
1181 let by_qn = graph.indices().by_qualified_name(str_id).to_vec();
1182 let by_nm = graph.indices().by_name(str_id).to_vec();
1183 let mut seen: std::collections::HashSet<(u32, u64)> = std::collections::HashSet::new();
1184 let mut matches = Vec::new();
1185 let arena = graph.nodes();
1186 let files = graph.files();
1187 for nid in by_qn.into_iter().chain(by_nm) {
1188 if !seen.insert((nid.index(), nid.generation())) {
1189 continue;
1190 }
1191 let Some(entry) = arena.get(nid) else {
1192 continue;
1193 };
1194 if !is_eligible_kind(&entry.kind) {
1195 continue;
1196 }
1197 if files.language_for_file(entry.file) != Some(crate::graph::Language::C) {
1198 continue;
1199 }
1200 matches.push(nid);
1201 }
1202 matches
1203}
1204
1205fn apply_address_taken_marks(
1206 graph: &mut CodeGraph,
1207 entries: &[crate::graph::unified::build::parallel_commit::DeferredAddressTakenEntry],
1208 callable_nodes: &DeferredCNodeCache,
1209 stats: &mut DeferredCIndirectStats,
1210) {
1211 for entry in entries {
1212 if let Some(node_ids) = callable_nodes.get(&entry.function_qualified_name) {
1213 for &nid in node_ids {
1214 graph.macro_metadata_mut().mark_address_taken(nid);
1215 stats.address_taken_marks_applied += 1;
1216 }
1217 }
1218 }
1219}
1220
1221fn insert_struct_field_signatures(
1222 graph: &mut CodeGraph,
1223 signatures: &[(String, String, String)],
1224 stats: &mut DeferredCIndirectStats,
1225) {
1226 if signatures.is_empty() {
1227 return;
1228 }
1229
1230 let mut interned: Vec<StructFieldSignature> = Vec::with_capacity(signatures.len());
1231 let mut intern_failures = 0usize;
1232 for (struct_tag, field_name, signature) in signatures {
1233 let strings = graph.strings_mut();
1234 let st = strings.intern(struct_tag);
1235 let fn_ = strings.intern(field_name);
1236 let sig = strings.intern(signature);
1237 match (st, fn_, sig) {
1238 (Ok(st), Ok(fn_), Ok(sig)) => interned.push(((st, fn_), sig)),
1239 _ => intern_failures += 1,
1240 }
1241 }
1242 if intern_failures > 0 {
1243 log::warn!(
1244 "Phase 4c-prime-post: {intern_failures} struct-field-signature triple(s) \
1245 failed to intern (capacity exhaustion?) - dropped"
1246 );
1247 }
1248 let tables = graph
1249 .c_indirect_tables_mut()
1250 .get_or_insert_with(CIndirectSideTables::new);
1251 for (key, value) in interned {
1252 tables.struct_field_fnptr.insert(key, value);
1253 stats.struct_field_signatures_inserted += 1;
1254 }
1255}
1256
1257fn insert_c_indirect_bindings(
1258 graph: &mut CodeGraph,
1259 bindings: &[(
1260 crate::graph::unified::file::FileId,
1261 super::staging::PendingBinding,
1262 )],
1263 instance_nodes: &DeferredCNodeCache,
1264 callable_nodes: &DeferredCNodeCache,
1265 stats: &mut DeferredCIndirectStats,
1266) {
1267 if bindings.is_empty() {
1268 return;
1269 }
1270
1271 let mut interned_bindings: Vec<BindingSideTableEntry> = Vec::with_capacity(bindings.len());
1272 let mut intern_failures = 0usize;
1273 for (_file_id, binding) in bindings {
1274 let strings = graph.strings_mut();
1275 let Ok(st_id) = strings.intern(&binding.struct_tag) else {
1276 intern_failures += 1;
1277 continue;
1278 };
1279 let Ok(fn_id) = strings.intern(&binding.field_name) else {
1280 intern_failures += 1;
1281 continue;
1282 };
1283
1284 let instances = instance_nodes
1285 .get(&binding.instance_name)
1286 .map_or(&[] as &[NodeId], Vec::as_slice);
1287 let targets = callable_nodes
1288 .get(&binding.target_fn_name)
1289 .map_or(&[] as &[NodeId], Vec::as_slice);
1290 if instances.is_empty() || targets.is_empty() {
1291 continue;
1292 }
1293 stats.bindings_resolved += 1;
1294
1295 for &instance_node in instances {
1296 for &target_fn in targets {
1297 interned_bindings.push((
1298 (st_id, fn_id),
1299 BindingEntry {
1300 instance_node,
1301 target_fn,
1302 site_kind: binding.site_kind,
1303 },
1304 ));
1305 }
1306 }
1307 }
1308 if intern_failures > 0 {
1309 log::warn!(
1310 "Phase 4c-prime-post: {intern_failures} binding key(s) failed to intern \
1311 (capacity exhaustion?) - dropped"
1312 );
1313 }
1314 let tables = graph
1315 .c_indirect_tables_mut()
1316 .get_or_insert_with(CIndirectSideTables::new);
1317 for (key, entry) in interned_bindings {
1318 tables.bindings_by_field.entry(key).or_default().push(entry);
1319 stats.binding_entries_inserted += 1;
1320 }
1321}
1322
1323fn insert_indirect_callsites(
1324 graph: &mut CodeGraph,
1325 callsites: &[(
1326 crate::graph::unified::file::FileId,
1327 super::staging::PendingIndirectCallsite,
1328 )],
1329 callable_nodes: &DeferredCNodeCache,
1330 stats: &mut DeferredCIndirectStats,
1331) {
1332 if callsites.is_empty() {
1333 return;
1334 }
1335
1336 let mut callsites_to_push = Vec::with_capacity(callsites.len());
1337 for (file_id, callsite) in callsites {
1338 let Some(callers) = callable_nodes.get(&callsite.caller_qualified_name) else {
1339 continue;
1340 };
1341 for &caller in callers {
1342 callsites_to_push.push(IndirectCallsite {
1343 caller,
1344 file_id: *file_id,
1345 use_span: callsite.use_span,
1346 shape: callsite.shape.clone(),
1347 argument_count: callsite.argument_count,
1348 is_async: callsite.is_async,
1349 });
1350 }
1351 }
1352 if callsites_to_push.is_empty() {
1353 return;
1354 }
1355
1356 let tables = graph
1357 .c_indirect_tables_mut()
1358 .get_or_insert_with(CIndirectSideTables::new);
1359 for callsite in callsites_to_push {
1360 tables.pending_callsites.push(callsite);
1361 stats.indirect_callsites_inserted += 1;
1362 }
1363}
1364
1365pub(crate) fn apply_deferred_address_taken_marks(
1419 graph: &mut CodeGraph,
1420 pending: PhaseCIndirectDrain,
1421) -> DeferredCIndirectStats {
1422 let mut stats = DeferredCIndirectStats::default();
1423 let PhaseCIndirectDrain {
1424 address_taken_names,
1425 struct_field_signatures,
1426 bindings,
1427 indirect_callsites,
1428 local_scope_indices,
1429 } = pending;
1430
1431 apply_local_scope_indices(graph, local_scope_indices, &mut stats);
1434
1435 let callable_names_iter = address_taken_names
1483 .iter()
1484 .map(|e| e.function_qualified_name.as_str())
1485 .chain(bindings.iter().map(|(_, b)| b.target_fn_name.as_str()))
1486 .chain(
1487 indirect_callsites
1488 .iter()
1489 .map(|(_, cs)| cs.caller_qualified_name.as_str()),
1490 );
1491 let name_to_node_ids = resolve_c_callable_names(graph, callable_names_iter, &mut stats);
1492
1493 let instance_to_node_ids = resolve_c_variable_names(
1499 graph,
1500 bindings
1501 .iter()
1502 .map(|(_, binding)| binding.instance_name.as_str()),
1503 &mut stats,
1504 );
1505
1506 apply_address_taken_marks(graph, &address_taken_names, &name_to_node_ids, &mut stats);
1516
1517 insert_struct_field_signatures(graph, &struct_field_signatures, &mut stats);
1523
1524 insert_c_indirect_bindings(
1535 graph,
1536 &bindings,
1537 &instance_to_node_ids,
1538 &name_to_node_ids,
1539 &mut stats,
1540 );
1541
1542 insert_indirect_callsites(graph, &indirect_callsites, &name_to_node_ids, &mut stats);
1552
1553 stats
1554}
1555
1556pub fn build_and_persist_graph(
1565 root: &Path,
1566 plugins: &PluginManager,
1567 config: &BuildConfig,
1568 build_command: &str,
1569) -> Result<(CodeGraph, BuildResult)> {
1570 build_and_persist_graph_with_progress(
1571 root,
1572 plugins,
1573 config,
1574 build_command,
1575 inferred_plugin_selection_manifest(plugins),
1576 no_op_reporter(),
1577 )
1578}
1579
1580#[must_use]
1586pub fn inferred_plugin_selection_manifest(
1587 plugins: &PluginManager,
1588) -> Option<crate::graph::unified::persistence::PluginSelectionManifest> {
1589 let active_plugin_ids = plugins
1590 .plugins()
1591 .iter()
1592 .map(|plugin| plugin.metadata().id.to_string())
1593 .collect::<Vec<_>>();
1594 if active_plugin_ids.is_empty() {
1595 return None;
1596 }
1597
1598 Some(
1599 crate::graph::unified::persistence::PluginSelectionManifest {
1600 active_plugin_ids,
1601 high_cost_mode: None,
1602 },
1603 )
1604}
1605
1606pub struct DurableGraphPersistenceRequest<'a> {
1608 pub root: &'a Path,
1610 pub plugins: &'a PluginManager,
1612 pub config: &'a BuildConfig,
1614 pub build_command: &'a str,
1616 pub plugin_selection: Option<crate::graph::unified::persistence::PluginSelectionManifest>,
1618 pub progress: SharedReporter,
1620 pub effective_threads: usize,
1622}
1623
1624#[allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
1634pub fn persist_and_analyze_graph(
1635 graph: CodeGraph,
1636 root: &Path,
1637 plugins: &PluginManager,
1638 config: &BuildConfig,
1639 build_command: &str,
1640 plugin_selection: Option<crate::graph::unified::persistence::PluginSelectionManifest>,
1641 progress: SharedReporter,
1642 effective_threads: usize,
1643) -> Result<(CodeGraph, BuildResult)> {
1644 persist_durable_graph_transaction(
1645 graph,
1646 DurableGraphPersistenceRequest {
1647 root,
1648 plugins,
1649 config,
1650 build_command,
1651 plugin_selection,
1652 progress,
1653 effective_threads,
1654 },
1655 )
1656}
1657
1658#[allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
1671pub fn persist_durable_graph_transaction(
1672 graph: CodeGraph,
1673 request: DurableGraphPersistenceRequest<'_>,
1674) -> Result<(CodeGraph, BuildResult)> {
1675 use crate::graph::unified::analysis::csr::CsrAdjacency;
1676 use crate::graph::unified::analysis::{AnalysisIdentity, GraphAnalyses, compute_node_id_hash};
1677 use crate::graph::unified::compaction::{Direction, build_compacted_csr, snapshot_edges};
1678 use crate::graph::unified::persistence::manifest::write_manifest_bytes_atomic;
1679 use crate::graph::unified::persistence::{
1680 BuildProvenance, GraphStorage, MANIFEST_SCHEMA_VERSION, Manifest, SNAPSHOT_FORMAT_VERSION,
1681 save_to_path,
1682 };
1683 use crate::progress::IndexProgress;
1684 use chrono::Utc;
1685 use sha2::{Digest, Sha256};
1686
1687 let DurableGraphPersistenceRequest {
1688 root,
1689 plugins,
1690 config,
1691 build_command,
1692 plugin_selection,
1693 progress,
1694 effective_threads,
1695 } = request;
1696
1697 let storage = GraphStorage::new(root);
1705 fs::create_dir_all(storage.graph_dir())
1706 .with_context(|| format!("Failed to create {}", storage.graph_dir().display()))?;
1707
1708 if storage.exists() {
1709 match fs::remove_file(storage.manifest_path()) {
1715 Ok(()) => {}
1716 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1717 Err(e) => {
1718 return Err(e).with_context(|| {
1719 format!(
1720 "Failed to remove old manifest at {} — rebuild cannot proceed safely",
1721 storage.manifest_path().display()
1722 )
1723 });
1724 }
1725 }
1726 }
1727
1728 let raw_edge_count = graph.edge_count();
1730 let node_count = graph.node_count();
1731
1732 progress.report(IndexProgress::StageStarted {
1739 stage_name: "Compacting edge stores for persistence",
1740 });
1741 let compaction_start = std::time::Instant::now();
1742
1743 let forward_compaction_snapshot = {
1745 let forward_store = graph.edges().forward();
1746 snapshot_edges(&forward_store, node_count)
1747 };
1748 let reverse_compaction_snapshot = {
1749 let reverse_store = graph.edges().reverse();
1750 snapshot_edges(&reverse_store, node_count)
1751 };
1752
1753 let (forward_result, reverse_result) = rayon::join(
1755 || build_compacted_csr(&forward_compaction_snapshot, Direction::Forward),
1756 || build_compacted_csr(&reverse_compaction_snapshot, Direction::Reverse),
1757 );
1758
1759 let (forward_csr, _forward_build_stats) =
1760 forward_result.context("Failed to build forward CSR for persistence compaction")?;
1761 let (reverse_csr, _reverse_build_stats) =
1762 reverse_result.context("Failed to build reverse CSR for persistence compaction")?;
1763
1764 drop(forward_compaction_snapshot);
1766 drop(reverse_compaction_snapshot);
1767
1768 let adjacency = CsrAdjacency::from_csr_graph(&forward_csr);
1771
1772 graph
1774 .edges()
1775 .swap_csrs_and_clear_deltas(forward_csr, reverse_csr);
1776
1777 progress.report(IndexProgress::StageCompleted {
1778 stage_name: "Compacting edge stores for persistence",
1779 stage_duration: compaction_start.elapsed(),
1780 });
1781
1782 progress.report(IndexProgress::SavingStarted {
1784 component_name: "unified graph",
1785 });
1786 let save_start = std::time::Instant::now();
1787
1788 save_to_path(&graph, storage.snapshot_path()).with_context(|| {
1789 format!(
1790 "Failed to save snapshot to {}",
1791 storage.snapshot_path().display()
1792 )
1793 })?;
1794
1795 progress.report(IndexProgress::SavingCompleted {
1796 component_name: "unified graph",
1797 save_duration: save_start.elapsed(),
1798 });
1799
1800 let snapshot_content =
1802 fs::read(storage.snapshot_path()).context("Failed to read snapshot for checksum")?;
1803 let snapshot_sha256 = hex::encode(Sha256::digest(&snapshot_content));
1804
1805 progress.report(IndexProgress::StageStarted {
1809 stage_name: "Computing graph analyses",
1810 });
1811 let analysis_start = std::time::Instant::now();
1812
1813 let analysis_pool = create_thread_pool(config)
1814 .context("Failed to create rayon thread pool for graph analysis")?;
1815 let analyses = analysis_pool
1816 .install(|| {
1817 GraphAnalyses::build_all_from_adjacency_with_budget(adjacency, &config.label_budget)
1818 })
1819 .context("Failed to build graph analyses")?;
1820
1821 progress.report(IndexProgress::StageCompleted {
1822 stage_name: "Computing graph analyses",
1823 stage_duration: analysis_start.elapsed(),
1824 });
1825
1826 let dedup_edge_count = analyses.adjacency.edge_count as usize;
1827
1828 let analysis_strategies = vec![
1829 AnalysisStrategySummary {
1830 edge_kind: "calls",
1831 strategy: analyses.cond_calls.strategy,
1832 },
1833 AnalysisStrategySummary {
1834 edge_kind: "imports",
1835 strategy: analyses.cond_imports.strategy,
1836 },
1837 AnalysisStrategySummary {
1838 edge_kind: "references",
1839 strategy: analyses.cond_references.strategy,
1840 },
1841 AnalysisStrategySummary {
1842 edge_kind: "inherits",
1843 strategy: analyses.cond_inherits.strategy,
1844 },
1845 ];
1846
1847 let mut file_counts: std::collections::HashMap<String, usize> =
1849 std::collections::HashMap::new();
1850 for (file_id, file_path) in graph.indexed_files() {
1851 if graph.files().is_external(file_id) {
1852 continue;
1853 }
1854 let language = plugins
1855 .plugin_for_path(file_path)
1856 .map_or_else(|| "unknown".to_string(), |p| p.metadata().id.to_string());
1857 *file_counts.entry(language).or_insert(0) += 1;
1858 }
1859 let total_files: usize = file_counts.values().sum();
1860
1861 let built_at = Utc::now().to_rfc3339();
1863
1864 let manifest = Manifest {
1865 schema_version: MANIFEST_SCHEMA_VERSION,
1866 snapshot_format_version: SNAPSHOT_FORMAT_VERSION,
1867 built_at: built_at.clone(),
1868 root_path: root.to_string_lossy().to_string(),
1869 node_count,
1870 edge_count: dedup_edge_count,
1871 raw_edge_count: Some(raw_edge_count),
1872 snapshot_sha256,
1873 build_provenance: BuildProvenance {
1874 sqry_version: env!("CARGO_PKG_VERSION").to_string(),
1875 build_timestamp: built_at.clone(),
1876 build_command: build_command.to_string(),
1877 plugin_hashes: std::collections::HashMap::default(),
1878 },
1879 file_count: file_counts.clone(),
1880 languages: Vec::default(),
1881 config: std::collections::HashMap::default(),
1882 confidence: graph.confidence().clone(),
1883 last_indexed_commit: get_git_head_commit(root),
1884 plugin_selection: plugin_selection.clone(),
1885 };
1886
1887 let manifest_bytes =
1889 serde_json::to_vec_pretty(&manifest).context("Failed to serialize manifest")?;
1890
1891 let manifest_hash = {
1892 let mut hasher = Sha256::new();
1893 hasher.update(&manifest_bytes);
1894 hex::encode(hasher.finalize())
1895 };
1896
1897 let snapshot = graph.snapshot();
1899 let node_id_hash = compute_node_id_hash(&snapshot);
1900 let identity = AnalysisIdentity::new(manifest_hash, node_id_hash);
1901
1902 fs::create_dir_all(storage.analysis_dir()).with_context(|| {
1903 format!(
1904 "Failed to create analysis directory at {}",
1905 storage.analysis_dir().display()
1906 )
1907 })?;
1908
1909 progress.report(IndexProgress::SavingStarted {
1910 component_name: "graph analyses",
1911 });
1912
1913 analyses
1914 .persist_all(&storage, &identity)
1915 .context("Failed to persist graph analyses")?;
1916
1917 log::info!(
1918 "Graph analyses persisted to {}",
1919 storage.analysis_dir().display()
1920 );
1921
1922 progress.report(IndexProgress::SavingCompleted {
1923 component_name: "graph analyses",
1924 save_duration: analysis_start.elapsed(),
1925 });
1926
1927 write_manifest_bytes_atomic(storage.manifest_path(), &manifest_bytes).with_context(|| {
1929 format!(
1930 "Failed to save manifest to {}",
1931 storage.manifest_path().display()
1932 )
1933 })?;
1934
1935 log::info!(
1936 "Manifest saved to {} (dedup edges: {}, raw edges: {})",
1937 storage.manifest_path().display(),
1938 dedup_edge_count,
1939 raw_edge_count
1940 );
1941
1942 let build_result = BuildResult {
1943 node_count,
1944 edge_count: dedup_edge_count,
1945 raw_edge_count,
1946 file_count: file_counts,
1947 total_files,
1948 built_at,
1949 root_path: root.to_string_lossy().to_string(),
1950 thread_count: effective_threads,
1951 active_plugin_ids: plugin_selection
1952 .map_or_else(Vec::new, |selection| selection.active_plugin_ids),
1953 analysis_strategies,
1954 };
1955
1956 Ok((graph, build_result))
1957}
1958
1959#[allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
1983pub fn build_and_persist_graph_with_progress(
1984 root: &Path,
1985 plugins: &PluginManager,
1986 config: &BuildConfig,
1987 build_command: &str,
1988 plugin_selection: Option<crate::graph::unified::persistence::PluginSelectionManifest>,
1989 progress: SharedReporter,
1990) -> Result<(CodeGraph, BuildResult)> {
1991 let (graph, effective_threads) = build_unified_graph_inner(
1992 root,
1993 plugins,
1994 config,
1995 progress.clone(),
1996 &CancellationToken::default(),
1997 )
1998 .map_err(anyhow::Error::from)?;
1999 persist_and_analyze_graph(
2000 graph,
2001 root,
2002 plugins,
2003 config,
2004 build_command,
2005 plugin_selection,
2006 progress,
2007 effective_threads,
2008 )
2009}
2010
2011#[must_use]
2013pub fn get_git_head_commit(path: &Path) -> Option<String> {
2014 let output = std::process::Command::new("git")
2015 .arg("-C")
2016 .arg(path)
2017 .args(["rev-parse", "HEAD"])
2018 .output()
2019 .ok()?;
2020
2021 if output.status.success() {
2022 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
2023 if sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit()) {
2024 return Some(sha);
2025 }
2026 }
2027 None
2028}
2029
2030fn find_source_files(root: &Path, config: &BuildConfig) -> Vec<std::path::PathBuf> {
2034 let mut builder = WalkBuilder::new(root);
2035
2036 builder
2037 .follow_links(config.follow_links)
2038 .hidden(!config.include_hidden)
2039 .git_ignore(true)
2040 .git_global(true)
2041 .git_exclude(true);
2042
2043 if let Some(depth) = config.max_depth {
2044 builder.max_depth(Some(depth));
2045 }
2046
2047 if let Some(threads) = config.num_threads {
2048 builder.threads(threads);
2049 }
2050
2051 let root_for_filter = root.to_path_buf();
2052 builder.filter_entry(move |entry| {
2053 entry
2054 .file_type()
2055 .is_none_or(|file_type| !file_type.is_dir())
2056 || should_visit_source_dir(&root_for_filter, entry.path())
2057 });
2058
2059 let mut files = Vec::new();
2060
2061 for entry in builder.build() {
2062 let entry = match entry {
2063 Ok(entry) => entry,
2064 Err(err) => {
2065 log::warn!("Failed to read directory entry: {err}");
2066 continue;
2067 }
2068 };
2069
2070 if entry.file_type().is_some_and(|ft| ft.is_file()) {
2071 files.push(entry.into_path());
2072 }
2073 }
2074
2075 files
2076}
2077
2078fn should_visit_source_dir(root: &Path, path: &Path) -> bool {
2079 if path == root {
2080 return true;
2081 }
2082
2083 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
2084 return true;
2085 };
2086
2087 !is_default_excluded_source_dir(name)
2088}
2089
2090fn is_default_excluded_source_dir(name: &str) -> bool {
2091 if std::env::var("SQRY_INCLUDE_DEFAULT_EXCLUDED_DIRS")
2092 .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
2093 {
2094 return false;
2095 }
2096
2097 DEFAULT_EXCLUDED_SOURCE_DIRS.contains(&name)
2098 || DEFAULT_EXCLUDED_SOURCE_DIR_PREFIXES
2099 .iter()
2100 .any(|prefix| name.starts_with(prefix))
2101}
2102
2103fn sort_files_for_build(root: &Path, files: &mut [PathBuf]) {
2104 let normalized_root = normalize_path_components(root);
2105 files.sort_by(|left, right| {
2106 let left_key = file_sort_key(&normalized_root, left);
2107 let right_key = file_sort_key(&normalized_root, right);
2108 left_key.cmp(&right_key).then_with(|| left.cmp(right))
2109 });
2110}
2111
2112fn file_sort_key(root: &Path, path: &Path) -> String {
2113 let normalized_path = normalize_path_components(path);
2114 let relative = normalized_path
2115 .strip_prefix(root)
2116 .unwrap_or(normalized_path.as_path());
2117 let mut key = relative.to_string_lossy().replace('\\', "/");
2118 if cfg!(windows) {
2119 key = key.to_ascii_lowercase();
2120 }
2121 key
2122}
2123
2124#[derive(Debug)]
2133pub(super) struct ParsedFile {
2134 pub(super) language: crate::graph::Language,
2136 pub(super) staging: StagingGraph,
2138}
2139
2140#[allow(clippy::large_enum_variant)]
2152#[derive(Debug)]
2153pub(super) enum ParsedFileOutcome {
2154 Parsed(ParsedFile),
2155 Skipped,
2156 TimedOut {
2157 file: PathBuf,
2158 phase: &'static str,
2159 timeout_ms: u64,
2160 },
2161}
2162
2163pub(super) fn parse_file(path: &Path, plugins: &PluginManager) -> Result<ParsedFileOutcome> {
2174 let plugin = plugins.plugin_for_path(path);
2175 let Some(plugin) = plugin else {
2176 return Ok(ParsedFileOutcome::Skipped);
2177 };
2178
2179 let Some(builder) = plugin.graph_builder() else {
2180 return Ok(ParsedFileOutcome::Skipped);
2181 };
2182
2183 let reader =
2184 FileReader::open(path).with_context(|| format!("failed to read {}", path.display()))?;
2185 let raw_content = reader.as_slice();
2186
2187 let safe_parser = SafeParser::new(SafeParserConfig::new().with_max_input_size(
2188 usize::try_from(crate::config::buffers::max_source_file_size()).unwrap_or(usize::MAX),
2189 ));
2190 let prepared_content = plugin.preprocess(raw_content);
2191 let parse_content = prepared_content.as_ref();
2192 let parse_start = Instant::now();
2193 let tree = safe_parser
2194 .parse_file(&plugin.language(), parse_content, path)
2195 .map_err(|err| map_parse_error(path, err))?;
2196 let parse_duration = parse_start.elapsed();
2197 if parse_duration >= Duration::from_secs(2) {
2198 log::warn!("Slow parse ({parse_duration:.2?}): {}", path.display());
2199 }
2200
2201 let mut staging = StagingGraph::new();
2202 let build_start = Instant::now();
2203 match builder.build_graph(&tree, parse_content, path, &mut staging) {
2204 Ok(()) => {}
2205 Err(GraphBuilderError::BuildTimedOut {
2206 phase, timeout_ms, ..
2207 }) => {
2208 return Ok(ParsedFileOutcome::TimedOut {
2209 file: path.to_path_buf(),
2210 phase,
2211 timeout_ms,
2212 });
2213 }
2214 Err(err) => return Err(map_builder_error(path, &err)),
2215 }
2216 let build_duration = build_start.elapsed();
2217 if build_duration >= Duration::from_secs(2) {
2218 log::warn!(
2219 "Slow graph build ({build_duration:.2?}): {}",
2220 path.display()
2221 );
2222 }
2223
2224 let shape_ctx = builder
2230 .shape_mapping()
2231 .map(|mapping| super::staging::ShapeAttachCtx::new(&tree, parse_content, mapping));
2232 staging.attach_body_hashes(raw_content, shape_ctx);
2233
2234 Ok(ParsedFileOutcome::Parsed(ParsedFile {
2235 language: builder.language(),
2236 staging,
2237 }))
2238}
2239
2240fn map_parse_error(path: &Path, err: ParseError) -> anyhow::Error {
2241 match err {
2242 ParseError::TreeSitterFailed => {
2243 anyhow::anyhow!("tree-sitter failed to parse {}", path.display())
2244 }
2245 ParseError::LanguageSetFailed(reason) => anyhow::anyhow!(
2246 "failed to configure tree-sitter for {}: {}",
2247 path.display(),
2248 reason
2249 ),
2250 ParseError::InputTooLarge { size, max, .. } => anyhow::anyhow!(
2251 "input too large for {}: {} bytes exceeds {} byte parser limit",
2252 path.display(),
2253 size,
2254 max
2255 ),
2256 ParseError::ParseTimedOut { timeout_micros, .. } => anyhow::anyhow!(
2257 "parse timed out for {} after {} ms",
2258 path.display(),
2259 timeout_micros / 1000
2260 ),
2261 ParseError::ParseCancelled { reason, .. } => {
2262 anyhow::anyhow!("parse cancelled for {}: {}", path.display(), reason)
2263 }
2264 _ => anyhow::anyhow!("parse error in {}: {:?}", path.display(), err),
2265 }
2266}
2267
2268fn map_builder_error(path: &Path, err: &GraphBuilderError) -> anyhow::Error {
2269 anyhow::anyhow!("graph builder error in {}: {}", path.display(), err)
2270}
2271
2272#[cfg(any(test, feature = "rebuild-internals"))]
2293pub mod testing {
2294 use super::CancellationToken;
2295 use std::cell::RefCell;
2296
2297 pub type AfterChunkHook = Box<dyn FnMut(&CancellationToken)>;
2302 pub type BeforePhase4Hook = Box<dyn FnMut(&CancellationToken)>;
2305 pub type BeforePass5Hook = Box<dyn FnMut(&CancellationToken)>;
2307
2308 thread_local! {
2309 static AFTER_CHUNK_HOOK: RefCell<Option<AfterChunkHook>> = const { RefCell::new(None) };
2310 static BEFORE_PHASE4_HOOK: RefCell<Option<BeforePhase4Hook>> = const { RefCell::new(None) };
2311 static BEFORE_PASS5_HOOK: RefCell<Option<BeforePass5Hook>> = const { RefCell::new(None) };
2312 }
2313
2314 pub fn set_after_chunk_hook<F>(hook: F) -> Option<AfterChunkHook>
2317 where
2318 F: FnMut(&CancellationToken) + 'static,
2319 {
2320 AFTER_CHUNK_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
2321 }
2322
2323 pub fn clear_after_chunk_hook() {
2325 AFTER_CHUNK_HOOK.with(|cell| {
2326 let _ = cell.replace(None);
2327 });
2328 }
2329
2330 pub fn set_before_phase4_hook<F>(hook: F) -> Option<BeforePhase4Hook>
2333 where
2334 F: FnMut(&CancellationToken) + 'static,
2335 {
2336 BEFORE_PHASE4_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
2337 }
2338
2339 pub fn clear_before_phase4_hook() {
2341 BEFORE_PHASE4_HOOK.with(|cell| {
2342 let _ = cell.replace(None);
2343 });
2344 }
2345
2346 pub fn set_before_pass5_hook<F>(hook: F) -> Option<BeforePass5Hook>
2349 where
2350 F: FnMut(&CancellationToken) + 'static,
2351 {
2352 BEFORE_PASS5_HOOK.with(|cell| cell.replace(Some(Box::new(hook))))
2353 }
2354
2355 pub fn clear_before_pass5_hook() {
2357 BEFORE_PASS5_HOOK.with(|cell| {
2358 let _ = cell.replace(None);
2359 });
2360 }
2361
2362 pub(super) fn fire_after_chunk_hook(cancellation: &CancellationToken) {
2365 AFTER_CHUNK_HOOK.with(|cell| {
2366 if let Some(hook) = cell.borrow_mut().as_mut() {
2367 hook(cancellation);
2368 }
2369 });
2370 }
2371
2372 pub(super) fn fire_before_phase4_hook(cancellation: &CancellationToken) {
2374 BEFORE_PHASE4_HOOK.with(|cell| {
2375 if let Some(hook) = cell.borrow_mut().as_mut() {
2376 hook(cancellation);
2377 }
2378 });
2379 }
2380
2381 pub(super) fn fire_before_pass5_hook(cancellation: &CancellationToken) {
2383 BEFORE_PASS5_HOOK.with(|cell| {
2384 if let Some(hook) = cell.borrow_mut().as_mut() {
2385 hook(cancellation);
2386 }
2387 });
2388 }
2389
2390 pub struct AfterChunkHookGuard {
2394 _sealed: (),
2395 }
2396
2397 impl AfterChunkHookGuard {
2398 pub fn install<F>(hook: F) -> Self
2400 where
2401 F: FnMut(&CancellationToken) + 'static,
2402 {
2403 let _previous = set_after_chunk_hook(hook);
2404 Self { _sealed: () }
2405 }
2406 }
2407
2408 impl Drop for AfterChunkHookGuard {
2409 fn drop(&mut self) {
2410 clear_after_chunk_hook();
2411 }
2412 }
2413
2414 pub struct BeforePhase4HookGuard {
2417 _sealed: (),
2418 }
2419
2420 impl BeforePhase4HookGuard {
2421 pub fn install<F>(hook: F) -> Self
2423 where
2424 F: FnMut(&CancellationToken) + 'static,
2425 {
2426 let _previous = set_before_phase4_hook(hook);
2427 Self { _sealed: () }
2428 }
2429 }
2430
2431 impl Drop for BeforePhase4HookGuard {
2432 fn drop(&mut self) {
2433 clear_before_phase4_hook();
2434 }
2435 }
2436
2437 pub struct BeforePass5HookGuard {
2440 _sealed: (),
2441 }
2442
2443 impl BeforePass5HookGuard {
2444 pub fn install<F>(hook: F) -> Self
2446 where
2447 F: FnMut(&CancellationToken) + 'static,
2448 {
2449 let _previous = set_before_pass5_hook(hook);
2450 Self { _sealed: () }
2451 }
2452 }
2453
2454 impl Drop for BeforePass5HookGuard {
2455 fn drop(&mut self) {
2456 clear_before_pass5_hook();
2457 }
2458 }
2459}
2460
2461#[cfg(test)]
2462mod tests {
2463 use super::*;
2464 use crate::ast::Scope;
2465 use crate::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language};
2466 use crate::plugin::error::{ParseError, ScopeError};
2467 use crate::plugin::{LanguageMetadata, LanguagePlugin};
2468 use serial_test::serial;
2469 use std::fs;
2470 use std::path::{Path, PathBuf};
2471 use tempfile::TempDir;
2472 use tree_sitter::{Parser, Tree};
2473
2474 const RUST_TEST_EXTENSIONS: &[&str] = &["rs"];
2475 const FILENAME_MATCH_EXTENSIONS: &[&str] = &["rmd", "bash_profile"];
2476
2477 #[test]
2478 fn index_worker_stack_size_uses_default_without_override() {
2479 assert_eq!(
2480 index_worker_stack_size_bytes_from_value(None),
2481 DEFAULT_INDEX_WORKER_STACK_SIZE_MB * BYTES_PER_MIB
2482 );
2483 }
2484
2485 #[test]
2486 fn index_worker_stack_size_honors_valid_override() {
2487 assert_eq!(
2488 index_worker_stack_size_bytes_from_value(Some("64")),
2489 64 * BYTES_PER_MIB
2490 );
2491 }
2492
2493 #[test]
2494 fn index_worker_stack_size_clamps_override_bounds() {
2495 assert_eq!(
2496 index_worker_stack_size_bytes_from_value(Some("1")),
2497 MIN_INDEX_WORKER_STACK_SIZE_MB * BYTES_PER_MIB
2498 );
2499 assert_eq!(
2500 index_worker_stack_size_bytes_from_value(Some("9999")),
2501 MAX_INDEX_WORKER_STACK_SIZE_MB * BYTES_PER_MIB
2502 );
2503 }
2504
2505 #[test]
2506 fn index_worker_stack_size_ignores_invalid_override() {
2507 assert_eq!(
2508 index_worker_stack_size_bytes_from_value(Some("not-a-number")),
2509 DEFAULT_INDEX_WORKER_STACK_SIZE_MB * BYTES_PER_MIB
2510 );
2511 }
2512
2513 fn commit_parsed_file_for_test(path: &Path, mut parsed: ParsedFile, graph: &mut CodeGraph) {
2518 let file_id = graph
2519 .files_mut()
2520 .register_with_language(path, Some(parsed.language))
2521 .expect("register file");
2522 parsed.staging.apply_file_id(file_id);
2523 let string_remap = parsed
2524 .staging
2525 .commit_strings(graph.strings_mut())
2526 .expect("commit strings");
2527 parsed
2528 .staging
2529 .apply_string_remap(&string_remap)
2530 .expect("apply string remap");
2531 let node_id_mapping = parsed
2532 .staging
2533 .commit_nodes(graph.nodes_mut())
2534 .expect("commit nodes");
2535 let edges = parsed.staging.get_remapped_edges(&node_id_mapping);
2536 for edge in edges {
2537 graph.edges_mut().add_edge_with_spans(
2538 edge.source,
2539 edge.target,
2540 edge.kind.clone(),
2541 file_id,
2542 edge.spans.clone(),
2543 );
2544 }
2545 }
2546
2547 fn expect_parsed_file(outcome: ParsedFileOutcome) -> ParsedFile {
2548 match outcome {
2549 ParsedFileOutcome::Parsed(parsed) => parsed,
2550 ParsedFileOutcome::Skipped => panic!("expected parsed file, got skipped outcome"),
2551 ParsedFileOutcome::TimedOut { file, phase, .. } => {
2552 panic!(
2553 "expected parsed file, got timeout outcome for {} during {}",
2554 file.display(),
2555 phase,
2556 )
2557 }
2558 }
2559 }
2560
2561 fn parse_rust_ast(content: &[u8]) -> Result<Tree, ParseError> {
2562 let mut parser = Parser::new();
2563 let language = tree_sitter_rust::LANGUAGE.into();
2564 parser
2565 .set_language(&language)
2566 .map_err(|err| ParseError::LanguageSetFailed(err.to_string()))?;
2567 parser
2568 .parse(content, None)
2569 .ok_or(ParseError::TreeSitterFailed)
2570 }
2571
2572 struct TestPlugin {
2573 metadata: LanguageMetadata,
2574 extensions: &'static [&'static str],
2575 builder: Option<Box<dyn GraphBuilder>>,
2576 }
2577
2578 impl TestPlugin {
2579 fn new(
2580 id: &'static str,
2581 extensions: &'static [&'static str],
2582 builder: Option<Box<dyn GraphBuilder>>,
2583 ) -> Self {
2584 Self {
2585 metadata: LanguageMetadata {
2586 id,
2587 name: "Rust",
2588 version: "test",
2589 author: "sqry-core tests",
2590 description: "Test-only Rust plugin for unified graph entrypoint tests",
2591 tree_sitter_version: "0.25",
2592 },
2593 extensions,
2594 builder,
2595 }
2596 }
2597 }
2598
2599 impl LanguagePlugin for TestPlugin {
2600 fn metadata(&self) -> LanguageMetadata {
2601 self.metadata.clone()
2602 }
2603
2604 fn extensions(&self) -> &'static [&'static str] {
2605 self.extensions
2606 }
2607
2608 fn language(&self) -> tree_sitter::Language {
2609 tree_sitter_rust::LANGUAGE.into()
2610 }
2611
2612 fn parse_ast(&self, content: &[u8]) -> Result<Tree, ParseError> {
2613 parse_rust_ast(content)
2614 }
2615
2616 fn extract_scopes(
2617 &self,
2618 _tree: &Tree,
2619 _content: &[u8],
2620 _file_path: &Path,
2621 ) -> Result<Vec<Scope>, ScopeError> {
2622 Ok(Vec::new())
2623 }
2624
2625 fn graph_builder(&self) -> Option<&dyn crate::graph::GraphBuilder> {
2626 self.builder.as_deref()
2627 }
2628 }
2629
2630 struct FailingGraphBuilder;
2631
2632 impl GraphBuilder for FailingGraphBuilder {
2633 fn build_graph(
2634 &self,
2635 _tree: &Tree,
2636 _content: &[u8],
2637 _file: &Path,
2638 _staging: &mut StagingGraph,
2639 ) -> GraphResult<()> {
2640 Err(GraphBuilderError::CrossLanguageError {
2641 reason: "forced failure".to_string(),
2642 })
2643 }
2644
2645 fn language(&self) -> Language {
2646 Language::Rust
2647 }
2648 }
2649
2650 struct NoopGraphBuilder;
2651
2652 impl GraphBuilder for NoopGraphBuilder {
2653 fn build_graph(
2654 &self,
2655 _tree: &Tree,
2656 _content: &[u8],
2657 _file: &Path,
2658 _staging: &mut StagingGraph,
2659 ) -> GraphResult<()> {
2660 Ok(())
2661 }
2662
2663 fn language(&self) -> Language {
2664 Language::Rust
2665 }
2666 }
2667
2668 struct TimeoutGraphBuilder;
2669
2670 impl GraphBuilder for TimeoutGraphBuilder {
2671 fn build_graph(
2672 &self,
2673 _tree: &Tree,
2674 _content: &[u8],
2675 file: &Path,
2676 _staging: &mut StagingGraph,
2677 ) -> GraphResult<()> {
2678 Err(GraphBuilderError::BuildTimedOut {
2679 file: file.to_path_buf(),
2680 phase: "test-timeout",
2681 timeout_ms: 42,
2682 })
2683 }
2684
2685 fn language(&self) -> Language {
2686 Language::Rust
2687 }
2688 }
2689
2690 struct SelectiveTimeoutGraphBuilder;
2691
2692 impl GraphBuilder for SelectiveTimeoutGraphBuilder {
2693 fn build_graph(
2694 &self,
2695 _tree: &Tree,
2696 _content: &[u8],
2697 file: &Path,
2698 staging: &mut StagingGraph,
2699 ) -> GraphResult<()> {
2700 use crate::graph::unified::build::helper::GraphBuildHelper;
2701
2702 let mut helper = GraphBuildHelper::new(staging, file, Language::Rust);
2703 let file_name = file
2704 .file_name()
2705 .and_then(|value| value.to_str())
2706 .unwrap_or_default();
2707
2708 if file_name == "timeout.rs" {
2709 helper.add_function("timeout_partial", None, false, false);
2710 return Err(GraphBuilderError::BuildTimedOut {
2711 file: file.to_path_buf(),
2712 phase: "test-timeout",
2713 timeout_ms: 42,
2714 });
2715 }
2716
2717 helper.add_function("survivor_fn", None, false, false);
2718 Ok(())
2719 }
2720
2721 fn language(&self) -> Language {
2722 Language::Rust
2723 }
2724 }
2725
2726 #[test]
2727 fn test_build_config_default() {
2728 let config = BuildConfig::default();
2729 assert_eq!(config.max_depth, None);
2730 assert!(!config.follow_links);
2731 assert!(!config.include_hidden);
2732 assert_eq!(config.num_threads, None);
2733 }
2734
2735 #[test]
2736 #[serial]
2737 fn test_find_source_files_excludes_generated_dependency_roots() {
2738 let temp_dir = TempDir::new().expect("temp dir");
2739 let root = temp_dir.path();
2740
2741 fs::write(root.join("src.rs"), "fn src() {}").expect("write source file");
2742 for dir in [
2743 "_work",
2744 "_actions",
2745 "_update",
2746 "externals.2.334.0",
2747 "node_modules",
2748 "target",
2749 "vendor",
2750 ] {
2751 let nested = root.join(dir).join("nested");
2752 fs::create_dir_all(&nested).expect("create excluded dir");
2753 fs::write(nested.join("ignored.rs"), "fn ignored() {}")
2754 .expect("write ignored source file");
2755 }
2756 for dir in ["external_tools", "vendorized"] {
2757 let nested = root.join(dir).join("nested");
2758 fs::create_dir_all(&nested).expect("create included sibling dir");
2759 fs::write(nested.join("included.rs"), "fn included() {}")
2760 .expect("write included source file");
2761 }
2762
2763 let config = BuildConfig::default();
2764 let mut relative_files: Vec<_> = find_source_files(root, &config)
2765 .iter()
2766 .map(|path| path.strip_prefix(root).expect("strip root").to_path_buf())
2767 .collect();
2768 relative_files.sort();
2769
2770 assert_eq!(
2771 relative_files,
2772 vec![
2773 PathBuf::from("external_tools/nested/included.rs"),
2774 PathBuf::from("src.rs"),
2775 PathBuf::from("vendorized/nested/included.rs"),
2776 ]
2777 );
2778 }
2779
2780 #[test]
2781 #[serial]
2782 fn test_find_source_files_can_include_default_excluded_roots() {
2783 let temp_dir = TempDir::new().expect("temp dir");
2784 let root = temp_dir.path();
2785 let nested = root.join("vendor").join("first_party");
2786 fs::create_dir_all(&nested).expect("create vendor dir");
2787 fs::write(nested.join("included.rs"), "fn included() {}").expect("write included source");
2788
2789 unsafe {
2790 std::env::set_var("SQRY_INCLUDE_DEFAULT_EXCLUDED_DIRS", "1");
2791 }
2792 let config = BuildConfig::default();
2793 let files = find_source_files(root, &config);
2794 unsafe {
2795 std::env::remove_var("SQRY_INCLUDE_DEFAULT_EXCLUDED_DIRS");
2796 }
2797
2798 let relative_files: Vec<_> = files
2799 .iter()
2800 .map(|path| path.strip_prefix(root).expect("strip root").to_path_buf())
2801 .collect();
2802
2803 assert_eq!(
2804 relative_files,
2805 vec![PathBuf::from("vendor/first_party/included.rs")]
2806 );
2807 }
2808
2809 #[test]
2810 fn test_build_unified_graph_empty_registry_error() {
2811 let plugins = PluginManager::new();
2812 let config = BuildConfig::default();
2813 let root = std::path::Path::new(".");
2814
2815 let result = build_unified_graph(root, &plugins, &config);
2816 let err = result.expect_err("empty registry must error");
2817 assert_eq!(
2824 err.to_string(),
2825 "Internal graph builder error: No graph builders registered – cannot build code graph"
2826 );
2827 }
2828
2829 #[test]
2830 fn test_build_unified_graph_no_graph_builders_error() {
2831 let mut plugins = PluginManager::new();
2832 plugins.register_builtin(Box::new(TestPlugin::new(
2833 "rust-no-graph-builder",
2834 RUST_TEST_EXTENSIONS,
2835 None,
2836 )));
2837 let config = BuildConfig::default();
2838 let root = std::path::Path::new(".");
2839
2840 let result = build_unified_graph(root, &plugins, &config);
2841 let err = result.expect_err("no graph builders must error");
2842 assert_eq!(
2843 err.to_string(),
2844 "Internal graph builder error: No graph builders registered – cannot build code graph"
2845 );
2846 }
2847
2848 #[test]
2849 fn test_build_unified_graph_all_failures_error() {
2850 let temp_dir = TempDir::new().expect("temp dir");
2851 let file_path = temp_dir.path().join("fail.rs");
2852 fs::write(&file_path, "fn main() {}").expect("write test file");
2853
2854 let mut plugins = PluginManager::new();
2855 plugins.register_builtin(Box::new(TestPlugin::new(
2856 "rust-failing-graph-builder",
2857 RUST_TEST_EXTENSIONS,
2858 Some(Box::new(FailingGraphBuilder)),
2859 )));
2860 let config = BuildConfig::default();
2861
2862 let result = build_unified_graph(temp_dir.path(), &plugins, &config);
2863 let err = result.expect_err("all-failures must error");
2864 assert_eq!(
2865 err.to_string(),
2866 "Internal graph builder error: All graph builds failed"
2867 );
2868 }
2869
2870 #[test]
2871 fn test_parse_file_matches_uppercase_extension() {
2872 let temp_dir = TempDir::new().expect("temp dir");
2873 let file_path = temp_dir.path().join("report.Rmd");
2874 fs::write(&file_path, "fn main() {}").expect("write test file");
2875
2876 let mut plugins = PluginManager::new();
2877 plugins.register_builtin(Box::new(TestPlugin::new(
2878 "rust-filename-match",
2879 FILENAME_MATCH_EXTENSIONS,
2880 Some(Box::new(NoopGraphBuilder)),
2881 )));
2882 let mut graph = CodeGraph::new();
2883
2884 let parsed = expect_parsed_file(parse_file(&file_path, &plugins).expect("parse file"));
2885 commit_parsed_file_for_test(&file_path, parsed, &mut graph);
2886 }
2887
2888 #[test]
2889 fn test_parse_file_matches_dotless_filename() {
2890 let temp_dir = TempDir::new().expect("temp dir");
2891 let file_path = temp_dir.path().join("bash_profile");
2892 fs::write(&file_path, "fn main() {}").expect("write test file");
2893
2894 let mut plugins = PluginManager::new();
2895 plugins.register_builtin(Box::new(TestPlugin::new(
2896 "rust-filename-match",
2897 FILENAME_MATCH_EXTENSIONS,
2898 Some(Box::new(NoopGraphBuilder)),
2899 )));
2900 let mut graph = CodeGraph::new();
2901
2902 let parsed = expect_parsed_file(parse_file(&file_path, &plugins).expect("parse file"));
2903 commit_parsed_file_for_test(&file_path, parsed, &mut graph);
2904 }
2905
2906 #[test]
2907 fn test_parse_file_matches_pulumi_stack_filename() {
2908 let temp_dir = TempDir::new().expect("temp dir");
2909 let file_path = temp_dir.path().join("Pulumi.dev.yaml");
2910 fs::write(&file_path, "fn main() {}").expect("write test file");
2911
2912 let mut plugins = PluginManager::new();
2913 plugins.register_builtin(Box::new(TestPlugin::new(
2914 "pulumi",
2915 &["pulumi.yaml"],
2916 Some(Box::new(NoopGraphBuilder)),
2917 )));
2918 let mut graph = CodeGraph::new();
2919
2920 let parsed = expect_parsed_file(parse_file(&file_path, &plugins).expect("parse file"));
2921 commit_parsed_file_for_test(&file_path, parsed, &mut graph);
2922 }
2923
2924 #[test]
2925 fn test_parse_file_returns_timed_out_outcome() {
2926 let temp_dir = TempDir::new().expect("temp dir");
2927 let file_path = temp_dir.path().join("timeout.rs");
2928 fs::write(&file_path, "fn main() {}").expect("write test file");
2929
2930 let mut plugins = PluginManager::new();
2931 plugins.register_builtin(Box::new(TestPlugin::new(
2932 "rust-timeout",
2933 RUST_TEST_EXTENSIONS,
2934 Some(Box::new(TimeoutGraphBuilder)),
2935 )));
2936
2937 let outcome = parse_file(&file_path, &plugins).expect("parse file");
2938 match outcome {
2939 ParsedFileOutcome::TimedOut {
2940 file,
2941 phase,
2942 timeout_ms,
2943 } => {
2944 assert_eq!(file, file_path);
2945 assert_eq!(phase, "test-timeout");
2946 assert_eq!(timeout_ms, 42);
2947 }
2948 other => panic!("expected timed out outcome, got {other:?}"),
2949 }
2950 }
2951
2952 #[test]
2953 #[serial]
2954 fn test_parse_file_rejects_oversized_input() {
2955 let temp_dir = TempDir::new().expect("temp dir");
2956 let file_path = temp_dir.path().join("oversized.rs");
2957 fs::write(&file_path, vec![b'a'; 1_048_577]).expect("write oversized file");
2958
2959 let mut plugins = PluginManager::new();
2960 plugins.register_builtin(Box::new(TestPlugin::new(
2961 "rust-oversized",
2962 RUST_TEST_EXTENSIONS,
2963 Some(Box::new(NoopGraphBuilder)),
2964 )));
2965
2966 unsafe {
2967 std::env::set_var("SQRY_MAX_SOURCE_FILE_SIZE", "1048576");
2968 }
2969 let err = parse_file(&file_path, &plugins).expect_err("oversized file should fail");
2970 unsafe {
2971 std::env::remove_var("SQRY_MAX_SOURCE_FILE_SIZE");
2972 }
2973
2974 let err_text = err.to_string();
2975 assert!(err_text.contains("oversized.rs"));
2976 }
2977
2978 #[test]
2979 fn test_build_unified_graph_skips_timed_out_file_without_partial_commit() {
2980 let temp_dir = TempDir::new().expect("temp dir");
2981 let ok_path = temp_dir.path().join("ok.rs");
2982 let timeout_path = temp_dir.path().join("timeout.rs");
2983 fs::write(&ok_path, "fn ok() {}").expect("write ok file");
2984 fs::write(&timeout_path, "fn timeout() {}").expect("write timeout file");
2985
2986 let mut plugins = PluginManager::new();
2987 plugins.register_builtin(Box::new(TestPlugin::new(
2988 "rust-selective-timeout",
2989 RUST_TEST_EXTENSIONS,
2990 Some(Box::new(SelectiveTimeoutGraphBuilder)),
2991 )));
2992 let config = BuildConfig::default();
2993
2994 let graph = build_unified_graph(temp_dir.path(), &plugins, &config)
2995 .expect("graph build should succeed with surviving files");
2996 let snapshot = graph.snapshot();
2997
2998 assert_eq!(snapshot.find_by_pattern("survivor_fn").len(), 1);
2999 assert!(
3000 snapshot.find_by_pattern("timeout_partial").is_empty(),
3001 "timed out file staging must not be committed"
3002 );
3003 }
3004
3005 struct SimpleGraphBuilder;
3011
3012 impl GraphBuilder for SimpleGraphBuilder {
3013 fn build_graph(
3014 &self,
3015 _tree: &Tree,
3016 _content: &[u8],
3017 file: &Path,
3018 staging: &mut StagingGraph,
3019 ) -> GraphResult<()> {
3020 use crate::graph::unified::build::helper::GraphBuildHelper;
3021
3022 let mut helper = GraphBuildHelper::new(staging, file, Language::Rust);
3023
3024 let fn1 = helper.add_function("main", None, false, false);
3026 let fn2 = helper.add_function("helper", None, false, false);
3027
3028 helper.add_call_edge(fn1, fn2);
3030
3031 Ok(())
3032 }
3033
3034 fn language(&self) -> Language {
3035 Language::Rust
3036 }
3037 }
3038
3039 #[test]
3041 fn test_build_and_persist_graph_returns_build_result() {
3042 let temp_dir = TempDir::new().expect("temp dir");
3043 let file_path = temp_dir.path().join("test.rs");
3044 fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3045
3046 let mut plugins = PluginManager::new();
3047 plugins.register_builtin(Box::new(TestPlugin::new(
3048 "rust-simple",
3049 RUST_TEST_EXTENSIONS,
3050 Some(Box::new(SimpleGraphBuilder)),
3051 )));
3052 let config = BuildConfig::default();
3053
3054 let result =
3055 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:build_result");
3056 assert!(result.is_ok(), "build_and_persist_graph should succeed");
3057
3058 let (_graph, build_result) = result.unwrap();
3059 assert!(build_result.node_count > 0, "Should have nodes");
3060 assert!(build_result.total_files > 0, "Should have indexed files");
3061 assert!(!build_result.built_at.is_empty(), "Should have timestamp");
3062 assert!(!build_result.root_path.is_empty(), "Should have root path");
3063 }
3064
3065 #[test]
3067 fn test_build_result_edge_count_le_raw() {
3068 let temp_dir = TempDir::new().expect("temp dir");
3069 let file_path = temp_dir.path().join("test.rs");
3070 fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3071
3072 let mut plugins = PluginManager::new();
3073 plugins.register_builtin(Box::new(TestPlugin::new(
3074 "rust-simple",
3075 RUST_TEST_EXTENSIONS,
3076 Some(Box::new(SimpleGraphBuilder)),
3077 )));
3078 let config = BuildConfig::default();
3079
3080 let (_graph, build_result) =
3081 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:edge_count").unwrap();
3082
3083 assert!(
3084 build_result.edge_count <= build_result.raw_edge_count,
3085 "Deduplicated edge count ({}) should be <= raw edge count ({})",
3086 build_result.edge_count,
3087 build_result.raw_edge_count
3088 );
3089 }
3090
3091 #[test]
3093 fn test_build_and_persist_graph_file_counts_use_plugins() {
3094 let temp_dir = TempDir::new().expect("temp dir");
3095 let file_path = temp_dir.path().join("test.rs");
3096 fs::write(&file_path, "fn main() {}").expect("write test file");
3097
3098 let mut plugins = PluginManager::new();
3099 plugins.register_builtin(Box::new(TestPlugin::new(
3100 "rust-simple",
3101 RUST_TEST_EXTENSIONS,
3102 Some(Box::new(SimpleGraphBuilder)),
3103 )));
3104 let config = BuildConfig::default();
3105
3106 let (_graph, build_result) =
3107 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:file_counts")
3108 .unwrap();
3109
3110 assert!(
3112 !build_result.file_count.is_empty(),
3113 "File counts should not be empty"
3114 );
3115 assert!(
3116 build_result.file_count.contains_key("rust-simple"),
3117 "File counts should use plugin ID. Got: {:?}",
3118 build_result.file_count
3119 );
3120 }
3121
3122 #[test]
3124 fn test_manifest_edge_count_is_deduplicated() {
3125 use crate::graph::unified::persistence::GraphStorage;
3126
3127 let temp_dir = TempDir::new().expect("temp dir");
3128 let file_path = temp_dir.path().join("test.rs");
3129 fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3130
3131 let mut plugins = PluginManager::new();
3132 plugins.register_builtin(Box::new(TestPlugin::new(
3133 "rust-simple",
3134 RUST_TEST_EXTENSIONS,
3135 Some(Box::new(SimpleGraphBuilder)),
3136 )));
3137 let config = BuildConfig::default();
3138
3139 let (_graph, build_result) =
3140 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:manifest_dedup")
3141 .unwrap();
3142
3143 let storage = GraphStorage::new(temp_dir.path());
3145 assert!(storage.exists(), "Manifest should exist after build");
3146
3147 let manifest = storage.load_manifest().unwrap();
3148 assert_eq!(
3149 manifest.edge_count, build_result.edge_count,
3150 "Manifest edge_count should match BuildResult (deduplicated)"
3151 );
3152 assert_eq!(
3153 manifest.raw_edge_count,
3154 Some(build_result.raw_edge_count),
3155 "Manifest raw_edge_count should match BuildResult"
3156 );
3157 }
3158
3159 #[test]
3161 fn test_build_command_provenance() {
3162 use crate::graph::unified::persistence::GraphStorage;
3163
3164 let temp_dir = TempDir::new().expect("temp dir");
3165 let file_path = temp_dir.path().join("test.rs");
3166 fs::write(&file_path, "fn main() {}").expect("write test file");
3167
3168 let mut plugins = PluginManager::new();
3169 plugins.register_builtin(Box::new(TestPlugin::new(
3170 "rust-simple",
3171 RUST_TEST_EXTENSIONS,
3172 Some(Box::new(SimpleGraphBuilder)),
3173 )));
3174 let config = BuildConfig::default();
3175
3176 build_and_persist_graph(temp_dir.path(), &plugins, &config, "cli:index").unwrap();
3177
3178 let storage = GraphStorage::new(temp_dir.path());
3179 let manifest = storage.load_manifest().unwrap();
3180 assert_eq!(
3181 manifest.build_provenance.build_command, "cli:index",
3182 "Build command provenance should match"
3183 );
3184 }
3185
3186 #[test]
3190 fn test_wrapper_infers_plugin_selection_from_manager() {
3191 use crate::graph::unified::persistence::GraphStorage;
3192
3193 let temp_dir = TempDir::new().expect("temp dir");
3194 let file_path = temp_dir.path().join("test.rs");
3195 fs::write(&file_path, "fn main() {}").expect("write test file");
3196
3197 let mut plugins = PluginManager::new();
3198 plugins.register_builtin(Box::new(TestPlugin::new(
3199 "rust-simple",
3200 RUST_TEST_EXTENSIONS,
3201 Some(Box::new(SimpleGraphBuilder)),
3202 )));
3203 let config = BuildConfig::default();
3204
3205 let (_graph, build_result) =
3206 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:wrapper_plugins")
3207 .expect("wrapper build should succeed");
3208
3209 assert_eq!(
3210 build_result.active_plugin_ids,
3211 vec!["rust-simple".to_string()],
3212 "build result should expose the inferred active plugin ids"
3213 );
3214
3215 let storage = GraphStorage::new(temp_dir.path());
3216 let manifest = storage.load_manifest().expect("manifest should load");
3217 let plugin_selection = manifest
3218 .plugin_selection
3219 .expect("wrapper should persist plugin selection metadata");
3220 assert_eq!(
3221 plugin_selection.active_plugin_ids,
3222 vec!["rust-simple".to_string()],
3223 "wrapper should persist the manager-derived plugin ids"
3224 );
3225 assert_eq!(
3226 plugin_selection.high_cost_mode, None,
3227 "wrapper-inferred plugin selection should keep high_cost_mode diagnostic-only"
3228 );
3229 }
3230
3231 #[test]
3233 fn test_analysis_identity_matches_manifest_hash() {
3234 use crate::graph::unified::analysis::persistence::load_csr;
3235 use crate::graph::unified::persistence::GraphStorage;
3236 use sha2::{Digest, Sha256};
3237
3238 let temp_dir = TempDir::new().expect("temp dir");
3239 let file_path = temp_dir.path().join("test.rs");
3240 fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3241
3242 let mut plugins = PluginManager::new();
3243 plugins.register_builtin(Box::new(TestPlugin::new(
3244 "rust-simple",
3245 RUST_TEST_EXTENSIONS,
3246 Some(Box::new(SimpleGraphBuilder)),
3247 )));
3248 let config = BuildConfig::default();
3249
3250 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:identity").unwrap();
3251
3252 let storage = GraphStorage::new(temp_dir.path());
3253
3254 let manifest_bytes = std::fs::read(storage.manifest_path()).unwrap();
3256 let expected_hash = hex::encode(Sha256::digest(&manifest_bytes));
3257
3258 let (_csr, identity) = load_csr(&storage.analysis_csr_path()).unwrap();
3260
3261 assert_eq!(
3262 identity.manifest_hash, expected_hash,
3263 "On-disk manifest hash should equal analysis identity hash"
3264 );
3265 }
3266
3267 #[test]
3274 fn test_old_manifest_removed_during_rebuild() {
3275 use crate::graph::unified::persistence::GraphStorage;
3276
3277 let temp_dir = tempfile::TempDir::new().unwrap();
3278 let src = temp_dir.path().join("lib.rs");
3279 std::fs::write(&src, "fn main() {}").unwrap();
3280
3281 let mut plugins = PluginManager::new();
3283 plugins.register_builtin(Box::new(TestPlugin::new(
3284 "rust-simple",
3285 RUST_TEST_EXTENSIONS,
3286 Some(Box::new(SimpleGraphBuilder)),
3287 )));
3288 let config = BuildConfig::default();
3289 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:initial").unwrap();
3290
3291 let storage = GraphStorage::new(temp_dir.path());
3292 assert!(
3293 storage.exists(),
3294 "Manifest should exist after initial build"
3295 );
3296
3297 let original_manifest = storage.load_manifest().unwrap();
3299 let original_built_at = original_manifest.built_at.clone();
3300
3301 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:rebuild").unwrap();
3303
3304 let new_manifest = storage.load_manifest().unwrap();
3306 assert_ne!(
3307 original_built_at, new_manifest.built_at,
3308 "Manifest should have been replaced with new timestamp"
3309 );
3310 assert_eq!(
3311 new_manifest.build_provenance.build_command, "test:rebuild",
3312 "Manifest should reflect the rebuild provenance"
3313 );
3314 }
3315
3316 #[test]
3330 fn test_failed_rebuild_leaves_index_not_ready() {
3331 use crate::graph::unified::persistence::GraphStorage;
3332
3333 let temp_dir = tempfile::TempDir::new().unwrap();
3334 let src = temp_dir.path().join("lib.rs");
3335 std::fs::write(&src, "fn main() {}").unwrap();
3336
3337 let mut plugins = PluginManager::new();
3339 plugins.register_builtin(Box::new(TestPlugin::new(
3340 "rust-simple",
3341 RUST_TEST_EXTENSIONS,
3342 Some(Box::new(SimpleGraphBuilder)),
3343 )));
3344 let config = BuildConfig::default();
3345 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:initial").unwrap();
3346
3347 let storage = GraphStorage::new(temp_dir.path());
3348 assert!(
3349 storage.exists(),
3350 "Manifest should exist after initial build"
3351 );
3352
3353 let analysis_dir = storage.analysis_dir().to_path_buf();
3359 std::fs::remove_dir_all(&analysis_dir).unwrap();
3360 std::fs::write(&analysis_dir, b"blocker").unwrap();
3361
3362 let result =
3364 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:failed_rebuild");
3365
3366 std::fs::remove_file(&analysis_dir).unwrap();
3368 std::fs::create_dir_all(&analysis_dir).unwrap();
3369
3370 assert!(
3372 result.is_err(),
3373 "Rebuild should fail when analysis dir is read-only"
3374 );
3375
3376 assert!(
3378 !storage.exists(),
3379 "After failed rebuild, manifest should have been removed — index is NOT ready"
3380 );
3381
3382 assert!(
3384 storage.snapshot_exists(),
3385 "Snapshot should still exist on disk (written before failure)"
3386 );
3387 }
3388
3389 struct DuplicateCallsGraphBuilder;
3393
3394 impl GraphBuilder for DuplicateCallsGraphBuilder {
3395 fn build_graph(
3396 &self,
3397 _tree: &Tree,
3398 _content: &[u8],
3399 file: &Path,
3400 staging: &mut StagingGraph,
3401 ) -> GraphResult<()> {
3402 use crate::graph::unified::build::helper::GraphBuildHelper;
3403
3404 let mut helper = GraphBuildHelper::new(staging, file, Language::Rust);
3405 let fn1 = helper.add_function("main", None, false, false);
3406 let fn2 = helper.add_function("helper", None, false, false);
3407
3408 helper.add_call_edge(fn1, fn2);
3410 helper.add_call_edge(fn1, fn2);
3411
3412 Ok(())
3413 }
3414
3415 fn language(&self) -> Language {
3416 Language::Rust
3417 }
3418 }
3419
3420 #[test]
3422 fn test_persisted_snapshot_compacts_both_edge_stores_before_save() {
3423 use crate::graph::unified::persistence::{GraphStorage, load_from_path};
3424
3425 let temp_dir = TempDir::new().expect("temp dir");
3426 let file_path = temp_dir.path().join("test.rs");
3427 fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3428
3429 let mut plugins = PluginManager::new();
3430 plugins.register_builtin(Box::new(TestPlugin::new(
3431 "rust-simple",
3432 RUST_TEST_EXTENSIONS,
3433 Some(Box::new(SimpleGraphBuilder)),
3434 )));
3435 let config = BuildConfig::default();
3436
3437 let _result =
3438 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:csr_compact")
3439 .expect("build should succeed");
3440
3441 let storage = GraphStorage::new(temp_dir.path());
3443 let loaded = load_from_path(storage.snapshot_path(), None).expect("load should succeed");
3444
3445 assert!(
3446 loaded.edges().forward().csr().is_some(),
3447 "Forward store must have CSR after persistence"
3448 );
3449 assert!(
3450 loaded.edges().reverse().csr().is_some(),
3451 "Reverse store must have CSR after persistence"
3452 );
3453
3454 let stats = loaded.edges().stats();
3455 assert_eq!(
3456 stats.forward.delta_edge_count, 0,
3457 "Forward delta must be empty after persistence"
3458 );
3459 assert_eq!(
3460 stats.reverse.delta_edge_count, 0,
3461 "Reverse delta must be empty after persistence"
3462 );
3463 }
3464
3465 #[test]
3467 fn test_loaded_snapshot_edges_to_works_after_round_trip() {
3468 use crate::graph::unified::edge::EdgeKind;
3469 use crate::graph::unified::persistence::{GraphStorage, load_from_path};
3470 use crate::graph::unified::{
3471 FileScope, ResolutionMode, SymbolCandidateOutcome, SymbolQuery,
3472 };
3473
3474 let temp_dir = TempDir::new().expect("temp dir");
3475 let file_path = temp_dir.path().join("test.rs");
3476 fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3477
3478 let mut plugins = PluginManager::new();
3479 plugins.register_builtin(Box::new(TestPlugin::new(
3480 "rust-simple",
3481 RUST_TEST_EXTENSIONS,
3482 Some(Box::new(SimpleGraphBuilder)),
3483 )));
3484 let config = BuildConfig::default();
3485
3486 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:round_trip")
3487 .expect("build should succeed");
3488
3489 let storage = GraphStorage::new(temp_dir.path());
3490 let loaded = load_from_path(storage.snapshot_path(), None).expect("load should succeed");
3491
3492 let snapshot = loaded.snapshot();
3494
3495 let main_id = match snapshot.find_symbol_candidates(&SymbolQuery {
3496 symbol: "main",
3497 file_scope: FileScope::Any,
3498 mode: ResolutionMode::AllowSuffixCandidates,
3499 }) {
3500 SymbolCandidateOutcome::Candidates(ids) => ids[0],
3501 _ => panic!("main node must exist"),
3502 };
3503
3504 let helper_id = match snapshot.find_symbol_candidates(&SymbolQuery {
3505 symbol: "helper",
3506 file_scope: FileScope::Any,
3507 mode: ResolutionMode::AllowSuffixCandidates,
3508 }) {
3509 SymbolCandidateOutcome::Candidates(ids) => ids[0],
3510 _ => panic!("helper node must exist"),
3511 };
3512
3513 let forward_edges = loaded.edges().edges_from(main_id);
3515 let has_call = forward_edges
3516 .iter()
3517 .any(|e| e.target == helper_id && matches!(e.kind, EdgeKind::Calls { .. }));
3518 assert!(has_call, "Forward traversal: main should call helper");
3519
3520 let reverse_edges = loaded.edges().edges_to(helper_id);
3522 let has_caller = reverse_edges
3523 .iter()
3524 .any(|e| e.source == main_id && matches!(e.kind, EdgeKind::Calls { .. }));
3525 assert!(
3526 has_caller,
3527 "Reverse traversal: helper should have main as caller"
3528 );
3529 }
3530
3531 #[test]
3533 fn test_raw_edge_count_preserved_across_pre_save_compaction() {
3534 use crate::graph::unified::persistence::GraphStorage;
3535
3536 let temp_dir = TempDir::new().expect("temp dir");
3537 let file_path = temp_dir.path().join("test.rs");
3538 fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3539
3540 let mut plugins = PluginManager::new();
3541 plugins.register_builtin(Box::new(TestPlugin::new(
3542 "rust-dup",
3543 RUST_TEST_EXTENSIONS,
3544 Some(Box::new(DuplicateCallsGraphBuilder)),
3545 )));
3546 let config = BuildConfig::default();
3547
3548 let (_graph, build_result) =
3549 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:raw_edge_count")
3550 .expect("build should succeed");
3551
3552 assert!(
3553 build_result.raw_edge_count > build_result.edge_count,
3554 "raw_edge_count ({}) must be > edge_count ({}) for duplicate builder",
3555 build_result.raw_edge_count,
3556 build_result.edge_count
3557 );
3558
3559 let storage = GraphStorage::new(temp_dir.path());
3561 let manifest = storage.load_manifest().expect("manifest should load");
3562
3563 assert_eq!(
3564 manifest.raw_edge_count,
3565 Some(build_result.raw_edge_count),
3566 "Manifest raw_edge_count must match build result"
3567 );
3568 assert_eq!(
3569 manifest.edge_count, build_result.edge_count,
3570 "Manifest edge_count must match build result"
3571 );
3572 }
3573
3574 #[test]
3576 fn test_build_save_load_query_round_trip_preserves_edge_queries() {
3577 use crate::graph::unified::edge::EdgeKind;
3578 use crate::graph::unified::persistence::{GraphStorage, load_from_path};
3579 use crate::graph::unified::{
3580 FileScope, ResolutionMode, SymbolCandidateOutcome, SymbolQuery,
3581 };
3582
3583 let temp_dir = TempDir::new().expect("temp dir");
3584 let file_path = temp_dir.path().join("test.rs");
3585 fs::write(&file_path, "fn main() {} fn helper() {}").expect("write test file");
3586
3587 let mut plugins = PluginManager::new();
3588 plugins.register_builtin(Box::new(TestPlugin::new(
3589 "rust-simple",
3590 RUST_TEST_EXTENSIONS,
3591 Some(Box::new(SimpleGraphBuilder)),
3592 )));
3593 let config = BuildConfig::default();
3594
3595 let (_original_graph, build_result) =
3596 build_and_persist_graph(temp_dir.path(), &plugins, &config, "test:full_round_trip")
3597 .expect("build should succeed");
3598
3599 let storage = GraphStorage::new(temp_dir.path());
3601 let loaded = load_from_path(storage.snapshot_path(), None).expect("load should succeed");
3602
3603 assert_eq!(
3605 loaded.edge_count(),
3606 build_result.edge_count,
3607 "Loaded graph edge count must match build result dedup count"
3608 );
3609
3610 assert_eq!(
3612 loaded.node_count(),
3613 build_result.node_count,
3614 "Loaded graph node count must match build result"
3615 );
3616
3617 let snapshot = loaded.snapshot();
3619
3620 let main_id = match snapshot.find_symbol_candidates(&SymbolQuery {
3621 symbol: "main",
3622 file_scope: FileScope::Any,
3623 mode: ResolutionMode::AllowSuffixCandidates,
3624 }) {
3625 SymbolCandidateOutcome::Candidates(ids) => {
3626 assert!(!ids.is_empty(), "main must exist");
3627 ids[0]
3628 }
3629 _ => panic!("main node must exist"),
3630 };
3631
3632 let helper_id = match snapshot.find_symbol_candidates(&SymbolQuery {
3633 symbol: "helper",
3634 file_scope: FileScope::Any,
3635 mode: ResolutionMode::AllowSuffixCandidates,
3636 }) {
3637 SymbolCandidateOutcome::Candidates(ids) => {
3638 assert!(!ids.is_empty(), "helper must exist");
3639 ids[0]
3640 }
3641 _ => panic!("helper node must exist"),
3642 };
3643
3644 let fwd = loaded.edges().edges_from(main_id);
3646 let has_fwd_call = fwd
3647 .iter()
3648 .any(|e| e.target == helper_id && matches!(e.kind, EdgeKind::Calls { .. }));
3649 assert!(has_fwd_call, "edges_from(main) must include call to helper");
3650
3651 let rev = loaded.edges().edges_to(helper_id);
3653 let has_rev_call = rev
3654 .iter()
3655 .any(|e| e.source == main_id && matches!(e.kind, EdgeKind::Calls { .. }));
3656 assert!(has_rev_call, "edges_to(helper) must include caller main");
3657 }
3658
3659 fn build_rust_test_fixture(dir: &Path, file_count: usize) {
3679 for i in 0..file_count {
3680 let path = dir.join(format!("fixture_{i}.rs"));
3681 fs::write(&path, format!("pub fn fn_{i}() {{ let _ = {i}; }}")).expect("write fixture");
3682 }
3683 }
3684
3685 fn make_rust_test_plugins() -> PluginManager {
3686 let mut plugins = PluginManager::new();
3687 plugins.register_builtin(Box::new(TestPlugin::new(
3688 "rust-noop-for-cancellation-tests",
3689 RUST_TEST_EXTENSIONS,
3690 Some(Box::new(NoopGraphBuilder)),
3691 )));
3692 plugins
3693 }
3694
3695 #[test]
3696 fn build_unified_graph_cancellable_preflight_cancellation_returns_cancelled() {
3697 let tmp = TempDir::new().expect("tmp");
3698 build_rust_test_fixture(tmp.path(), 4);
3699 let plugins = make_rust_test_plugins();
3700 let config = BuildConfig::default();
3701
3702 let cancel = CancellationToken::new();
3703 cancel.cancel();
3704
3705 let result = build_unified_graph_cancellable(tmp.path(), &plugins, &config, &cancel);
3706 let err = result.expect_err("pre-cancelled token must short-circuit");
3707 assert!(
3708 matches!(err, GraphBuilderError::Cancelled),
3709 "expected Cancelled, got: {err:?}"
3710 );
3711 }
3712
3713 #[test]
3714 fn build_unified_graph_cancellable_mid_chunk_cancellation_returns_cancelled() {
3715 let tmp = TempDir::new().expect("tmp");
3716 build_rust_test_fixture(tmp.path(), 8);
3718 let plugins = make_rust_test_plugins();
3719 let config = BuildConfig {
3721 staging_memory_limit: 1,
3722 ..BuildConfig::default()
3723 };
3724
3725 let cancel = CancellationToken::new();
3726
3727 let cancel_for_hook = cancel.clone();
3732 let mut call_count = 0u32;
3733 let _guard = testing::AfterChunkHookGuard::install(move |tok| {
3734 call_count += 1;
3735 if call_count >= 2 {
3736 cancel_for_hook.cancel();
3737 assert!(tok.is_cancelled());
3739 }
3740 });
3741
3742 let result = build_unified_graph_cancellable(tmp.path(), &plugins, &config, &cancel);
3743 let err = result.expect_err("mid-chunk cancellation must short-circuit");
3744 assert!(
3745 matches!(err, GraphBuilderError::Cancelled),
3746 "expected Cancelled, got: {err:?}"
3747 );
3748 }
3749
3750 #[test]
3751 fn build_unified_graph_cancellable_pre_phase4_cancellation_short_circuits() {
3752 let tmp = TempDir::new().expect("tmp");
3753 build_rust_test_fixture(tmp.path(), 4);
3754 let plugins = make_rust_test_plugins();
3755 let config = BuildConfig::default();
3756
3757 let cancel = CancellationToken::new();
3758 let cancel_for_hook = cancel.clone();
3759 let _guard = testing::BeforePhase4HookGuard::install(move |_tok| {
3760 cancel_for_hook.cancel();
3761 });
3762
3763 let result = build_unified_graph_cancellable(tmp.path(), &plugins, &config, &cancel);
3764 let err = result.expect_err("pre-Phase-4 cancellation must short-circuit");
3765 assert!(
3766 matches!(err, GraphBuilderError::Cancelled),
3767 "expected Cancelled, got: {err:?}"
3768 );
3769 }
3770
3771 #[test]
3772 fn build_unified_graph_cancellable_pre_pass5_cancellation_short_circuits() {
3773 let tmp = TempDir::new().expect("tmp");
3774 build_rust_test_fixture(tmp.path(), 4);
3775 let plugins = make_rust_test_plugins();
3776 let config = BuildConfig::default();
3777
3778 let cancel = CancellationToken::new();
3779 let cancel_for_hook = cancel.clone();
3780 let _guard = testing::BeforePass5HookGuard::install(move |_tok| {
3781 cancel_for_hook.cancel();
3782 });
3783
3784 let result = build_unified_graph_cancellable(tmp.path(), &plugins, &config, &cancel);
3785 let err = result.expect_err("pre-Pass-5 cancellation must short-circuit");
3786 assert!(
3787 matches!(err, GraphBuilderError::Cancelled),
3788 "expected Cancelled, got: {err:?}"
3789 );
3790 }
3791
3792 #[test]
3793 fn build_unified_graph_default_path_is_backwards_compatible() {
3794 let tmp = TempDir::new().expect("tmp");
3795 build_rust_test_fixture(tmp.path(), 3);
3796 let plugins = make_rust_test_plugins();
3797 let config = BuildConfig::default();
3798
3799 let _graph = build_unified_graph(tmp.path(), &plugins, &config)
3804 .expect("legacy path must still build successfully");
3805 }
3806}