use std::{fmt::Display, fs, str::FromStr};
use anyhow::Result;
use fraiseql_core::schema::{CompiledSchema, CyclePath, SchemaDependencyGraph};
use serde::Serialize;
use serde_json::Value;
use crate::output::CommandResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum GraphFormat {
#[default]
Json,
Dot,
Mermaid,
D2,
Console,
}
impl FromStr for GraphFormat {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(GraphFormat::Json),
"dot" | "graphviz" => Ok(GraphFormat::Dot),
"mermaid" | "md" => Ok(GraphFormat::Mermaid),
"d2" => Ok(GraphFormat::D2),
"console" | "text" | "txt" => Ok(GraphFormat::Console),
other => Err(format!(
"Unknown format: '{other}'. Valid formats: json, dot, mermaid, d2, console"
)),
}
}
}
impl Display for GraphFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GraphFormat::Json => write!(f, "json"),
GraphFormat::Dot => write!(f, "dot"),
GraphFormat::Mermaid => write!(f, "mermaid"),
GraphFormat::D2 => write!(f, "d2"),
GraphFormat::Console => write!(f, "console"),
}
}
}
#[derive(Debug, Serialize)]
pub struct DependencyGraphOutput {
pub type_count: usize,
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
pub cycles: Vec<CycleInfo>,
pub unused_types: Vec<String>,
pub stats: GraphStats,
}
#[derive(Debug, Serialize)]
pub struct GraphNode {
pub name: String,
pub dependency_count: usize,
pub dependent_count: usize,
pub is_root: bool,
}
#[derive(Debug, Serialize)]
pub struct GraphEdge {
pub from: String,
pub to: String,
}
#[derive(Debug, Serialize)]
pub struct CycleInfo {
pub types: Vec<String>,
pub path: String,
pub is_self_reference: bool,
}
impl From<&CyclePath> for CycleInfo {
fn from(cycle: &CyclePath) -> Self {
Self {
types: cycle.nodes.clone(),
path: cycle.path_string(),
is_self_reference: cycle.is_self_reference(),
}
}
}
#[derive(Debug, Serialize)]
pub struct GraphStats {
pub total_types: usize,
pub total_edges: usize,
pub cycle_count: usize,
pub unused_count: usize,
pub avg_dependencies: f64,
pub max_depth: usize,
pub most_depended_on: Vec<String>,
}
pub fn run(schema_path: &str, format: GraphFormat) -> Result<CommandResult> {
let schema_content = fs::read_to_string(schema_path)?;
let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
let graph = SchemaDependencyGraph::build(&schema);
let cycles = graph.find_cycles();
let unused = graph.find_unused();
let output = build_output(&graph, &cycles, &unused);
let warnings: Vec<String> = unused
.iter()
.map(|t| format!("Unused type: '{t}' has no incoming references"))
.collect();
let data = match format {
GraphFormat::Json => serde_json::to_value(&output)?,
GraphFormat::Dot => Value::String(to_dot(&output)),
GraphFormat::Mermaid => Value::String(to_mermaid(&output)),
GraphFormat::D2 => Value::String(to_d2(&output)),
GraphFormat::Console => Value::String(to_console(&output)),
};
if !cycles.is_empty() {
let errors: Vec<String> = cycles
.iter()
.map(|c| format!("Circular dependency: {}", c.path_string()))
.collect();
return Ok(CommandResult {
status: "validation-failed".to_string(),
command: "dependency-graph".to_string(),
data: Some(data),
message: Some(format!("Schema has {} circular dependencies", cycles.len())),
code: Some("CIRCULAR_DEPENDENCY".to_string()),
errors,
warnings,
});
}
if warnings.is_empty() {
Ok(CommandResult::success("dependency-graph", data))
} else {
Ok(CommandResult::success_with_warnings("dependency-graph", data, warnings))
}
}
fn build_output(
graph: &SchemaDependencyGraph,
cycles: &[CyclePath],
unused: &[String],
) -> DependencyGraphOutput {
let all_types = graph.all_types();
let root_types = ["Query", "Mutation", "Subscription"];
let mut nodes: Vec<GraphNode> = all_types
.iter()
.map(|name| GraphNode {
name: name.clone(),
dependency_count: graph.dependencies_of(name).len(),
dependent_count: graph.dependents_of(name).len(),
is_root: root_types.contains(&name.as_str()),
})
.collect();
nodes.sort_by_key(|n| std::cmp::Reverse(n.dependent_count));
let mut edges: Vec<GraphEdge> = Vec::new();
for type_name in &all_types {
for dep in graph.dependencies_of(type_name) {
edges.push(GraphEdge {
from: type_name.clone(),
to: dep,
});
}
}
edges.sort_by(|a, b| (&a.from, &a.to).cmp(&(&b.from, &b.to)));
let cycle_info: Vec<CycleInfo> = cycles.iter().map(CycleInfo::from).collect();
let total_deps: usize = nodes.iter().map(|n| n.dependency_count).sum();
#[allow(clippy::cast_precision_loss)]
let avg_deps = if nodes.is_empty() {
0.0
} else {
total_deps as f64 / nodes.len() as f64
};
let most_depended: Vec<String> = nodes
.iter()
.filter(|n| n.dependent_count > 0 && !n.is_root)
.take(5)
.map(|n| n.name.clone())
.collect();
let max_depth = calculate_max_depth(graph, &root_types);
let stats = GraphStats {
total_types: nodes.len(),
total_edges: edges.len(),
cycle_count: cycles.len(),
unused_count: unused.len(),
avg_dependencies: (avg_deps * 100.0).round() / 100.0,
max_depth,
most_depended_on: most_depended,
};
DependencyGraphOutput {
type_count: nodes.len(),
nodes,
edges,
cycles: cycle_info,
unused_types: unused.to_vec(),
stats,
}
}
fn calculate_max_depth(graph: &SchemaDependencyGraph, root_types: &[&str]) -> usize {
use std::collections::{HashSet, VecDeque};
let mut max_depth = 0;
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
for &root in root_types {
if graph.has_type(root) {
queue.push_back((root.to_string(), 0));
visited.insert(root.to_string());
}
}
while let Some((type_name, depth)) = queue.pop_front() {
max_depth = max_depth.max(depth);
for dep in graph.dependencies_of(&type_name) {
if !visited.contains(&dep) {
visited.insert(dep.clone());
queue.push_back((dep, depth + 1));
}
}
}
max_depth
}
pub(crate) fn to_dot(output: &DependencyGraphOutput) -> String {
use std::fmt::Write;
let mut dot = String::from("digraph schema_dependencies {\n");
dot.push_str(" rankdir=LR;\n");
dot.push_str(" node [shape=box, style=rounded];\n\n");
dot.push_str(" // Root types (Query, Mutation, Subscription)\n");
for node in &output.nodes {
let style = if node.is_root {
"style=\"rounded,bold\", color=blue"
} else if output.unused_types.contains(&node.name) {
"style=\"rounded,dashed\", color=gray"
} else {
"style=rounded"
};
let name = &node.name;
let deps = node.dependency_count;
let refs = node.dependent_count;
let _ = writeln!(
dot,
" \"{name}\" [label=\"{name}\\n(deps: {deps}, refs: {refs})\", {style}];"
);
}
dot.push_str("\n // Dependencies\n");
for edge in &output.edges {
let from = &edge.from;
let to = &edge.to;
let _ = writeln!(dot, " \"{from}\" -> \"{to}\";");
}
if !output.cycles.is_empty() {
dot.push_str("\n // Cycles (highlighted in red)\n");
for cycle in &output.cycles {
for i in 0..cycle.types.len() {
let from = &cycle.types[i];
let to = &cycle.types[(i + 1) % cycle.types.len()];
let _ = writeln!(dot, " \"{from}\" -> \"{to}\" [color=red, penwidth=2];");
}
}
}
dot.push_str("}\n");
dot
}
pub(crate) fn to_mermaid(output: &DependencyGraphOutput) -> String {
use std::fmt::Write;
let mut mermaid = String::from("```mermaid\ngraph LR\n");
mermaid.push_str(" subgraph Roots\n");
for node in &output.nodes {
if node.is_root {
let name = &node.name;
let _ = writeln!(mermaid, " {name}[\"{name}\"]");
}
}
mermaid.push_str(" end\n\n");
for node in &output.nodes {
if !node.is_root {
let style = if output.unused_types.contains(&node.name) {
":::unused"
} else {
""
};
let name = &node.name;
let _ = writeln!(mermaid, " {name}[\"{name}\"]{style}");
}
}
mermaid.push('\n');
for edge in &output.edges {
let is_cycle_edge = output.cycles.iter().any(|c| {
let types = &c.types;
for i in 0..types.len() {
let from = &types[i];
let to = &types[(i + 1) % types.len()];
if from == &edge.from && to == &edge.to {
return true;
}
}
false
});
let from = &edge.from;
let to = &edge.to;
if is_cycle_edge {
let _ = writeln!(mermaid, " {from} -->|CYCLE| {to}");
} else {
let _ = writeln!(mermaid, " {from} --> {to}");
}
}
mermaid.push_str("\n classDef unused fill:#f9f,stroke:#333,stroke-dasharray: 5 5\n");
mermaid.push_str("```\n");
mermaid
}
pub(crate) fn to_d2(output: &DependencyGraphOutput) -> String {
use std::fmt::Write;
let mut d2 = String::new();
d2.push_str("# Schema Dependency Graph\n");
d2.push_str("# Generated by FraiseQL CLI\n");
d2.push_str("# Render with: d2 schema.d2 schema.svg\n\n");
d2.push_str("direction: right\n\n");
let has_roots = output.nodes.iter().any(|n| n.is_root);
if has_roots {
d2.push_str("roots: {\n");
d2.push_str(" label: \"Root Types\"\n");
d2.push_str(" style.fill: \"#e3f2fd\"\n");
d2.push_str(" style.stroke: \"#1976d2\"\n\n");
for node in &output.nodes {
if node.is_root {
let name = &node.name;
let deps = node.dependency_count;
let refs = node.dependent_count;
let _ = writeln!(d2, " {name}: \"{name}\\n(deps: {deps}, refs: {refs})\" {{");
d2.push_str(" style.bold: true\n");
d2.push_str(" style.fill: \"#bbdefb\"\n");
d2.push_str(" }\n");
}
}
d2.push_str("}\n\n");
}
if !output.unused_types.is_empty() {
d2.push_str("unused: {\n");
d2.push_str(" label: \"Unused Types\"\n");
d2.push_str(" style.fill: \"#fff3e0\"\n");
d2.push_str(" style.stroke: \"#ff9800\"\n");
d2.push_str(" style.stroke-dash: 3\n\n");
for node in &output.nodes {
if output.unused_types.contains(&node.name) {
let name = &node.name;
let _ = writeln!(d2, " {name}: \"{name}\" {{");
d2.push_str(" style.fill: \"#ffe0b2\"\n");
d2.push_str(" style.stroke-dash: 3\n");
d2.push_str(" }\n");
}
}
d2.push_str("}\n\n");
}
for node in &output.nodes {
if !node.is_root && !output.unused_types.contains(&node.name) {
let name = &node.name;
let deps = node.dependency_count;
let refs = node.dependent_count;
let _ = writeln!(d2, "{name}: \"{name}\\n(deps: {deps}, refs: {refs})\"");
}
}
d2.push('\n');
d2.push_str("# Dependencies\n");
for edge in &output.edges {
let is_cycle_edge = output.cycles.iter().any(|c| {
let types = &c.types;
for i in 0..types.len() {
let from = &types[i];
let to = &types[(i + 1) % types.len()];
if from == &edge.from && to == &edge.to {
return true;
}
}
false
});
let from = &edge.from;
let to = &edge.to;
let from_ref = if output.nodes.iter().any(|n| n.is_root && &n.name == from) {
format!("roots.{from}")
} else if output.unused_types.contains(from) {
format!("unused.{from}")
} else {
from.clone()
};
let to_ref = if output.nodes.iter().any(|n| n.is_root && &n.name == to) {
format!("roots.{to}")
} else if output.unused_types.contains(to) {
format!("unused.{to}")
} else {
to.clone()
};
if is_cycle_edge {
let _ = writeln!(d2, "{from_ref} -> {to_ref}: \"CYCLE\" {{");
d2.push_str(" style.stroke: \"#d32f2f\"\n");
d2.push_str(" style.stroke-width: 2\n");
d2.push_str("}\n");
} else {
let _ = writeln!(d2, "{from_ref} -> {to_ref}");
}
}
if !output.cycles.is_empty() {
d2.push_str("\n# WARNING: Circular dependencies detected!\n");
for cycle in &output.cycles {
let _ = writeln!(d2, "# Cycle: {}", cycle.path);
}
}
d2
}
pub(crate) fn to_console(output: &DependencyGraphOutput) -> String {
use std::fmt::Write;
let mut console = String::new();
console.push_str("Schema Dependency Graph Analysis\n");
console.push_str("================================\n\n");
let _ = writeln!(console, "Total types: {}", output.stats.total_types);
let _ = writeln!(console, "Total dependencies: {}", output.stats.total_edges);
let _ =
writeln!(console, "Average dependencies per type: {:.2}", output.stats.avg_dependencies);
let _ = writeln!(console, "Maximum depth from roots: {}", output.stats.max_depth);
console.push('\n');
if !output.cycles.is_empty() {
let _ = writeln!(console, "CIRCULAR DEPENDENCIES ({}):", output.cycles.len());
for cycle in &output.cycles {
let _ = writeln!(console, " - {}", cycle.path);
}
console.push('\n');
}
if !output.unused_types.is_empty() {
let _ = writeln!(console, "UNUSED TYPES ({}):", output.unused_types.len());
for unused in &output.unused_types {
let _ = writeln!(console, " - {unused}");
}
console.push('\n');
}
if !output.stats.most_depended_on.is_empty() {
console.push_str("Most referenced types:\n");
for (i, type_name) in output.stats.most_depended_on.iter().enumerate() {
let node = output.nodes.iter().find(|n| &n.name == type_name);
if let Some(node) = node {
let _ = writeln!(
console,
" {}. {type_name} ({} references)",
i + 1,
node.dependent_count
);
}
}
console.push('\n');
}
console.push_str("Type Details:\n");
console.push_str("-------------\n");
for node in &output.nodes {
let prefix = if node.is_root {
"[ROOT] "
} else if output.unused_types.contains(&node.name) {
"[UNUSED] "
} else {
""
};
let _ = writeln!(
console,
"{prefix}{}: {} deps, {} refs",
node.name, node.dependency_count, node.dependent_count
);
}
console
}