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)
334fn 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
389fn 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/
454fn 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
577fn 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}
651
652#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    #[test]
658    fn test_graph_format_from_str() {
659        assert_eq!("json".parse::<GraphFormat>().unwrap(), GraphFormat::Json);
660        assert_eq!("dot".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
661        assert_eq!("graphviz".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
662        assert_eq!("mermaid".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
663        assert_eq!("md".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
664        assert_eq!("d2".parse::<GraphFormat>().unwrap(), GraphFormat::D2);
665        assert_eq!("console".parse::<GraphFormat>().unwrap(), GraphFormat::Console);
666        assert_eq!("text".parse::<GraphFormat>().unwrap(), GraphFormat::Console);
667    }
668
669    #[test]
670    fn test_graph_format_case_insensitive() {
671        assert_eq!("JSON".parse::<GraphFormat>().unwrap(), GraphFormat::Json);
672        assert_eq!("DOT".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
673        assert_eq!("MERMAID".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
674        assert_eq!("D2".parse::<GraphFormat>().unwrap(), GraphFormat::D2);
675    }
676
677    #[test]
678    fn test_graph_format_invalid() {
679        let result = "invalid".parse::<GraphFormat>();
680        let err = result.expect_err("expected Err for unknown graph format");
681        assert!(err.contains("Unknown format"), "expected 'Unknown format' in: {err}");
682    }
683
684    #[test]
685    fn test_graph_format_display() {
686        assert_eq!(GraphFormat::Json.to_string(), "json");
687        assert_eq!(GraphFormat::Dot.to_string(), "dot");
688        assert_eq!(GraphFormat::Mermaid.to_string(), "mermaid");
689        assert_eq!(GraphFormat::D2.to_string(), "d2");
690        assert_eq!(GraphFormat::Console.to_string(), "console");
691    }
692
693    #[test]
694    fn test_to_dot_contains_expected_elements() {
695        let output = DependencyGraphOutput {
696            type_count:   2,
697            nodes:        vec![
698                GraphNode {
699                    name:             "Query".to_string(),
700                    dependency_count: 1,
701                    dependent_count:  0,
702                    is_root:          true,
703                },
704                GraphNode {
705                    name:             "User".to_string(),
706                    dependency_count: 0,
707                    dependent_count:  1,
708                    is_root:          false,
709                },
710            ],
711            edges:        vec![GraphEdge {
712                from: "Query".to_string(),
713                to:   "User".to_string(),
714            }],
715            cycles:       vec![],
716            unused_types: vec![],
717            stats:        GraphStats {
718                total_types:      2,
719                total_edges:      1,
720                cycle_count:      0,
721                unused_count:     0,
722                avg_dependencies: 0.5,
723                max_depth:        1,
724                most_depended_on: vec!["User".to_string()],
725            },
726        };
727
728        let dot = to_dot(&output);
729        assert!(dot.contains("digraph schema_dependencies"));
730        assert!(dot.contains("Query"));
731        assert!(dot.contains("User"));
732        assert!(dot.contains("\"Query\" -> \"User\""));
733    }
734
735    #[test]
736    fn test_to_mermaid_contains_expected_elements() {
737        let output = DependencyGraphOutput {
738            type_count:   2,
739            nodes:        vec![
740                GraphNode {
741                    name:             "Query".to_string(),
742                    dependency_count: 1,
743                    dependent_count:  0,
744                    is_root:          true,
745                },
746                GraphNode {
747                    name:             "User".to_string(),
748                    dependency_count: 0,
749                    dependent_count:  1,
750                    is_root:          false,
751                },
752            ],
753            edges:        vec![GraphEdge {
754                from: "Query".to_string(),
755                to:   "User".to_string(),
756            }],
757            cycles:       vec![],
758            unused_types: vec![],
759            stats:        GraphStats {
760                total_types:      2,
761                total_edges:      1,
762                cycle_count:      0,
763                unused_count:     0,
764                avg_dependencies: 0.5,
765                max_depth:        1,
766                most_depended_on: vec!["User".to_string()],
767            },
768        };
769
770        let mermaid = to_mermaid(&output);
771        assert!(mermaid.contains("```mermaid"));
772        assert!(mermaid.contains("graph LR"));
773        assert!(mermaid.contains("Query"));
774        assert!(mermaid.contains("User"));
775        assert!(mermaid.contains("Query --> User"));
776    }
777
778    #[test]
779    fn test_to_d2_contains_expected_elements() {
780        let output = DependencyGraphOutput {
781            type_count:   2,
782            nodes:        vec![
783                GraphNode {
784                    name:             "Query".to_string(),
785                    dependency_count: 1,
786                    dependent_count:  0,
787                    is_root:          true,
788                },
789                GraphNode {
790                    name:             "User".to_string(),
791                    dependency_count: 0,
792                    dependent_count:  1,
793                    is_root:          false,
794                },
795            ],
796            edges:        vec![GraphEdge {
797                from: "Query".to_string(),
798                to:   "User".to_string(),
799            }],
800            cycles:       vec![],
801            unused_types: vec![],
802            stats:        GraphStats {
803                total_types:      2,
804                total_edges:      1,
805                cycle_count:      0,
806                unused_count:     0,
807                avg_dependencies: 0.5,
808                max_depth:        1,
809                most_depended_on: vec!["User".to_string()],
810            },
811        };
812
813        let d2 = to_d2(&output);
814        assert!(d2.contains("# Schema Dependency Graph"));
815        assert!(d2.contains("direction: right"));
816        assert!(d2.contains("roots:"));
817        assert!(d2.contains("Query"));
818        assert!(d2.contains("User"));
819        assert!(d2.contains("roots.Query -> User"));
820    }
821
822    #[test]
823    fn test_to_d2_shows_unused() {
824        let output = DependencyGraphOutput {
825            type_count:   1,
826            nodes:        vec![GraphNode {
827                name:             "Orphan".to_string(),
828                dependency_count: 0,
829                dependent_count:  0,
830                is_root:          false,
831            }],
832            edges:        vec![],
833            cycles:       vec![],
834            unused_types: vec!["Orphan".to_string()],
835            stats:        GraphStats {
836                total_types:      1,
837                total_edges:      0,
838                cycle_count:      0,
839                unused_count:     1,
840                avg_dependencies: 0.0,
841                max_depth:        0,
842                most_depended_on: vec![],
843            },
844        };
845
846        let d2 = to_d2(&output);
847        assert!(d2.contains("unused:"));
848        assert!(d2.contains("Unused Types"));
849        assert!(d2.contains("Orphan"));
850        assert!(d2.contains("stroke-dash"));
851    }
852
853    #[test]
854    fn test_to_d2_shows_cycles() {
855        let output = DependencyGraphOutput {
856            type_count:   2,
857            nodes:        vec![
858                GraphNode {
859                    name:             "A".to_string(),
860                    dependency_count: 1,
861                    dependent_count:  1,
862                    is_root:          false,
863                },
864                GraphNode {
865                    name:             "B".to_string(),
866                    dependency_count: 1,
867                    dependent_count:  1,
868                    is_root:          false,
869                },
870            ],
871            edges:        vec![
872                GraphEdge {
873                    from: "A".to_string(),
874                    to:   "B".to_string(),
875                },
876                GraphEdge {
877                    from: "B".to_string(),
878                    to:   "A".to_string(),
879                },
880            ],
881            cycles:       vec![CycleInfo {
882                types:             vec!["A".to_string(), "B".to_string()],
883                path:              "A -> B -> A".to_string(),
884                is_self_reference: false,
885            }],
886            unused_types: vec![],
887            stats:        GraphStats {
888                total_types:      2,
889                total_edges:      2,
890                cycle_count:      1,
891                unused_count:     0,
892                avg_dependencies: 1.0,
893                max_depth:        0,
894                most_depended_on: vec![],
895            },
896        };
897
898        let d2 = to_d2(&output);
899        assert!(d2.contains("CYCLE"));
900        assert!(d2.contains("stroke: \"#d32f2f\""));
901        assert!(d2.contains("# WARNING: Circular dependencies detected!"));
902    }
903
904    #[test]
905    fn test_to_console_contains_expected_elements() {
906        let output = DependencyGraphOutput {
907            type_count:   2,
908            nodes:        vec![
909                GraphNode {
910                    name:             "Query".to_string(),
911                    dependency_count: 1,
912                    dependent_count:  0,
913                    is_root:          true,
914                },
915                GraphNode {
916                    name:             "User".to_string(),
917                    dependency_count: 0,
918                    dependent_count:  1,
919                    is_root:          false,
920                },
921            ],
922            edges:        vec![GraphEdge {
923                from: "Query".to_string(),
924                to:   "User".to_string(),
925            }],
926            cycles:       vec![],
927            unused_types: vec![],
928            stats:        GraphStats {
929                total_types:      2,
930                total_edges:      1,
931                cycle_count:      0,
932                unused_count:     0,
933                avg_dependencies: 0.5,
934                max_depth:        1,
935                most_depended_on: vec!["User".to_string()],
936            },
937        };
938
939        let console = to_console(&output);
940        assert!(console.contains("Schema Dependency Graph Analysis"));
941        assert!(console.contains("Total types: 2"));
942        assert!(console.contains("[ROOT] Query"));
943        assert!(console.contains("User"));
944    }
945
946    #[test]
947    fn test_to_console_shows_cycles() {
948        let output = DependencyGraphOutput {
949            type_count:   2,
950            nodes:        vec![
951                GraphNode {
952                    name:             "A".to_string(),
953                    dependency_count: 1,
954                    dependent_count:  1,
955                    is_root:          false,
956                },
957                GraphNode {
958                    name:             "B".to_string(),
959                    dependency_count: 1,
960                    dependent_count:  1,
961                    is_root:          false,
962                },
963            ],
964            edges:        vec![
965                GraphEdge {
966                    from: "A".to_string(),
967                    to:   "B".to_string(),
968                },
969                GraphEdge {
970                    from: "B".to_string(),
971                    to:   "A".to_string(),
972                },
973            ],
974            cycles:       vec![CycleInfo {
975                types:             vec!["A".to_string(), "B".to_string()],
976                path:              "A -> B -> A".to_string(),
977                is_self_reference: false,
978            }],
979            unused_types: vec![],
980            stats:        GraphStats {
981                total_types:      2,
982                total_edges:      2,
983                cycle_count:      1,
984                unused_count:     0,
985                avg_dependencies: 1.0,
986                max_depth:        0,
987                most_depended_on: vec![],
988            },
989        };
990
991        let console = to_console(&output);
992        assert!(console.contains("CIRCULAR DEPENDENCIES"));
993        assert!(console.contains("A -> B -> A"));
994    }
995
996    #[test]
997    fn test_to_console_shows_unused() {
998        let output = DependencyGraphOutput {
999            type_count:   1,
1000            nodes:        vec![GraphNode {
1001                name:             "Orphan".to_string(),
1002                dependency_count: 0,
1003                dependent_count:  0,
1004                is_root:          false,
1005            }],
1006            edges:        vec![],
1007            cycles:       vec![],
1008            unused_types: vec!["Orphan".to_string()],
1009            stats:        GraphStats {
1010                total_types:      1,
1011                total_edges:      0,
1012                cycle_count:      0,
1013                unused_count:     1,
1014                avg_dependencies: 0.0,
1015                max_depth:        0,
1016                most_depended_on: vec![],
1017            },
1018        };
1019
1020        let console = to_console(&output);
1021        assert!(console.contains("UNUSED TYPES"));
1022        assert!(console.contains("Orphan"));
1023        assert!(console.contains("[UNUSED]"));
1024    }
1025
1026    #[test]
1027    fn test_cycle_info_from_cycle_path() {
1028        use fraiseql_core::schema::CyclePath;
1029
1030        let cycle = CyclePath::new(vec!["A".to_string(), "B".to_string(), "C".to_string()]);
1031        let info = CycleInfo::from(&cycle);
1032
1033        assert_eq!(info.types, vec!["A", "B", "C"]);
1034        assert_eq!(info.path, "A → B → C → A");
1035        assert!(!info.is_self_reference);
1036    }
1037
1038    #[test]
1039    fn test_cycle_info_self_reference() {
1040        use fraiseql_core::schema::CyclePath;
1041
1042        let cycle = CyclePath::new(vec!["Node".to_string()]);
1043        let info = CycleInfo::from(&cycle);
1044
1045        assert!(info.is_self_reference);
1046        assert_eq!(info.path, "Node → Node");
1047    }
1048}