cargo-rail 0.22.1

Cargo-native Rust workspace engine for affected CI, Rust source-surface analysis, compiler reuse, dependency coherence, exact-SHA releases, and split/sync
Documentation
//! `cargo rail graph` - planner/action reasoning graph output.

use crate::commands::common::PlanOutputFormat;
use crate::commands::plan::{PlanOptions, build_plan_output};
use crate::error::{RailError, RailResult};
use crate::workspace::WorkspaceContext;
use serde::Serialize;
use std::collections::BTreeSet;
use std::io::Write as _;
use std::path::PathBuf;

/// Options for the `graph` command.
pub struct GraphOptions {
    /// Git ref to compare against.
    pub since: Option<String>,
    /// Start of SHA range (used with `to`).
    pub from: Option<String>,
    /// End of SHA range (used with `from`).
    pub to: Option<String>,
    /// Use merge-base with default branch.
    pub merge_base: bool,
    /// Planner confidence profile override.
    pub confidence_profile: Option<String>,
    /// Emit GraphViz DOT output.
    pub dot: bool,
    /// Optional output path.
    pub output: Option<PathBuf>,
}

#[derive(Serialize)]
struct GraphNode {
    id: String,
    kind: String,
    label: String,
}

#[derive(Serialize)]
struct GraphEdge {
    from: String,
    to: String,
    relation: String,
}

#[derive(Serialize)]
struct PlannerGraph {
    nodes: Vec<GraphNode>,
    edges: Vec<GraphEdge>,
}

/// Run the graph command.
pub fn run_graph(ctx: &WorkspaceContext, opts: GraphOptions) -> RailResult<()> {
    crate::output::set_json_mode(true);

    let plan = build_plan_output(
        ctx,
        &PlanOptions {
            since: opts.since,
            from: opts.from,
            to: opts.to,
            merge_base: opts.merge_base,
            format: PlanOutputFormat::Json,
            output: None,
            explain: false,
            confidence_profile: opts.confidence_profile,
        },
    )?;

    let graph = build_graph(&plan);
    let rendered = if opts.dot {
        to_dot(&graph)
    } else {
        let payload =
            serde_json::to_value(&graph).map_err(|e| RailError::message(format!("graph JSON failed: {}", e)))?;
        let output = crate::output::machine_json_envelope("graph", "inspect", "success", 0, payload);
        serde_json::to_string_pretty(&output).map_err(|e| RailError::message(format!("graph JSON failed: {}", e)))?
    };

    write_output(&rendered, opts.output.as_ref())
}

fn build_graph(plan: &crate::commands::plan::PlanOutput) -> PlannerGraph {
    // Estimate: each file typically has 1-2 owners, so nodes ~ 2*files, edges ~ 2*files
    let file_count = plan.files.len();
    let mut nodes = Vec::with_capacity(file_count * 2);
    let mut edges = Vec::with_capacity(file_count * 2);
    let mut seen = BTreeSet::new();

    for file in &plan.files {
        let file_id = format!("file:{}", file.path);
        let is_new_file = seen.insert(file_id.clone());
        if is_new_file {
            nodes.push(GraphNode {
                id: file_id.clone(),
                kind: "file".to_string(),
                label: file.path.clone(),
            });
        }

        for owner in &file.owners {
            let crate_id = format!("crate:{}", owner);
            let is_new_crate = seen.insert(crate_id.clone());
            if is_new_crate {
                nodes.push(GraphNode {
                    id: crate_id.clone(),
                    kind: "crate".to_string(),
                    label: owner.clone(),
                });
            }
            edges.push(GraphEdge {
                from: file_id.clone(),
                to: crate_id, // Move instead of clone - last use
                relation: "owned_by".to_string(),
            });
        }
    }

    for (surface, decision) in &plan.surfaces {
        let surface_id = format!("surface:{}", surface);
        let is_new_surface = seen.insert(surface_id.clone());
        if is_new_surface {
            nodes.push(GraphNode {
                id: surface_id.clone(),
                kind: "surface".to_string(),
                label: format!(
                    "{} ({})",
                    surface,
                    if decision.enabled { "enabled" } else { "disabled" }
                ),
            });
        }

        for reason_id in &decision.reasons {
            let reason_node_id = format!("reason:{}", reason_id);
            let is_new_reason = seen.insert(reason_node_id.clone());
            if is_new_reason {
                let reason_label = plan
                    .trace
                    .iter()
                    .find(|r| r.id == *reason_id)
                    .map(|r| r.code.to_string())
                    .unwrap_or_else(|| format!("reason_{}", reason_id));
                nodes.push(GraphNode {
                    id: reason_node_id.clone(),
                    kind: "reason".to_string(),
                    label: reason_label,
                });
            }
            edges.push(GraphEdge {
                from: reason_node_id, // Move - last use
                to: surface_id.clone(),
                relation: "enables".to_string(),
            });
        }
    }

    PlannerGraph { nodes, edges }
}

fn to_dot(graph: &PlannerGraph) -> String {
    let mut out = String::new();
    out.push_str("digraph rail_plan {\n");
    out.push_str("  rankdir=LR;\n");
    for node in &graph.nodes {
        let shape = match node.kind.as_str() {
            "file" => "box",
            "crate" => "ellipse",
            "surface" => "diamond",
            _ => "oval",
        };
        out.push_str(&format!(
            "  \"{}\" [label=\"{}\", shape={}];\n",
            escape_dot(&node.id),
            escape_dot(&node.label),
            shape
        ));
    }
    for edge in &graph.edges {
        out.push_str(&format!(
            "  \"{}\" -> \"{}\" [label=\"{}\"];\n",
            escape_dot(&edge.from),
            escape_dot(&edge.to),
            escape_dot(&edge.relation)
        ));
    }
    out.push_str("}\n");
    out
}

fn escape_dot(input: &str) -> String {
    input.replace('\\', "\\\\").replace('"', "\\\"")
}

fn write_output(content: &str, output_file: Option<&PathBuf>) -> RailResult<()> {
    match output_file {
        Some(path) => {
            let mut file = std::fs::OpenOptions::new()
                .create(true)
                .truncate(true)
                .write(true)
                .open(path)
                .map_err(|e| RailError::message(format!("failed to open '{}': {}", path.display(), e)))?;
            writeln!(file, "{}", content)
                .map_err(|e| RailError::message(format!("failed to write '{}': {}", path.display(), e)))?;
            crate::progress!("output: {}", path.display());
            Ok(())
        }
        None => {
            println!("{}", content);
            Ok(())
        }
    }
}