//! Static, offline tool dependency-graph inference.
//!
//! Given a `tools/list` catalog (each tool a raw [`serde_json::Value`]), this
//! module infers likely call-ordering relationships between tools without any
//! network access or trace data. It is a heuristic so every edge carries a
//! confidence score and a typed [`EdgeKind`] explaining why it was drawn.
//!
//! The analyzer is fully deterministic: tools are sorted by name, edges are
//! deduplicated and sorted, and the topological layering uses a stable Kahn's
//! algorithm. The same catalog always yields the same graph, so the Mermaid
//! and table renders are safe to snapshot in tests and docs.
//!
//! This is intentionally separate from the trace-based composition engine.
//! That code observes real call sequences; this one reasons purely over the
//! advertised schemas and descriptions before a single tool has been called.

use std::collections::{BTreeMap, BTreeSet};

use serde_json::Value;

/// Why one tool likely depends on another.
///
/// Each variant maps to one inference rule with a fixed confidence. When two
/// rules fire for the same ordered pair the higher-confidence kind wins (see
/// [`EdgeKind::confidence`] for the ordering).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum EdgeKind {
    /// A property name in the source tool's `outputSchema` matches a required
    /// property name in the target tool's `inputSchema`, with a compatible
    /// (non-mismatched) JSON type. Confidence 0.7.
    OutputInput,
    /// The target tool's exact name appears as a token in the source tool's
    /// description, immediately preceded by ordering language such as "first
    /// call", "after", "then call", "step N", or "once you have". Confidence
    /// 0.6.
    Sequence,
    /// The target tool's exact name appears as a plain token in the source
    /// tool's description text. Confidence 0.5.
    Mention,
    /// Both tools reference the same resource identifier token: a shared
    /// `*_id`/`*Id`/`uri` required input property, or the same `resource://`
    /// URI in their descriptions. Confidence 0.4.
    ResourceRef,
}

impl EdgeKind {
    /// The fixed confidence weight for this kind, in `[0, 1]`.
    ///
    /// Higher means a stronger signal that the dependency is real. Used to
    /// pick a winner when several rules fire for the same ordered pair.
    pub fn confidence(self) -> f64 {
        match self {
            EdgeKind::OutputInput => 0.7,
            EdgeKind::Sequence => 0.6,
            EdgeKind::Mention => 0.5,
            EdgeKind::ResourceRef => 0.4,
        }
    }

    /// A short, stable label for renders (Mermaid edge labels, table rows).
    pub fn label(self) -> &'static str {
        match self {
            EdgeKind::OutputInput => "output->input",
            EdgeKind::Sequence => "sequence",
            EdgeKind::Mention => "mention",
            EdgeKind::ResourceRef => "resource-ref",
        }
    }
}

/// One inferred directed dependency: `from` should likely run before `to`.
#[derive(Debug, Clone, PartialEq)]
pub struct Edge {
    /// Name of the tool that should run first.
    pub from: String,
    /// Name of the tool that depends on `from`.
    pub to: String,
    /// Why this edge was inferred (the winning rule).
    pub kind: EdgeKind,
    /// Confidence in `[0, 1]`, copied from [`EdgeKind::confidence`].
    pub confidence: f64,
}

/// The inferred dependency graph over a tool catalog.
///
/// `nodes` are every tool name (sorted). `edges` are deduplicated and sorted.
/// `layers` is the topological layering of the acyclic part: layer 0 has no
/// inbound edges, each later layer depends only on earlier ones. `cycles`
/// holds any strongly-connected groups Kahn's algorithm could not order, so
/// cyclic tools are reported rather than silently dropped.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DependencyGraph {
    /// Every tool name in the catalog, sorted, deduplicated.
    pub nodes: Vec<String>,
    /// Inferred edges, one per `(from, to)` pair, sorted by `(from, to)`.
    pub edges: Vec<Edge>,
    /// Topological layers of the acyclic portion; each inner vec is sorted.
    pub layers: Vec<Vec<String>>,
    /// Tools that remained in at least one cycle, grouped and sorted.
    pub cycles: Vec<Vec<String>>,
}

/// Infer a dependency graph from a `tools/list` catalog.
///
/// Pure and deterministic: the input order does not affect the output. Each
/// tool is a raw MCP tool object with `name`, optional `description`,
/// `inputSchema`, and `outputSchema` fields. Unknown or malformed tools (no
/// string `name`) are skipped.
pub fn infer_dependency_graph(tools: &[Value]) -> DependencyGraph {
    let named: BTreeMap<String, &Value> = tools
        .iter()
        .filter_map(|t| {
            t.get("name")
                .and_then(Value::as_str)
                .map(|n| (n.to_owned(), t))
        })
        .collect();
    let nodes: Vec<String> = named.keys().cloned().collect();

    let mut by_pair: BTreeMap<(String, String), EdgeKind> = BTreeMap::new();
    for (from_name, from_tool) in &named {
        for (to_name, to_tool) in &named {
            if from_name == to_name {
                continue;
            }
            if let Some(kind) = infer_edge(from_name, from_tool, to_name, to_tool) {
                let key = (from_name.clone(), to_name.clone());
                let entry = by_pair.entry(key).or_insert(kind);
                // Keep the higher-confidence kind for a duplicated pair.
                if kind.confidence() > entry.confidence() {
                    *entry = kind;
                }
            }
        }
    }

    let edges: Vec<Edge> = by_pair
        .into_iter()
        .map(|((from, to), kind)| Edge {
            from,
            to,
            kind,
            confidence: kind.confidence(),
        })
        .collect();

    let (layers, cycles) = topological_layers(&nodes, &edges);
    DependencyGraph {
        nodes,
        edges,
        layers,
        cycles,
    }
}

/// Decide the single best edge kind from `from` to `to`, if any.
///
/// Rules are checked from strongest to weakest and the first match wins, so a
/// pair that is both an output->input link and a mention is recorded as the
/// stronger [`EdgeKind::OutputInput`].
fn infer_edge(from_name: &str, from: &Value, to_name: &str, to: &Value) -> Option<EdgeKind> {
    if output_feeds_input(from, to) {
        return Some(EdgeKind::OutputInput);
    }
    let desc = description_text(from);
    if sequence_reference(&desc, to_name) {
        return Some(EdgeKind::Sequence);
    }
    if mentions_token(&desc, to_name) {
        return Some(EdgeKind::Mention);
    }
    if shares_resource(from_name, from, to) {
        return Some(EdgeKind::ResourceRef);
    }
    None
}

/// True when a property in `from`'s `outputSchema` matches a *required*
/// property in `to`'s `inputSchema` with a compatible JSON type.
///
/// "Compatible" means the declared `type` strings are equal or at least one
/// side is absent/unknown. A concrete mismatch (e.g. `string` vs `number`)
/// rejects the pair so we do not link incompatible shapes.
fn output_feeds_input(from: &Value, to: &Value) -> bool {
    let out_props = schema_properties(from.get("outputSchema"));
    let in_props = schema_properties(to.get("inputSchema"));
    let required = required_names(to.get("inputSchema"));
    for (name, out_ty) in &out_props {
        if !required.contains(name) {
            continue;
        }
        if let Some(in_ty) = in_props.get(name) {
            if types_compatible(out_ty.as_deref(), in_ty.as_deref()) {
                return true;
            }
        }
    }
    false
}

/// True when both tools name the same resource identifier.
///
/// Either they share a required `*_id`/`*Id`/`uri` input property name, or
/// both descriptions cite the same `resource://...` URI. The check is order
/// independent; the caller emits the edge in stable name order.
fn shares_resource(_from_name: &str, from: &Value, to: &Value) -> bool {
    let from_ids = resource_id_props(from);
    let to_ids = resource_id_props(to);
    if from_ids.intersection(&to_ids).next().is_some() {
        return true;
    }
    let from_uris = resource_uris(&description_text(from));
    let to_uris = resource_uris(&description_text(to));
    from_uris.intersection(&to_uris).next().is_some()
}

/// Required input property names that look like resource identifiers.
fn resource_id_props(tool: &Value) -> BTreeSet<String> {
    let required = required_names(tool.get("inputSchema"));
    required
        .into_iter()
        .filter(|n| {
            let lower = n.to_ascii_lowercase();
            lower == "uri" || lower.ends_with("_id") || lower.ends_with("id")
        })
        .collect()
}

/// Extract `resource://<token>` URIs mentioned in a description.
fn resource_uris(desc: &str) -> BTreeSet<String> {
    let mut out = BTreeSet::new();
    for raw in desc.split(|c: char| c.is_whitespace() || c == '(' || c == ')') {
        let token = raw.trim_matches(|c: char| !c.is_alphanumeric() && c != ':' && c != '/');
        if token.starts_with("resource://") && token.len() > "resource://".len() {
            out.insert(token.to_owned());
        }
    }
    out
}

/// Map of property name to declared `type` string for a JSON Schema object.
fn schema_properties(schema: Option<&Value>) -> BTreeMap<String, Option<String>> {
    let mut out = BTreeMap::new();
    let Some(props) = schema
        .and_then(|s| s.get("properties"))
        .and_then(Value::as_object)
    else {
        return out;
    };
    for (name, def) in props {
        let ty = def.get("type").and_then(Value::as_str).map(str::to_owned);
        out.insert(name.clone(), ty);
    }
    out
}

/// Set of required property names declared on a JSON Schema object.
fn required_names(schema: Option<&Value>) -> BTreeSet<String> {
    schema
        .and_then(|s| s.get("required"))
        .and_then(Value::as_array)
        .map(|arr| {
            arr.iter()
                .filter_map(Value::as_str)
                .map(str::to_owned)
                .collect()
        })
        .unwrap_or_default()
}

/// Two JSON Schema `type` strings are compatible unless both are present and
/// differ. A missing type on either side is treated as "unknown, allow it".
fn types_compatible(a: Option<&str>, b: Option<&str>) -> bool {
    match (a, b) {
        (Some(x), Some(y)) => x == y,
        _ => true,
    }
}

/// The `description` field as a plain string, or empty when absent.
fn description_text(tool: &Value) -> String {
    tool.get("description")
        .and_then(Value::as_str)
        .unwrap_or_default()
        .to_owned()
}

/// True when `token` (a tool name) appears as a standalone token in `text`.
///
/// Matching is case-insensitive over non-identifier boundaries so a tool
/// `get_user` is found in "call get_user next" but not inside `get_users`.
fn mentions_token(text: &str, token: &str) -> bool {
    if token.is_empty() {
        return false;
    }
    tokens(text).any(|t| t.eq_ignore_ascii_case(token))
}

/// True when `token` appears right after ordering language in `text`.
///
/// We scan for cue phrases ("first call", "after", "then call", "then",
/// "step", "once you have") and accept the edge when the target token shows
/// up within a short window after the cue.
fn sequence_reference(text: &str, token: &str) -> bool {
    if token.is_empty() {
        return false;
    }
    let haystack = text.to_ascii_lowercase();
    let needle = token.to_ascii_lowercase();
    let words: Vec<&str> = tokens(&haystack).collect();
    const CUES: &[&str] = &["first", "after", "then", "step", "once", "before"];
    for (i, word) in words.iter().enumerate() {
        if !CUES.contains(word) {
            continue;
        }
        // Look ahead a few tokens for the target name.
        let end = (i + 5).min(words.len());
        if words[i + 1..end].iter().any(|w| *w == needle) {
            return true;
        }
    }
    false
}

/// Split text into lowercase identifier-ish tokens (letters, digits, `_`).
fn tokens(text: &str) -> impl Iterator<Item = &str> {
    text.split(|c: char| !(c.is_alphanumeric() || c == '_'))
        .filter(|s| !s.is_empty())
}

/// Layer the graph with Kahn's algorithm; return `(layers, cycles)`.
///
/// Nodes with no remaining inbound edges form each successive layer. Any nodes
/// left when no zero-indegree node remains are in a cycle; they are grouped
/// (sorted) into `cycles` so callers can report them.
fn topological_layers(nodes: &[String], edges: &[Edge]) -> (Vec<Vec<String>>, Vec<Vec<String>>) {
    let mut indegree: BTreeMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect();
    let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    for e in edges {
        adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
        *indegree.entry(e.to.as_str()).or_insert(0) += 1;
    }

    let mut remaining: BTreeSet<&str> = nodes.iter().map(String::as_str).collect();
    let mut layers: Vec<Vec<String>> = Vec::new();
    loop {
        let ready: Vec<&str> = remaining
            .iter()
            .copied()
            .filter(|n| indegree.get(n).copied().unwrap_or(0) == 0)
            .collect();
        if ready.is_empty() {
            break;
        }
        let mut layer: Vec<String> = ready.iter().map(|s| s.to_string()).collect();
        layer.sort();
        for n in &ready {
            remaining.remove(n);
            if let Some(neighbors) = adj.get(n) {
                for m in neighbors {
                    if let Some(d) = indegree.get_mut(m) {
                        *d = d.saturating_sub(1);
                    }
                }
            }
        }
        layers.push(layer);
    }

    let cycles = collect_cycles(&remaining, &adj);
    (layers, cycles)
}

/// Group the leftover (cyclic) nodes into connected components.
///
/// Anything still in `remaining` after Kahn's loop is part of a cycle. We
/// walk the directed-but-treated-as-undirected adjacency among those nodes to
/// group them, then sort for determinism.
fn collect_cycles(remaining: &BTreeSet<&str>, adj: &BTreeMap<&str, Vec<&str>>) -> Vec<Vec<String>> {
    let mut undirected: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
    for &n in remaining {
        undirected.entry(n).or_default();
        if let Some(neighbors) = adj.get(n) {
            for &m in neighbors {
                if remaining.contains(m) {
                    undirected.entry(n).or_default().insert(m);
                    undirected.entry(m).or_default().insert(n);
                }
            }
        }
    }
    let mut seen: BTreeSet<&str> = BTreeSet::new();
    let mut groups: Vec<Vec<String>> = Vec::new();
    for &start in remaining {
        if seen.contains(start) {
            continue;
        }
        let mut stack = vec![start];
        let mut group: Vec<String> = Vec::new();
        while let Some(node) = stack.pop() {
            if !seen.insert(node) {
                continue;
            }
            group.push(node.to_string());
            if let Some(neighbors) = undirected.get(node) {
                for &m in neighbors {
                    if !seen.contains(m) {
                        stack.push(m);
                    }
                }
            }
        }
        group.sort();
        groups.push(group);
    }
    groups.sort();
    groups
}

/// Render the graph as a Mermaid `graph TD` document.
///
/// Every node is declared (so isolated tools still appear) and every edge is
/// drawn with a kind+confidence label. Node ids are sanitized to be Mermaid
/// safe; the original tool name is the display label.
pub fn render_mermaid(graph: &DependencyGraph) -> String {
    let mut out = String::from("graph TD\n");
    let ids: BTreeMap<&str, String> = graph
        .nodes
        .iter()
        .map(|n| (n.as_str(), node_id(n)))
        .collect();
    for name in &graph.nodes {
        let id = ids
            .get(name.as_str())
            .cloned()
            .unwrap_or_else(|| node_id(name));
        out.push_str(&format!("    {id}[\"{}\"]\n", mermaid_label(name)));
    }
    for edge in &graph.edges {
        let from = ids
            .get(edge.from.as_str())
            .cloned()
            .unwrap_or_else(|| node_id(&edge.from));
        let to = ids
            .get(edge.to.as_str())
            .cloned()
            .unwrap_or_else(|| node_id(&edge.to));
        out.push_str(&format!(
            "    {from} -->|{} {:.1}| {to}\n",
            edge.kind.label(),
            edge.confidence
        ));
    }
    out
}

/// Render the graph as a plain-text table of edges, layers, and cycles.
pub fn render_table(graph: &DependencyGraph) -> String {
    let mut out = String::new();
    out.push_str("Tool dependency graph (static inference)\n");
    out.push_str(&format!(
        "  nodes: {}  edges: {}  layers: {}  cycles: {}\n\n",
        graph.nodes.len(),
        graph.edges.len(),
        graph.layers.len(),
        graph.cycles.len()
    ));
    out.push_str("Edges:\n");
    if graph.edges.is_empty() {
        out.push_str("  (none inferred)\n");
    }
    for edge in &graph.edges {
        out.push_str(&format!(
            "  {} -> {}  [{}, conf {:.1}]\n",
            edge.from,
            edge.to,
            edge.kind.label(),
            edge.confidence
        ));
    }
    out.push_str("\nLayers (call order):\n");
    for (i, layer) in graph.layers.iter().enumerate() {
        out.push_str(&format!("  {i}: {}\n", layer.join(", ")));
    }
    if !graph.cycles.is_empty() {
        out.push_str("\nCycles (no safe order):\n");
        for cycle in &graph.cycles {
            out.push_str(&format!("  - {}\n", cycle.join(" <-> ")));
        }
    }
    out
}

/// Mermaid-safe node id derived from a tool name.
fn node_id(name: &str) -> String {
    let mut id: String = name
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { '_' })
        .collect();
    if id.is_empty() || !id.starts_with(|c: char| c.is_alphabetic() || c == '_') {
        id.insert(0, 'n');
    }
    id
}

/// Escape a tool name for use inside a Mermaid double-quoted label.
fn mermaid_label(name: &str) -> String {
    name.replace('"', "&quot;")
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn tool_with_output(name: &str, out_prop: &str, ty: &str) -> Value {
        json!({
            "name": name,
            "outputSchema": {
                "type": "object",
                "properties": { out_prop: { "type": ty } }
            }
        })
    }

    fn tool_with_required_input(name: &str, in_prop: &str, ty: &str) -> Value {
        json!({
            "name": name,
            "inputSchema": {
                "type": "object",
                "properties": { in_prop: { "type": ty } },
                "required": [in_prop]
            }
        })
    }

    #[test]
    fn detects_output_input_edge_with_matching_type() {
        let producer = tool_with_output("create_order", "order_id", "string");
        let consumer = tool_with_required_input("get_order", "order_id", "string");
        let graph = infer_dependency_graph(&[consumer, producer]);
        let edge = graph
            .edges
            .iter()
            .find(|e| e.from == "create_order" && e.to == "get_order")
            .expect("output->input edge should exist");
        assert_eq!(edge.kind, EdgeKind::OutputInput);
        assert!((edge.confidence - 0.7).abs() < 1e-9);
    }

    #[test]
    fn rejects_output_input_edge_on_type_mismatch() {
        let producer = tool_with_output("create_order", "order_id", "number");
        let consumer = tool_with_required_input("get_order", "order_id", "string");
        let graph = infer_dependency_graph(&[producer, consumer]);
        assert!(graph
            .edges
            .iter()
            .all(|e| !(e.from == "create_order" && e.to == "get_order")));
    }

    #[test]
    fn ignores_optional_input_for_output_input_edge() {
        // order_id present in input but not required: no edge.
        let producer = tool_with_output("create_order", "order_id", "string");
        let consumer = json!({
            "name": "get_order",
            "inputSchema": {
                "type": "object",
                "properties": { "order_id": { "type": "string" } }
            }
        });
        let graph = infer_dependency_graph(&[producer, consumer]);
        assert!(graph.edges.is_empty());
    }

    #[test]
    fn detects_mention_edge_from_description() {
        let a = json!({
            "name": "list_users",
            "description": "Returns user ids; pass one to get_user."
        });
        let b = json!({ "name": "get_user" });
        let graph = infer_dependency_graph(&[a, b]);
        let edge = graph
            .edges
            .iter()
            .find(|e| e.from == "list_users" && e.to == "get_user")
            .expect("mention edge should exist");
        assert_eq!(edge.kind, EdgeKind::Mention);
        assert!((edge.confidence - 0.5).abs() < 1e-9);
    }

    #[test]
    fn mention_does_not_match_substring_of_another_name() {
        // get_users must not be detected inside the word get_users when
        // looking for get_user; tokenization is on identifier boundaries.
        let a = json!({
            "name": "describe",
            "description": "see get_users for the list"
        });
        let b = json!({ "name": "get_user" });
        let graph = infer_dependency_graph(&[a, b]);
        assert!(graph
            .edges
            .iter()
            .all(|e| !(e.from == "describe" && e.to == "get_user")));
    }

    #[test]
    fn detects_sequence_edge_with_ordering_language() {
        let a = json!({
            "name": "authenticate",
            "description": "First call authenticate, then call fetch_data to read."
        });
        let b = json!({ "name": "fetch_data" });
        let graph = infer_dependency_graph(&[a, b]);
        let edge = graph
            .edges
            .iter()
            .find(|e| e.from == "authenticate" && e.to == "fetch_data")
            .expect("sequence edge should exist");
        assert_eq!(edge.kind, EdgeKind::Sequence);
    }

    #[test]
    fn detects_resource_ref_edge_on_shared_required_id() {
        let a = json!({
            "name": "open_doc",
            "inputSchema": {
                "type": "object",
                "properties": { "doc_id": { "type": "string" } },
                "required": ["doc_id"]
            }
        });
        let b = json!({
            "name": "close_doc",
            "inputSchema": {
                "type": "object",
                "properties": { "doc_id": { "type": "string" } },
                "required": ["doc_id"]
            }
        });
        let graph = infer_dependency_graph(&[a, b]);
        let edge = graph
            .edges
            .iter()
            .find(|e| e.from == "close_doc" && e.to == "open_doc")
            .expect("resource-ref edge should exist in stable name order");
        assert_eq!(edge.kind, EdgeKind::ResourceRef);
    }

    #[test]
    fn three_tool_catalog_layers_independent_before_dependent() {
        let producer = tool_with_output("create_order", "order_id", "string");
        let consumer = tool_with_required_input("get_order", "order_id", "string");
        let isolated = json!({ "name": "ping" });
        let graph = infer_dependency_graph(&[consumer, producer, isolated]);
        // Layer 0 has the producer and the isolated tool; the consumer
        // depends on the producer so it must land in a later layer.
        assert!(graph.layers[0].contains(&"create_order".to_string()));
        assert!(graph.layers[0].contains(&"ping".to_string()));
        let consumer_layer = graph
            .layers
            .iter()
            .position(|l| l.contains(&"get_order".to_string()))
            .expect("consumer should be layered");
        assert!(consumer_layer > 0);
        assert!(graph.cycles.is_empty());
    }

    #[test]
    fn detects_two_tool_cycle() {
        // a mentions b, b mentions a -> a 2-node cycle that cannot layer.
        let a = json!({ "name": "alpha", "description": "uses beta to finish" });
        let b = json!({ "name": "beta", "description": "uses alpha to start" });
        let graph = infer_dependency_graph(&[a, b]);
        assert_eq!(graph.cycles.len(), 1);
        let cycle = &graph.cycles[0];
        assert!(cycle.contains(&"alpha".to_string()));
        assert!(cycle.contains(&"beta".to_string()));
        // Cyclic nodes are not also placed in a layer.
        assert!(graph
            .layers
            .iter()
            .all(|l| !l.contains(&"alpha".to_string())));
    }

    #[test]
    fn render_mermaid_contains_nodes_and_edge() {
        let producer = tool_with_output("create_order", "order_id", "string");
        let consumer = tool_with_required_input("get_order", "order_id", "string");
        let graph = infer_dependency_graph(&[producer, consumer]);
        let mermaid = render_mermaid(&graph);
        assert!(mermaid.starts_with("graph TD"));
        assert!(mermaid.contains("create_order[\"create_order\"]"));
        assert!(mermaid.contains("get_order[\"get_order\"]"));
        assert!(mermaid.contains("create_order -->|output->input 0.7| get_order"));
    }

    #[test]
    fn render_table_lists_edges_and_layers() {
        let producer = tool_with_output("create_order", "order_id", "string");
        let consumer = tool_with_required_input("get_order", "order_id", "string");
        let graph = infer_dependency_graph(&[producer, consumer]);
        let table = render_table(&graph);
        assert!(table.contains("create_order -> get_order"));
        assert!(table.contains("output->input"));
        assert!(table.contains("Layers (call order):"));
    }

    #[test]
    fn output_of_pair_is_deterministic_regardless_of_input_order() {
        let producer = tool_with_output("create_order", "order_id", "string");
        let consumer = tool_with_required_input("get_order", "order_id", "string");
        let isolated = json!({ "name": "ping" });
        let g1 = infer_dependency_graph(&[producer.clone(), consumer.clone(), isolated.clone()]);
        let g2 = infer_dependency_graph(&[isolated, consumer, producer]);
        assert_eq!(g1, g2);
    }

    #[test]
    fn skips_tools_without_string_name() {
        let valid = json!({ "name": "ok" });
        let bad = json!({ "description": "no name field" });
        let graph = infer_dependency_graph(&[valid, bad]);
        assert_eq!(graph.nodes, vec!["ok".to_string()]);
    }
}
