Skip to main content

openquiver_graph/
node.rs

1//! Nodes, and the names that identify them.
2
3use std::borrow::Borrow;
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7use unicode_normalization::UnicodeNormalization;
8
9use crate::Value;
10
11/// A node's name within its graph, which is also its identity.
12///
13/// Nodes deliberately have no hidden ids: a readable DSL requires referring to
14/// a node by name. The price is that renaming becomes a refactor that rewrites
15/// every reference, exactly as in any programming language.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17pub struct NodeName(String);
18
19impl NodeName {
20    /// Normalizes to NFC, as Rust does for its own identifiers.
21    ///
22    /// `é` typed directly and `e` followed by a combining acute are the same
23    /// name to a reader and different byte strings to a `BTreeMap`. macOS
24    /// filesystems and several input methods emit the decomposed form, so
25    /// without this two people on different machines can write what looks like
26    /// the same node name and get two nodes — in a format whose entire job is
27    /// surviving merges between exactly those machines.
28    pub fn new(name: impl Into<String>) -> Self {
29        let name = name.into();
30        if name.is_ascii() {
31            return Self(name);
32        }
33        Self(name.nfc().collect())
34    }
35
36    pub fn as_str(&self) -> &str {
37        &self.0
38    }
39}
40
41/// Lets a `BTreeMap<NodeName, _>` be probed with a plain `&str`.
42///
43/// Sound because `NodeName`'s `Ord` and `Eq` are the wrapped `String`'s. Callers
44/// that may hold a denormalized name still have to go through [`NodeName::new`];
45/// see [`Graph::node`] for how that is kept cheap.
46impl Borrow<str> for NodeName {
47    fn borrow(&self) -> &str {
48        &self.0
49    }
50}
51
52/// A node type identifier, such as `acp.prompt` or `flow.branch`.
53///
54/// The namespace is open: built-in nodes, project functions (`fn.*`), MCP
55/// server tools and types from native plugins all live in it as equals.
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
57pub struct NodeTypeId(String);
58
59/// Unlike [`NodeName`], a type id is never normalized, so a borrowed probe is
60/// unconditionally sound and needs no ASCII guard.
61impl Borrow<str> for NodeTypeId {
62    fn borrow(&self) -> &str {
63        &self.0
64    }
65}
66
67impl NodeTypeId {
68    pub fn new(id: impl Into<String>) -> Self {
69        Self(id.into())
70    }
71
72    pub fn as_str(&self) -> &str {
73        &self.0
74    }
75}
76
77/// A node instance in a graph.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Node {
80    pub(crate) name: NodeName,
81    type_id: NodeTypeId,
82    /// The node's description, written as a `///` comment in the source.
83    ///
84    /// This is the one kind of comment that belongs in the IR. A `///` is not
85    /// trivia: it is what the editor shows as the node's tooltip and what a
86    /// published workflow reports as its tool description. Ordinary `//`
87    /// comments are formatting and stay in the syntax layer, or two graphs
88    /// differing only in whitespace would compare unequal — which would quietly
89    /// break undo and merge.
90    doc: Option<String>,
91    /// Literals given to this node's data inputs, in canonical order by pin.
92    config: BTreeMap<String, Value>,
93}
94
95impl Node {
96    pub fn new(name: NodeName, type_id: NodeTypeId) -> Self {
97        Self {
98            name,
99            type_id,
100            doc: None,
101            config: BTreeMap::new(),
102        }
103    }
104
105    pub fn with_doc(mut self, doc: Option<String>) -> Self {
106        self.doc = doc;
107        self
108    }
109
110    pub fn name(&self) -> &str {
111        self.name.as_str()
112    }
113
114    pub fn type_id(&self) -> &str {
115        self.type_id.as_str()
116    }
117
118    pub fn doc(&self) -> Option<&str> {
119        self.doc.as_deref()
120    }
121
122    pub fn set_config(&mut self, pin: impl Into<String>, value: Value) {
123        self.config.insert(pin.into(), value);
124    }
125
126    pub fn config(&self, pin: &str) -> Option<&Value> {
127        self.config.get(pin)
128    }
129
130    /// Configuration entries in canonical order, by pin name.
131    pub fn config_entries(&self) -> impl Iterator<Item = (&String, &Value)> {
132        self.config.iter()
133    }
134}