use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::pipeline::model::{DslFlag, DslFlagKind, LayoutItem, NodeFieldDef, NodeFieldType};
use crate::pipeline::{
NodeDefinition, PipelineError,
nodes::{NodeExecutionInput, NodeExecutionOutput, NodeHandler},
};
pub const NODE_KIND: &str = "logic.concept";
const INPUT_PIN_IN: &str = "in";
const OUTPUT_PIN_OUT: &str = "out";
pub fn definition() -> NodeDefinition {
NodeDefinition {
kind: NODE_KIND.to_string(),
title: "Concept".to_string(),
description: "A described but unbuilt step. Passes its input through unchanged so a \
pipeline can be wired and run before every step exists. The editor shows \
its text as markdown in the node body."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"description": "Whatever arrives — it leaves unchanged."
}),
output_schema: serde_json::json!({
"type": "object",
"description": "Exactly the input payload."
}),
input_pins: vec![INPUT_PIN_IN.to_string()],
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
script_available: false,
script_bridge: None,
config_schema: serde_json::json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "What this step will do, in markdown. Rendered in the node body."
}
}
}),
dsl_flags: vec![DslFlag {
flag: "--text".to_string(),
config_key: "text".to_string(),
description: "What this step will do, in markdown.".to_string(),
kind: DslFlagKind::Scalar,
required: false,
..Default::default()
}],
fields: vec![NodeFieldDef {
name: "text".to_string(),
label: "Description".to_string(),
field_type: NodeFieldType::Textarea,
rows: Some(6),
help: Some(
"What this step will do once it is built. Markdown, shown in the node body."
.to_string(),
),
..Default::default()
}],
layout: vec![LayoutItem::Field("text".to_string())],
ai_tool: Default::default(),
examples: vec![
crate::pipeline::model::NodeExample::dsl("A step to build later", r#"logic.concept --text "Enrich the lead with company data from Clearbit, then score it 1–5.""#)
.note("Passes the payload through unchanged so the pipeline runs end to end today; replace it with real nodes when ready."),
],
..Default::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub text: String,
}
pub struct Node {
config: Config,
}
impl Node {
pub fn new(config: Config) -> Self {
Self { config }
}
}
#[async_trait]
impl NodeHandler for Node {
fn kind(&self) -> &'static str {
NODE_KIND
}
fn input_pins(&self) -> &'static [&'static str] {
&[INPUT_PIN_IN]
}
fn output_pins(&self) -> &'static [&'static str] {
&[OUTPUT_PIN_OUT]
}
async fn execute_async(
&self,
input: NodeExecutionInput,
) -> Result<NodeExecutionOutput, PipelineError> {
let note = self.config.text.trim();
let trace = if note.is_empty() {
format!("{NODE_KIND}: not built yet")
} else {
format!("{NODE_KIND}: not built yet — {note}")
};
Ok(NodeExecutionOutput {
output_pins: vec![OUTPUT_PIN_OUT.to_string()],
payload: input.payload,
trace: vec![trace],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn run(config: Config, payload: serde_json::Value) -> NodeExecutionOutput {
let node = Node::new(config);
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(node.execute_async(NodeExecutionInput {
node_id: "c".to_string(),
input_pin: INPUT_PIN_IN.to_string(),
payload,
metadata: json!({}),
bus: None,
}))
.expect("concept never fails")
}
#[test]
fn whatever_arrives_leaves_unchanged() {
let payload = json!({ "researcher": { "name": "Sari" }, "n": 3 });
let out = run(
Config {
text: "look them up in ORCID".to_string(),
},
payload.clone(),
);
assert_eq!(out.payload, payload);
assert_eq!(out.output_pins, vec![OUTPUT_PIN_OUT.to_string()]);
}
#[test]
fn the_trace_names_the_step_as_unbuilt() {
let out = run(
Config {
text: "look them up in ORCID".to_string(),
},
json!({}),
);
assert!(out.trace[0].contains("not built yet"), "{:?}", out.trace);
assert!(out.trace[0].contains("ORCID"), "{:?}", out.trace);
let bare = run(Config::default(), json!({}));
assert!(bare.trace[0].contains("not built yet"), "{:?}", bare.trace);
}
#[test]
fn a_concept_declares_no_capabilities() {
assert!(definition().capabilities.is_empty());
}
}