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}