pub mod basic;
mod interface;
pub mod shared;
pub use interface::{NodeExecutionInput, NodeExecutionOutput, NodeHandler};
use std::collections::HashSet;
use crate::pipeline::model::{
DslFlagKind, LayoutItem, NodeCapability, NodeDefinition, NodeFieldType,
};
pub fn builtin_node_definitions() -> Vec<crate::pipeline::NodeDefinition> {
basic::builtin_node_definitions()
}
pub fn native_node_capabilities()
-> &'static std::collections::BTreeMap<String, std::collections::BTreeSet<NodeCapability>> {
static NATIVE: std::sync::LazyLock<
std::collections::BTreeMap<String, std::collections::BTreeSet<NodeCapability>>,
> = std::sync::LazyLock::new(|| {
builtin_node_definitions()
.into_iter()
.map(|definition| {
(
definition.kind,
definition.capabilities.into_iter().collect(),
)
})
.collect()
});
&NATIVE
}
fn validate_layout_item(
item: &LayoutItem,
field_names: &HashSet<String>,
errors: &mut Vec<String>,
) {
match item {
LayoutItem::Field(name) => {
if !field_names.contains(name) {
errors.push(format!("layout references unknown field '{}'", name));
}
}
LayoutItem::Row { row } => {
if row.is_empty() {
errors.push("layout row must not be empty".to_string());
}
for child in row {
validate_layout_item(child, field_names, errors);
}
}
LayoutItem::Col { col } => {
if col.is_empty() {
errors.push("layout col must not be empty".to_string());
}
for child in col {
validate_layout_item(child, field_names, errors);
}
}
}
}
pub fn validate_node_definition_contract(def: &NodeDefinition) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
let kind = def.kind.trim();
if kind.is_empty() {
errors.push("kind must not be empty".to_string());
} else if !kind.starts_with("n.") {
errors.push(format!("kind '{}' must start with 'n.'", kind));
}
if def.title.trim().is_empty() {
errors.push("title must not be empty".to_string());
}
if def.description.trim().is_empty() {
errors.push("description must not be empty".to_string());
}
for (pin_role, pins) in [
("input", def.input_pins.as_slice()),
("output", def.output_pins.as_slice()),
] {
for pin in pins {
let trimmed = pin.trim();
if trimmed.is_empty() {
errors.push(format!("{} pin must not be empty", pin_role));
} else if trimmed != pin || trimmed.contains(char::is_whitespace) {
errors.push(format!(
"{} pin '{}' must be a compact token",
pin_role, pin
));
}
}
}
let mut flag_keys = HashSet::new();
for flag in &def.dsl_flags {
if !flag.flag.starts_with("--") || flag.flag.trim() != flag.flag {
errors.push(format!(
"DSL flag '{}' must start with -- and have no surrounding space",
flag.flag
));
}
if flag.config_key.trim().is_empty() {
errors.push(format!(
"DSL flag '{}' config_key must not be empty",
flag.flag
));
} else {
flag_keys.insert(flag.config_key.clone());
}
if flag.description.trim().is_empty() {
errors.push(format!(
"DSL flag '{}' description must not be empty",
flag.flag
));
}
}
let mut field_names = HashSet::new();
for field in &def.fields {
if field.name.trim().is_empty() {
errors.push("field name must not be empty".to_string());
continue;
}
if !field_names.insert(field.name.clone()) {
errors.push(format!("field '{}' is declared more than once", field.name));
}
if field.label.trim().is_empty() {
errors.push(format!("field '{}' label must not be empty", field.name));
}
if !matches!(field.field_type, NodeFieldType::Section)
&& field.help.as_deref().unwrap_or("").trim().is_empty()
{
errors.push(format!("field '{}' help must not be empty", field.name));
}
}
for item in &def.layout {
validate_layout_item(item, &field_names, &mut errors);
}
if let Some(properties) = def
.config_schema
.get("properties")
.and_then(|value| value.as_object())
{
for key in properties.keys() {
if key == "title" || key == "timeout_secs" {
continue;
}
if !field_names.contains(key) && !flag_keys.contains(key) {
errors.push(format!(
"config property '{}' must be documented by a field or DSL flag",
key
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn format_node_definition_markdown(def: &NodeDefinition) -> String {
let mut s = String::new();
s.push_str(&format!("### `{}` — {}\n\n", def.kind, def.title));
s.push_str(def.description.trim());
s.push_str("\n\n");
let ip = if def.input_pins.is_empty() {
"*(none — trigger / entry)*".to_string()
} else {
format!("`{}`", def.input_pins.join("`, `"))
};
let op = if def.output_pins.is_empty() {
"*(dynamic / graph-defined)*".to_string()
} else {
format!("`{}`", def.output_pins.join("`, `"))
};
s.push_str(&format!(
"- **Input pins:** {ip}\n- **Output pins:** {op}\n\n"
));
if !def.dsl_flags.is_empty() {
s.push_str("| DSL flag | Config key | Required | Kind | Description |\n");
s.push_str("|----------|------------|----------|------|-------------|\n");
for f in &def.dsl_flags {
let req = if f.required { "yes" } else { "no" };
let kind_s = match f.kind {
DslFlagKind::Scalar => "scalar",
DslFlagKind::CommaSeparatedList => "comma-list",
DslFlagKind::RepeatedList => "repeated-list",
DslFlagKind::Bool => "bool",
DslFlagKind::KeyValuePairs => "key-value-pairs",
DslFlagKind::SchemaField => "schema-field",
};
let desc = f.description.replace('|', "\\|");
s.push_str(&format!(
"| `{}` | `{}` | {} | {} | {} |\n",
f.flag, f.config_key, req, kind_s, desc
));
}
s.push_str("\n");
}
if !def.input_schema.is_null() {
s.push_str("**Input payload (schema):**\n```json\n");
if let Ok(pretty) = serde_json::to_string_pretty(&def.input_schema) {
s.push_str(&pretty);
}
s.push_str("\n```\n\n");
}
if !def.output_schema.is_null() {
s.push_str("**Output payload (schema):**\n```json\n");
if let Ok(pretty) = serde_json::to_string_pretty(&def.output_schema) {
s.push_str(&pretty);
}
s.push_str("\n```\n\n");
}
for example in &def.examples {
let title = if example.title.is_empty() { "Example" } else { example.title.as_str() };
s.push_str(&format!("**{title}:**\n"));
if !example.dsl.is_empty() {
s.push_str(&format!("```\n| {}\n```\n", example.dsl));
}
if !example.description.is_empty() {
s.push_str(example.description.trim());
s.push_str("\n");
}
if !example.input.is_null() || !example.output.is_null() {
s.push_str("```json\n");
if !example.input.is_null() {
s.push_str(&format!("// input\n{}\n", example.input));
}
if !example.output.is_null() {
s.push_str(&format!("// output\n{}\n", example.output));
}
s.push_str("```\n");
}
s.push_str("\n");
}
s.push_str(&format!(
"**MCP:** `help_nodes` with `kind=\"{}\"` for this section only.\n\n",
def.kind
));
s
}
pub fn kind_query_matches_def(def: &NodeDefinition, query: &str) -> bool {
let q = query.trim();
if q.is_empty() {
return false;
}
let kn = def.kind.to_lowercase();
let qn = q.to_lowercase();
if kn == qn {
return true;
}
if qn.starts_with("n.") {
return kn == qn;
}
if kn == format!("n.{qn}") {
return true;
}
kn.strip_prefix("n.").is_some_and(|tail| tail == qn)
}
pub fn node_markdown_by_kind_query(query: &str) -> Option<String> {
basic::builtin_node_definitions()
.into_iter()
.find(|d| kind_query_matches_def(d, query))
.map(|d| format_node_definition_markdown(&d))
}
pub fn builtin_nodes_markdown_reference() -> String {
let mut s = String::from(
"## Node kinds (live — from `builtin_node_definitions()`)\n\n\
This block matches the pipeline editor / node API: titles, descriptions, pins, DSL flags, and input/output schemas.\n\n\
- **Full catalog:** `help_nodes` with no `kind` (same as this section).\n\
- **One kind:** `help_nodes` with `kind=\"n.script\"` (or `script`, `trigger.webhook`, etc.).\n\n\
---\n\n",
);
for def in basic::builtin_node_definitions() {
s.push_str(&format_node_definition_markdown(&def));
s.push_str("---\n\n");
}
s
}
#[cfg(test)]
mod tests {
#[test]
fn every_composite_node_uses_only_settings_its_inner_nodes_read() {
use std::collections::{BTreeMap, BTreeSet};
let known: BTreeMap<String, BTreeSet<String>> = super::builtin_node_definitions()
.into_iter()
.map(|def| {
let mut keys: BTreeSet<String> = def.dsl_flags.iter().map(|f| f.config_key.clone()).collect();
keys.extend(def.fields.iter().map(|f| f.name.clone()));
if let Some(props) = def.config_schema.get("properties").and_then(|p| p.as_object()) {
keys.extend(props.keys().cloned());
}
keys.insert("ui".to_string()); (def.kind, keys)
})
.collect();
let crate_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut stack = vec![crate_dir.join("src/pipeline/nodes/bundled")];
let (mut checked, mut problems) = (0, Vec::new());
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("read bundled dir").flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if !path.to_string_lossy().ends_with(".zf.json") {
continue;
}
let shown = path.strip_prefix(crate_dir).unwrap_or(&path).display().to_string();
let doc: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).expect("read bundle")).expect("bundle is JSON");
let spec = doc.get("spec").unwrap_or(&doc);
for node in spec["nodes"].as_array().into_iter().flatten() {
checked += 1;
let kind = node["kind"].as_str().unwrap_or_default();
let Some(keys) = known.get(kind) else {
problems.push(format!("{shown}#{}: no built-in node '{kind}'", node["id"]));
continue;
};
for key in node["config"].as_object().into_iter().flat_map(|c| c.keys()) {
if !keys.contains(key) {
problems.push(format!("{shown}#{}: {kind} does not read '{key}'", node["id"]));
}
}
}
}
}
assert!(checked > 0, "no bundled node found; the walk is looking in the wrong place");
assert!(problems.is_empty(), "stale composite-node settings:\n{}", problems.join("\n"));
}
#[test]
fn no_node_definition_names_a_retired_path_or_expr_flag() {
let defs = super::builtin_node_definitions();
let declared: std::collections::BTreeSet<&str> =
defs.iter().flat_map(|d| d.dsl_flags.iter().map(|f| f.flag.as_str())).collect();
let mut stale = Vec::new();
for def in &defs {
let text = serde_json::to_string(def).expect("definition serialises");
let bytes = text.as_bytes();
let mut at = 0;
while let Some(pos) = text[at..].find("--") {
let start = at + pos;
let mut end = start + 2;
while end < bytes.len() && (bytes[end].is_ascii_lowercase() || bytes[end].is_ascii_digit() || bytes[end] == b'-') {
end += 1;
}
let flag = &text[start..end];
let preceded_by_word = start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'-');
if !preceded_by_word && (flag.ends_with("-path") || flag.ends_with("-expr")) && !declared.contains(flag) {
stale.push(format!("{}: {flag}", def.kind));
}
at = end.max(start + 2);
}
}
stale.sort();
stale.dedup();
assert!(stale.is_empty(), "definitions naming a flag no node has:\n{}", stale.join("\n"));
}
#[test]
fn native_node_definitions_have_complete_contracts() {
let mut failures = Vec::new();
for def in super::builtin_node_definitions() {
if let Err(error) = crate::contracts::kinds::validate_normalized_node_definition(&def) {
failures.push(format!("{}: {}", def.kind, error));
}
}
assert!(
failures.is_empty(),
"incomplete node definitions:\n{}",
failures.join("\n")
);
}
#[test]
fn every_node_takes_the_canvas_preview_flags() {
let defs = super::builtin_node_definitions();
let flags_of = |kind: &str| {
defs.iter()
.find(|d| d.kind == kind)
.unwrap_or_else(|| panic!("{kind} missing"))
.dsl_flags
.iter()
.map(|f| (f.flag.clone(), f.config_key.clone()))
.collect::<Vec<_>>()
};
for kind in ["n.table.query", "n.table.convert", "n.script"] {
let flags = flags_of(kind);
assert!(
flags.iter().any(|(flag, key)| flag == "--preview" && key == "preview.out"),
"{kind} takes --preview: {flags:?}"
);
assert!(
flags.iter().any(|(flag, key)| flag == "--preview-in" && key == "preview.in"),
"{kind} takes --preview-in: {flags:?}"
);
assert!(
!flags.iter().any(|(_, key)| key == "preview"),
"{kind} no longer stores anything flat at `preview`: {flags:?}"
);
}
for kind in ["n.table.query", "n.table.convert"] {
let flags = flags_of(kind);
assert!(
flags.iter().any(|(flag, key)| flag == "--preview-rows" && key == "preview_rows"),
"{kind} samples rows with --preview-rows: {flags:?}"
);
}
}
#[test]
fn every_node_file_lives_in_its_family_folder() {
let basic = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/pipeline/nodes/basic");
let families: std::collections::BTreeSet<String> = super::builtin_node_definitions()
.iter()
.filter_map(|def| def.kind.strip_prefix("n."))
.map(|rest| rest.split('.').next().unwrap_or(rest).to_string())
.collect();
let mut failures = Vec::new();
let mut folders = std::collections::BTreeSet::new();
for entry in std::fs::read_dir(&basic).expect("basic/ is readable") {
let entry = entry.expect("entry");
let name = entry.file_name().to_string_lossy().to_string();
if entry.path().is_dir() {
if !families.contains(&name) {
failures.push(format!(
"basic/{name}/ is not a DSL family of the catalogue (families: {})",
families.iter().cloned().collect::<Vec<_>>().join(", ")
));
}
folders.insert(name);
} else if name != "mod.rs" {
failures.push(format!(
"basic/{name} sits beside mod.rs; a node file is basic/<family>/<node>.rs"
));
}
}
for family in &families {
if !folders.contains(family) {
failures.push(format!("family `{family}` has nodes in the catalogue but no basic/{family}/ folder"));
}
}
assert!(failures.is_empty(), "node layout:\n{}", failures.join("\n"));
}
#[test]
fn embedded_official_node_definitions_have_complete_contracts() {
let mut failures = Vec::new();
for def in crate::platform::services::node_registry::NodeRegistryService::embedded_official_definitions() {
if let Err(error) = crate::contracts::kinds::validate_normalized_node_definition(&def) {
failures.push(format!("{}: {}", def.kind, error));
}
}
assert!(
failures.is_empty(),
"incomplete embedded official node definitions:\n{}",
failures.join("\n")
);
}
}