sdivi_graph/
dependency_graph.rs1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use petgraph::graph::NodeIndex;
7use petgraph::Graph;
8use thiserror::Error;
9use tracing::debug;
10
11#[derive(Debug, Error)]
13pub enum GraphError {
14 #[error("no source records provided")]
16 Empty,
17}
18
19#[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 pub fn node_count(&self) -> usize {
45 self.graph.node_count()
46 }
47
48 pub fn edge_count(&self) -> usize {
50 self.graph.edge_count()
51 }
52
53 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 pub fn node_for_path(&self, path: &Path) -> Option<usize> {
61 self.path_to_node.get(path).map(|ni| ni.index())
62 }
63
64 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 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
80pub 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#[cfg(feature = "pipeline-records")]
148pub fn build_dependency_graph(
149 records: &[sdivi_parsing::feature_record::FeatureRecord],
150) -> DependencyGraph {
151 let mut graph: Graph<PathBuf, ()> = Graph::new();
152 let mut path_to_node: BTreeMap<PathBuf, NodeIndex> = BTreeMap::new();
153
154 for record in records {
155 let ni = graph.add_node(record.path.clone());
156 path_to_node.insert(record.path.clone(), ni);
157 }
158
159 let stem_map = build_stem_map(&path_to_node);
160
161 for record in records {
162 let from_ni = path_to_node[&record.path];
163 for import in &record.imports {
164 match resolve_import(import, &record.path, &stem_map, &path_to_node) {
165 Some(to_ni) if to_ni != from_ni => {
166 if !graph.contains_edge(from_ni, to_ni) {
167 graph.add_edge(from_ni, to_ni, ());
168 }
169 }
170 Some(_) => {}
171 None => {
172 debug!(%import, path = ?record.path, "unresolved import dropped");
173 }
174 }
175 }
176 }
177
178 DependencyGraph {
179 graph,
180 path_to_node,
181 }
182}
183
184#[cfg(feature = "pipeline-records")]
185fn build_stem_map(path_to_node: &BTreeMap<PathBuf, NodeIndex>) -> BTreeMap<String, Vec<NodeIndex>> {
186 let mut map: BTreeMap<String, Vec<NodeIndex>> = BTreeMap::new();
187 for (path, &ni) in path_to_node {
188 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
189 map.entry(stem.to_ascii_lowercase()).or_default().push(ni);
190 }
191 }
192 map
193}
194
195#[cfg(feature = "pipeline-records")]
196fn resolve_import(
197 import: &str,
198 from_path: &Path,
199 stem_map: &BTreeMap<String, Vec<NodeIndex>>,
200 path_to_node: &BTreeMap<PathBuf, NodeIndex>,
201) -> Option<NodeIndex> {
202 if import.starts_with("./") || import.starts_with("../") {
203 return resolve_relative(import, from_path, path_to_node);
204 }
205
206 let local = import
207 .strip_prefix("crate::")
208 .or_else(|| import.strip_prefix("self::"))
209 .or_else(|| import.strip_prefix("super::"));
210
211 if let Some(local) = local {
212 let first = local.split("::").next()?;
213 return resolve_stem(first, stem_map);
214 }
215
216 None
217}
218
219#[cfg(feature = "pipeline-records")]
220fn resolve_relative(
221 import: &str,
222 from_path: &Path,
223 path_to_node: &BTreeMap<PathBuf, NodeIndex>,
224) -> Option<NodeIndex> {
225 let from_dir = from_path.parent()?;
226 let rel = import.trim_start_matches("./").trim_start_matches("../");
227 for ext in &["rs", "py", "ts", "tsx", "js", "go", "java"] {
228 let candidate = from_dir.join(format!("{rel}.{ext}"));
229 if let Some(&ni) = path_to_node.get(&candidate) {
230 return Some(ni);
231 }
232 }
233 for index in &["mod.rs", "index.ts", "index.js", "__init__.py"] {
234 let candidate = from_dir.join(rel).join(index);
235 if let Some(&ni) = path_to_node.get(&candidate) {
236 return Some(ni);
237 }
238 }
239 None
240}
241
242#[cfg(feature = "pipeline-records")]
243fn resolve_stem(stem: &str, stem_map: &BTreeMap<String, Vec<NodeIndex>>) -> Option<NodeIndex> {
244 let candidates = stem_map.get(&stem.to_ascii_lowercase())?;
245 if candidates.len() == 1 {
246 Some(candidates[0])
247 } else {
248 None
249 }
250}