1use crate::auth_catalog::AuthCatalog;
12use crate::config::{ConnectorSpec, NodeSpec, PipelineConfig};
13use crate::error::{CliError, CliResult};
14use crate::executor::{InvocationOutcome, RunSummary};
15use crate::merge::merge_value;
16use crate::registry::{build_sink, build_source};
17use crate::transforms::compile_transforms;
18use faucet_core::stage::compile_stage;
19use faucet_core::topology::{
20 JoinConfig, JoinNode, NodeKind, Topology, TopologyOnError, TopologyOptions,
21};
22use serde_json::Value;
23use std::collections::HashMap;
24use tokio_util::sync::CancellationToken;
25
26pub fn is_topology(cfg: &PipelineConfig) -> bool {
28 !cfg.pipeline.nodes.is_empty()
29}
30
31fn resolve_connector(
34 templates: &HashMap<String, ConnectorSpec>,
35 legacy: &Option<ConnectorSpec>,
36 template_ref: Option<&str>,
37 kind_override: Option<&str>,
38 config_override: Option<&Value>,
39 node_id: &str,
40 kind_label: &'static str,
41) -> CliResult<(String, Value)> {
42 let name = template_ref.unwrap_or("default");
43 let base: ConnectorSpec = if name == "default" {
44 templates
45 .get("default")
46 .cloned()
47 .or_else(|| legacy.clone())
48 .ok_or(CliError::MissingTemplate {
49 kind: kind_label,
50 row_id: node_id.to_string(),
51 })?
52 } else {
53 templates
54 .get(name)
55 .cloned()
56 .ok_or_else(|| CliError::UnknownTemplate {
57 kind: kind_label,
58 name: name.to_string(),
59 row_id: node_id.to_string(),
60 known: {
61 let mut k: Vec<String> = templates.keys().cloned().collect();
62 if legacy.is_some() {
63 k.push("default".to_string());
64 }
65 k.sort();
66 k
67 },
68 })?
69 };
70 let mut kind = base.kind;
71 let mut config = base.config;
72 if let Some(k) = kind_override {
73 kind = k.to_string();
74 }
75 if let Some(c) = config_override {
76 merge_value(&mut config, c.clone());
77 }
78 Ok((kind, config))
79}
80
81pub async fn build_topology(cfg: &PipelineConfig, auth: &AuthCatalog) -> CliResult<Topology> {
84 if !cfg.matrix.is_empty() {
85 return Err(CliError::MatrixAndNodesBothPresent);
86 }
87
88 let spec = &cfg.pipeline;
89 let mut builder = Topology::builder();
90
91 let mut node_ids: Vec<&String> = spec.nodes.keys().collect();
93 node_ids.sort();
94
95 for id in &node_ids {
96 let node = &spec.nodes[*id];
97 let kind: NodeKind = match node {
98 NodeSpec::Source {
99 template,
100 kind,
101 config,
102 } => {
103 let (k, c) = resolve_connector(
104 &spec.sources,
105 &spec.source,
106 template.as_deref(),
107 kind.as_deref(),
108 config.as_ref(),
109 id,
110 "source",
111 )?;
112 NodeKind::Source(build_source(&k, c, auth, None).await?)
113 }
114 NodeSpec::Sink {
115 template,
116 kind,
117 config,
118 } => {
119 let (k, c) = resolve_connector(
120 &spec.sinks,
121 &spec.sink,
122 template.as_deref(),
123 kind.as_deref(),
124 config.as_ref(),
125 id,
126 "sink",
127 )?;
128 NodeKind::Sink(build_sink(&k, c, auth).await?)
129 }
130 NodeSpec::Transform { transforms } => {
131 let stages = compile_transforms(transforms)?;
132 let compiled = stages
133 .iter()
134 .map(compile_stage)
135 .collect::<Result<Vec<_>, _>>()?;
136 NodeKind::Transform(compiled)
137 }
138 NodeSpec::Tee {
139 channel_capacity,
140 fanout,
141 } => NodeKind::Tee {
142 capacity: *channel_capacity,
143 fanout: *fanout,
144 },
145 NodeSpec::Merge => NodeKind::Merge,
146 NodeSpec::Join(js) => NodeKind::Join(JoinNode {
147 config: JoinConfig {
148 mode: js.mode,
149 build_key: js.build.key.clone(),
150 probe_key: js.probe.key.clone(),
151 projections: js.project.clone(),
152 on_missing: js.on_missing.clone(),
153 on_duplicate: js.on_duplicate,
154 on_collision: js.on_collision,
155 key_normalize: js.key_normalize,
156 max_build_records: js.max_build_records,
157 },
158 build_edge: js.build.edge.clone(),
159 probe_edge: js.probe.edge.clone(),
160 }),
161 };
162 builder = builder.node((*id).clone(), kind);
163 }
164
165 let known: Vec<String> = node_ids.iter().map(|s| (*s).clone()).collect();
167 for e in &spec.edges {
168 if !spec.nodes.contains_key(&e.from) {
169 return Err(CliError::EdgeEndpointMissing {
170 name: e.from.clone(),
171 known: known.clone(),
172 });
173 }
174 if !spec.nodes.contains_key(&e.to) {
175 return Err(CliError::EdgeEndpointMissing {
176 name: e.to.clone(),
177 known: known.clone(),
178 });
179 }
180 builder = match &e.label {
181 Some(label) => builder.labelled_edge(e.from.clone(), e.to.clone(), label.clone()),
182 None => builder.edge(e.from.clone(), e.to.clone()),
183 };
184 }
185
186 builder.build().map_err(|e| CliError::InvalidTopology {
187 message: e.to_string(),
188 })
189}
190
191pub async fn preview_records(
195 cfg: &PipelineConfig,
196 auth: &AuthCatalog,
197 limit: usize,
198) -> CliResult<Vec<(String, Vec<Value>)>> {
199 if !cfg.matrix.is_empty() {
200 return Err(CliError::MatrixAndNodesBothPresent);
201 }
202 let spec = &cfg.pipeline;
203 let mut ids: Vec<&String> = spec.nodes.keys().collect();
204 ids.sort();
205
206 let mut out = Vec::new();
207 for id in ids {
208 if let NodeSpec::Source {
209 template,
210 kind,
211 config,
212 } = &spec.nodes[id]
213 {
214 let (k, c) = resolve_connector(
215 &spec.sources,
216 &spec.source,
217 template.as_deref(),
218 kind.as_deref(),
219 config.as_ref(),
220 id,
221 "source",
222 )?;
223 let source = build_source(&k, c, auth, None).await?;
224 let records = source.fetch_all().await?;
225 out.push((
226 id.clone(),
227 records.into_iter().take(limit).collect::<Vec<_>>(),
228 ));
229 }
230 }
231 if out.is_empty() {
232 return Err(CliError::InvalidTopology {
233 message: "no source nodes to preview".to_string(),
234 });
235 }
236 Ok(out)
237}
238
239pub async fn preview(cfg: &PipelineConfig, auth: &AuthCatalog, limit: usize) -> CliResult<()> {
242 for (id, records) in preview_records(cfg, auth, limit).await? {
243 tracing::info!(node = %id, "previewing source node");
244 for rec in records {
245 println!("{}", serde_json::to_string(&rec).unwrap_or_default());
246 }
247 }
248 Ok(())
249}
250
251pub async fn preview_to_string(
253 cfg: &PipelineConfig,
254 auth: &AuthCatalog,
255 limit: usize,
256) -> CliResult<String> {
257 let sources = preview_records(cfg, auth, limit).await?;
258 let doc: Vec<Value> = sources
259 .into_iter()
260 .map(|(id, records)| {
261 serde_json::json!({ "node": id, "count": records.len(), "records": records })
262 })
263 .collect();
264 Ok(
265 serde_json::to_string_pretty(&serde_json::json!({ "sources": doc }))
266 .unwrap_or_else(|_| "[]".to_string()),
267 )
268}
269
270pub async fn run_topology(
274 cfg: &PipelineConfig,
275 auth: &AuthCatalog,
276 cancel: Option<CancellationToken>,
277) -> CliResult<RunSummary> {
278 let topo = build_topology(cfg, auth).await?;
279
280 let pipeline_name = cfg.name.clone().unwrap_or_else(|| "unnamed".to_string());
281 let run_id = uuid::Uuid::now_v7().to_string();
282
283 let on_error = match cfg.execution.as_ref().map(|e| e.on_error) {
284 Some(crate::config::OnError::Stop) => TopologyOnError::Propagate,
285 _ => TopologyOnError::Continue,
286 };
287
288 let mut opts = TopologyOptions::new(pipeline_name).with_on_error(on_error);
289 opts.run_id = run_id;
290
291 if let Some(state) = &cfg.pipeline.state {
292 opts = opts.with_state_store(crate::state::build_state_store(state).await?);
293 }
294 if let Some(dlq) = &cfg.pipeline.dlq {
295 opts = opts.with_dlq(crate::executor::build_dlq_config(dlq).await?);
296 }
297 if let Some(c) = cancel {
298 opts = opts.with_cancel(c);
299 }
300
301 let result = topo.run(opts).await?;
302
303 let mut invocations: Vec<InvocationOutcome> = result
304 .per_sink
305 .into_iter()
306 .map(|(node_id, records)| InvocationOutcome {
307 row_id: node_id,
308 parent_record_key: None,
309 records_written: records,
310 error: None,
311 metrics: None,
312 })
313 .collect();
314 invocations.sort_by(|a, b| a.row_id.cmp(&b.row_id));
315
316 for msg in result.errors {
317 invocations.push(InvocationOutcome {
318 row_id: "topology".to_string(),
319 parent_record_key: None,
320 records_written: 0,
321 error: Some(msg),
322 metrics: None,
323 });
324 }
325
326 Ok(RunSummary { invocations })
327}