Skip to main content

repoctl_engine/
graph.rs

1//! Repository graph construction.
2
3use std::{collections::BTreeMap, sync::Arc};
4
5use repoctl_core::{
6    DependencySurface, DependencyTarget, EdgeKind, GraphBuildInput, GraphBuilder, GraphEdge,
7    GraphNode, GraphNodeKind, ProjectKind, RepoGraph, RepoctlError, WorkspaceInspectionInput,
8    WorkspaceInspector,
9};
10
11use crate::RustWorkspaceInspector;
12
13/// Default graph builder combining manifest edges and workspace inspector edges.
14#[derive(Clone)]
15pub struct DefaultGraphBuilder {
16    inspectors: Vec<Arc<dyn WorkspaceInspector>>,
17}
18
19impl std::fmt::Debug for DefaultGraphBuilder {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        f.debug_struct("DefaultGraphBuilder")
22            .field("inspectors", &self.inspectors.len())
23            .finish()
24    }
25}
26
27impl Default for DefaultGraphBuilder {
28    fn default() -> Self {
29        Self {
30            inspectors: vec![Arc::new(RustWorkspaceInspector)],
31        }
32    }
33}
34
35impl DefaultGraphBuilder {
36    /// Creates a graph builder from explicit workspace inspectors.
37    pub fn new(inspectors: Vec<Arc<dyn WorkspaceInspector>>) -> Self {
38        Self { inspectors }
39    }
40}
41
42impl GraphBuilder for DefaultGraphBuilder {
43    fn build(&self, input: GraphBuildInput) -> Result<RepoGraph, RepoctlError> {
44        let mut graph = RepoGraph::default();
45        let by_name = input
46            .projects
47            .iter()
48            .map(|project| (project.name.clone(), project))
49            .collect::<BTreeMap<_, _>>();
50        for project in &input.projects {
51            graph.add_node(GraphNode {
52                id: project.node_id(),
53                kind: GraphNodeKind::Project,
54                label: project.name.to_string(),
55                project: Some(project.name.clone()),
56                workspace: None,
57            });
58            for workspace in &project.workspaces {
59                let workspace_node = format!("workspace:{}:{}", project.name, workspace.name);
60                graph.add_node(GraphNode {
61                    id: workspace_node.clone(),
62                    kind: GraphNodeKind::Workspace,
63                    label: workspace.name.to_string(),
64                    project: Some(project.name.clone()),
65                    workspace: Some(workspace.name.clone()),
66                });
67                graph.add_edge(GraphEdge {
68                    from: project.node_id(),
69                    to: workspace_node,
70                    kind: EdgeKind::ContainsWorkspace,
71                    evidence: Some(workspace.manifest.to_string()),
72                });
73            }
74            add_proto_edges(&mut graph, project);
75            add_iac_edges(&mut graph, project);
76        }
77        for project in &input.projects {
78            for dependency in &project.depends_on {
79                match &dependency.target {
80                    DependencyTarget::Project(target_name) => {
81                        if let Some(target) = by_name.get(target_name) {
82                            graph.add_edge(GraphEdge {
83                                from: project.node_id(),
84                                to: target.node_id(),
85                                kind: classify_declared_dependency(
86                                    &project.kind,
87                                    &target.kind,
88                                    &dependency.surface,
89                                ),
90                                evidence: Some(dependency.id.clone()),
91                            });
92                        }
93                    }
94                    DependencyTarget::ProtoPackage(package) => {
95                        let node_id = format!("proto:{package}");
96                        graph.add_node(GraphNode {
97                            id: node_id.clone(),
98                            kind: GraphNodeKind::ProtoPackage,
99                            label: package.to_string(),
100                            project: None,
101                            workspace: None,
102                        });
103                        graph.add_edge(GraphEdge {
104                            from: project.node_id(),
105                            to: node_id,
106                            kind: EdgeKind::ConsumesProto,
107                            evidence: Some(dependency.id.clone()),
108                        });
109                    }
110                }
111            }
112        }
113        for project in &input.projects {
114            for workspace in &project.workspaces {
115                for inspector in &self.inspectors {
116                    if inspector.language() != workspace.language {
117                        continue;
118                    }
119                    let inspection = WorkspaceInspectionInput {
120                        root: &input.root,
121                        projects: &input.projects,
122                        project,
123                        workspace,
124                    };
125                    for discovered in inspector.inspect(&inspection)? {
126                        graph.add_edge(repoctl_core::discovered_to_graph_edge(&discovered));
127                    }
128                }
129            }
130        }
131        graph.nodes.sort_by(|left, right| left.id.cmp(&right.id));
132        graph.edges.sort_by(|left, right| {
133            (&left.from, &left.to, edge_rank(&left.kind), &left.evidence).cmp(&(
134                &right.from,
135                &right.to,
136                edge_rank(&right.kind),
137                &right.evidence,
138            ))
139        });
140        Ok(graph)
141    }
142}
143
144fn add_proto_edges(graph: &mut RepoGraph, project: &repoctl_core::ProjectManifest) {
145    for pattern in &project.protos.owns {
146        let node_id = format!("proto:{}", pattern.as_str());
147        graph.add_node(GraphNode {
148            id: node_id.clone(),
149            kind: GraphNodeKind::ProtoPackage,
150            label: pattern.to_string(),
151            project: None,
152            workspace: None,
153        });
154        graph.add_edge(GraphEdge {
155            from: project.node_id(),
156            to: node_id,
157            kind: EdgeKind::OwnsProto,
158            evidence: Some(pattern.to_string()),
159        });
160    }
161    for pattern in &project.protos.consumes {
162        let node_id = format!("proto:{}", pattern.as_str());
163        graph.add_node(GraphNode {
164            id: node_id.clone(),
165            kind: GraphNodeKind::ProtoPackage,
166            label: pattern.to_string(),
167            project: None,
168            workspace: None,
169        });
170        graph.add_edge(GraphEdge {
171            from: project.node_id(),
172            to: node_id,
173            kind: EdgeKind::ConsumesProto,
174            evidence: Some(pattern.to_string()),
175        });
176    }
177}
178
179fn add_iac_edges(graph: &mut RepoGraph, project: &repoctl_core::ProjectManifest) {
180    let Some(iac) = &project.iac else {
181        return;
182    };
183    if iac.stacks.is_empty() {
184        add_iac_target(graph, project, "default", &iac.root);
185        return;
186    }
187    for stack in &iac.stacks {
188        add_iac_target(graph, project, stack, &iac.root);
189    }
190}
191
192fn add_iac_target(
193    graph: &mut RepoGraph,
194    project: &repoctl_core::ProjectManifest,
195    stack: &str,
196    root: &repoctl_core::ProjectRelativePath,
197) {
198    let node_id = format!("iac:{}:{stack}", project.name);
199    graph.add_node(GraphNode {
200        id: node_id.clone(),
201        kind: GraphNodeKind::IacTarget,
202        label: stack.to_string(),
203        project: Some(project.name.clone()),
204        workspace: None,
205    });
206    graph.add_edge(GraphEdge {
207        from: project.node_id(),
208        to: node_id,
209        kind: EdgeKind::OwnsIac,
210        evidence: Some(root.to_string()),
211    });
212}
213
214fn classify_declared_dependency(
215    source: &ProjectKind,
216    target: &ProjectKind,
217    surface: &DependencySurface,
218) -> EdgeKind {
219    match (source, target, surface) {
220        (ProjectKind::App, ProjectKind::Framework, DependencySurface::FrameworkInternal) => {
221            EdgeKind::UsesFrameworkInternal
222        }
223        (ProjectKind::App, ProjectKind::Framework, _) => EdgeKind::UsesFrameworkFacade,
224        (
225            ProjectKind::App,
226            ProjectKind::FoundationService,
227            DependencySurface::FoundationPublicClient,
228        ) => EdgeKind::UsesFoundationClient,
229        (ProjectKind::App, ProjectKind::FoundationService, _) => EdgeKind::UsesFoundationInternal,
230        (
231            _,
232            ProjectKind::CoreInfra | ProjectKind::CoreInfraComponent,
233            DependencySurface::CoreInfraInternalModule,
234        ) => EdgeKind::UsesCoreInfraInternalModule,
235        (_, ProjectKind::CoreInfra | ProjectKind::CoreInfraComponent, _) => {
236            EdgeKind::UsesCoreInfraModule
237        }
238        _ => EdgeKind::DependsOnProject,
239    }
240}
241
242fn edge_rank(kind: &EdgeKind) -> u8 {
243    match kind {
244        EdgeKind::DependsOnProject => 0,
245        EdgeKind::ContainsWorkspace => 1,
246        EdgeKind::ConsumesProto => 2,
247        EdgeKind::OwnsProto => 3,
248        EdgeKind::UsesFrameworkFacade => 4,
249        EdgeKind::UsesFrameworkInternal => 5,
250        EdgeKind::UsesFoundationClient => 6,
251        EdgeKind::UsesFoundationInternal => 7,
252        EdgeKind::UsesCoreInfraModule => 8,
253        EdgeKind::UsesCoreInfraInternalModule => 9,
254        EdgeKind::OwnsIac => 10,
255        EdgeKind::RunsTask => 11,
256    }
257}