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
333pub(crate) fn to_dot(output: &DependencyGraphOutput) -> String {
335 use std::fmt::Write;
336
337 let mut dot = String::from("digraph schema_dependencies {\n");
338 dot.push_str(" rankdir=LR;\n");
339 dot.push_str(" node [shape=box, style=rounded];\n\n");
340
341 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
388pub(crate) fn to_mermaid(output: &DependencyGraphOutput) -> String {
390 use std::fmt::Write;
391
392 let mut mermaid = String::from("```mermaid\ngraph LR\n");
393
394 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
450pub(crate) fn 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
576pub(crate) fn 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}