openquiver-graph 0.1.0

OpenQuiver graph IR: nodes, pins, types. Bottom layer, no internal dependencies.
Documentation
//! Nodes, and the names that identify them.

use std::borrow::Borrow;
use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;

use crate::Value;

/// A node's name within its graph, which is also its identity.
///
/// Nodes deliberately have no hidden ids: a readable DSL requires referring to
/// a node by name. The price is that renaming becomes a refactor that rewrites
/// every reference, exactly as in any programming language.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct NodeName(String);

impl NodeName {
    /// Normalizes to NFC, as Rust does for its own identifiers.
    ///
    /// `é` typed directly and `e` followed by a combining acute are the same
    /// name to a reader and different byte strings to a `BTreeMap`. macOS
    /// filesystems and several input methods emit the decomposed form, so
    /// without this two people on different machines can write what looks like
    /// the same node name and get two nodes — in a format whose entire job is
    /// surviving merges between exactly those machines.
    pub fn new(name: impl Into<String>) -> Self {
        let name = name.into();
        if name.is_ascii() {
            return Self(name);
        }
        Self(name.nfc().collect())
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Lets a `BTreeMap<NodeName, _>` be probed with a plain `&str`.
///
/// Sound because `NodeName`'s `Ord` and `Eq` are the wrapped `String`'s. Callers
/// that may hold a denormalized name still have to go through [`NodeName::new`];
/// see [`Graph::node`] for how that is kept cheap.
impl Borrow<str> for NodeName {
    fn borrow(&self) -> &str {
        &self.0
    }
}

/// A node type identifier, such as `acp.prompt` or `flow.branch`.
///
/// The namespace is open: built-in nodes, project functions (`fn.*`), MCP
/// server tools and types from native plugins all live in it as equals.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct NodeTypeId(String);

/// Unlike [`NodeName`], a type id is never normalized, so a borrowed probe is
/// unconditionally sound and needs no ASCII guard.
impl Borrow<str> for NodeTypeId {
    fn borrow(&self) -> &str {
        &self.0
    }
}

impl NodeTypeId {
    pub fn new(id: impl Into<String>) -> Self {
        Self(id.into())
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// A node instance in a graph.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Node {
    pub(crate) name: NodeName,
    type_id: NodeTypeId,
    /// The node's description, written as a `///` comment in the source.
    ///
    /// This is the one kind of comment that belongs in the IR. A `///` is not
    /// trivia: it is what the editor shows as the node's tooltip and what a
    /// published workflow reports as its tool description. Ordinary `//`
    /// comments are formatting and stay in the syntax layer, or two graphs
    /// differing only in whitespace would compare unequal — which would quietly
    /// break undo and merge.
    doc: Option<String>,
    /// Literals given to this node's data inputs, in canonical order by pin.
    config: BTreeMap<String, Value>,
}

impl Node {
    pub fn new(name: NodeName, type_id: NodeTypeId) -> Self {
        Self {
            name,
            type_id,
            doc: None,
            config: BTreeMap::new(),
        }
    }

    pub fn with_doc(mut self, doc: Option<String>) -> Self {
        self.doc = doc;
        self
    }

    pub fn name(&self) -> &str {
        self.name.as_str()
    }

    pub fn type_id(&self) -> &str {
        self.type_id.as_str()
    }

    pub fn doc(&self) -> Option<&str> {
        self.doc.as_deref()
    }

    pub fn set_config(&mut self, pin: impl Into<String>, value: Value) {
        self.config.insert(pin.into(), value);
    }

    pub fn config(&self, pin: &str) -> Option<&Value> {
        self.config.get(pin)
    }

    /// Configuration entries in canonical order, by pin name.
    pub fn config_entries(&self) -> impl Iterator<Item = (&String, &Value)> {
        self.config.iter()
    }
}