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 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")
);
}
}