Skip to main content

fraiseql_cli/commands/federation/
graph.rs

1//! Federation graph export command
2//!
3//! Usage: fraiseql federation graph <schema.compiled.json> [--format=json|dot|mermaid]
4
5use std::{fmt::Display, fs, str::FromStr};
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::output::CommandResult;
11
12/// Export format for federation graph
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum GraphFormat {
16    /// JSON format (machine-readable)
17    Json,
18    /// DOT format (Graphviz)
19    Dot,
20    /// Mermaid format (documentation)
21    Mermaid,
22}
23
24impl FromStr for GraphFormat {
25    type Err = String;
26
27    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
28        match s.to_lowercase().as_str() {
29            "json" => Ok(GraphFormat::Json),
30            "dot" => Ok(GraphFormat::Dot),
31            "mermaid" => Ok(GraphFormat::Mermaid),
32            other => Err(format!("Unknown format: {other}. Use json, dot, or mermaid")),
33        }
34    }
35}
36
37impl Display for GraphFormat {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            GraphFormat::Json => write!(f, "json"),
41            GraphFormat::Dot => write!(f, "dot"),
42            GraphFormat::Mermaid => write!(f, "mermaid"),
43        }
44    }
45}
46
47/// Federation graph data
48#[derive(Debug, Serialize)]
49pub struct FederationGraph {
50    /// Subgraphs in the federation
51    pub subgraphs: Vec<Subgraph>,
52
53    /// Edges representing entity relationships
54    pub edges: Vec<Edge>,
55}
56
57/// Subgraph in federation
58#[derive(Debug, Serialize)]
59pub struct Subgraph {
60    /// Subgraph name
61    pub name: String,
62
63    /// Subgraph endpoint URL
64    pub url: String,
65
66    /// Entities provided by this subgraph
67    pub entities: Vec<String>,
68}
69
70/// Edge representing entity relationship between subgraphs
71#[derive(Debug, Serialize)]
72pub struct Edge {
73    /// Source subgraph
74    pub from: String,
75
76    /// Target subgraph
77    pub to: String,
78
79    /// Entity linking the subgraphs
80    pub entity: String,
81}
82
83/// Run federation graph command
84///
85/// # Errors
86///
87/// Returns an error if the schema file cannot be read, cannot be parsed as JSON,
88/// or if JSON serialization of the graph output fails.
89pub fn run(schema_path: &str, format: GraphFormat) -> Result<CommandResult> {
90    // Load schema file
91    let schema_content = fs::read_to_string(schema_path)?;
92
93    // Parse as JSON to verify structure (validation only)
94    let _schema: serde_json::Value = serde_json::from_str(&schema_content)?;
95
96    // Build federation graph (simulated for now)
97    let graph = FederationGraph {
98        subgraphs: vec![
99            Subgraph {
100                name:     "users".to_string(),
101                url:      "http://users.service/graphql".to_string(),
102                entities: vec!["User".to_string()],
103            },
104            Subgraph {
105                name:     "posts".to_string(),
106                url:      "http://posts.service/graphql".to_string(),
107                entities: vec!["Post".to_string()],
108            },
109            Subgraph {
110                name:     "comments".to_string(),
111                url:      "http://comments.service/graphql".to_string(),
112                entities: vec!["Comment".to_string()],
113            },
114        ],
115        edges:     vec![
116            Edge {
117                from:   "users".to_string(),
118                to:     "posts".to_string(),
119                entity: "User".to_string(),
120            },
121            Edge {
122                from:   "posts".to_string(),
123                to:     "comments".to_string(),
124                entity: "Post".to_string(),
125            },
126        ],
127    };
128
129    // Export in requested format
130    let output = match format {
131        GraphFormat::Json => serde_json::to_value(&graph)?,
132        GraphFormat::Dot => serde_json::Value::String(to_dot(&graph)),
133        GraphFormat::Mermaid => serde_json::Value::String(to_mermaid(&graph)),
134    };
135
136    Ok(CommandResult::success("federation/graph", output))
137}
138
139/// Convert federation graph to DOT format (Graphviz)
140fn to_dot(graph: &FederationGraph) -> String {
141    let mut dot = String::from("digraph federation {\n");
142
143    // Add subgraph nodes
144    for subgraph in &graph.subgraphs {
145        let entities = subgraph.entities.join(", ");
146        dot.push_str(&format!(
147            "    {} [label=\"{}\\n[{}]\"];\n",
148            subgraph.name, subgraph.name, entities
149        ));
150    }
151
152    // Add edges
153    for edge in &graph.edges {
154        dot.push_str(&format!("    {} -> {} [label=\"{}\"];\n", edge.from, edge.to, edge.entity));
155    }
156
157    dot.push_str("}\n");
158    dot
159}
160
161/// Convert federation graph to Mermaid format
162fn to_mermaid(graph: &FederationGraph) -> String {
163    let mut mermaid = String::from("graph LR\n");
164
165    // Add nodes
166    for subgraph in &graph.subgraphs {
167        let entities = subgraph.entities.join("<br/>");
168        mermaid.push_str(&format!(
169            "    {}[\"{}\\n[{}\\n]\"]\n",
170            subgraph.name, subgraph.name, entities
171        ));
172    }
173
174    // Add edges
175    for edge in &graph.edges {
176        mermaid.push_str(&format!("    {} -->|{}| {}\n", edge.from, edge.entity, edge.to));
177    }
178
179    mermaid
180}
181
182#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_graph_format_from_str() {
189        assert_eq!("json".parse::<GraphFormat>().unwrap(), GraphFormat::Json);
190        assert_eq!("dot".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
191        assert_eq!("mermaid".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
192    }
193
194    #[test]
195    fn test_graph_format_case_insensitive() {
196        assert_eq!("JSON".parse::<GraphFormat>().unwrap(), GraphFormat::Json);
197        assert_eq!("DOT".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
198    }
199
200    #[test]
201    fn test_graph_format_invalid() {
202        assert!(
203            "invalid".parse::<GraphFormat>().is_err(),
204            "expected Err for unknown federation graph format"
205        );
206    }
207
208    #[test]
209    fn test_to_dot_format() {
210        let graph = FederationGraph {
211            subgraphs: vec![Subgraph {
212                name:     "a".to_string(),
213                url:      "http://a".to_string(),
214                entities: vec!["A".to_string()],
215            }],
216            edges:     vec![],
217        };
218
219        let dot = to_dot(&graph);
220        assert!(dot.contains("digraph"));
221        assert!(dot.contains('a'));
222    }
223
224    #[test]
225    fn test_to_mermaid_format() {
226        let graph = FederationGraph {
227            subgraphs: vec![Subgraph {
228                name:     "a".to_string(),
229                url:      "http://a".to_string(),
230                entities: vec!["A".to_string()],
231            }],
232            edges:     vec![],
233        };
234
235        let mermaid = to_mermaid(&graph);
236        assert!(mermaid.contains("graph"));
237        assert!(mermaid.contains('a'));
238    }
239}