use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use tracing::{info, warn};
use crate::discover::{FileInfo, Walker};
use crate::index::error::IndexError;
use crate::index::incremental::{diff_files, FileDiff};
use crate::index::pipeline::{build_file_nodes, now_unix_seconds, with_retry, DEFAULT_MAX_RETRIES};
use crate::ir::ExtractResult;
use crate::model::{
new_project_id, ConfidenceTier, Edge, EdgeType, Graph, Language, Node, NodeLabel,
};
use crate::parse::parallel::{parallel_parse, parallel_parse_ram_first, RamFirstSources};
use crate::resolve::{
build_symbol_table, prune_dangling_type_edges_vec, resolve_all, resolve_include, IncludesGraph,
};
use crate::storage::Repository;
use super::pipeline::IndexResult;
use super::pipeline_dag::{Phase, PhaseError, PipelineCtx};
const CONFIDENCE_INCLUDES: f32 = 0.95;
pub struct ScanInput {
pub path: PathBuf,
pub project_name: String,
pub force: bool,
pub start: Instant,
}
pub struct ScanOutput {
pub project_id: String,
pub project_name: String,
pub root_path: PathBuf,
pub disk_files: Vec<FileInfo>,
pub diff: FileDiff,
pub start: Instant,
}
pub struct ParseOutput {
pub results: Vec<ExtractResult>,
pub errors: Vec<(String, String)>,
pub files_parsed: usize,
pub to_parse: Vec<FileInfo>,
}
pub struct ScopeOutput {
pub graph: Graph,
pub all_nodes: Vec<Node>,
pub all_edges: Vec<Edge>,
pub path_to_rel: HashMap<String, String>,
}
pub struct ResolveOutput {
pub all_nodes: Vec<Node>,
pub all_edges: Vec<Edge>,
pub files_parsed: usize,
pub files_skipped: usize,
pub includes_graph: IncludesGraph,
}
pub struct LoadOutput {
pub index_result: IndexResult,
}
fn phase_err(phase: &'static str, e: IndexError) -> PhaseError {
PhaseError::ExecutionFailed {
phase,
inner: Box::new(e),
}
}
pub struct ScanPhase {
pub repo: Arc<Repository>,
}
impl Phase for ScanPhase {
type Input = ScanInput;
type Output = ScanOutput;
const NAME: &'static str = "scan";
fn deps() -> &'static [&'static str] {
&[]
}
fn run(&self, input: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
let ScanInput {
path,
project_name,
force,
start,
} = input;
let disk_files = Walker::new(&path)
.discover()
.map_err(|e| phase_err(Self::NAME, IndexError::from(e)))?;
let project_id = lookup_or_create_project_id(&self.repo, &project_name)
.map_err(|e| phase_err(Self::NAME, e))?;
let db_hashes = with_retry(DEFAULT_MAX_RETRIES, || {
self.repo
.get_all_file_hashes(&project_id)
.map_err(IndexError::from)
})
.unwrap_or_default();
let diff = diff_files(&disk_files, &db_hashes, force)
.map_err(|e| phase_err(Self::NAME, IndexError::Io(e)))?;
Ok(ScanOutput {
project_id,
project_name,
root_path: path,
disk_files,
diff,
start,
})
}
}
#[derive(Default)]
pub struct ParsePhase {
pub ram_first_compressed: Option<RamFirstSources>,
}
impl Phase for ParsePhase {
type Input = ();
type Output = ParseOutput;
const NAME: &'static str = "parse";
fn deps() -> &'static [&'static str] {
&["scan"]
}
fn run(&self, _: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
let scan = ctx
.get::<ScanOutput>("scan")
.ok_or(PhaseError::MissingInput("scan"))?;
let mut to_parse: Vec<FileInfo> = scan.diff.changed.clone();
to_parse.extend(scan.diff.added.iter().cloned());
let parse_result = match &self.ram_first_compressed {
Some(compressed) => parallel_parse_ram_first(&to_parse, compressed, &scan.project_id),
None => parallel_parse(&to_parse, &scan.project_id),
};
for (file_path, error_msg) in &parse_result.errors {
warn!(file = %file_path, error = %error_msg, "parse failed, skipping file");
}
Ok(ParseOutput {
results: parse_result.results,
errors: parse_result.errors,
files_parsed: parse_result.files_parsed,
to_parse,
})
}
}
pub struct ScopeResolutionPhase;
impl Phase for ScopeResolutionPhase {
type Input = ();
type Output = ScopeOutput;
const NAME: &'static str = "scope";
fn deps() -> &'static [&'static str] {
&["scan", "parse"]
}
fn run(&self, _: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
let scan = ctx
.get::<ScanOutput>("scan")
.ok_or(PhaseError::MissingInput("scan"))?;
let parse = ctx
.get::<ParseOutput>("parse")
.ok_or(PhaseError::MissingInput("parse"))?;
let project_id = &scan.project_id;
let mut graph = Graph::new();
let mut all_nodes: Vec<Node> = Vec::new();
let mut all_edges: Vec<Edge> = Vec::new();
let file_nodes = build_file_nodes(&scan.diff, project_id);
for file_node in &file_nodes {
graph.add_node(file_node.clone());
}
all_nodes.extend(file_nodes.iter().cloned());
let mut path_to_file_id: HashMap<&str, &str> = HashMap::new();
for file in parse.to_parse.iter() {
if let Some(abs) = file.path.to_str() {
let rel = file.relative_path.as_str();
for fn_node in &file_nodes {
if fn_node.name == rel {
path_to_file_id.insert(abs, &fn_node.id);
break;
}
}
}
}
let path_to_rel: HashMap<String, String> = parse
.to_parse
.iter()
.filter_map(|f| {
f.path
.to_str()
.map(|p| (p.to_string(), f.relative_path.clone()))
})
.collect();
for result in &parse.results {
let rel_path = path_to_rel
.get(result.file_path.as_str())
.map(|s| s.as_str())
.unwrap_or(result.file_path.as_str());
let mut id_remap: HashMap<String, String> = HashMap::new();
for node in &result.nodes {
let mut g = node.clone();
if !matches!(
g.label,
NodeLabel::Project | NodeLabel::File | NodeLabel::Folder
) {
let old_id = g.id.clone();
g.id = node.qualified_name.clone();
if old_id != g.id {
id_remap.insert(old_id, g.id.clone());
}
}
if let Some(fp) = g.file_path.as_mut() {
*fp = rel_path.to_string();
}
if g.project.is_empty() {
g.project = project_id.clone();
}
graph.add_node(g.clone());
all_nodes.push(g);
}
for edge in &result.edges {
let mut e = edge.clone();
if let Some(file_id) = path_to_file_id.get(e.source.as_str()) {
e.source = (*file_id).to_string();
}
if let Some(new_target) = id_remap.get(&e.target) {
e.target = new_target.clone();
}
graph.add_edge(e.clone());
all_edges.push(e);
}
}
Ok(ScopeOutput {
graph,
all_nodes,
all_edges,
path_to_rel,
})
}
}
fn build_includes_edges(
results: &[ExtractResult],
graph: &mut Graph,
path_to_rel: &HashMap<String, String>,
project_id: &str,
) -> (Vec<Edge>, IncludesGraph) {
let mut edges = Vec::new();
let mut includes_graph = IncludesGraph::new();
#[cfg(feature = "lang-cpp")]
{
let mut file_index: HashMap<String, String> = HashMap::new();
for node in graph.nodes_by_label(NodeLabel::File) {
if let Some(fp) = &node.file_path {
file_index
.entry(fp.clone())
.or_insert_with(|| node.id.clone());
}
}
let all_files: Vec<String> = file_index.keys().cloned().collect();
let mut rel_to_abs: HashMap<&String, &String> = HashMap::new();
for (abs, rel) in path_to_rel {
rel_to_abs.insert(rel, abs);
}
for result in results {
if result.language != Language::Cpp {
continue;
}
let source_rel = path_to_rel
.get(&result.file_path)
.cloned()
.unwrap_or_else(|| result.file_path.clone());
let Some(source_file_id) = file_index.get(&source_rel).cloned() else {
warn!(
file = %result.file_path,
"INCLUDES source File node not found; skipping #include edges for this file"
);
continue;
};
let source_abs = &result.file_path;
for import in &result.imports {
if import.source_file.is_empty() {
continue;
}
let Some(target_rel) =
resolve_include(&import.source_file, &source_rel, &all_files)
else {
continue;
};
let Some(target_file_id) = file_index.get(&target_rel).cloned() else {
continue;
};
let edge = Edge::builder(
source_file_id.clone(),
target_file_id.clone(),
EdgeType::Includes,
project_id,
)
.confidence(CONFIDENCE_INCLUDES)
.confidence_tier(ConfidenceTier::ImportScoped)
.start_line(import.line)
.build();
graph.add_edge(edge.clone());
edges.push(edge);
let target_abs = rel_to_abs
.get(&target_rel)
.map(|s| s.to_string())
.unwrap_or(target_rel.clone());
includes_graph.add_include(source_abs, &target_abs);
}
}
}
#[cfg(not(feature = "lang-cpp"))]
{
let _ = (results, graph, path_to_rel, project_id);
}
(edges, includes_graph)
}
pub struct ResolvePhase;
impl Phase for ResolvePhase {
type Input = ();
type Output = ResolveOutput;
const NAME: &'static str = "resolve";
fn deps() -> &'static [&'static str] {
&["scan", "parse", "scope"]
}
fn run(&self, _: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
let scan = ctx
.get::<ScanOutput>("scan")
.ok_or(PhaseError::MissingInput("scan"))?;
let parse = ctx
.get::<ParseOutput>("parse")
.ok_or(PhaseError::MissingInput("parse"))?;
let scope = ctx
.get::<ScopeOutput>("scope")
.ok_or(PhaseError::MissingInput("scope"))?;
let project_id = &scan.project_id;
let mut graph = scope.graph.clone();
let mut all_nodes = scope.all_nodes.clone();
let mut all_edges = scope.all_edges.clone();
let (includes_edges, includes_graph) =
build_includes_edges(&parse.results, &mut graph, &scope.path_to_rel, project_id);
all_edges.extend(includes_edges);
let symbol_table = build_symbol_table(&parse.results, project_id);
let resolved_edges = resolve_all(
&parse.results,
&symbol_table,
project_id,
&mut graph,
&includes_graph,
);
all_edges.extend(resolved_edges);
let node_ids: std::collections::HashSet<String> = graph.nodes.keys().cloned().collect();
prune_dangling_type_edges_vec(&mut all_edges, &node_ids);
for label in [NodeLabel::Parameter, NodeLabel::Variable] {
for node in graph.nodes_by_label(label) {
let mut n = node.clone();
if let Some(fp) = n.file_path.as_mut() {
if let Some(rel) = scope.path_to_rel.get(fp) {
*fp = rel.clone();
}
}
all_nodes.push(n);
}
}
Ok(ResolveOutput {
all_nodes,
all_edges,
files_parsed: parse.files_parsed,
files_skipped: scan.diff.unchanged.len(),
includes_graph,
})
}
}
pub struct ConfidencePhase;
impl Phase for ConfidencePhase {
type Input = ();
type Output = ();
const NAME: &'static str = "confidence";
fn deps() -> &'static [&'static str] {
&["resolve"]
}
fn run(&self, _: Self::Input, _ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
Ok(())
}
}
pub struct LoadPhase {
pub repo: Arc<Repository>,
}
impl Phase for LoadPhase {
type Input = ();
type Output = LoadOutput;
const NAME: &'static str = "load";
fn deps() -> &'static [&'static str] {
&["scan", "resolve", "confidence"]
}
fn run(&self, _: Self::Input, ctx: &PipelineCtx) -> Result<Self::Output, PhaseError> {
let scan = ctx
.get::<ScanOutput>("scan")
.ok_or(PhaseError::MissingInput("scan"))?;
let resolve = ctx
.get::<ResolveOutput>("resolve")
.ok_or(PhaseError::MissingInput("resolve"))?;
let project_id = &scan.project_id;
let project_name = &scan.project_name;
let root = &scan.root_path;
let disk_files = &scan.disk_files;
let all_nodes = &resolve.all_nodes;
let all_edges = &resolve.all_edges;
let mut paths_to_delete: Vec<String> = scan.diff.deleted.clone();
paths_to_delete.extend(scan.diff.changed.iter().map(|f| f.relative_path.clone()));
if !paths_to_delete.is_empty() {
if let Err(err) = self
.repo
.delete_file_nodes_batch(&paths_to_delete, project_id)
{
warn!(
file_count = paths_to_delete.len(),
error = %err,
"failed to batch delete file nodes for deleted+changed files"
);
}
}
save_project_node(&self.repo, project_id, project_name, root, disk_files)
.map_err(|e| phase_err(Self::NAME, e))?;
save_nodes_by_label(&self.repo, all_nodes).map_err(|e| phase_err(Self::NAME, e))?;
if !all_edges.is_empty() {
with_retry(DEFAULT_MAX_RETRIES, || {
self.repo.save_edges(all_edges).map_err(IndexError::from)
})
.map_err(|e| phase_err(Self::NAME, e))?;
}
let duration_ms = scan.start.elapsed().as_millis().min(u64::MAX as u128) as u64;
let files_indexed = resolve.files_parsed;
let files_skipped = resolve.files_skipped;
let nodes_created = all_nodes.len();
let edges_created = all_edges.len();
info!(
event = "index_completed",
project = %project_name,
path = %root.display(),
files_indexed = files_indexed,
files_skipped = files_skipped,
nodes_created = nodes_created,
edges_created = edges_created,
duration_ms = duration_ms,
"indexing completed"
);
let files_per_second = if duration_ms > 0 {
(files_indexed as f64 * 1000.0 / duration_ms as f64).round() as u64
} else {
0
};
info!(
event = "performance",
files_per_second = files_per_second,
files_indexed = files_indexed,
duration_ms = duration_ms,
"indexing performance metrics"
);
Ok(LoadOutput {
index_result: IndexResult::new(
project_id.clone(),
files_indexed,
files_skipped,
nodes_created,
edges_created,
duration_ms,
),
})
}
}
fn lookup_or_create_project_id(
repo: &Repository,
project_name: &str,
) -> std::result::Result<String, IndexError> {
let projects = repo.list_projects().unwrap_or_default();
for project in projects {
if project.name == project_name {
return Ok(project.id);
}
}
Ok(new_project_id())
}
fn save_project_node(
repo: &Repository,
project_id: &str,
project_name: &str,
root: &Path,
disk_files: &[FileInfo],
) -> std::result::Result<(), IndexError> {
if repo.get_project(project_id)?.is_some() {
let cypher = format!(
"MATCH (p:Project {{id: '{}'}}) DELETE p;",
project_id.replace('\'', "\\'"),
);
let _ = repo.connection().execute(&cypher);
}
let last_commit = git_head_commit(root);
let project_node = Node::builder(NodeLabel::Project, project_name, project_name)
.id(project_id)
.properties(serde_json::json!({
"rootPath": root.display().to_string(),
"fileCount": disk_files.len() as i64,
"indexedAt": now_unix_seconds(),
"lastCommit": last_commit,
}))
.build();
repo.save_project(&project_node)?;
Ok(())
}
fn git_head_commit(root: &Path) -> String {
std::process::Command::new("git")
.arg("-C")
.arg(root)
.arg("rev-parse")
.arg("HEAD")
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
fn save_nodes_by_label(repo: &Repository, nodes: &[Node]) -> std::result::Result<(), IndexError> {
let mut by_label: HashMap<NodeLabel, Vec<Node>> = HashMap::new();
for node in nodes {
by_label.entry(node.label).or_default().push(node.clone());
}
for (label, group) in by_label {
if group.is_empty() {
continue;
}
if label == NodeLabel::Project {
continue;
}
let mut seen: HashMap<String, usize> = HashMap::new();
let mut deduped: Vec<Node> = Vec::with_capacity(group.len());
for node in group {
if let Some(&idx) = seen.get(&node.id) {
deduped[idx] = node;
} else {
seen.insert(node.id.clone(), deduped.len());
deduped.push(node);
}
}
with_retry(DEFAULT_MAX_RETRIES, || {
repo.save_nodes(&deduped, label).map_err(IndexError::from)
})?;
}
Ok(())
}
#[cfg(test)]
const _: () = {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ScanPhase>();
assert_send_sync::<ParsePhase>();
assert_send_sync::<ScopeResolutionPhase>();
assert_send_sync::<ResolvePhase>();
assert_send_sync::<ConfidencePhase>();
assert_send_sync::<LoadPhase>();
assert_send_sync::<ScanInput>();
assert_send_sync::<ScanOutput>();
assert_send_sync::<ParseOutput>();
assert_send_sync::<ScopeOutput>();
assert_send_sync::<ResolveOutput>();
assert_send_sync::<LoadOutput>();
};
#[cfg(all(test, feature = "lang-cpp", feature = "lang-typescript"))]
mod tests {
use super::*;
#[test]
fn git_head_commit_non_git_dir_returns_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let commit = git_head_commit(tmp.path());
assert_eq!(commit, "", "non-git dir should return empty commit hash");
}
#[test]
fn git_head_commit_codenexus_repo_returns_nonempty() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let commit = git_head_commit(manifest_dir);
if let Ok(output) = std::process::Command::new("git").arg("--version").output() {
if output.status.success() {
assert!(
!commit.is_empty(),
"CodeNexus repo should have a HEAD commit"
);
assert!(
commit.len() == 40 || commit.len() == 64,
"commit hash has unexpected length {}: {commit}",
commit.len()
);
}
}
}
#[test]
fn phase_err_wraps_index_error_as_execution_failed() {
let err = IndexError::PathNotFound("/no/such/dir".to_string());
let phase = phase_err("scan", err);
match phase {
PhaseError::ExecutionFailed { phase, inner } => {
assert_eq!(phase, "scan");
assert!(
inner.to_string().contains("/no/such/dir"),
"inner error should carry original message: {inner}"
);
}
other => panic!("expected ExecutionFailed, got {other:?}"),
}
}
fn make_file_node(path: &str, project: &str, lang: Language) -> Node {
Node::builder(NodeLabel::File, path, path)
.id(path)
.project(project)
.file_path(path)
.language(lang)
.build()
}
#[test]
fn build_includes_edges_for_cpp() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/main.cpp", "proj", Language::Cpp));
graph.add_node(make_file_node("src/foo.h", "proj", Language::Cpp));
let mut main_result = ExtractResult::new("src/main.cpp", Language::Cpp);
main_result.imports.push(crate::ir::ImportInfo {
source_file: "foo.h".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![main_result];
let path_to_rel = HashMap::new();
let (edges, includes_graph) =
build_includes_edges(&results, &mut graph, &path_to_rel, "proj");
assert_eq!(edges.len(), 1, "should create 1 INCLUDES edge");
assert_eq!(edges[0].edge_type, EdgeType::Includes);
assert_eq!(edges[0].source, "src/main.cpp");
assert_eq!(edges[0].target, "src/foo.h");
assert_eq!(
edges[0].confidence_tier,
ConfidenceTier::ImportScoped,
"INCLUDES edges use ImportScoped tier (matches IMPORTS pattern)"
);
assert!(
includes_graph.contains("src/main.cpp", "src/foo.h"),
"IncludesGraph should have direct edge main.cpp → foo.h"
);
}
#[test]
fn build_includes_edges_skips_non_cpp_languages() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/main.ts", "proj", Language::TypeScript));
graph.add_node(make_file_node("src/utils.ts", "proj", Language::TypeScript));
let mut ts_result = ExtractResult::new("src/main.ts", Language::TypeScript);
ts_result.imports.push(crate::ir::ImportInfo {
source_file: "./utils.ts".to_string(),
imported_names: vec!["foo".to_string()],
line: 1,
});
let results = vec![ts_result];
let path_to_rel = HashMap::new();
let (edges, includes_graph) =
build_includes_edges(&results, &mut graph, &path_to_rel, "proj");
assert!(
edges.is_empty(),
"non-C++ languages should NOT produce INCLUDES edges"
);
assert!(
includes_graph.is_empty(),
"IncludesGraph should be empty for non-C++ results"
);
}
#[test]
fn build_includes_edges_skips_system_headers() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/main.cpp", "proj", Language::Cpp));
let mut main_result = ExtractResult::new("src/main.cpp", Language::Cpp);
main_result.imports.push(crate::ir::ImportInfo {
source_file: "iostream".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![main_result];
let path_to_rel = HashMap::new();
let (edges, includes_graph) =
build_includes_edges(&results, &mut graph, &path_to_rel, "proj");
assert!(
edges.is_empty(),
"system header #include <iostream> should not produce INCLUDES edge"
);
assert!(includes_graph.is_empty());
}
#[test]
fn build_includes_edges_populates_transitive_graph() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/main.cpp", "proj", Language::Cpp));
graph.add_node(make_file_node("src/foo.h", "proj", Language::Cpp));
graph.add_node(make_file_node("src/bar.h", "proj", Language::Cpp));
let mut main_result = ExtractResult::new("src/main.cpp", Language::Cpp);
main_result.imports.push(crate::ir::ImportInfo {
source_file: "foo.h".to_string(),
imported_names: vec![],
line: 1,
});
let mut foo_result = ExtractResult::new("src/foo.h", Language::Cpp);
foo_result.imports.push(crate::ir::ImportInfo {
source_file: "bar.h".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![main_result, foo_result];
let path_to_rel = HashMap::new();
let (edges, includes_graph) =
build_includes_edges(&results, &mut graph, &path_to_rel, "proj");
assert_eq!(edges.len(), 2, "should create 2 INCLUDES edges");
assert!(includes_graph.contains("src/main.cpp", "src/foo.h"));
assert!(includes_graph.contains("src/foo.h", "src/bar.h"));
let reachable = includes_graph.reachable_from("src/main.cpp");
assert!(reachable.contains("src/main.cpp"));
assert!(reachable.contains("src/foo.h"));
assert!(reachable.contains("src/bar.h"));
}
#[test]
fn build_includes_edges_with_absolute_paths() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/main.cpp", "proj", Language::Cpp));
graph.add_node(make_file_node("src/foo.h", "proj", Language::Cpp));
let mut main_result = ExtractResult::new("/home/dev/proj/src/main.cpp", Language::Cpp);
main_result.imports.push(crate::ir::ImportInfo {
source_file: "foo.h".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![main_result];
let mut path_to_rel = HashMap::new();
path_to_rel.insert(
"/home/dev/proj/src/main.cpp".to_string(),
"src/main.cpp".to_string(),
);
path_to_rel.insert(
"/home/dev/proj/src/foo.h".to_string(),
"src/foo.h".to_string(),
);
let (edges, includes_graph) =
build_includes_edges(&results, &mut graph, &path_to_rel, "proj");
assert_eq!(
edges.len(),
1,
"absolute source path should resolve via path_to_rel"
);
assert_eq!(edges[0].source, "src/main.cpp");
assert_eq!(edges[0].target, "src/foo.h");
assert!(
includes_graph.contains("/home/dev/proj/src/main.cpp", "/home/dev/proj/src/foo.h"),
"IncludesGraph keys should be absolute paths (matching SymbolEntry.file_path)"
);
}
#[test]
fn build_includes_edges_partial_path_include() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/main.cpp", "proj", Language::Cpp));
graph.add_node(make_file_node(
"include/fmt/format.h",
"proj",
Language::Cpp,
));
let mut main_result = ExtractResult::new("src/main.cpp", Language::Cpp);
main_result.imports.push(crate::ir::ImportInfo {
source_file: "fmt/format.h".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![main_result];
let path_to_rel = HashMap::new();
let (edges, _includes_graph) =
build_includes_edges(&results, &mut graph, &path_to_rel, "proj");
assert_eq!(
edges.len(),
1,
"partial-path #include should resolve via suffix matching"
);
assert_eq!(edges[0].target, "include/fmt/format.h");
}
#[test]
fn build_includes_edges_skips_when_source_file_not_in_graph() {
let mut graph = Graph::new();
graph.add_node(make_file_node("src/foo.h", "proj", Language::Cpp));
let mut main_result = ExtractResult::new("src/main.cpp", Language::Cpp);
main_result.imports.push(crate::ir::ImportInfo {
source_file: "foo.h".to_string(),
imported_names: vec![],
line: 1,
});
let results = vec![main_result];
let path_to_rel = HashMap::new();
let (edges, includes_graph) =
build_includes_edges(&results, &mut graph, &path_to_rel, "proj");
assert!(
edges.is_empty(),
"no edges when source File node not in graph"
);
assert!(includes_graph.is_empty());
}
}