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)]
18pub enum GraphFormat {
19    /// JSON format (machine-readable, default)
20    #[default]
21    Json,
22    /// DOT format (Graphviz)
23    Dot,
24    /// Mermaid format (documentation/markdown)
25    Mermaid,
26    /// D2 format (modern diagram language)
27    D2,
28    /// Console format (human-readable text)
29    Console,
30}
31
32impl FromStr for GraphFormat {
33    type Err = String;
34
35    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
36        match s.to_lowercase().as_str() {
37            "json" => Ok(GraphFormat::Json),
38            "dot" | "graphviz" => Ok(GraphFormat::Dot),
39            "mermaid" | "md" => Ok(GraphFormat::Mermaid),
40            "d2" => Ok(GraphFormat::D2),
41            "console" | "text" | "txt" => Ok(GraphFormat::Console),
42            other => Err(format!(
43                "Unknown format: '{other}'. Valid formats: json, dot, mermaid, d2, console"
44            )),
45        }
46    }
47}
48
49impl Display for GraphFormat {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            GraphFormat::Json => write!(f, "json"),
53            GraphFormat::Dot => write!(f, "dot"),
54            GraphFormat::Mermaid => write!(f, "mermaid"),
55            GraphFormat::D2 => write!(f, "d2"),
56            GraphFormat::Console => write!(f, "console"),
57        }
58    }
59}
60
61/// Serializable representation of the dependency graph
62#[derive(Debug, Serialize)]
63pub struct DependencyGraphOutput {
64    /// Total number of types in the schema
65    pub type_count: usize,
66
67    /// All nodes (types) in the graph
68    pub nodes: Vec<GraphNode>,
69
70    /// All edges (dependencies) in the graph
71    pub edges: Vec<GraphEdge>,
72
73    /// Circular dependencies detected (empty if none)
74    pub cycles: Vec<CycleInfo>,
75
76    /// Types with no incoming references (orphaned)
77    pub unused_types: Vec<String>,
78
79    /// Summary statistics
80    pub stats: GraphStats,
81}
82
83/// A node in the dependency graph
84#[derive(Debug, Serialize)]
85pub struct GraphNode {
86    /// Type name
87    pub name: String,
88
89    /// Number of types this type depends on
90    pub dependency_count: usize,
91
92    /// Number of types that depend on this type
93    pub dependent_count: usize,
94
95    /// Whether this is a root type (Query, Mutation, Subscription)
96    pub is_root: bool,
97}
98
99/// An edge in the dependency graph
100#[derive(Debug, Serialize)]
101pub struct GraphEdge {
102    /// Source type (the type that has the dependency)
103    pub from: String,
104
105    /// Target type (the type being depended on)
106    pub to: String,
107}
108
109/// Information about a detected cycle
110#[derive(Debug, Serialize)]
111pub struct CycleInfo {
112    /// Types involved in the cycle
113    pub types: Vec<String>,
114
115    /// Human-readable path string
116    pub path: String,
117
118    /// Whether this is a self-reference
119    pub is_self_reference: bool,
120}
121
122impl From<&CyclePath> for CycleInfo {
123    fn from(cycle: &CyclePath) -> Self {
124        Self {
125            types:             cycle.nodes.clone(),
126            path:              cycle.path_string(),
127            is_self_reference: cycle.is_self_reference(),
128        }
129    }
130}
131
132/// Statistics about the dependency graph
133#[derive(Debug, Serialize)]
134pub struct GraphStats {
135    /// Total number of types
136    pub total_types: usize,
137
138    /// Total number of edges (dependencies)
139    pub total_edges: usize,
140
141    /// Number of circular dependencies
142    pub cycle_count: usize,
143
144    /// Number of unused types
145    pub unused_count: usize,
146
147    /// Average dependencies per type
148    pub avg_dependencies: f64,
149
150    /// Maximum dependency depth from any root
151    pub max_depth: usize,
152
153    /// Types with the most dependents (most "important")
154    pub most_depended_on: Vec<String>,
155}
156
157/// Run the dependency graph command
158pub fn run(schema_path: &str, format: GraphFormat) -> Result<CommandResult> {
159    // Load and parse schema
160    let schema_content = fs::read_to_string(schema_path)?;
161    let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
162
163    // Build dependency graph
164    let graph = SchemaDependencyGraph::build(&schema);
165
166    // Analyze the graph
167    let cycles = graph.find_cycles();
168    let unused = graph.find_unused();
169
170    // Build output structure
171    let output = build_output(&graph, &cycles, &unused);
172
173    // Check for cycles (these are errors)
174    let warnings: Vec<String> = unused
175        .iter()
176        .map(|t| format!("Unused type: '{t}' has no incoming references"))
177        .collect();
178
179    // Format output based on requested format
180    let data = match format {
181        GraphFormat::Json => serde_json::to_value(&output)?,
182        GraphFormat::Dot => Value::String(to_dot(&output)),
183        GraphFormat::Mermaid => Value::String(to_mermaid(&output)),
184        GraphFormat::D2 => Value::String(to_d2(&output)),
185        GraphFormat::Console => Value::String(to_console(&output)),
186    };
187
188    // If cycles exist, return validation failure
189    if !cycles.is_empty() {
190        let errors: Vec<String> = cycles
191            .iter()
192            .map(|c| format!("Circular dependency: {}", c.path_string()))
193            .collect();
194
195        // Include the graph data in the error response
196        return Ok(CommandResult {
197            status: "validation-failed".to_string(),
198            command: "dependency-graph".to_string(),
199            data: Some(data),
200            message: Some(format!("Schema has {} circular dependencies", cycles.len())),
201            code: Some("CIRCULAR_DEPENDENCY".to_string()),
202            errors,
203            warnings,
204            exit_code: 2,
205        });
206    }
207
208    // Success - return graph with any warnings
209    if warnings.is_empty() {
210        Ok(CommandResult::success("dependency-graph", data))
211    } else {
212        Ok(CommandResult::success_with_warnings("dependency-graph", data, warnings))
213    }
214}
215
216/// Build the output structure from the dependency graph
217fn build_output(
218    graph: &SchemaDependencyGraph,
219    cycles: &[CyclePath],
220    unused: &[String],
221) -> DependencyGraphOutput {
222    let all_types = graph.all_types();
223    let root_types = ["Query", "Mutation", "Subscription"];
224
225    // Build nodes
226    let mut nodes: Vec<GraphNode> = all_types
227        .iter()
228        .map(|name| GraphNode {
229            name:             name.clone(),
230            dependency_count: graph.dependencies_of(name).len(),
231            dependent_count:  graph.dependents_of(name).len(),
232            is_root:          root_types.contains(&name.as_str()),
233        })
234        .collect();
235
236    // Sort by dependent count (most depended on first)
237    nodes.sort_by_key(|n| std::cmp::Reverse(n.dependent_count));
238
239    // Build edges
240    let mut edges: Vec<GraphEdge> = Vec::new();
241    for type_name in &all_types {
242        for dep in graph.dependencies_of(type_name) {
243            edges.push(GraphEdge {
244                from: type_name.clone(),
245                to:   dep,
246            });
247        }
248    }
249
250    // Sort edges for consistent output
251    edges.sort_by(|a, b| (&a.from, &a.to).cmp(&(&b.from, &b.to)));
252
253    // Build cycle info
254    let cycle_info: Vec<CycleInfo> = cycles.iter().map(CycleInfo::from).collect();
255
256    // Calculate stats
257    let total_deps: usize = nodes.iter().map(|n| n.dependency_count).sum();
258    #[allow(clippy::cast_precision_loss)] // Schema type counts won't exceed f64 precision
259    let avg_deps = if nodes.is_empty() {
260        0.0
261    } else {
262        total_deps as f64 / nodes.len() as f64
263    };
264
265    // Find most depended on types (top 5)
266    let most_depended: Vec<String> = nodes
267        .iter()
268        .filter(|n| n.dependent_count > 0 && !n.is_root)
269        .take(5)
270        .map(|n| n.name.clone())
271        .collect();
272
273    // Calculate max depth (BFS from roots)
274    let max_depth = calculate_max_depth(graph, &root_types);
275
276    let stats = GraphStats {
277        total_types: nodes.len(),
278        total_edges: edges.len(),
279        cycle_count: cycles.len(),
280        unused_count: unused.len(),
281        avg_dependencies: (avg_deps * 100.0).round() / 100.0,
282        max_depth,
283        most_depended_on: most_depended,
284    };
285
286    DependencyGraphOutput {
287        type_count: nodes.len(),
288        nodes,
289        edges,
290        cycles: cycle_info,
291        unused_types: unused.to_vec(),
292        stats,
293    }
294}
295
296/// Calculate maximum depth from root types using BFS
297fn calculate_max_depth(graph: &SchemaDependencyGraph, root_types: &[&str]) -> usize {
298    use std::collections::{HashSet, VecDeque};
299
300    let mut max_depth = 0;
301    let mut visited = HashSet::new();
302    let mut queue = VecDeque::new();
303
304    // Start from each root that exists
305    for &root in root_types {
306        if graph.has_type(root) {
307            queue.push_back((root.to_string(), 0));
308            visited.insert(root.to_string());
309        }
310    }
311
312    while let Some((type_name, depth)) = queue.pop_front() {
313        max_depth = max_depth.max(depth);
314
315        for dep in graph.dependencies_of(&type_name) {
316            if !visited.contains(&dep) {
317                visited.insert(dep.clone());
318                queue.push_back((dep, depth + 1));
319            }
320        }
321    }
322
323    max_depth
324}
325
326/// Convert dependency graph to DOT format (Graphviz)
327fn to_dot(output: &DependencyGraphOutput) -> String {
328    use std::fmt::Write;
329
330    let mut dot = String::from("digraph schema_dependencies {\n");
331    dot.push_str("    rankdir=LR;\n");
332    dot.push_str("    node [shape=box, style=rounded];\n\n");
333
334    // Add legend comment
335    dot.push_str("    // Root types (Query, Mutation, Subscription)\n");
336
337    // Add nodes with styling
338    for node in &output.nodes {
339        let style = if node.is_root {
340            "style=\"rounded,bold\", color=blue"
341        } else if output.unused_types.contains(&node.name) {
342            "style=\"rounded,dashed\", color=gray"
343        } else {
344            "style=rounded"
345        };
346
347        let name = &node.name;
348        let deps = node.dependency_count;
349        let refs = node.dependent_count;
350        let _ = writeln!(
351            dot,
352            "    \"{name}\" [label=\"{name}\\n(deps: {deps}, refs: {refs})\", {style}];"
353        );
354    }
355
356    dot.push_str("\n    // Dependencies\n");
357
358    // Add edges
359    for edge in &output.edges {
360        let from = &edge.from;
361        let to = &edge.to;
362        let _ = writeln!(dot, "    \"{from}\" -> \"{to}\";");
363    }
364
365    // Highlight cycles
366    if !output.cycles.is_empty() {
367        dot.push_str("\n    // Cycles (highlighted in red)\n");
368        for cycle in &output.cycles {
369            for i in 0..cycle.types.len() {
370                let from = &cycle.types[i];
371                let to = &cycle.types[(i + 1) % cycle.types.len()];
372                let _ = writeln!(dot, "    \"{from}\" -> \"{to}\" [color=red, penwidth=2];");
373            }
374        }
375    }
376
377    dot.push_str("}\n");
378    dot
379}
380
381/// Convert dependency graph to Mermaid format
382fn to_mermaid(output: &DependencyGraphOutput) -> String {
383    use std::fmt::Write;
384
385    let mut mermaid = String::from("```mermaid\ngraph LR\n");
386
387    // Add subgraph for root types
388    mermaid.push_str("    subgraph Roots\n");
389    for node in &output.nodes {
390        if node.is_root {
391            let name = &node.name;
392            let _ = writeln!(mermaid, "        {name}[\"{name}\"]");
393        }
394    }
395    mermaid.push_str("    end\n\n");
396
397    // Add other nodes
398    for node in &output.nodes {
399        if !node.is_root {
400            let style = if output.unused_types.contains(&node.name) {
401                ":::unused"
402            } else {
403                ""
404            };
405            let name = &node.name;
406            let _ = writeln!(mermaid, "    {name}[\"{name}\"]{style}");
407        }
408    }
409
410    mermaid.push('\n');
411
412    // Add edges
413    for edge in &output.edges {
414        // Check if this edge is part of a cycle
415        let is_cycle_edge = output.cycles.iter().any(|c| {
416            let types = &c.types;
417            for i in 0..types.len() {
418                let from = &types[i];
419                let to = &types[(i + 1) % types.len()];
420                if from == &edge.from && to == &edge.to {
421                    return true;
422                }
423            }
424            false
425        });
426
427        let from = &edge.from;
428        let to = &edge.to;
429        if is_cycle_edge {
430            let _ = writeln!(mermaid, "    {from} -->|CYCLE| {to}");
431        } else {
432            let _ = writeln!(mermaid, "    {from} --> {to}");
433        }
434    }
435
436    // Add styling
437    mermaid.push_str("\n    classDef unused fill:#f9f,stroke:#333,stroke-dasharray: 5 5\n");
438
439    mermaid.push_str("```\n");
440    mermaid
441}
442
443/// Convert dependency graph to D2 format (modern diagram language)
444///
445/// D2 is a modern diagram scripting language that compiles to SVG.
446/// See: https://d2lang.com/
447fn to_d2(output: &DependencyGraphOutput) -> String {
448    use std::fmt::Write;
449
450    let mut d2 = String::new();
451
452    // Header comment
453    d2.push_str("# Schema Dependency Graph\n");
454    d2.push_str("# Generated by FraiseQL CLI\n");
455    d2.push_str("# Render with: d2 schema.d2 schema.svg\n\n");
456
457    // Global styling
458    d2.push_str("direction: right\n\n");
459
460    // Root types container
461    let has_roots = output.nodes.iter().any(|n| n.is_root);
462    if has_roots {
463        d2.push_str("roots: {\n");
464        d2.push_str("  label: \"Root Types\"\n");
465        d2.push_str("  style.fill: \"#e3f2fd\"\n");
466        d2.push_str("  style.stroke: \"#1976d2\"\n\n");
467        for node in &output.nodes {
468            if node.is_root {
469                let name = &node.name;
470                let deps = node.dependency_count;
471                let refs = node.dependent_count;
472                let _ = writeln!(d2, "  {name}: \"{name}\\n(deps: {deps}, refs: {refs})\" {{");
473                d2.push_str("    style.bold: true\n");
474                d2.push_str("    style.fill: \"#bbdefb\"\n");
475                d2.push_str("  }\n");
476            }
477        }
478        d2.push_str("}\n\n");
479    }
480
481    // Unused types container (if any)
482    if !output.unused_types.is_empty() {
483        d2.push_str("unused: {\n");
484        d2.push_str("  label: \"Unused Types\"\n");
485        d2.push_str("  style.fill: \"#fff3e0\"\n");
486        d2.push_str("  style.stroke: \"#ff9800\"\n");
487        d2.push_str("  style.stroke-dash: 3\n\n");
488        for node in &output.nodes {
489            if output.unused_types.contains(&node.name) {
490                let name = &node.name;
491                let _ = writeln!(d2, "  {name}: \"{name}\" {{");
492                d2.push_str("    style.fill: \"#ffe0b2\"\n");
493                d2.push_str("    style.stroke-dash: 3\n");
494                d2.push_str("  }\n");
495            }
496        }
497        d2.push_str("}\n\n");
498    }
499
500    // Regular types (not root, not unused)
501    for node in &output.nodes {
502        if !node.is_root && !output.unused_types.contains(&node.name) {
503            let name = &node.name;
504            let deps = node.dependency_count;
505            let refs = node.dependent_count;
506            let _ = writeln!(d2, "{name}: \"{name}\\n(deps: {deps}, refs: {refs})\"");
507        }
508    }
509
510    d2.push('\n');
511
512    // Edges
513    d2.push_str("# Dependencies\n");
514    for edge in &output.edges {
515        // Check if this edge is part of a cycle
516        let is_cycle_edge = output.cycles.iter().any(|c| {
517            let types = &c.types;
518            for i in 0..types.len() {
519                let from = &types[i];
520                let to = &types[(i + 1) % types.len()];
521                if from == &edge.from && to == &edge.to {
522                    return true;
523                }
524            }
525            false
526        });
527
528        let from = &edge.from;
529        let to = &edge.to;
530
531        // Handle edges from root types (need to reference inside container)
532        let from_ref = if output.nodes.iter().any(|n| n.is_root && &n.name == from) {
533            format!("roots.{from}")
534        } else if output.unused_types.contains(from) {
535            format!("unused.{from}")
536        } else {
537            from.clone()
538        };
539
540        let to_ref = if output.nodes.iter().any(|n| n.is_root && &n.name == to) {
541            format!("roots.{to}")
542        } else if output.unused_types.contains(to) {
543            format!("unused.{to}")
544        } else {
545            to.clone()
546        };
547
548        if is_cycle_edge {
549            let _ = writeln!(d2, "{from_ref} -> {to_ref}: \"CYCLE\" {{");
550            d2.push_str("  style.stroke: \"#d32f2f\"\n");
551            d2.push_str("  style.stroke-width: 2\n");
552            d2.push_str("}\n");
553        } else {
554            let _ = writeln!(d2, "{from_ref} -> {to_ref}");
555        }
556    }
557
558    // Cycle warning comment
559    if !output.cycles.is_empty() {
560        d2.push_str("\n# WARNING: Circular dependencies detected!\n");
561        for cycle in &output.cycles {
562            let _ = writeln!(d2, "# Cycle: {}", cycle.path);
563        }
564    }
565
566    d2
567}
568
569/// Convert dependency graph to console (human-readable) format
570fn to_console(output: &DependencyGraphOutput) -> String {
571    use std::fmt::Write;
572
573    let mut console = String::new();
574
575    // Header
576    console.push_str("Schema Dependency Graph Analysis\n");
577    console.push_str("================================\n\n");
578
579    // Summary stats
580    let _ = writeln!(console, "Total types: {}", output.stats.total_types);
581    let _ = writeln!(console, "Total dependencies: {}", output.stats.total_edges);
582    let _ =
583        writeln!(console, "Average dependencies per type: {:.2}", output.stats.avg_dependencies);
584    let _ = writeln!(console, "Maximum depth from roots: {}", output.stats.max_depth);
585    console.push('\n');
586
587    // Cycles (errors)
588    if !output.cycles.is_empty() {
589        let _ = writeln!(console, "CIRCULAR DEPENDENCIES ({}):", output.cycles.len());
590        for cycle in &output.cycles {
591            let _ = writeln!(console, "  - {}", cycle.path);
592        }
593        console.push('\n');
594    }
595
596    // Unused types (warnings)
597    if !output.unused_types.is_empty() {
598        let _ = writeln!(console, "UNUSED TYPES ({}):", output.unused_types.len());
599        for unused in &output.unused_types {
600            let _ = writeln!(console, "  - {unused}");
601        }
602        console.push('\n');
603    }
604
605    // Most depended on types
606    if !output.stats.most_depended_on.is_empty() {
607        console.push_str("Most referenced types:\n");
608        for (i, type_name) in output.stats.most_depended_on.iter().enumerate() {
609            let node = output.nodes.iter().find(|n| &n.name == type_name);
610            if let Some(node) = node {
611                let _ = writeln!(
612                    console,
613                    "  {}. {type_name} ({} references)",
614                    i + 1,
615                    node.dependent_count
616                );
617            }
618        }
619        console.push('\n');
620    }
621
622    // Type details
623    console.push_str("Type Details:\n");
624    console.push_str("-------------\n");
625
626    for node in &output.nodes {
627        let prefix = if node.is_root {
628            "[ROOT] "
629        } else if output.unused_types.contains(&node.name) {
630            "[UNUSED] "
631        } else {
632            ""
633        };
634
635        let _ = writeln!(
636            console,
637            "{prefix}{}: {} deps, {} refs",
638            node.name, node.dependency_count, node.dependent_count
639        );
640    }
641
642    console
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn test_graph_format_from_str() {
651        assert_eq!("json".parse::<GraphFormat>().unwrap(), GraphFormat::Json);
652        assert_eq!("dot".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
653        assert_eq!("graphviz".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
654        assert_eq!("mermaid".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
655        assert_eq!("md".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
656        assert_eq!("d2".parse::<GraphFormat>().unwrap(), GraphFormat::D2);
657        assert_eq!("console".parse::<GraphFormat>().unwrap(), GraphFormat::Console);
658        assert_eq!("text".parse::<GraphFormat>().unwrap(), GraphFormat::Console);
659    }
660
661    #[test]
662    fn test_graph_format_case_insensitive() {
663        assert_eq!("JSON".parse::<GraphFormat>().unwrap(), GraphFormat::Json);
664        assert_eq!("DOT".parse::<GraphFormat>().unwrap(), GraphFormat::Dot);
665        assert_eq!("MERMAID".parse::<GraphFormat>().unwrap(), GraphFormat::Mermaid);
666        assert_eq!("D2".parse::<GraphFormat>().unwrap(), GraphFormat::D2);
667    }
668
669    #[test]
670    fn test_graph_format_invalid() {
671        let result = "invalid".parse::<GraphFormat>();
672        assert!(result.is_err());
673        assert!(result.unwrap_err().contains("Unknown format"));
674    }
675
676    #[test]
677    fn test_graph_format_display() {
678        assert_eq!(GraphFormat::Json.to_string(), "json");
679        assert_eq!(GraphFormat::Dot.to_string(), "dot");
680        assert_eq!(GraphFormat::Mermaid.to_string(), "mermaid");
681        assert_eq!(GraphFormat::D2.to_string(), "d2");
682        assert_eq!(GraphFormat::Console.to_string(), "console");
683    }
684
685    #[test]
686    fn test_to_dot_contains_expected_elements() {
687        let output = DependencyGraphOutput {
688            type_count:   2,
689            nodes:        vec![
690                GraphNode {
691                    name:             "Query".to_string(),
692                    dependency_count: 1,
693                    dependent_count:  0,
694                    is_root:          true,
695                },
696                GraphNode {
697                    name:             "User".to_string(),
698                    dependency_count: 0,
699                    dependent_count:  1,
700                    is_root:          false,
701                },
702            ],
703            edges:        vec![GraphEdge {
704                from: "Query".to_string(),
705                to:   "User".to_string(),
706            }],
707            cycles:       vec![],
708            unused_types: vec![],
709            stats:        GraphStats {
710                total_types:      2,
711                total_edges:      1,
712                cycle_count:      0,
713                unused_count:     0,
714                avg_dependencies: 0.5,
715                max_depth:        1,
716                most_depended_on: vec!["User".to_string()],
717            },
718        };
719
720        let dot = to_dot(&output);
721        assert!(dot.contains("digraph schema_dependencies"));
722        assert!(dot.contains("Query"));
723        assert!(dot.contains("User"));
724        assert!(dot.contains("\"Query\" -> \"User\""));
725    }
726
727    #[test]
728    fn test_to_mermaid_contains_expected_elements() {
729        let output = DependencyGraphOutput {
730            type_count:   2,
731            nodes:        vec![
732                GraphNode {
733                    name:             "Query".to_string(),
734                    dependency_count: 1,
735                    dependent_count:  0,
736                    is_root:          true,
737                },
738                GraphNode {
739                    name:             "User".to_string(),
740                    dependency_count: 0,
741                    dependent_count:  1,
742                    is_root:          false,
743                },
744            ],
745            edges:        vec![GraphEdge {
746                from: "Query".to_string(),
747                to:   "User".to_string(),
748            }],
749            cycles:       vec![],
750            unused_types: vec![],
751            stats:        GraphStats {
752                total_types:      2,
753                total_edges:      1,
754                cycle_count:      0,
755                unused_count:     0,
756                avg_dependencies: 0.5,
757                max_depth:        1,
758                most_depended_on: vec!["User".to_string()],
759            },
760        };
761
762        let mermaid = to_mermaid(&output);
763        assert!(mermaid.contains("```mermaid"));
764        assert!(mermaid.contains("graph LR"));
765        assert!(mermaid.contains("Query"));
766        assert!(mermaid.contains("User"));
767        assert!(mermaid.contains("Query --> User"));
768    }
769
770    #[test]
771    fn test_to_d2_contains_expected_elements() {
772        let output = DependencyGraphOutput {
773            type_count:   2,
774            nodes:        vec![
775                GraphNode {
776                    name:             "Query".to_string(),
777                    dependency_count: 1,
778                    dependent_count:  0,
779                    is_root:          true,
780                },
781                GraphNode {
782                    name:             "User".to_string(),
783                    dependency_count: 0,
784                    dependent_count:  1,
785                    is_root:          false,
786                },
787            ],
788            edges:        vec![GraphEdge {
789                from: "Query".to_string(),
790                to:   "User".to_string(),
791            }],
792            cycles:       vec![],
793            unused_types: vec![],
794            stats:        GraphStats {
795                total_types:      2,
796                total_edges:      1,
797                cycle_count:      0,
798                unused_count:     0,
799                avg_dependencies: 0.5,
800                max_depth:        1,
801                most_depended_on: vec!["User".to_string()],
802            },
803        };
804
805        let d2 = to_d2(&output);
806        assert!(d2.contains("# Schema Dependency Graph"));
807        assert!(d2.contains("direction: right"));
808        assert!(d2.contains("roots:"));
809        assert!(d2.contains("Query"));
810        assert!(d2.contains("User"));
811        assert!(d2.contains("roots.Query -> User"));
812    }
813
814    #[test]
815    fn test_to_d2_shows_unused() {
816        let output = DependencyGraphOutput {
817            type_count:   1,
818            nodes:        vec![GraphNode {
819                name:             "Orphan".to_string(),
820                dependency_count: 0,
821                dependent_count:  0,
822                is_root:          false,
823            }],
824            edges:        vec![],
825            cycles:       vec![],
826            unused_types: vec!["Orphan".to_string()],
827            stats:        GraphStats {
828                total_types:      1,
829                total_edges:      0,
830                cycle_count:      0,
831                unused_count:     1,
832                avg_dependencies: 0.0,
833                max_depth:        0,
834                most_depended_on: vec![],
835            },
836        };
837
838        let d2 = to_d2(&output);
839        assert!(d2.contains("unused:"));
840        assert!(d2.contains("Unused Types"));
841        assert!(d2.contains("Orphan"));
842        assert!(d2.contains("stroke-dash"));
843    }
844
845    #[test]
846    fn test_to_d2_shows_cycles() {
847        let output = DependencyGraphOutput {
848            type_count:   2,
849            nodes:        vec![
850                GraphNode {
851                    name:             "A".to_string(),
852                    dependency_count: 1,
853                    dependent_count:  1,
854                    is_root:          false,
855                },
856                GraphNode {
857                    name:             "B".to_string(),
858                    dependency_count: 1,
859                    dependent_count:  1,
860                    is_root:          false,
861                },
862            ],
863            edges:        vec![
864                GraphEdge {
865                    from: "A".to_string(),
866                    to:   "B".to_string(),
867                },
868                GraphEdge {
869                    from: "B".to_string(),
870                    to:   "A".to_string(),
871                },
872            ],
873            cycles:       vec![CycleInfo {
874                types:             vec!["A".to_string(), "B".to_string()],
875                path:              "A -> B -> A".to_string(),
876                is_self_reference: false,
877            }],
878            unused_types: vec![],
879            stats:        GraphStats {
880                total_types:      2,
881                total_edges:      2,
882                cycle_count:      1,
883                unused_count:     0,
884                avg_dependencies: 1.0,
885                max_depth:        0,
886                most_depended_on: vec![],
887            },
888        };
889
890        let d2 = to_d2(&output);
891        assert!(d2.contains("CYCLE"));
892        assert!(d2.contains("stroke: \"#d32f2f\""));
893        assert!(d2.contains("# WARNING: Circular dependencies detected!"));
894    }
895
896    #[test]
897    fn test_to_console_contains_expected_elements() {
898        let output = DependencyGraphOutput {
899            type_count:   2,
900            nodes:        vec![
901                GraphNode {
902                    name:             "Query".to_string(),
903                    dependency_count: 1,
904                    dependent_count:  0,
905                    is_root:          true,
906                },
907                GraphNode {
908                    name:             "User".to_string(),
909                    dependency_count: 0,
910                    dependent_count:  1,
911                    is_root:          false,
912                },
913            ],
914            edges:        vec![GraphEdge {
915                from: "Query".to_string(),
916                to:   "User".to_string(),
917            }],
918            cycles:       vec![],
919            unused_types: vec![],
920            stats:        GraphStats {
921                total_types:      2,
922                total_edges:      1,
923                cycle_count:      0,
924                unused_count:     0,
925                avg_dependencies: 0.5,
926                max_depth:        1,
927                most_depended_on: vec!["User".to_string()],
928            },
929        };
930
931        let console = to_console(&output);
932        assert!(console.contains("Schema Dependency Graph Analysis"));
933        assert!(console.contains("Total types: 2"));
934        assert!(console.contains("[ROOT] Query"));
935        assert!(console.contains("User"));
936    }
937
938    #[test]
939    fn test_to_console_shows_cycles() {
940        let output = DependencyGraphOutput {
941            type_count:   2,
942            nodes:        vec![
943                GraphNode {
944                    name:             "A".to_string(),
945                    dependency_count: 1,
946                    dependent_count:  1,
947                    is_root:          false,
948                },
949                GraphNode {
950                    name:             "B".to_string(),
951                    dependency_count: 1,
952                    dependent_count:  1,
953                    is_root:          false,
954                },
955            ],
956            edges:        vec![
957                GraphEdge {
958                    from: "A".to_string(),
959                    to:   "B".to_string(),
960                },
961                GraphEdge {
962                    from: "B".to_string(),
963                    to:   "A".to_string(),
964                },
965            ],
966            cycles:       vec![CycleInfo {
967                types:             vec!["A".to_string(), "B".to_string()],
968                path:              "A -> B -> A".to_string(),
969                is_self_reference: false,
970            }],
971            unused_types: vec![],
972            stats:        GraphStats {
973                total_types:      2,
974                total_edges:      2,
975                cycle_count:      1,
976                unused_count:     0,
977                avg_dependencies: 1.0,
978                max_depth:        0,
979                most_depended_on: vec![],
980            },
981        };
982
983        let console = to_console(&output);
984        assert!(console.contains("CIRCULAR DEPENDENCIES"));
985        assert!(console.contains("A -> B -> A"));
986    }
987
988    #[test]
989    fn test_to_console_shows_unused() {
990        let output = DependencyGraphOutput {
991            type_count:   1,
992            nodes:        vec![GraphNode {
993                name:             "Orphan".to_string(),
994                dependency_count: 0,
995                dependent_count:  0,
996                is_root:          false,
997            }],
998            edges:        vec![],
999            cycles:       vec![],
1000            unused_types: vec!["Orphan".to_string()],
1001            stats:        GraphStats {
1002                total_types:      1,
1003                total_edges:      0,
1004                cycle_count:      0,
1005                unused_count:     1,
1006                avg_dependencies: 0.0,
1007                max_depth:        0,
1008                most_depended_on: vec![],
1009            },
1010        };
1011
1012        let console = to_console(&output);
1013        assert!(console.contains("UNUSED TYPES"));
1014        assert!(console.contains("Orphan"));
1015        assert!(console.contains("[UNUSED]"));
1016    }
1017
1018    #[test]
1019    fn test_cycle_info_from_cycle_path() {
1020        use fraiseql_core::schema::CyclePath;
1021
1022        let cycle = CyclePath::new(vec!["A".to_string(), "B".to_string(), "C".to_string()]);
1023        let info = CycleInfo::from(&cycle);
1024
1025        assert_eq!(info.types, vec!["A", "B", "C"]);
1026        assert_eq!(info.path, "A → B → C → A");
1027        assert!(!info.is_self_reference);
1028    }
1029
1030    #[test]
1031    fn test_cycle_info_self_reference() {
1032        use fraiseql_core::schema::CyclePath;
1033
1034        let cycle = CyclePath::new(vec!["Node".to_string()]);
1035        let info = CycleInfo::from(&cycle);
1036
1037        assert!(info.is_self_reference);
1038        assert_eq!(info.path, "Node → Node");
1039    }
1040}