Skip to main content

bnto_core/executor/
mod.rs

1// Pipeline Executor — top-level orchestrator for pipeline execution.
2//
3// Walks nodes in order, chaining outputs to inputs. Split across seven files:
4//   - `mod.rs` — public API, node chaining and dispatch, shared types
5//   - `auto_iteration.rs` — implicit per-file loops for `iteration: "auto"`
6//   - `primitive.rs` — leaf node execution (image compress, file rename, etc.)
7//   - `container.rs` — container node execution (loop, group, parallel)
8//   - `loop_config.rs` — loop container params (error handling, persistence)
9//   - `resolve.rs` — `{{fields.*}}` template resolution
10//   - `template.rs` — multi-namespace template resolution (env, ctx, node, item)
11//
12// Pure Rust — no WASM deps. Works with `cargo test` natively.
13// The WASM bridge (`bnto-wasm/src/execute.rs`) handles JS type conversions.
14
15mod auto_iteration;
16mod container;
17pub mod loop_config;
18mod primitive;
19pub mod resolve;
20pub mod template;
21
22use crate::context::ProcessContext;
23use crate::errors::BntoError;
24use crate::events::{NodeInfo, PipelineEvent, PipelineReporter};
25use crate::pipeline::{
26    IterationMode, PipelineDefinition, PipelineFile, PipelineFileResult, PipelineNode,
27    PipelineResult, is_container_node, is_io_node,
28};
29use crate::registry::NodeRegistry;
30
31#[cfg(test)]
32use crate::processor::NodeInput;
33#[cfg(test)]
34use crate::progress::ProgressReporter;
35
36// --- Shared Types ---
37
38type PipelineNodeRef<'a> = &'a PipelineNode;
39
40/// Bundles shared immutable state for executor functions, keeping
41/// signatures clean and under clippy's 7-argument limit.
42struct PipelineContext<'a, F: Fn() -> u64 + Copy> {
43    registry: &'a NodeRegistry,
44    reporter: &'a PipelineReporter,
45    /// System access boundary for processors (commands, temp files, env vars).
46    process_ctx: &'a dyn ProcessContext,
47    /// Original input file count — used in progress events so the UI
48    /// reports global position even inside loop container iterations.
49    pipeline_total_files: usize,
50    /// Returns current time in ms (injected for testability in WASM).
51    now_ms: F,
52    /// Current loop iteration's per-file metadata (CSV row columns, etc.).
53    /// Set by loop containers from PipelineFile.metadata each iteration.
54    loop_item: Option<serde_json::Map<String, serde_json::Value>>,
55    /// If executing inside a container, the container's node ID.
56    /// Used to set `parent_node_id` on child `NodeStarted` events.
57    parent_node_id: Option<String>,
58    /// Per-node output metadata for {{node.<id>.*}} templates.
59    /// Seeded with input file metadata before execution begins.
60    node_outputs: std::collections::BTreeMap<String, serde_json::Value>,
61}
62
63/// Where a node chain sits among its sibling nodes, for `NodeStarted` numbering.
64///
65/// Auto-iteration runs a pipeline as several chains; each one is a slice of
66/// the pipeline and must report positions against the whole of it.
67#[derive(Clone, Copy)]
68struct ChainPosition {
69    /// Position of the chain's first node among its siblings.
70    first_index: usize,
71    /// Number of sibling processing nodes (pipeline-wide, or within a container).
72    total_nodes: usize,
73}
74
75impl ChainPosition {
76    /// A chain that covers every one of its `total_nodes` siblings.
77    fn whole(total_nodes: usize) -> Self {
78        Self::starting_at(0, total_nodes)
79    }
80
81    /// A chain whose first node is at `first_index` among `total_nodes` siblings.
82    fn starting_at(first_index: usize, total_nodes: usize) -> Self {
83        Self {
84            first_index,
85            total_nodes,
86        }
87    }
88}
89
90/// Result of executing a single node or container sub-pipeline.
91struct NodeExecutionResult {
92    output_files: Vec<PipelineFile>,
93    files_processed: usize,
94    /// Non-fatal warnings (e.g. skipped loop iterations with continue-on-error).
95    warnings: Vec<String>,
96}
97
98// --- Public API ---
99
100/// Filter out I/O marker nodes (input/output) that don't perform processing.
101fn filter_processing_nodes(definition: &PipelineDefinition) -> Vec<&PipelineNode> {
102    definition
103        .nodes
104        .iter()
105        .filter(|n| !is_io_node(&n.node_type))
106        .collect()
107}
108
109/// Convert final pipeline files to result format, preserving processor metadata.
110fn build_pipeline_result(
111    files: Vec<PipelineFile>,
112    duration_ms: u64,
113    warnings: Vec<String>,
114) -> PipelineResult {
115    let result_files = files
116        .into_iter()
117        .map(|f| PipelineFileResult {
118            name: f.name,
119            data: f.data,
120            mime_type: f.mime_type,
121            metadata: f.metadata,
122        })
123        .collect();
124    PipelineResult {
125        files: result_files,
126        duration_ms,
127        warnings,
128    }
129}
130
131/// Execute a complete pipeline: walk nodes, iterate files, chain outputs.
132///
133/// Main entry point for the engine. `now_ms` is injected for testability
134/// (no `std::time::Instant` in WASM — uses `js_sys::Date::now()` instead).
135pub fn execute_pipeline(
136    definition: &PipelineDefinition,
137    files: Vec<PipelineFile>,
138    registry: &NodeRegistry,
139    reporter: &PipelineReporter,
140    process_ctx: &dyn ProcessContext,
141    now_ms: impl Fn() -> u64 + Copy,
142) -> Result<PipelineResult, BntoError> {
143    let start_ms = now_ms();
144    let processing_nodes = filter_processing_nodes(definition);
145    let ctx = build_root_context(definition, &files, registry, reporter, process_ctx, now_ms);
146    emit_pipeline_started(&ctx, &processing_nodes, files.len());
147    let result = run_processing_nodes(&ctx, definition, &processing_nodes, files)?;
148    Ok(finish_pipeline(&ctx, start_ms, result))
149}
150
151/// Build the top-level context, seeding node outputs from the input files.
152fn build_root_context<'a, F: Fn() -> u64 + Copy>(
153    definition: &PipelineDefinition,
154    files: &[PipelineFile],
155    registry: &'a NodeRegistry,
156    reporter: &'a PipelineReporter,
157    process_ctx: &'a dyn ProcessContext,
158    now_ms: F,
159) -> PipelineContext<'a, F> {
160    PipelineContext {
161        registry,
162        reporter,
163        process_ctx,
164        pipeline_total_files: files.len(),
165        now_ms,
166        loop_item: None,
167        parent_node_id: None,
168        node_outputs: template::build_node_outputs_for_input(&definition.nodes, files),
169    }
170}
171
172fn emit_pipeline_started<F: Fn() -> u64 + Copy>(
173    ctx: &PipelineContext<F>,
174    processing_nodes: &[&PipelineNode],
175    total_files: usize,
176) {
177    let nodes = processing_nodes
178        .iter()
179        .map(|n| NodeInfo {
180            id: n.id.clone(),
181            node_type: n.node_type.clone(),
182        })
183        .collect();
184    ctx.reporter.emit(PipelineEvent::PipelineStarted {
185        total_nodes: processing_nodes.len(),
186        total_files,
187        nodes,
188    });
189}
190
191/// Run the top-level nodes with the definition's iteration strategy.
192fn run_processing_nodes<F: Fn() -> u64 + Copy>(
193    ctx: &PipelineContext<F>,
194    definition: &PipelineDefinition,
195    processing_nodes: &[&PipelineNode],
196    files: Vec<PipelineFile>,
197) -> Result<NodeExecutionResult, BntoError> {
198    match definition.resolved_iteration() {
199        IterationMode::Explicit => {
200            let position = ChainPosition::whole(processing_nodes.len());
201            run_node_chain(ctx, processing_nodes, files, 0, position)
202        }
203        IterationMode::Auto => auto_iteration::run_auto_iteration(ctx, processing_nodes, files),
204    }
205}
206
207/// Emit PipelineCompleted and convert the final files into the result.
208fn finish_pipeline<F: Fn() -> u64 + Copy>(
209    ctx: &PipelineContext<F>,
210    start_ms: u64,
211    result: NodeExecutionResult,
212) -> PipelineResult {
213    let duration_ms = (ctx.now_ms)() - start_ms;
214    ctx.reporter.emit(PipelineEvent::PipelineCompleted {
215        duration_ms,
216        total_files_processed: result.files_processed,
217    });
218    build_pipeline_result(result.output_files, duration_ms, result.warnings)
219}
220
221/// Chain nodes sequentially, passing each node's output as the next node's input.
222///
223/// `position` places the chain among its siblings, so a chain that is a
224/// slice of the pipeline still reports pipeline-wide progress.
225fn run_node_chain<F: Fn() -> u64 + Copy>(
226    ctx: &PipelineContext<F>,
227    nodes: &[&PipelineNode],
228    files: Vec<PipelineFile>,
229    file_offset: usize,
230    position: ChainPosition,
231) -> Result<NodeExecutionResult, BntoError> {
232    let mut current_files = files;
233    let mut total_files_processed: usize = 0;
234    let mut all_warnings: Vec<String> = Vec::new();
235
236    for (i, node) in nodes.iter().enumerate() {
237        let node_index = position.first_index + i;
238        let result = execute_node(
239            ctx,
240            node,
241            current_files,
242            node_index,
243            position.total_nodes,
244            file_offset,
245        )?;
246        total_files_processed += result.files_processed;
247        all_warnings.extend(result.warnings);
248        current_files = result.output_files;
249    }
250
251    Ok(NodeExecutionResult {
252        output_files: current_files,
253        files_processed: total_files_processed,
254        warnings: all_warnings,
255    })
256}
257
258// --- Internal: Node Dispatch ---
259
260/// Emit failure events (NodeFailed + PipelineFailed) and return the error.
261fn emit_node_failure<F: Fn() -> u64 + Copy>(
262    ctx: &PipelineContext<F>,
263    node_id: &str,
264    error: BntoError,
265) -> BntoError {
266    let error_msg = error.to_string();
267    ctx.reporter.emit(PipelineEvent::NodeFailed {
268        node_id: node_id.to_string(),
269        error: error_msg.clone(),
270        parent_node_id: ctx.parent_node_id.clone(),
271    });
272    ctx.reporter.emit(PipelineEvent::PipelineFailed {
273        node_id: node_id.to_string(),
274        error: error_msg,
275    });
276    error
277}
278
279/// Execute a single node — dispatches to primitive or container handler.
280///
281/// Emits NodeStarted, delegates to the appropriate handler, then emits
282/// NodeCompleted or NodeFailed. Container children call back into this
283/// for recursive execution.
284fn execute_node<F: Fn() -> u64 + Copy>(
285    ctx: &PipelineContext<F>,
286    node: &PipelineNode,
287    files: Vec<PipelineFile>,
288    node_index: usize,
289    total_nodes: usize,
290    file_offset: usize,
291) -> Result<NodeExecutionResult, BntoError> {
292    let node_start = (ctx.now_ms)();
293    emit_node_started(ctx, node, node_index, total_nodes);
294
295    let result = dispatch_node(ctx, node, files, file_offset);
296
297    match result {
298        Ok(exec_result) => {
299            emit_node_completed(ctx, &node.id, node_start, exec_result.files_processed);
300            Ok(exec_result)
301        }
302        Err(error) => Err(emit_node_failure(ctx, &node.id, error)),
303    }
304}
305
306fn emit_node_started<F: Fn() -> u64 + Copy>(
307    ctx: &PipelineContext<F>,
308    node: &PipelineNode,
309    node_index: usize,
310    total_nodes: usize,
311) {
312    ctx.reporter.emit(PipelineEvent::NodeStarted {
313        node_id: node.id.clone(),
314        node_index,
315        total_nodes,
316        node_type: node.node_type.clone(),
317        parent_node_id: ctx.parent_node_id.clone(),
318    });
319}
320
321fn emit_node_completed<F: Fn() -> u64 + Copy>(
322    ctx: &PipelineContext<F>,
323    node_id: &str,
324    node_start: u64,
325    files_processed: usize,
326) {
327    let duration_ms = (ctx.now_ms)() - node_start;
328    ctx.reporter.emit(PipelineEvent::NodeCompleted {
329        node_id: node_id.to_string(),
330        duration_ms,
331        files_processed,
332        parent_node_id: ctx.parent_node_id.clone(),
333    });
334}
335
336fn dispatch_node<F: Fn() -> u64 + Copy>(
337    ctx: &PipelineContext<F>,
338    node: &PipelineNode,
339    files: Vec<PipelineFile>,
340    file_offset: usize,
341) -> Result<NodeExecutionResult, BntoError> {
342    if is_container_node(&node.node_type) {
343        container::execute_container_node(ctx, node, files, file_offset)
344    } else {
345        primitive::execute_primitive_node(ctx, node, files, file_offset)
346    }
347}
348
349#[cfg(test)]
350mod tests;