1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18#[non_exhaustive]
19pub enum GraphFormat {
20 #[default]
22 Json,
23 Dot,
25 Mermaid,
27 D2,
29 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#[derive(Debug, Serialize)]
64pub struct DependencyGraphOutput {
65 pub type_count: usize,
67
68 pub nodes: Vec<GraphNode>,
70
71 pub edges: Vec<GraphEdge>,
73
74 pub cycles: Vec<CycleInfo>,
76
77 pub unused_types: Vec<String>,
79
80 pub stats: GraphStats,
82}
83
84#[derive(Debug, Serialize)]
86pub struct GraphNode {
87 pub name: String,
89
90 pub dependency_count: usize,
92
93 pub dependent_count: usize,
95
96 pub is_root: bool,
98}
99
100#[derive(Debug, Serialize)]
102pub struct GraphEdge {
103 pub from: String,
105
106 pub to: String,
108}
109
110#[derive(Debug, Serialize)]
112pub struct CycleInfo {
113 pub types: Vec<String>,
115
116 pub path: String,
118
119 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#[derive(Debug, Serialize)]
135pub struct GraphStats {
136 pub total_types: usize,
138
139 pub total_edges: usize,
141
142 pub cycle_count: usize,
144
145 pub unused_count: usize,
147
148 pub avg_dependencies: f64,
150
151 pub max_depth: usize,
153
154 pub most_depended_on: Vec<String>,
156}
157
158pub fn run(schema_path: &str, format: GraphFormat) -> Result<CommandResult> {
166 let schema_content = fs::read_to_string(schema_path)?;
168 let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
169
170 let graph = SchemaDependencyGraph::build(&schema);
172
173 let cycles = graph.find_cycles();
175 let unused = graph.find_unused();
176
177 let output = build_output(&graph, &cycles, &unused);
179
180 let warnings: Vec<String> = unused
182 .iter()
183 .map(|t| format!("Unused type: '{t}' has no incoming references"))
184 .collect();
185
186 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.is_empty() {
197 let errors: Vec<String> = cycles
198 .iter()
199 .map(|c| format!("Circular dependency: {}", c.path_string()))
200 .collect();
201
202 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 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
222fn 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 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 nodes.sort_by_key(|n| std::cmp::Reverse(n.dependent_count));
244
245 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 edges.sort_by(|a, b| (&a.from, &a.to).cmp(&(&b.from, &b.to)));
258
259 let cycle_info: Vec<CycleInfo> = cycles.iter().map(CycleInfo::from).collect();
261
262 let total_deps: usize = nodes.iter().map(|n| n.dependency_count).sum();
264 #[allow(clippy::cast_precision_loss)]
265 let avg_deps = if nodes.is_empty() {
267 0.0
268 } else {
269 total_deps as f64 / nodes.len() as f64
270 };
271
272 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 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
303fn 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 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
333fn 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 dot.push_str(" // Root types (Query, Mutation, Subscription)\n");
343
344 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 for edge in &output.edges {
367 let from = &edge.from;
368 let to = &edge.to;
369 let _ = writeln!(dot, " \"{from}\" -> \"{to}\";");
370 }
371
372 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
388fn to_mermaid(output: &DependencyGraphOutput) -> String {
390 use std::fmt::Write;
391
392 let mut mermaid = String::from("```mermaid\ngraph LR\n");
393
394 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 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 for edge in &output.edges {
421 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 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
450fn to_d2(output: &DependencyGraphOutput) -> String {
455 use std::fmt::Write;
456
457 let mut d2 = String::new();
458
459 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 d2.push_str("direction: right\n\n");
466
467 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 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 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 d2.push_str("# Dependencies\n");
521 for edge in &output.edges {
522 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 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 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
576fn to_console(output: &DependencyGraphOutput) -> String {
578 use std::fmt::Write;
579
580 let mut console = String::new();
581
582 console.push_str("Schema Dependency Graph Analysis\n");
584 console.push_str("================================\n\n");
585
586 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 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 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 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 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)] #[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}