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)]
18pub enum GraphFormat {
19 #[default]
21 Json,
22 Dot,
24 Mermaid,
26 D2,
28 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#[derive(Debug, Serialize)]
63pub struct DependencyGraphOutput {
64 pub type_count: usize,
66
67 pub nodes: Vec<GraphNode>,
69
70 pub edges: Vec<GraphEdge>,
72
73 pub cycles: Vec<CycleInfo>,
75
76 pub unused_types: Vec<String>,
78
79 pub stats: GraphStats,
81}
82
83#[derive(Debug, Serialize)]
85pub struct GraphNode {
86 pub name: String,
88
89 pub dependency_count: usize,
91
92 pub dependent_count: usize,
94
95 pub is_root: bool,
97}
98
99#[derive(Debug, Serialize)]
101pub struct GraphEdge {
102 pub from: String,
104
105 pub to: String,
107}
108
109#[derive(Debug, Serialize)]
111pub struct CycleInfo {
112 pub types: Vec<String>,
114
115 pub path: String,
117
118 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#[derive(Debug, Serialize)]
134pub struct GraphStats {
135 pub total_types: usize,
137
138 pub total_edges: usize,
140
141 pub cycle_count: usize,
143
144 pub unused_count: usize,
146
147 pub avg_dependencies: f64,
149
150 pub max_depth: usize,
152
153 pub most_depended_on: Vec<String>,
155}
156
157pub fn run(schema_path: &str, format: GraphFormat) -> Result<CommandResult> {
159 let schema_content = fs::read_to_string(schema_path)?;
161 let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
162
163 let graph = SchemaDependencyGraph::build(&schema);
165
166 let cycles = graph.find_cycles();
168 let unused = graph.find_unused();
169
170 let output = build_output(&graph, &cycles, &unused);
172
173 let warnings: Vec<String> = unused
175 .iter()
176 .map(|t| format!("Unused type: '{t}' has no incoming references"))
177 .collect();
178
179 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.is_empty() {
190 let errors: Vec<String> = cycles
191 .iter()
192 .map(|c| format!("Circular dependency: {}", c.path_string()))
193 .collect();
194
195 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 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
216fn 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 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 nodes.sort_by_key(|n| std::cmp::Reverse(n.dependent_count));
238
239 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 edges.sort_by(|a, b| (&a.from, &a.to).cmp(&(&b.from, &b.to)));
252
253 let cycle_info: Vec<CycleInfo> = cycles.iter().map(CycleInfo::from).collect();
255
256 let total_deps: usize = nodes.iter().map(|n| n.dependency_count).sum();
258 #[allow(clippy::cast_precision_loss)] let avg_deps = if nodes.is_empty() {
260 0.0
261 } else {
262 total_deps as f64 / nodes.len() as f64
263 };
264
265 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 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
296fn 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 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
326fn 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 dot.push_str(" // Root types (Query, Mutation, Subscription)\n");
336
337 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 for edge in &output.edges {
360 let from = &edge.from;
361 let to = &edge.to;
362 let _ = writeln!(dot, " \"{from}\" -> \"{to}\";");
363 }
364
365 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
381fn to_mermaid(output: &DependencyGraphOutput) -> String {
383 use std::fmt::Write;
384
385 let mut mermaid = String::from("```mermaid\ngraph LR\n");
386
387 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 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 for edge in &output.edges {
414 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 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
443fn to_d2(output: &DependencyGraphOutput) -> String {
448 use std::fmt::Write;
449
450 let mut d2 = String::new();
451
452 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 d2.push_str("direction: right\n\n");
459
460 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 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 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 d2.push_str("# Dependencies\n");
514 for edge in &output.edges {
515 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 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 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
569fn to_console(output: &DependencyGraphOutput) -> String {
571 use std::fmt::Write;
572
573 let mut console = String::new();
574
575 console.push_str("Schema Dependency Graph Analysis\n");
577 console.push_str("================================\n\n");
578
579 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 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 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 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 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}