sdivi_graph/dependency_graph.rs
1//! [`DependencyGraph`] construction from raw edges or `FeatureRecord` slices.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use petgraph::graph::NodeIndex;
7use petgraph::Graph;
8use thiserror::Error;
9use tracing::debug;
10
11/// Errors that can occur during graph construction.
12#[derive(Debug, Error)]
13pub enum GraphError {
14 /// No source files were provided to the builder.
15 #[error("no source records provided")]
16 Empty,
17}
18
19/// Directed dependency graph with one node per source file.
20///
21/// Nodes carry the file path (relative to the repository root).
22/// Edges are directed from the importing file to the imported file.
23///
24/// # Examples
25///
26/// ```rust
27/// use sdivi_graph::dependency_graph::{DependencyGraph, build_dependency_graph_from_edges};
28///
29/// let dg = build_dependency_graph_from_edges(
30/// &["src/lib.rs".to_string(), "src/models.rs".to_string()],
31/// &[(0, 1)],
32/// );
33/// assert_eq!(dg.node_count(), 2);
34/// assert_eq!(dg.edge_count(), 1);
35/// ```
36#[derive(Debug, Clone)]
37pub struct DependencyGraph {
38 pub(crate) graph: Graph<PathBuf, ()>,
39 pub(crate) path_to_node: BTreeMap<PathBuf, NodeIndex>,
40}
41
42impl DependencyGraph {
43 /// Number of nodes (source files) in the graph.
44 pub fn node_count(&self) -> usize {
45 self.graph.node_count()
46 }
47
48 /// Number of directed edges in the graph.
49 pub fn edge_count(&self) -> usize {
50 self.graph.edge_count()
51 }
52
53 /// Returns the file path for node index `idx`.
54 pub fn node_path(&self, idx: usize) -> Option<&Path> {
55 let ni = NodeIndex::new(idx);
56 self.graph.node_weight(ni).map(|p| p.as_path())
57 }
58
59 /// Returns the node index for `path`, or `None` if not present.
60 pub fn node_for_path(&self, path: &Path) -> Option<usize> {
61 self.path_to_node.get(path).map(|ni| ni.index())
62 }
63
64 /// Returns all directed edges as `(from_idx, to_idx)` pairs.
65 pub fn edges_as_pairs(&self) -> Vec<(usize, usize)> {
66 self.graph
67 .raw_edges()
68 .iter()
69 .map(|e| (e.source().index(), e.target().index()))
70 .collect()
71 }
72
73 /// Returns the neighbors of node `idx` (nodes that `idx` imports from).
74 pub fn neighbors(&self, idx: usize) -> Vec<usize> {
75 let ni = NodeIndex::new(idx);
76 self.graph.neighbors(ni).map(|n| n.index()).collect()
77 }
78}
79
80/// Builds a [`DependencyGraph`] from node paths and raw `(from, to)` edge pairs.
81///
82/// Each entry in `node_paths` becomes one node; edges reference nodes by
83/// 0-based index into `node_paths`. Out-of-range edge indices and self-loops
84/// are silently dropped. Duplicate edges are deduplicated.
85///
86/// This constructor is always available (no `pipeline-records` feature needed).
87///
88/// # Examples
89///
90/// ```rust
91/// use sdivi_graph::dependency_graph::build_dependency_graph_from_edges;
92///
93/// let dg = build_dependency_graph_from_edges(
94/// &["src/lib.rs".to_string(), "src/models.rs".to_string()],
95/// &[(0, 1)],
96/// );
97/// assert_eq!(dg.node_count(), 2);
98/// assert_eq!(dg.edge_count(), 1);
99/// ```
100pub fn build_dependency_graph_from_edges(
101 node_paths: &[String],
102 edges: &[(usize, usize)],
103) -> DependencyGraph {
104 let mut graph: Graph<PathBuf, ()> = Graph::new();
105 let mut path_to_node: BTreeMap<PathBuf, NodeIndex> = BTreeMap::new();
106
107 for path_str in node_paths {
108 let path = PathBuf::from(path_str);
109 let ni = graph.add_node(path.clone());
110 path_to_node.insert(path, ni);
111 }
112
113 let node_count = node_paths.len();
114 for &(from, to) in edges {
115 if from >= node_count || to >= node_count || from == to {
116 continue;
117 }
118 let from_ni = NodeIndex::new(from);
119 let to_ni = NodeIndex::new(to);
120 if !graph.contains_edge(from_ni, to_ni) {
121 graph.add_edge(from_ni, to_ni, ());
122 }
123 }
124
125 DependencyGraph {
126 graph,
127 path_to_node,
128 }
129}
130
131/// Builds a [`DependencyGraph`] from a slice of `FeatureRecord`s.
132///
133/// Import strings are resolved against the set of known file paths; unresolvable
134/// imports (external packages, missing files) are dropped at `DEBUG` level.
135/// Go module-path imports are not resolved because the module prefix requires
136/// explicit supply — use [`build_dependency_graph_with_go_module`] from the
137/// pipeline layer when `go.mod` is available.
138///
139/// # Examples
140///
141/// ```rust
142/// use sdivi_graph::dependency_graph::build_dependency_graph;
143/// use sdivi_parsing::feature_record::FeatureRecord;
144/// use std::path::PathBuf;
145///
146/// let records: Vec<FeatureRecord> = vec![];
147/// let dg = build_dependency_graph(&records);
148/// assert_eq!(dg.node_count(), 0);
149/// ```
150#[cfg(feature = "pipeline-records")]
151pub fn build_dependency_graph(
152 records: &[sdivi_parsing::feature_record::FeatureRecord],
153) -> DependencyGraph {
154 build_dependency_graph_with_tsconfig(records, None, None)
155}
156
157/// Builds a [`DependencyGraph`] with an explicit Go module prefix for resolution.
158///
159/// `go_module` should be the module path from `go.mod` (e.g.
160/// `"github.com/myorg/myapp"`). Pass `None` to treat all Go imports as
161/// external (same as [`build_dependency_graph`]).
162///
163/// The pipeline layer reads `go.mod` from the repository root and calls this
164/// function to enable internal Go package edges.
165///
166/// # Examples
167///
168/// ```rust
169/// use sdivi_graph::dependency_graph::build_dependency_graph_with_go_module;
170/// use sdivi_parsing::feature_record::FeatureRecord;
171///
172/// let records: Vec<FeatureRecord> = vec![];
173/// let dg = build_dependency_graph_with_go_module(&records, Some("example.com/myapp"));
174/// assert_eq!(dg.node_count(), 0);
175/// ```
176#[cfg(feature = "pipeline-records")]
177pub fn build_dependency_graph_with_go_module(
178 records: &[sdivi_parsing::feature_record::FeatureRecord],
179 go_module: Option<&str>,
180) -> DependencyGraph {
181 build_dependency_graph_with_tsconfig(records, go_module, None)
182}
183
184/// Builds a [`DependencyGraph`] with Go module prefix and tsconfig path aliases.
185///
186/// Combines M26 Go-module resolution with M27 tsconfig path-alias resolution.
187/// `tsconfig` is parsed by the pipeline from `tsconfig.json` / `jsconfig.json`
188/// at the repository root and passed here; `None` disables alias resolution.
189///
190/// # Examples
191///
192/// ```rust
193/// use sdivi_graph::dependency_graph::build_dependency_graph_with_tsconfig;
194/// use sdivi_parsing::feature_record::FeatureRecord;
195///
196/// let records: Vec<FeatureRecord> = vec![];
197/// let dg = build_dependency_graph_with_tsconfig(&records, None, None);
198/// assert_eq!(dg.node_count(), 0);
199/// ```
200#[cfg(feature = "pipeline-records")]
201pub fn build_dependency_graph_with_tsconfig(
202 records: &[sdivi_parsing::feature_record::FeatureRecord],
203 go_module: Option<&str>,
204 tsconfig: Option<&crate::tsconfig::TsConfigPaths>,
205) -> DependencyGraph {
206 use crate::resolve::{build_stem_map, compute_java_roots, resolve_imports};
207
208 let mut graph: Graph<PathBuf, ()> = Graph::new();
209 let mut path_to_node: BTreeMap<PathBuf, NodeIndex> = BTreeMap::new();
210
211 for record in records {
212 let ni = graph.add_node(record.path.clone());
213 path_to_node.insert(record.path.clone(), ni);
214 }
215
216 let stem_map = build_stem_map(&path_to_node);
217 let java_roots = compute_java_roots(&path_to_node);
218
219 for record in records {
220 let from_ni = path_to_node[&record.path];
221 for import in &record.imports {
222 let targets = resolve_imports(
223 import,
224 &record.path,
225 &record.language,
226 &stem_map,
227 &path_to_node,
228 go_module,
229 &java_roots,
230 tsconfig,
231 );
232 if targets.is_empty() {
233 debug!(%import, path = ?record.path, "unresolved import dropped");
234 }
235 for to_ni in targets {
236 if to_ni != from_ni && !graph.contains_edge(from_ni, to_ni) {
237 graph.add_edge(from_ni, to_ni, ());
238 }
239 }
240 }
241 }
242
243 DependencyGraph {
244 graph,
245 path_to_node,
246 }
247}