Skip to main content

fraiseql_cli/commands/
dependency_graph.rs

1//! Schema dependency graph command
2//!
3//! Analyzes and exports schema type dependencies in multiple formats.
4//!
5//! Usage: fraiseql dependency-graph <schema.compiled.json> [--format=json|dot|mermaid|d2|console]
6
7use std::{fmt::Display, fs, str::FromStr};
8
9use anyhow::Result;
10use fraiseql_core::schema::{CompiledSchema, CyclePath, SchemaDependencyGraph};
11use serde::Serialize;
12use serde_json::Value;
13
14use crate::output::CommandResult;
15
16/// Export format for dependency graph
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18#[non_exhaustive]
19pub enum GraphFormat {
20    /// JSON format (machine-readable, default)
21    #[default]
22    Json,
23    /// DOT format (Graphviz)
24    Dot,
25    /// Mermaid format (documentation/markdown)
26    Mermaid,
27    /// D2 format (modern diagram language)
28    D2,
29    /// Console format (human-readable text)
30    Console,
31}
32
33impl FromStr for GraphFormat {
34    type Err = String;
35
36    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
37        match s.to_lowercase().as_str() {
38            "json" => Ok(GraphFormat::Json),
39            "dot" | "graphviz" => Ok(GraphFormat::Dot),
40            "mermaid" | "md" => Ok(GraphFormat::Mermaid),
41            "d2" => Ok(GraphFormat::D2),
42            "console" | "text" | "txt" => Ok(GraphFormat::Console),
43            other => Err(format!(
44                "Unknown format: '{other}'. Valid formats: json, dot, mermaid, d2, console"
45            )),
46        }
47    }
48}
49
50impl Display for GraphFormat {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            GraphFormat::Json => write!(f, "json"),
54            GraphFormat::Dot => write!(f, "dot"),
55            GraphFormat::Mermaid => write!(f, "mermaid"),
56            GraphFormat::D2 => write!(f, "d2"),
57            GraphFormat::Console => write!(f, "console"),
58        }
59    }
60}
61
62/// Serializable representation of the dependency graph
63#[derive(Debug, Serialize)]
64pub struct DependencyGraphOutput {
65    /// Total number of types in the schema
66    pub type_count: usize,
67
68    /// All nodes (types) in the graph
69    pub nodes: Vec<GraphNode>,
70
71    /// All edges (dependencies) in the graph
72    pub edges: Vec<GraphEdge>,
73
74    /// Circular dependencies detected (empty if none)
75    pub cycles: Vec<CycleInfo>,
76
77    /// Types with no incoming references (orphaned)
78    pub unused_types: Vec<String>,
79
80    /// Summary statistics
81    pub stats: GraphStats,
82}
83
84/// A node in the dependency graph
85#[derive(Debug, Serialize)]
86pub struct GraphNode {
87    /// Type name
88    pub name: String,
89
90    /// Number of types this type depends on
91    pub dependency_count: usize,
92
93    /// Number of types that depend on this type
94    pub dependent_count: usize,
95
96    /// Whether this is a root type (Query, Mutation, Subscription)
97    pub is_root: bool,
98}
99
100/// An edge in the dependency graph
101#[derive(Debug, Serialize)]
102pub struct GraphEdge {
103    /// Source type (the type that has the dependency)
104    pub from: String,
105
106    /// Target type (the type being depended on)
107    pub to: String,
108}
109
110/// Information about a detected cycle
111#[derive(Debug, Serialize)]
112pub struct CycleInfo {
113    /// Types involved in the cycle
114    pub types: Vec<String>,
115
116    /// Human-readable path string
117    pub path: String,
118
119    /// Whether this is a self-reference
120    pub is_self_reference: bool,
121}
122
123impl From<&CyclePath> for CycleInfo {
124    fn from(cycle: &CyclePath) -> Self {
125        Self {
126            types:             cycle.nodes.clone(),
127            path:              cycle.path_string(),
128            is_self_reference: cycle.is_self_reference(),
129        }
130    }
131}
132
133/// Statistics about the dependency graph
134#[derive(Debug, Serialize)]
135pub struct GraphStats {
136    /// Total number of types
137    pub total_types: usize,
138
139    /// Total number of edges (dependencies)
140    pub total_edges: usize,
141
142    /// Number of circular dependencies
143    pub cycle_count: usize,
144
145    /// Number of unused types
146    pub unused_count: usize,
147
148    /// Average dependencies per type
149    pub avg_dependencies: f64,
150
151    /// Maximum dependency depth from any root
152    pub max_depth: usize,
153
154    /// Types with the most dependents (most "important")
155    pub most_depended_on: Vec<String>,
156}
157
158/// Run the dependency graph command
159///
160/// # Errors
161///
162/// Returns an error if the schema file cannot be read or cannot be deserialized
163/// as a `CompiledSchema`. Also propagates JSON serialization errors for the
164/// selected output format.
165pub fn run(schema_path: &str, format: GraphFormat) -> Result<CommandResult> {
166    // Load and parse schema
167    let schema_content = fs::read_to_string(schema_path)?;
168    let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
169
170    // Build dependency graph
171    let graph = SchemaDependencyGraph::build(&schema);
172
173    // Analyze the graph
174    let cycles = graph.find_cycles();
175    let unused = graph.find_unused();
176
177    // Build output structure
178    let output = build_output(&graph, &cycles, &unused);
179
180    // Check for cycles (these are errors)
181    let warnings: Vec<String> = unused
182        .iter()
183        .map(|t| format!("Unused type: '{t}' has no incoming references"))
184        .collect();
185
186    // Format output based on requested format
187    let data = match format {
188        GraphFormat::Json => serde_json::to_value(&output)?,
189        GraphFormat::Dot => Value::String(to_dot(&output)),
190        GraphFormat::Mermaid => Value::String(to_mermaid(&output)),
191        GraphFormat::D2 => Value::String(to_d2(&output)),
192        GraphFormat::Console => Value::String(to_console(&output)),
193    };
194
195    // If cycles exist, return validation failure
196    if !cycles.is_empty() {
197        let errors: Vec<String> = cycles
198            .iter()
199            .map(|c| format!("Circular dependency: {}", c.path_string()))
200            .collect();
201
202        // Include the graph data in the error response
203        return Ok(CommandResult {
204            status: "validation-failed".to_string(),
205            command: "dependency-graph".to_string(),
206            data: Some(data),
207            message: Some(format!("Schema has {} circular dependencies", cycles.len())),
208            code: Some("CIRCULAR_DEPENDENCY".to_string()),
209            errors,
210            warnings,
211        });
212    }
213
214    // Success - return graph with any warnings
215    if warnings.is_empty() {
216        Ok(CommandResult::success("dependency-graph", data))
217    } else {
218        Ok(CommandResult::success_with_warnings("dependency-graph", data, warnings))
219    }
220}
221
222/// Build the output structure from the dependency graph
223fn build_output(
224    graph: &SchemaDependencyGraph,
225    cycles: &[CyclePath],
226    unused: &[String],
227) -> DependencyGraphOutput {
228    let all_types = graph.all_types();
229    let root_types = ["Query", "Mutation", "Subscription"];
230
231    // Build nodes
232    let mut nodes: Vec<GraphNode> = all_types
233        .iter()
234        .map(|name| GraphNode {
235            name:             name.clone(),
236            dependency_count: graph.dependencies_of(name).len(),
237            dependent_count:  graph.dependents_of(name).len(),
238            is_root:          root_types.contains(&name.as_str()),
239        })
240        .collect();
241
242    // Sort by dependent count (most depended on first)
243    nodes.sort_by_key(|n| std::cmp::Reverse(n.dependent_count));
244
245    // Build edges
246    let mut edges: Vec<GraphEdge> = Vec::new();
247    for type_name in &all_types {
248        for dep in graph.dependencies_of(type_name) {
249            edges.push(GraphEdge {
250                from: type_name.clone(),
251                to:   dep,
252            });
253        }
254    }
255
256    // Sort edges for consistent output
257    edges.sort_by(|a, b| (&a.from, &a.to).cmp(&(&b.from, &b.to)));
258
259    // Build cycle info
260    let cycle_info: Vec<CycleInfo> = cycles.iter().map(CycleInfo::from).collect();
261
262    // Calculate stats
263    let total_deps: usize = nodes.iter().map(|n| n.dependency_count).sum();
264    #[allow(clippy::cast_precision_loss)]
265    // Reason: precision loss acceptable for metric/ratio calculations
266    let avg_deps = if nodes.is_empty() {
267        0.0
268    } else {
269        total_deps as f64 / nodes.len() as f64
270    };
271
272    // Find most depended on types (top 5)
273    let most_depended: Vec<String> = nodes
274        .iter()
275        .filter(|n| n.dependent_count > 0 && !n.is_root)
276        .take(5)
277        .map(|n| n.name.clone())
278        .collect();
279
280    // Calculate max depth (BFS from roots)
281    let max_depth = calculate_max_depth(graph, &root_types);
282
283    let stats = GraphStats {
284        total_types: nodes.len(),
285        total_edges: edges.len(),
286        cycle_count: cycles.len(),
287        unused_count: unused.len(),
288        avg_dependencies: (avg_deps * 100.0).round() / 100.0,
289        max_depth,
290        most_depended_on: most_depended,
291    };
292
293    DependencyGraphOutput {
294        type_count: nodes.len(),
295        nodes,
296        edges,
297        cycles: cycle_info,
298        unused_types: unused.to_vec(),
299        stats,
300    }
301}
302
303/// Calculate maximum depth from root types using BFS
304fn calculate_max_depth(graph: &SchemaDependencyGraph, root_types: &[&str]) -> usize {
305    use std::collections::{HashSet, VecDeque};
306
307    let mut max_depth = 0;
308    let mut visited = HashSet::new();
309    let mut queue = VecDeque::new();
310
311    // Start from each root that exists
312    for &root in root_types {
313        if graph.has_type(root) {
314            queue.push_back((root.to_string(), 0));
315            visited.insert(root.to_string());
316        }
317    }
318
319    while let Some((type_name, depth)) = queue.pop_front() {
320        max_depth = max_depth.max(depth);
321
322        for dep in graph.dependencies_of(&type_name) {
323            if !visited.contains(&dep) {
324                visited.insert(dep.clone());
325                queue.push_back((dep, depth + 1));
326            }
327        }
328    }
329
330    max_depth
331}
332
333/// Convert dependency graph to DOT format (Graphviz)
334pub(crate) fn to_dot(output: &DependencyGraphOutput) -> String {
335    use std::fmt::Write;
336
337    let mut dot = String::from("digraph schema_dependencies {\n");
338    dot.push_str("    rankdir=LR;\n");
339    dot.push_str("    node [shape=box, style=rounded];\n\n");
340
341    // Add legend comment
342    dot.push_str("    // Root types (Query, Mutation, Subscription)\n");
343
344    // Add nodes with styling
345    for node in &output.nodes {
346        let style = if node.is_root {
347            "style=\"rounded,bold\", color=blue"
348        } else if output.unused_types.contains(&node.name) {
349            "style=\"rounded,dashed\", color=gray"
350        } else {
351            "style=rounded"
352        };
353
354        let name = &node.name;
355        let deps = node.dependency_count;
356        let refs = node.dependent_count;
357        let _ = writeln!(
358            dot,
359            "    \"{name}\" [label=\"{name}\\n(deps: {deps}, refs: {refs})\", {style}];"
360        );
361    }
362
363    dot.push_str("\n    // Dependencies\n");
364
365    // Add edges
366    for edge in &output.edges {
367        let from = &edge.from;
368        let to = &edge.to;
369        let _ = writeln!(dot, "    \"{from}\" -> \"{to}\";");
370    }
371
372    // Highlight cycles
373    if !output.cycles.is_empty() {
374        dot.push_str("\n    // Cycles (highlighted in red)\n");
375        for cycle in &output.cycles {
376            for i in 0..cycle.types.len() {
377                let from = &cycle.types[i];
378                let to = &cycle.types[(i + 1) % cycle.types.len()];
379                let _ = writeln!(dot, "    \"{from}\" -> \"{to}\" [color=red, penwidth=2];");
380            }
381        }
382    }
383
384    dot.push_str("}\n");
385    dot
386}
387
388/// Convert dependency graph to Mermaid format
389pub(crate) fn to_mermaid(output: &DependencyGraphOutput) -> String {
390    use std::fmt::Write;
391
392    let mut mermaid = String::from("```mermaid\ngraph LR\n");
393
394    // Add subgraph for root types
395    mermaid.push_str("    subgraph Roots\n");
396    for node in &output.nodes {
397        if node.is_root {
398            let name = &node.name;
399            let _ = writeln!(mermaid, "        {name}[\"{name}\"]");
400        }
401    }
402    mermaid.push_str("    end\n\n");
403
404    // Add other nodes
405    for node in &output.nodes {
406        if !node.is_root {
407            let style = if output.unused_types.contains(&node.name) {
408                ":::unused"
409            } else {
410                ""
411            };
412            let name = &node.name;
413            let _ = writeln!(mermaid, "    {name}[\"{name}\"]{style}");
414        }
415    }
416
417    mermaid.push('\n');
418
419    // Add edges
420    for edge in &output.edges {
421        // Check if this edge is part of a cycle
422        let is_cycle_edge = output.cycles.iter().any(|c| {
423            let types = &c.types;
424            for i in 0..types.len() {
425                let from = &types[i];
426                let to = &types[(i + 1) % types.len()];
427                if from == &edge.from && to == &edge.to {
428                    return true;
429                }
430            }
431            false
432        });
433
434        let from = &edge.from;
435        let to = &edge.to;
436        if is_cycle_edge {
437            let _ = writeln!(mermaid, "    {from} -->|CYCLE| {to}");
438        } else {
439            let _ = writeln!(mermaid, "    {from} --> {to}");
440        }
441    }
442
443    // Add styling
444    mermaid.push_str("\n    classDef unused fill:#f9f,stroke:#333,stroke-dasharray: 5 5\n");
445
446    mermaid.push_str("```\n");
447    mermaid
448}
449
450/// Convert dependency graph to D2 format (modern diagram language)
451///
452/// D2 is a modern diagram scripting language that compiles to SVG.
453/// See: https://d2lang.com/
454pub(crate) fn to_d2(output: &DependencyGraphOutput) -> String {
455    use std::fmt::Write;
456
457    let mut d2 = String::new();
458
459    // Header comment
460    d2.push_str("# Schema Dependency Graph\n");
461    d2.push_str("# Generated by FraiseQL CLI\n");
462    d2.push_str("# Render with: d2 schema.d2 schema.svg\n\n");
463
464    // Global styling
465    d2.push_str("direction: right\n\n");
466
467    // Root types container
468    let has_roots = output.nodes.iter().any(|n| n.is_root);
469    if has_roots {
470        d2.push_str("roots: {\n");
471        d2.push_str("  label: \"Root Types\"\n");
472        d2.push_str("  style.fill: \"#e3f2fd\"\n");
473        d2.push_str("  style.stroke: \"#1976d2\"\n\n");
474        for node in &output.nodes {
475            if node.is_root {
476                let name = &node.name;
477                let deps = node.dependency_count;
478                let refs = node.dependent_count;
479                let _ = writeln!(d2, "  {name}: \"{name}\\n(deps: {deps}, refs: {refs})\" {{");
480                d2.push_str("    style.bold: true\n");
481                d2.push_str("    style.fill: \"#bbdefb\"\n");
482                d2.push_str("  }\n");
483            }
484        }
485        d2.push_str("}\n\n");
486    }
487
488    // Unused types container (if any)
489    if !output.unused_types.is_empty() {
490        d2.push_str("unused: {\n");
491        d2.push_str("  label: \"Unused Types\"\n");
492        d2.push_str("  style.fill: \"#fff3e0\"\n");
493        d2.push_str("  style.stroke: \"#ff9800\"\n");
494        d2.push_str("  style.stroke-dash: 3\n\n");
495        for node in &output.nodes {
496            if output.unused_types.contains(&node.name) {
497                let name = &node.name;
498                let _ = writeln!(d2, "  {name}: \"{name}\" {{");
499                d2.push_str("    style.fill: \"#ffe0b2\"\n");
500                d2.push_str("    style.stroke-dash: 3\n");
501                d2.push_str("  }\n");
502            }
503        }
504        d2.push_str("}\n\n");
505    }
506
507    // Regular types (not root, not unused)
508    for node in &output.nodes {
509        if !node.is_root && !output.unused_types.contains(&node.name) {
510            let name = &node.name;
511            let deps = node.dependency_count;
512            let refs = node.dependent_count;
513            let _ = writeln!(d2, "{name}: \"{name}\\n(deps: {deps}, refs: {refs})\"");
514        }
515    }
516
517    d2.push('\n');
518
519    // Edges
520    d2.push_str("# Dependencies\n");
521    for edge in &output.edges {
522        // Check if this edge is part of a cycle
523        let is_cycle_edge = output.cycles.iter().any(|c| {
524            let types = &c.types;
525            for i in 0..types.len() {
526                let from = &types[i];
527                let to = &types[(i + 1) % types.len()];
528                if from == &edge.from && to == &edge.to {
529                    return true;
530                }
531            }
532            false
533        });
534
535        let from = &edge.from;
536        let to = &edge.to;
537
538        // Handle edges from root types (need to reference inside container)
539        let from_ref = if output.nodes.iter().any(|n| n.is_root && &n.name == from) {
540            format!("roots.{from}")
541        } else if output.unused_types.contains(from) {
542            format!("unused.{from}")
543        } else {
544            from.clone()
545        };
546
547        let to_ref = if output.nodes.iter().any(|n| n.is_root && &n.name == to) {
548            format!("roots.{to}")
549        } else if output.unused_types.contains(to) {
550            format!("unused.{to}")
551        } else {
552            to.clone()
553        };
554
555        if is_cycle_edge {
556            let _ = writeln!(d2, "{from_ref} -> {to_ref}: \"CYCLE\" {{");
557            d2.push_str("  style.stroke: \"#d32f2f\"\n");
558            d2.push_str("  style.stroke-width: 2\n");
559            d2.push_str("}\n");
560        } else {
561            let _ = writeln!(d2, "{from_ref} -> {to_ref}");
562        }
563    }
564
565    // Cycle warning comment
566    if !output.cycles.is_empty() {
567        d2.push_str("\n# WARNING: Circular dependencies detected!\n");
568        for cycle in &output.cycles {
569            let _ = writeln!(d2, "# Cycle: {}", cycle.path);
570        }
571    }
572
573    d2
574}
575
576/// Convert dependency graph to console (human-readable) format
577pub(crate) fn to_console(output: &DependencyGraphOutput) -> String {
578    use std::fmt::Write;
579
580    let mut console = String::new();
581
582    // Header
583    console.push_str("Schema Dependency Graph Analysis\n");
584    console.push_str("================================\n\n");
585
586    // Summary stats
587    let _ = writeln!(console, "Total types: {}", output.stats.total_types);
588    let _ = writeln!(console, "Total dependencies: {}", output.stats.total_edges);
589    let _ =
590        writeln!(console, "Average dependencies per type: {:.2}", output.stats.avg_dependencies);
591    let _ = writeln!(console, "Maximum depth from roots: {}", output.stats.max_depth);
592    console.push('\n');
593
594    // Cycles (errors)
595    if !output.cycles.is_empty() {
596        let _ = writeln!(console, "CIRCULAR DEPENDENCIES ({}):", output.cycles.len());
597        for cycle in &output.cycles {
598            let _ = writeln!(console, "  - {}", cycle.path);
599        }
600        console.push('\n');
601    }
602
603    // Unused types (warnings)
604    if !output.unused_types.is_empty() {
605        let _ = writeln!(console, "UNUSED TYPES ({}):", output.unused_types.len());
606        for unused in &output.unused_types {
607            let _ = writeln!(console, "  - {unused}");
608        }
609        console.push('\n');
610    }
611
612    // Most depended on types
613    if !output.stats.most_depended_on.is_empty() {
614        console.push_str("Most referenced types:\n");
615        for (i, type_name) in output.stats.most_depended_on.iter().enumerate() {
616            let node = output.nodes.iter().find(|n| &n.name == type_name);
617            if let Some(node) = node {
618                let _ = writeln!(
619                    console,
620                    "  {}. {type_name} ({} references)",
621                    i + 1,
622                    node.dependent_count
623                );
624            }
625        }
626        console.push('\n');
627    }
628
629    // Type details
630    console.push_str("Type Details:\n");
631    console.push_str("-------------\n");
632
633    for node in &output.nodes {
634        let prefix = if node.is_root {
635            "[ROOT] "
636        } else if output.unused_types.contains(&node.name) {
637            "[UNUSED] "
638        } else {
639            ""
640        };
641
642        let _ = writeln!(
643            console,
644            "{prefix}{}: {} deps, {} refs",
645            node.name, node.dependency_count, node.dependent_count
646        );
647    }
648
649    console
650}