Skip to main content

code_system_graph_core/
package_graph.rs

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/// Package identity used for deterministic cross-repository ownership resolution.
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
11pub struct PackageIdentity {
12    /// Package ecosystem.
13    pub ecosystem: PackageEcosystem,
14    /// Ecosystem-specific package name.
15    pub name: String,
16}
17
18/// Pending dependency ownership resolution retained with extracted package graph facts.
19#[derive(Debug, Clone)]
20pub struct PackageDependencyFact {
21    /// Repository declaring the dependency.
22    pub source_repo_id: RepoId,
23    /// Node that declares the dependency.
24    pub source_node_id: NodeId,
25    /// Target package node.
26    pub target_node_id: NodeId,
27    /// Canonical target package identity.
28    pub target: PackageIdentity,
29    /// Direct declaration evidence.
30    pub evidence_id: EvidenceId,
31    /// Exact declaration confidence.
32    pub confidence: f32,
33}
34
35/// Graph-ready package facts from one source-owned manifest.
36#[derive(Debug, Clone, Default)]
37pub struct PackageGraphFacts {
38    /// Repository, artifact, and package nodes.
39    pub nodes: Vec<Node>,
40    /// Manifest containment and package dependency edges.
41    pub edges: Vec<Edge>,
42    /// Direct package declaration evidence.
43    pub evidence: Vec<Evidence>,
44    /// Package coordinates owned by this source repository.
45    pub owned_packages: Vec<(PackageIdentity, RepoId)>,
46    /// Dependency facts used to distinguish internal repositories from external packages.
47    pub dependencies: Vec<PackageDependencyFact>,
48}
49
50/// Converts one package-manifest payload into deterministic graph facts.
51#[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
149/// Adds exact repository dependency edges for packages with one unambiguous registered owner.
150///
151/// Package dependencies remain present even when ownership is absent or ambiguous.
152pub 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}