1use std::collections::{BTreeMap, BTreeSet};
2
3use code_system_graph_model::{
4 Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, Node, NodeId, NodeKind, Provenance, RepoId, stable_id
5};
6
7use crate::{PackageDependency, PackageEcosystem, PackageManifest};
8
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
11pub struct PackageIdentity {
12 pub ecosystem: PackageEcosystem,
14 pub name: String,
16}
17
18#[derive(Debug, Clone)]
20pub struct PackageDependencyFact {
21 pub source_repo_id: RepoId,
23 pub source_node_id: NodeId,
25 pub target_node_id: NodeId,
27 pub target: PackageIdentity,
29 pub evidence_id: EvidenceId,
31 pub confidence: f32,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct PackageGraphFacts {
38 pub nodes: Vec<Node>,
40 pub edges: Vec<Edge>,
42 pub evidence: Vec<Evidence>,
44 pub owned_packages: Vec<(PackageIdentity, RepoId)>,
46 pub dependencies: Vec<PackageDependencyFact>,
48}
49
50#[must_use]
52pub fn package_manifest_to_graph(
53 repo_id: &RepoId,
54 source_path: &str,
55 content_hash: &str,
56 manifest: &PackageManifest,
57) -> PackageGraphFacts {
58 let repository = repository_node(repo_id);
59 let artifact = artifact_node(repo_id, source_path);
60 let mut result = PackageGraphFacts {
61 nodes: vec![repository.clone(), artifact.clone()],
62 ..PackageGraphFacts::default()
63 };
64 let artifact_evidence = package_evidence(
65 repo_id,
66 source_path,
67 content_hash,
68 1,
69 "manifest artifact",
70 1.0,
71 );
72 result.evidence.push(artifact_evidence.clone());
73 result.edges.push(edge(
74 &repository.id,
75 &artifact.id,
76 EdgeKind::Contains,
77 artifact_evidence.id,
78 1.0,
79 ));
80
81 let mut owner_nodes = Vec::new();
82 for package in &manifest.packages {
83 let identity = PackageIdentity {
84 ecosystem: package.ecosystem,
85 name: package.name.clone(),
86 };
87 let node = package_node(&identity);
88 let evidence = package_evidence(
89 repo_id,
90 source_path,
91 content_hash,
92 package.evidence.line,
93 "package coordinate",
94 package.confidence,
95 );
96 result.edges.push(edge(
97 &artifact.id,
98 &node.id,
99 EdgeKind::Contains,
100 evidence.id.clone(),
101 package.confidence,
102 ));
103 result.owned_packages.push((identity, repo_id.clone()));
104 result.evidence.push(evidence);
105 owner_nodes.push(node.id.clone());
106 result.nodes.push(node);
107 }
108 for member in &manifest.workspace_members {
109 if member.value.contains(['*', '?', '[', ']']) {
110 continue;
111 }
112 let Some(member_manifest) = workspace_member_manifest(source_path, &member.value) else {
113 continue;
114 };
115 let node = artifact_node(repo_id, &member_manifest);
116 let evidence = package_evidence(
117 repo_id,
118 source_path,
119 content_hash,
120 member.evidence.line,
121 "workspace member",
122 member.confidence,
123 );
124 result.edges.push(edge(
125 &artifact.id,
126 &node.id,
127 EdgeKind::Contains,
128 evidence.id.clone(),
129 member.confidence,
130 ));
131 result.nodes.push(node);
132 result.evidence.push(evidence);
133 }
134 let dependency_source = owner_nodes.first().unwrap_or(&artifact.id).clone();
135 for dependency in &manifest.dependencies {
136 append_dependency(
137 &mut result,
138 repo_id,
139 source_path,
140 content_hash,
141 &dependency_source,
142 dependency,
143 );
144 }
145 deduplicate_package_facts(&mut result);
146 result
147}
148
149pub fn link_registered_package_owners(facts: &mut [PackageGraphFacts]) {
153 let mut owners: BTreeMap<PackageIdentity, BTreeSet<RepoId>> = BTreeMap::new();
154 for fact in facts.iter() {
155 for (package, repo_id) in &fact.owned_packages {
156 owners
157 .entry(package.clone())
158 .or_default()
159 .insert(repo_id.clone());
160 }
161 }
162 for fact in facts {
163 for dependency in &fact.dependencies {
164 let Some(owner_set) = owners.get(&dependency.target) else {
165 continue;
166 };
167 if owner_set.len() != 1 || owner_set.contains(&dependency.source_repo_id) {
168 continue;
169 }
170 let Some(owner) = owner_set.first() else {
171 continue;
172 };
173 let owner_repository = repository_node(owner);
174 fact.edges.push(edge(
175 &dependency.source_node_id,
176 &owner_repository.id,
177 EdgeKind::DependsOnRepository,
178 dependency.evidence_id.clone(),
179 dependency.confidence,
180 ));
181 fact.nodes.push(owner_repository);
182 }
183 deduplicate_package_facts(fact);
184 }
185}
186
187fn append_dependency(
188 result: &mut PackageGraphFacts,
189 repo_id: &RepoId,
190 source_path: &str,
191 content_hash: &str,
192 source_node: &NodeId,
193 dependency: &PackageDependency,
194) {
195 let identity = PackageIdentity {
196 ecosystem: dependency.ecosystem,
197 name: dependency.name.clone(),
198 };
199 let node = package_node(&identity);
200 let evidence = package_evidence(
201 repo_id,
202 source_path,
203 content_hash,
204 dependency.evidence.line,
205 "package dependency",
206 dependency.confidence,
207 );
208 result.edges.push(edge(
209 source_node,
210 &node.id,
211 EdgeKind::DependsOnPackage,
212 evidence.id.clone(),
213 dependency.confidence,
214 ));
215 result.dependencies.push(PackageDependencyFact {
216 source_repo_id: repo_id.clone(),
217 source_node_id: source_node.clone(),
218 target_node_id: node.id.clone(),
219 target: identity,
220 evidence_id: evidence.id.clone(),
221 confidence: dependency.confidence,
222 });
223 result.nodes.push(node);
224 result.evidence.push(evidence);
225}
226
227fn repository_node(repo_id: &RepoId) -> Node {
228 let stable_key = format!("repository:{}", repo_id.as_str());
229 Node {
230 id: NodeId::new(stable_id("node", &stable_key)),
231 kind: NodeKind::Repository,
232 repo_id: Some(repo_id.clone()),
233 stable_key,
234 label: repo_id.as_str().to_owned(),
235 }
236}
237
238fn artifact_node(repo_id: &RepoId, source_path: &str) -> Node {
239 let stable_key = format!("artifact:{}:{source_path}", repo_id.as_str());
240 Node {
241 id: NodeId::new(stable_id("node", &stable_key)),
242 kind: NodeKind::Artifact,
243 repo_id: Some(repo_id.clone()),
244 stable_key,
245 label: source_path.to_owned(),
246 }
247}
248
249fn workspace_member_manifest(manifest_path: &str, member: &str) -> Option<String> {
250 let parent = manifest_path
251 .rsplit_once('/')
252 .map_or("", |(parent, _)| parent);
253 let member = member.replace('\\', "/");
254 if member.starts_with('/')
255 || member
256 .split('/')
257 .any(|component| component == ".." || component.is_empty())
258 {
259 return None;
260 }
261 let member = member
262 .split('/')
263 .filter(|component| *component != ".")
264 .collect::<Vec<_>>()
265 .join("/");
266 if member.is_empty() {
267 return Some(manifest_path.to_owned());
268 }
269 if parent.is_empty() {
270 Some(format!("{member}/Cargo.toml"))
271 } else {
272 Some(format!("{parent}/{member}/Cargo.toml"))
273 }
274}
275
276fn package_node(identity: &PackageIdentity) -> Node {
277 let ecosystem = ecosystem_name(identity.ecosystem);
278 let stable_key = format!("package:{ecosystem}:{}", identity.name);
279 Node {
280 id: NodeId::new(stable_id("node", &stable_key)),
281 kind: NodeKind::Package,
282 repo_id: None,
283 stable_key,
284 label: format!("{ecosystem}:{}", identity.name),
285 }
286}
287
288fn package_evidence(
289 repo_id: &RepoId,
290 source_path: &str,
291 content_hash: &str,
292 line: u32,
293 note: &str,
294 confidence: f32,
295) -> Evidence {
296 let key = format!(
297 "{}:{source_path}:{line}:{note}:{content_hash}",
298 repo_id.as_str()
299 );
300 Evidence {
301 id: EvidenceId::new(stable_id("evidence", &key)),
302 repo_id: Some(repo_id.clone()),
303 file_path: Some(source_path.to_owned()),
304 start_line: Some(line),
305 end_line: Some(line),
306 extractor: "code-system-graph.packages".to_owned(),
307 extractor_version: "1.0.0".to_owned(),
308 provenance: Provenance::Extracted,
309 confidence,
310 observed_at_commit: None,
311 content_hash: Some(content_hash.to_owned()),
312 note: Some(note.to_owned()),
313 }
314}
315
316fn edge(
317 source: &NodeId,
318 target: &NodeId,
319 kind: EdgeKind,
320 evidence_id: EvidenceId,
321 confidence: f32,
322) -> Edge {
323 let key = format!("{}:{kind:?}:{}", source.as_str(), target.as_str());
324 Edge {
325 id: EdgeId::new(stable_id("edge", &key)),
326 source: source.clone(),
327 target: target.clone(),
328 kind,
329 confidence,
330 status: EpistemicStatus::Confirmed,
331 evidence: vec![evidence_id],
332 }
333}
334
335fn ecosystem_name(ecosystem: PackageEcosystem) -> &'static str {
336 match ecosystem {
337 PackageEcosystem::Npm => "npm",
338 PackageEcosystem::Python => "python",
339 PackageEcosystem::Cargo => "cargo",
340 PackageEcosystem::Go => "go",
341 PackageEcosystem::Maven => "maven",
342 PackageEcosystem::Gradle => "gradle",
343 PackageEcosystem::NuGet => "nuget",
344 }
345}
346
347fn deduplicate_package_facts(facts: &mut PackageGraphFacts) {
348 facts.nodes.sort_by(|left, right| left.id.cmp(&right.id));
349 facts.nodes.dedup_by(|left, right| left.id == right.id);
350 facts.edges.sort_by(|left, right| left.id.cmp(&right.id));
351 facts.edges.dedup_by(|left, right| left.id == right.id);
352 facts.evidence.sort_by(|left, right| left.id.cmp(&right.id));
353 facts.evidence.dedup_by(|left, right| left.id == right.id);
354 facts
355 .owned_packages
356 .sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
357 facts.owned_packages.dedup();
358}
359
360#[cfg(test)]
361mod tests {
362 use std::collections::BTreeSet;
363
364 use code_system_graph_model::{EdgeKind, RepoId};
365
366 use super::{link_registered_package_owners, package_manifest_to_graph};
367 use crate::extract_package_manifest;
368
369 #[test]
370 fn package_linking_should_distinguish_registered_repo_from_external_dependency() {
371 let api_manifest = extract_package_manifest(
372 "Cargo.toml",
373 "[package]\nname = \"api-contract\"\nversion = \"1\"\n[dependencies]\nserde = \"1\"\n",
374 )
375 .unwrap_or_else(|error| panic!("fixture must parse: {error}"));
376 let web_manifest = extract_package_manifest(
377 "Cargo.toml",
378 "[package]\nname = \"web\"\nversion = \"1\"\n[dependencies]\napi-contract = \"1\"\n",
379 )
380 .unwrap_or_else(|error| panic!("fixture must parse: {error}"));
381 let mut facts = vec![
382 package_manifest_to_graph(&RepoId::new("repo:api"), "Cargo.toml", "api", &api_manifest),
383 package_manifest_to_graph(&RepoId::new("repo:web"), "Cargo.toml", "web", &web_manifest),
384 ];
385
386 link_registered_package_owners(&mut facts);
387
388 assert!(
389 facts[1]
390 .edges
391 .iter()
392 .any(|edge| edge.kind == EdgeKind::DependsOnRepository)
393 );
394 assert!(
395 facts[0]
396 .edges
397 .iter()
398 .any(|edge| edge.kind == EdgeKind::DependsOnPackage)
399 );
400 }
401
402 #[test]
403 fn cargo_workspace_should_contain_declared_member_manifests() {
404 let manifest = extract_package_manifest(
405 "Cargo.toml",
406 "[workspace]\nmembers = [\"crates/api\", \"crates/core\"]\n",
407 )
408 .unwrap_or_else(|error| panic!("fixture must parse: {error}"));
409
410 let facts = package_manifest_to_graph(
411 &RepoId::new("repo:workspace"),
412 "Cargo.toml",
413 "root",
414 &manifest,
415 );
416
417 let member_ids = facts
418 .nodes
419 .iter()
420 .filter(|node| {
421 matches!(
422 node.label.as_str(),
423 "crates/api/Cargo.toml" | "crates/core/Cargo.toml"
424 )
425 })
426 .map(|node| &node.id)
427 .collect::<BTreeSet<_>>();
428 assert_eq!(member_ids.len(), 2);
429 assert_eq!(
430 facts
431 .edges
432 .iter()
433 .filter(|edge| {
434 edge.kind == EdgeKind::Contains && member_ids.contains(&edge.target)
435 })
436 .count(),
437 2
438 );
439 }
440}