Skip to main content

composable_runtime/composition/
graph.rs

1use anyhow::Result;
2use petgraph::graph::{DiGraph, NodeIndex};
3use petgraph::visit::EdgeRef;
4use std::collections::HashMap;
5use std::ops::{Index, IndexMut};
6use std::path::PathBuf;
7
8use crate::config::loaders::{TomlLoader, WasmLoader};
9use crate::config::processor::ConfigProcessor;
10use crate::types::{CapabilityDefinition, ComponentDefinition};
11
12/// Directed graph of component and capability definitions
13/// with dependency and interceptor edges.
14pub struct ComponentGraph {
15    graph: DiGraph<Node, Edge>,
16    node_map: HashMap<String, NodeIndex>,
17}
18
19impl ComponentGraph {
20    /// Create a new GraphBuilder.
21    pub fn builder() -> GraphBuilder {
22        GraphBuilder::new()
23    }
24
25    /// Create a graph where each component and capability is a node
26    /// and each dependency or interceptor relationship is an edge.
27    pub(crate) fn build(
28        component_definitions: &[ComponentDefinition],
29        capability_definitions: &[CapabilityDefinition],
30    ) -> Result<Self> {
31        let mut graph = DiGraph::<Node, Edge>::new();
32        let mut node_map = HashMap::<String, NodeIndex>::new();
33
34        for definition in capability_definitions {
35            let index = graph.add_node(Node::Capability(definition.clone()));
36            node_map.insert(definition.name.clone(), index);
37        }
38
39        // Determine which components are used only as interceptor templates.
40        // These don't get their own graph node — only synthetic clones do.
41        let interceptor_names: std::collections::HashSet<&str> = component_definitions
42            .iter()
43            .flat_map(|d| d.interceptors.iter().map(|s| s.as_str()))
44            .collect();
45        let imported_names: std::collections::HashSet<&str> = component_definitions
46            .iter()
47            .flat_map(|d| d.imports.iter().map(|s| s.as_str()))
48            .collect();
49
50        for definition in component_definitions {
51            let is_template_only = interceptor_names.contains(definition.name.as_str())
52                && !imported_names.contains(definition.name.as_str());
53            if is_template_only {
54                continue;
55            }
56            let index = graph.add_node(Node::Component(definition.clone()));
57            node_map.insert(definition.name.clone(), index);
58        }
59
60        // Build interceptor chains with name takeover.
61        //
62        // Interceptors wrap a component's exports, not its imports. The
63        // component's own imports connect directly to its own node.
64        //
65        // List order is call order (outer-to-inner), reversed here to
66        // build the chain outward from the component.
67        //
68        // Given: [component.client] interceptors = ["auth", "logger"]
69        //   _client$0 (original) -> _client$1 (logger) -> client (auth, outermost)
70        //
71        // The outermost interceptor takes over the original name so that
72        // importers and public APIs see the intercepted version transparently.
73        // Internal nodes use _name$N naming (reserved, validated at config time).
74        //
75        // This pattern is reusable: future [interceptor.*] support will
76        // apply the same rename-and-replace on top of the existing chain.
77        let mut interceptor_clones = std::collections::HashSet::<NodeIndex>::new();
78
79        for definition in component_definitions {
80            if definition.interceptors.is_empty() {
81                continue;
82            }
83
84            let original_name = &definition.name;
85            let internal_name = format!("_{original_name}$0");
86
87            // Rename the original component node to its internal name.
88            let component_index = node_map.remove(original_name).unwrap();
89            if let Node::Component(ref mut def) = graph[component_index] {
90                def.name = internal_name.clone();
91            }
92            node_map.insert(internal_name, component_index);
93
94            let mut current = component_index;
95            let interceptor_count = definition.interceptors.len();
96
97            for (position, interceptor_name) in definition.interceptors.iter().rev().enumerate() {
98                let interceptor_def = component_definitions
99                    .iter()
100                    .find(|d| d.name == *interceptor_name)
101                    .ok_or_else(|| {
102                        anyhow::anyhow!(
103                            "Component '{}' references interceptor '{}', which is not defined.",
104                            definition.name,
105                            interceptor_name,
106                        )
107                    })?;
108
109                let is_outermost = position == interceptor_count - 1;
110                let synthetic_name = if is_outermost {
111                    original_name.clone()
112                } else {
113                    format!("_{original_name}${}", position + 1)
114                };
115
116                let mut cloned_def = interceptor_def.clone();
117                cloned_def.name = synthetic_name.clone();
118
119                let cloned_index = graph.add_node(Node::Component(cloned_def));
120                node_map.insert(synthetic_name, cloned_index);
121                interceptor_clones.insert(cloned_index);
122
123                graph.update_edge(current, cloned_index, Edge::Interceptor(position as i32));
124                current = cloned_index;
125            }
126        }
127
128        // Add dependency edges for original component definitions.
129        // For intercepted components, the node was renamed to _name$0.
130        for definition in component_definitions {
131            let lookup_name = if definition.interceptors.is_empty() {
132                definition.name.clone()
133            } else {
134                format!("_{}$0", definition.name)
135            };
136
137            let Some(importer_index) = node_map.get(&lookup_name).copied() else {
138                continue; // Template-only interceptor, not in the graph.
139            };
140
141            for exporter_name in &definition.imports {
142                if let Some(exporter_index) = node_map.get(exporter_name).copied() {
143                    graph.update_edge(exporter_index, importer_index, Edge::Dependency);
144                } else {
145                    tracing::warn!(
146                        "Component '{}' imports '{}', which is not defined.",
147                        definition.name,
148                        exporter_name
149                    );
150                }
151            }
152        }
153
154        // Add dependency edges for interceptor clones' own imports.
155        for clone_index in &interceptor_clones {
156            let Node::Component(def) = &graph[*clone_index] else {
157                continue;
158            };
159            let clone_name = def.name.clone();
160            let imports = def.imports.clone();
161            for exporter_name in &imports {
162                if let Some(exporter_index) = node_map.get(exporter_name).copied() {
163                    graph.update_edge(exporter_index, *clone_index, Edge::Dependency);
164                } else {
165                    tracing::warn!(
166                        "Interceptor '{}' imports '{}', which is not defined.",
167                        clone_name,
168                        exporter_name
169                    );
170                }
171            }
172        }
173
174        // Validate the graph for cycles
175        if let Err(cycle) = petgraph::algo::toposort(&graph, None) {
176            let node_name = match &graph[cycle.node_id()] {
177                Node::Component(def) => &def.name,
178                Node::Capability(def) => &def.name,
179            };
180            return Err(anyhow::anyhow!(
181                "Circular dependency detected involving '{node_name}'"
182            ));
183        }
184
185        Ok(Self { graph, node_map })
186    }
187
188    /// Write the graph to a DOT file.
189    pub fn write_dot_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
190        let dot_content = self.dot();
191        std::fs::write(path, dot_content)
192            .map_err(|e| anyhow::anyhow!("Failed to write DOT file: {e}"))?;
193        Ok(())
194    }
195
196    pub fn nodes(&self) -> impl Iterator<Item = &petgraph::graph::Node<Node>> {
197        self.graph.raw_nodes().iter()
198    }
199
200    pub fn get_build_order(&self) -> Vec<NodeIndex> {
201        petgraph::algo::toposort(&self.graph, None).unwrap()
202    }
203
204    pub fn get_node_index(&self, name: &str) -> Option<NodeIndex> {
205        self.node_map.get(name).copied()
206    }
207
208    pub fn get_dependencies(&self, index: NodeIndex) -> impl Iterator<Item = (NodeIndex, &Edge)> {
209        self.graph
210            .edges_directed(index, petgraph::Direction::Incoming)
211            .map(|edge_ref| (edge_ref.source(), edge_ref.weight()))
212    }
213
214    fn dot(&self) -> String {
215        let mut output = String::from("digraph ComponentGraph {\n");
216        output.push_str("  rankdir=BT;\n");
217        output.push_str("  node [fontname=\"Arial\", fontsize=10];\n");
218        output.push_str("  edge [fontname=\"Arial\", fontsize=9];\n");
219
220        for node_index in self.graph.node_indices() {
221            let node = &self.graph[node_index];
222            let node_attrs = match node {
223                Node::Component(def) => {
224                    let is_internal = def.name.starts_with('_');
225                    let color = if is_internal { "yellow" } else { "lightblue" };
226                    format!(
227                        "[label=\"{}\", shape=box, fillcolor={color}, style=\"rounded,filled\"]",
228                        def.name
229                    )
230                }
231                Node::Capability(def) => {
232                    format!(
233                        "[label=\"{}\", shape=ellipse, fillcolor=orange, style=\"rounded,filled\"]",
234                        def.name
235                    )
236                }
237            };
238            output.push_str(&format!("  {} {};\n", node_index.index(), node_attrs));
239        }
240
241        for edge_ref in self.graph.edge_references() {
242            let edge_attrs = match edge_ref.weight() {
243                Edge::Dependency => "[color=blue, style=solid]".to_string(),
244                Edge::Interceptor(position) => {
245                    format!("[color=red, style=dashed, label=\"interceptor: {position}\"]")
246                }
247            };
248            output.push_str(&format!(
249                "  {} -> {} {};\n",
250                edge_ref.source().index(),
251                edge_ref.target().index(),
252                edge_attrs
253            ));
254        }
255
256        output.push_str("}\n");
257        output
258    }
259}
260
261impl std::fmt::Debug for ComponentGraph {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        struct FlatNode<'a>(&'a Node);
264        impl<'a> std::fmt::Debug for FlatNode<'a> {
265            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266                match self.0 {
267                    Node::Component(def) => std::fmt::Debug::fmt(def, f),
268                    Node::Capability(def) => std::fmt::Debug::fmt(def, f),
269                }
270            }
271        }
272
273        let mut debug_struct = f.debug_struct("ComponentGraph");
274
275        let nodes: Vec<_> = self
276            .graph
277            .raw_nodes()
278            .iter()
279            .map(|n| FlatNode(&n.weight))
280            .collect();
281        debug_struct.field("nodes", &nodes);
282
283        let edges: Vec<String> = self
284            .graph
285            .edge_references()
286            .map(|edge| {
287                let source_node = &self.graph[edge.source()];
288                let target_node = &self.graph[edge.target()];
289                let source_name = match source_node {
290                    Node::Component(def) => &def.name,
291                    Node::Capability(def) => &def.name,
292                };
293                let target_name = match target_node {
294                    Node::Component(def) => &def.name,
295                    Node::Capability(def) => &def.name,
296                };
297                format!("{} -> {} ({:?})", source_name, target_name, edge.weight())
298            })
299            .collect();
300        debug_struct.field("edges", &edges);
301        debug_struct.finish()
302    }
303}
304
305impl Index<NodeIndex> for ComponentGraph {
306    type Output = Node;
307
308    fn index(&self, index: NodeIndex) -> &Self::Output {
309        &self.graph[index]
310    }
311}
312
313impl IndexMut<NodeIndex> for ComponentGraph {
314    fn index_mut(&mut self, index: NodeIndex) -> &mut Self::Output {
315        &mut self.graph[index]
316    }
317}
318
319#[derive(Debug, Clone)]
320pub enum Node {
321    Component(ComponentDefinition),
322    Capability(CapabilityDefinition),
323}
324
325#[derive(Debug, Clone)]
326#[allow(dead_code)]
327pub enum Edge {
328    Dependency,
329    Interceptor(i32), // Position in chain (0 = innermost)
330}
331
332/// Builder for constructing a ComponentGraph.
333pub struct GraphBuilder {
334    paths: Vec<PathBuf>,
335    loaders: Vec<Box<dyn crate::config::types::DefinitionLoader>>,
336    handlers: Vec<Box<dyn crate::config::types::ConfigHandler>>,
337    use_default_loaders: bool,
338}
339
340impl GraphBuilder {
341    fn new() -> Self {
342        Self {
343            paths: Vec::new(),
344            loaders: Vec::new(),
345            handlers: Vec::new(),
346            use_default_loaders: true,
347        }
348    }
349
350    /// Add a definition source path (.toml, .wasm, oci://, etc.).
351    pub fn from_path(mut self, path: impl Into<PathBuf>) -> Self {
352        self.paths.push(path.into());
353        self
354    }
355
356    /// Add multiple definition source paths.
357    pub fn from_paths(mut self, paths: &[PathBuf]) -> Self {
358        self.paths.extend_from_slice(paths);
359        self
360    }
361
362    /// Add a definition loader.
363    pub fn add_loader(mut self, loader: Box<dyn crate::config::types::DefinitionLoader>) -> Self {
364        self.loaders.push(loader);
365        self
366    }
367
368    /// Add a config handler.
369    pub fn add_handler(mut self, handler: Box<dyn crate::config::types::ConfigHandler>) -> Self {
370        self.handlers.push(handler);
371        self
372    }
373
374    /// Do not enable the default definition loaders (.toml and .wasm).
375    pub fn no_default_loaders(mut self) -> Self {
376        self.use_default_loaders = false;
377        self
378    }
379
380    /// Build the ComponentGraph from all loaded definitions.
381    pub fn build(self) -> Result<ComponentGraph> {
382        let mut processor = ConfigProcessor::new();
383
384        if self.use_default_loaders {
385            processor.add_loader(Box::new(TomlLoader::new()));
386            processor.add_loader(Box::new(WasmLoader::new()));
387        }
388        for loader in self.loaders {
389            processor.add_loader(loader);
390        }
391        for handler in self.handlers {
392            processor.add_handler(handler);
393        }
394
395        let (component_definitions, capability_definitions) = processor.process(&self.paths)?;
396        ComponentGraph::build(&component_definitions, &capability_definitions)
397    }
398}