Skip to main content

frp_domain/
block.rs

1use std::collections::HashSet;
2
3use frp_plexus::{AtomId, BlockId};
4use serde::{Deserialize, Serialize};
5
6use crate::error::DomainError;
7use crate::meta::Meta;
8use crate::port::{Port, PortDirection};
9
10// ---------------------------------------------------------------------------
11// BlockSchema
12// ---------------------------------------------------------------------------
13
14/// Declares the typed port interface of a [`Block`]: which inputs it accepts
15/// and which outputs it produces.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct BlockSchema {
18    pub inputs: Vec<Port>,
19    pub outputs: Vec<Port>,
20}
21
22impl BlockSchema {
23    /// Create a new schema from explicit port lists.
24    pub fn new(inputs: Vec<Port>, outputs: Vec<Port>) -> Self {
25        Self { inputs, outputs }
26    }
27
28    /// Validate that:
29    /// - All input ports have `direction == Input`
30    /// - All output ports have `direction == Output`
31    /// - No two inputs share a name
32    /// - No two outputs share a name
33    pub fn validate(&self) -> Result<(), DomainError> {
34        let mut seen_inputs = HashSet::new();
35        for p in &self.inputs {
36            if p.direction != PortDirection::Input {
37                return Err(DomainError::InvalidSchema(format!(
38                    "port '{}' listed as input but has direction {}",
39                    p.name, p.direction
40                )));
41            }
42            if !seen_inputs.insert(&p.name) {
43                return Err(DomainError::DuplicatePort(p.name.clone()));
44            }
45        }
46
47        let mut seen_outputs = HashSet::new();
48        for p in &self.outputs {
49            if p.direction != PortDirection::Output {
50                return Err(DomainError::InvalidSchema(format!(
51                    "port '{}' listed as output but has direction {}",
52                    p.name, p.direction
53                )));
54            }
55            if !seen_outputs.insert(&p.name) {
56                return Err(DomainError::DuplicatePort(p.name.clone()));
57            }
58        }
59
60        Ok(())
61    }
62
63    /// Find an input port by name.
64    pub fn find_input(&self, name: &str) -> Option<&Port> {
65        self.inputs.iter().find(|p| p.name == name)
66    }
67
68    /// Find an output port by name.
69    pub fn find_output(&self, name: &str) -> Option<&Port> {
70        self.outputs.iter().find(|p| p.name == name)
71    }
72
73    /// Find any port (input or output) by name.
74    pub fn find_port(&self, name: &str) -> Option<&Port> {
75        self.find_input(name).or_else(|| self.find_output(name))
76    }
77}
78
79// ---------------------------------------------------------------------------
80// Block
81// ---------------------------------------------------------------------------
82
83/// A composable unit in an frp graph: a group of [`Atom`](crate::atom::Atom)s
84/// with a shared typed port interface ([`BlockSchema`]) and metadata.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Block {
87    pub id: BlockId,
88    pub schema: BlockSchema,
89    /// IDs of the atoms that make up this block.
90    pub atoms: Vec<AtomId>,
91    pub meta: Meta,
92}
93
94impl Block {
95    /// Create a block directly (prefer [`BlockBuilder`] for ergonomics).
96    pub fn new(id: BlockId, schema: BlockSchema, atoms: Vec<AtomId>, meta: Meta) -> Self {
97        Self { id, schema, atoms, meta }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// frp-loom integration: HasBlockId
103// ---------------------------------------------------------------------------
104
105impl frp_loom::memory::HasBlockId for Block {
106    fn block_id(&self) -> BlockId {
107        self.id
108    }
109}
110
111// ---------------------------------------------------------------------------
112// BlockBuilder
113// ---------------------------------------------------------------------------
114
115/// Fluent builder for [`Block`].
116#[derive(Default)]
117pub struct BlockBuilder {
118    id: Option<BlockId>,
119    schema: Option<BlockSchema>,
120    atoms: Vec<AtomId>,
121    meta: Meta,
122}
123
124impl BlockBuilder {
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    pub fn id(mut self, id: BlockId) -> Self {
130        self.id = Some(id);
131        self
132    }
133
134    pub fn schema(mut self, schema: BlockSchema) -> Self {
135        self.schema = Some(schema);
136        self
137    }
138
139    pub fn atom(mut self, id: AtomId) -> Self {
140        self.atoms.push(id);
141        self
142    }
143
144    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
145        self.meta = self.meta.with_label(key, value);
146        self
147    }
148
149    /// Build the [`Block`], validating the schema before returning.
150    pub fn build(self) -> Result<Block, DomainError> {
151        let id = self.id.ok_or_else(|| DomainError::MissingField("id".into()))?;
152        let schema = self.schema.ok_or_else(|| DomainError::MissingField("schema".into()))?;
153        schema.validate()?;
154        Ok(Block::new(id, schema, self.atoms, self.meta))
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Tests
160// ---------------------------------------------------------------------------
161
162#[cfg(test)]
163mod tests {
164    use frp_plexus::{IdGen, TypeSig};
165
166    use super::*;
167    use crate::port::Port;
168
169    fn make_schema(ids: &IdGen) -> BlockSchema {
170        BlockSchema::new(
171            vec![Port::new_input(ids.next_port_id(), "x", TypeSig::Int)],
172            vec![Port::new_output(ids.next_port_id(), "y", TypeSig::Int)],
173        )
174    }
175
176    #[test]
177    fn schema_validate_passes() {
178        let ids = IdGen::new();
179        assert!(make_schema(&ids).validate().is_ok());
180    }
181
182    #[test]
183    fn schema_duplicate_input_name_fails() {
184        let ids = IdGen::new();
185        let schema = BlockSchema::new(
186            vec![
187                Port::new_input(ids.next_port_id(), "x", TypeSig::Int),
188                Port::new_input(ids.next_port_id(), "x", TypeSig::Float),
189            ],
190            vec![],
191        );
192        assert!(matches!(schema.validate(), Err(DomainError::DuplicatePort(_))));
193    }
194
195    #[test]
196    fn block_builder_builds_successfully() {
197        let ids = IdGen::new();
198        let block = BlockBuilder::new()
199            .id(ids.next_block_id())
200            .schema(make_schema(&ids))
201            .label("env", "test")
202            .build()
203            .unwrap();
204        assert_eq!(block.meta.labels["env"], "test");
205    }
206
207    #[test]
208    fn block_builder_missing_id_fails() {
209        let ids = IdGen::new();
210        let err = BlockBuilder::new().schema(make_schema(&ids)).build().unwrap_err();
211        assert!(matches!(err, DomainError::MissingField(_)));
212    }
213
214    #[test]
215    fn schema_find_port() {
216        let ids = IdGen::new();
217        let schema = make_schema(&ids);
218        assert!(schema.find_input("x").is_some());
219        assert!(schema.find_output("y").is_some());
220        assert!(schema.find_port("x").is_some());
221        assert!(schema.find_port("z").is_none());
222    }
223}