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