pub mod basic;
pub mod check;
mod conventions;
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.split('.').count() < 2 || kind.split('.').any(|segment| segment.is_empty()) {
errors.push(format!("kind '{}' must be dotted segments, e.g. 'fs.image.thumbnail'", 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 == crate::pipeline::model::NODE_TIMEOUT_KEY {
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 node_signature(def: &NodeDefinition) -> String {
let common: Vec<String> = crate::pipeline::model::engine_common_dsl_flags()
.into_iter()
.map(|flag| flag.flag)
.collect();
let kind = def.kind.as_str();
let mut out = kind.to_string();
for flag in def.dsl_flags.iter().filter(|flag| !common.contains(&flag.flag)) {
out.push(' ');
out.push_str(&flag_token(flag, &flag.choices, flag.required));
}
if let Some(key) = answer_key(kind) {
out.push_str(&format!(" → {key}"));
}
out
}
fn value_token(value: &str, choices: &[String]) -> String {
if !choices.is_empty() {
return choices.join("|");
}
match value {
"" => "VALUE".to_string(),
"number" => "N".to_string(),
"expression" => "EXPR".to_string(),
other => other.trim_start_matches("file:").to_ascii_uppercase(),
}
}
fn flag_token(flag: &crate::pipeline::model::DslFlag, choices: &[String], required: bool) -> String {
let value = value_token(&flag.value, choices);
let token = match flag.kind {
DslFlagKind::Bool => flag.flag.clone(),
DslFlagKind::KeyValuePairs => format!("{} KEY={value}…", flag.flag),
DslFlagKind::RepeatedList => format!("{} {value}…", flag.flag),
_ => format!("{} {value}", flag.flag),
};
if required { token } else { format!("[{token}]") }
}
pub fn provider_signature(def: &NodeDefinition, provider: &str) -> Option<String> {
use crate::pipeline::nodes::shared::profile::{MODEL_FLAG, OPTION_FLAG, PROVIDER_FLAG, gated_roles};
let profile = def.profiles.iter().find(|p| p.provider == provider)?;
let gated = gated_roles(def);
let common: Vec<String> = crate::pipeline::model::engine_common_dsl_flags()
.into_iter()
.map(|flag| flag.flag)
.collect();
let mut out = def.kind.clone();
for flag in def.dsl_flags.iter().filter(|flag| !common.contains(&flag.flag)) {
let name = flag.flag.as_str();
let token = if name == PROVIDER_FLAG {
format!("{PROVIDER_FLAG} {provider}")
} else if name == OPTION_FLAG {
let options: Vec<String> = profile
.options
.iter()
.map(|option| format!("[{OPTION_FLAG} {}={}]", option.key, value_token(&option.value, &option.choices)))
.collect();
if options.is_empty() {
continue;
}
options.join(" ")
} else if gated.contains(name) {
let Some(role) = profile.roles.get(name) else { continue };
let mut token = flag_token(flag, &flag.choices, role.required);
if let Some(max) = role.max_repeat {
token = token.replacen('…', &format!("…(≤{max})"), 1);
}
token
} else {
let choices = match (name, profile.choices.get(name)) {
(_, Some(words)) => words.clone(),
(MODEL_FLAG, None) if !profile.models.is_empty() => profile.models.clone(),
_ => flag.choices.clone(),
};
flag_token(flag, &choices, flag.required)
};
out.push(' ');
out.push_str(&token);
}
if let Some(key) = answer_key(&def.kind) {
out.push_str(&format!(" → {key}"));
}
Some(out)
}
pub fn format_provider_profiles_markdown(def: &NodeDefinition) -> String {
if def.profiles.is_empty() {
return String::new();
}
let mut s = String::from("**Per provider** (`--provider` picks one; each line is generated from its profile):\n\n```\n");
for profile in &def.profiles {
if let Some(signature) = provider_signature(def, &profile.provider) {
s.push_str(&signature);
s.push('\n');
}
}
s.push_str("```\n\n");
for profile in &def.profiles {
let kinds = if profile.credential_kinds.is_empty() {
"any kind".to_string()
} else {
profile.credential_kinds.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(" or ")
};
s.push_str(&format!("- `{}` — `--credential` of kind {kinds}", profile.provider));
if !profile.models.is_empty() {
s.push_str(&format!("; `--model` {}", profile.models.iter().map(|m| format!("`{m}`")).collect::<Vec<_>>().join(" ")));
}
s.push('\n');
for option in &profile.options {
s.push_str(&format!(" - `--option {}=…` — {}\n", option.key, option.description.trim()));
}
}
s.push('\n');
s
}
pub fn answer_key(kind: &str) -> Option<String> {
let parts: Vec<&str> = kind.split('.').collect();
match parts.as_slice() {
["trigger", source, ..] => Some((*source).to_string()),
["logic", verb @ ("reduce" | "collect")] => Some((*verb).to_string()),
["logic", ..] | ["input", ..] => None,
["web", "response", "send"] => None,
["x", _package, noun, _verb] => Some((*noun).to_string()),
["x", ..] => None,
[_, noun, _] => Some((*noun).to_string()),
_ => None,
}
}
pub fn failure_key(kind: &str, name: Option<&str>) -> String {
if kind.starts_with("input.")
&& let Some(name) = name.and_then(|n| n.split('.').next()).map(str::trim)
&& !name.is_empty()
&& !name.contains("{{")
{
return name.to_string();
}
answer_key(kind)
.or_else(|| kind.split('.').nth(1).filter(|w| !w.is_empty()).map(str::to_string))
.unwrap_or_else(|| "error".to_string())
}
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(&format!("```\n{}\n```\n\n", node_signature(def)));
s.push_str(&format_provider_profiles_markdown(def));
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::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 topic=\"pipeline/nodes/{}\"` 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;
}
def.kind.eq_ignore_ascii_case(q)
}
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\
- **The index:** `help(\"pipeline/nodes\")` — every kind on one line.\n\
- **One kind:** `help(\"pipeline/nodes/javascript.script.run\")` (or `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 ["table.query.run", "table.data.convert", "javascript.script.run"] {
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 ["table.query.run", "table.data.convert"] {
let flags = flags_of(kind);
assert!(
!flags.iter().any(|(flag, _)| flag == "--preview-rows"),
"{kind} has no row sample of its own: {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()
.map(|def| def.kind.split('.').next().unwrap_or_default().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 a_kind_with_profiles_prints_each_providers_signature() {
let def = crate::platform::services::node_registry::NodeRegistryService::embedded_official_definitions()
.into_iter()
.find(|d| d.kind == "ai.embedding.generate")
.expect("the embedding composite ships");
let help = crate::platform::help::get_help("pipeline/nodes/ai.embedding.generate").expect("help");
assert!(help.contains("**Per provider**"), "{help}");
assert!(help.contains("ai.embedding.generate --provider openai --credential TEXT [--model text-embedding-3-small|text-embedding-3-large|text-embedding-ada-002] --text TEXT… → embedding"), "{help}");
assert!(help.contains("ai.embedding.generate --provider openrouter --credential TEXT [--model TEXT] --text TEXT… → embedding"), "{help}");
assert!(help.contains("- `openai` — `--credential` of kind `openai`"), "{help}");
assert_eq!(super::provider_signature(&def, "cohere"), None);
let tts = crate::platform::help::get_help("pipeline/nodes/ai.audio.generate").expect("help");
assert!(tts.contains("[--option volume=N] [--option lipsync=none|basic|timed_words|audio_guided|audio_segmented]"), "{tts}");
assert!(tts.contains(" - `--option volume=…` — Loudness multiplier"), "{tts}");
}
#[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")
);
}
}