runtara_workflows/
compile.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Rustc-based scenario compilation to native binaries
4//!
5//! This module provides compilation without database dependencies.
6//! Child scenarios must be pre-loaded and passed to compilation functions.
7//!
8//! Scenarios are compiled to native binaries for the host platform.
9
10use runtara_dsl::ExecutionGraph;
11use serde_json::Value;
12use sha2::{Digest, Sha256};
13use std::collections::HashMap;
14use std::fs;
15use std::io;
16use std::path::PathBuf;
17use std::process::Command;
18use tracing::info;
19
20use crate::codegen::ast;
21use crate::paths::get_scenario_json_path;
22
23// ============================================================================
24// Rustc Error Parsing
25// ============================================================================
26
27/// Parse rustc stderr and provide a user-friendly error message.
28///
29/// This function attempts to extract meaningful information from rustc errors
30/// and provide actionable suggestions.
31fn parse_rustc_error(stderr: &str, target: &str) -> String {
32    // Check for common errors and provide helpful suggestions
33
34    // Missing target
35    if stderr.contains("error[E0463]") && stderr.contains("can't find crate") {
36        if stderr.contains("std") {
37            return format!(
38                "Compilation failed: The Rust standard library for target '{}' is not installed.\n\n\
39                 To fix this, run:\n  rustup target add {}",
40                target, target
41            );
42        }
43    }
44
45    // Target not installed
46    if stderr.contains("could not find specification for target") {
47        return format!(
48            "Compilation failed: Target '{}' is not installed.\n\n\
49             To fix this, run:\n  rustup target add {}",
50            target, target
51        );
52    }
53
54    // Linker not found (common on Linux for musl)
55    if stderr.contains("linker") && stderr.contains("not found") {
56        if target.contains("musl") {
57            return format!(
58                "Compilation failed: The musl linker is not installed.\n\n\
59                 To fix this on Ubuntu/Debian, run:\n  sudo apt install musl-tools\n\n\
60                 To fix this on Fedora/RHEL, run:\n  sudo dnf install musl-gcc"
61            );
62        }
63    }
64
65    // Can't find crate (stdlib not compiled)
66    if stderr.contains("can't find crate for") {
67        if let Some(crate_name) = extract_pattern(stderr, "can't find crate for `", "`") {
68            if crate_name == "runtara_workflow_stdlib" {
69                return format!(
70                    "Compilation failed: The workflow stdlib library is not compiled.\n\n\
71                     To fix this, run:\n  cargo build -p runtara-workflow-stdlib --release --target {}\n\n\
72                     Or set RUNTARA_NATIVE_LIBRARY_DIR to point to a pre-compiled stdlib.",
73                    target
74                );
75            }
76            return format!(
77                "Compilation failed: Cannot find crate '{}'.\n\n\
78                 This may indicate the workflow stdlib is not properly compiled.\n\
79                 Try rebuilding: cargo build -p runtara-workflow-stdlib --release --target {}",
80                crate_name, target
81            );
82        }
83    }
84
85    // Unresolved import
86    if stderr.contains("error[E0432]") && stderr.contains("unresolved import") {
87        if let Some(import) = extract_pattern(stderr, "unresolved import `", "`") {
88            return format!(
89                "Compilation failed: Unresolved import '{}'.\n\n\
90                 This is likely a code generation bug. Please report this issue.",
91                import
92            );
93        }
94    }
95
96    // Type errors (usually code generation bugs)
97    if stderr.contains("error[E0308]") && stderr.contains("mismatched types") {
98        return format!(
99            "Compilation failed: Type mismatch in generated code.\n\n\
100             This is likely a code generation bug. Please report this issue."
101        );
102    }
103
104    // Borrow checker errors (usually code generation bugs)
105    if stderr.contains("error[E0382]")
106        || stderr.contains("error[E0502]")
107        || stderr.contains("error[E0499]")
108    {
109        return format!(
110            "Compilation failed: Borrow checker error in generated code.\n\n\
111             This is likely a code generation bug. Please report this issue."
112        );
113    }
114
115    // Extract first error message for unknown errors
116    if let Some(first_error) = extract_first_error(stderr) {
117        return format!(
118            "Compilation failed: {}\n\n\
119             If this error persists, please contact support.",
120            first_error
121        );
122    }
123
124    // Fallback: generic message
125    "Compilation failed. Please contact support if this issue persists.".to_string()
126}
127
128/// Extract a pattern from text: prefix...suffix
129fn extract_pattern<'a>(text: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> {
130    let start = text.find(prefix)? + prefix.len();
131    let rest = &text[start..];
132    let end = rest.find(suffix)?;
133    Some(&rest[..end])
134}
135
136/// Extract the first error message from rustc output.
137fn extract_first_error(stderr: &str) -> Option<String> {
138    for line in stderr.lines() {
139        let line = line.trim();
140        if line.starts_with("error[E") {
141            // Extract the error message after the code
142            if let Some(msg_start) = line.find("]: ") {
143                let msg = &line[msg_start + 3..];
144                return Some(msg.to_string());
145            }
146        } else if line.starts_with("error:") {
147            let msg = line.trim_start_matches("error:").trim();
148            if !msg.is_empty() {
149                return Some(msg.to_string());
150            }
151        }
152    }
153    None
154}
155
156/// Get the native target triple for the current host platform
157///
158/// This must match the target used in build.rs when precompiling libraries.
159/// We use musl on Linux for static linking (scenarios run in minimal containers).
160fn get_host_target() -> &'static str {
161    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
162    {
163        "aarch64-apple-darwin"
164    }
165    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
166    {
167        "x86_64-apple-darwin"
168    }
169    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
170    {
171        // Use musl for static linking - scenarios run in minimal containers
172        "x86_64-unknown-linux-musl"
173    }
174    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
175    {
176        // Use musl for static linking - scenarios run in minimal containers
177        "aarch64-unknown-linux-musl"
178    }
179    #[cfg(not(any(
180        all(target_os = "macos", target_arch = "aarch64"),
181        all(target_os = "macos", target_arch = "x86_64"),
182        all(target_os = "linux", target_arch = "x86_64"),
183        all(target_os = "linux", target_arch = "aarch64"),
184    )))]
185    {
186        compile_error!("Unsupported platform for scenario compilation")
187    }
188}
189
190/// List of operator+operation combinations that have side effects (non-deterministic or external I/O)
191const SIDE_EFFECT_OPERATIONS: &[(&str, &str)] = &[
192    // Utils operator - random/timing operations
193    ("utils", "random-double"),
194    ("utils", "random-array"),
195    ("utils", "get-current-unix-timestamp"),
196    ("utils", "get-current-iso-datetime"),
197    ("utils", "get-current-formatted-datetime"),
198    ("utils", "delay-in-ms"),
199    // HTTP operator - external network I/O
200    ("http", "http-request"),
201    // SFTP operator - external file I/O
202    ("sftp", "sftp-list-files"),
203    ("sftp", "sftp-download-file"),
204    ("sftp", "sftp-upload-file"),
205    ("sftp", "sftp-delete-file"),
206];
207
208/// Checks if a workflow has any operations with side effects
209///
210/// # Arguments
211/// * `workflow` - The workflow JSON definition
212///
213/// # Returns
214/// `true` if the workflow contains any operator+operation combination that has side effects
215pub fn workflow_has_side_effects(workflow: &Value) -> bool {
216    // Get the steps object
217    let steps = match workflow.get("steps") {
218        Some(Value::Object(steps)) => steps,
219        _ => return false,
220    };
221
222    // Check each step for side effect operations
223    for (_step_id, step) in steps {
224        // Only check Agent steps (other step types don't execute operators)
225        if let Some(Value::String(step_type)) = step.get("stepType") {
226            if step_type != "Agent" {
227                continue;
228            }
229        }
230
231        // Get operator and operation IDs (case-insensitive comparison)
232        let operator_id = step
233            .get("operatorId")
234            .and_then(|v| v.as_str())
235            .map(|s| s.to_lowercase());
236        let operation_id = step
237            .get("operationId")
238            .and_then(|v| v.as_str())
239            .map(|s| s.to_lowercase());
240
241        if let (Some(operator), Some(operation)) = (operator_id, operation_id) {
242            // Check if this operator+operation combination has side effects
243            for (side_effect_op, side_effect_operation) in SIDE_EFFECT_OPERATIONS {
244                if operator == side_effect_op.to_lowercase()
245                    && operation == side_effect_operation.to_lowercase()
246                {
247                    return true;
248                }
249            }
250        }
251    }
252
253    false
254}
255
256/// Dependency information for a child scenario.
257///
258/// When a workflow contains `StartScenario` steps, each one creates a dependency
259/// on a child workflow. This struct captures the relationship.
260#[derive(Debug, Clone)]
261pub struct ChildDependency {
262    /// The step ID in the parent workflow that starts this child.
263    pub step_id: String,
264    /// The scenario ID of the child workflow.
265    pub child_scenario_id: String,
266    /// The version requested (e.g., "latest", "current", or explicit number).
267    pub child_version_requested: String,
268    /// The resolved version number that will actually be used.
269    pub child_version_resolved: i32,
270}
271
272/// Input for a child scenario (pre-loaded by caller).
273///
274/// This crate has no database dependencies, so child scenarios must be loaded
275/// by the caller and passed to compilation functions.
276#[derive(Debug, Clone)]
277pub struct ChildScenarioInput {
278    /// The step ID in the parent workflow that references this child.
279    pub step_id: String,
280    /// The scenario ID of the child workflow.
281    pub scenario_id: String,
282    /// The version requested (e.g., "latest", "current", or explicit number).
283    pub version_requested: String,
284    /// The resolved version number.
285    pub version_resolved: i32,
286    /// The child's execution graph.
287    pub execution_graph: ExecutionGraph,
288}
289
290/// Input for compilation (all data pre-loaded, no DB access needed).
291///
292/// This struct contains everything needed to compile a workflow to a native binary.
293/// The caller is responsible for loading all required data (including child scenarios)
294/// before calling compilation functions.
295#[derive(Debug)]
296pub struct CompilationInput {
297    /// Tenant ID for multi-tenant isolation.
298    pub tenant_id: String,
299    /// Unique scenario identifier.
300    pub scenario_id: String,
301    /// Version number for this scenario.
302    pub version: u32,
303    /// The workflow's execution graph definition.
304    pub execution_graph: ExecutionGraph,
305    /// Whether to enable debug mode (additional logging).
306    pub debug_mode: bool,
307    /// Pre-loaded child scenarios (empty if none).
308    pub child_scenarios: Vec<ChildScenarioInput>,
309    /// URL for fetching connections at runtime.
310    /// If provided, generated code will fetch connections from this service.
311    /// Expected endpoint: GET {url}/{tenant_id}/{connection_id}
312    pub connection_service_url: Option<String>,
313}
314
315/// Result of native binary compilation.
316///
317/// Contains the compiled binary and metadata about the compilation.
318#[derive(Debug)]
319pub struct NativeCompilationResult {
320    /// Path to the compiled binary.
321    pub binary_path: PathBuf,
322    /// Size of the binary in bytes.
323    pub binary_size: usize,
324    /// SHA-256 checksum of the binary.
325    pub binary_checksum: String,
326    /// Path to the build directory containing intermediate files.
327    pub build_dir: PathBuf,
328    /// Whether the workflow has side effects (e.g., HTTP calls, external actions).
329    pub has_side_effects: bool,
330    /// Child workflow dependencies.
331    pub child_dependencies: Vec<ChildDependency>,
332}
333
334/// Get the rustc compilation directory for scenarios
335fn get_rustc_compile_dir(tenant_id: &str, workflow_id: &str, version: u32) -> PathBuf {
336    let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| ".data".to_string());
337    PathBuf::from(data_dir)
338        .join(tenant_id)
339        .join("scenarios")
340        .join(workflow_id)
341        .join("native_build")
342        .join(format!("version_{}", version))
343}
344
345/// Get the native library information (runtime, agents, deps)
346fn get_native_libs() -> io::Result<crate::agents_library::NativeLibraryInfo> {
347    crate::agents_library::get_native_library()
348}
349
350/// Compile a scenario to a native Linux binary
351///
352/// This is the main compilation entry point. All required data (including child scenarios)
353/// must be pre-loaded and passed in the CompilationInput.
354///
355/// # Arguments
356/// * `input` - All compilation inputs including pre-loaded child scenarios
357///
358/// # Returns
359/// Result with native binary compilation data
360pub fn compile_scenario(input: CompilationInput) -> io::Result<NativeCompilationResult> {
361    let CompilationInput {
362        tenant_id,
363        scenario_id,
364        version,
365        execution_graph,
366        debug_mode,
367        child_scenarios,
368        connection_service_url,
369    } = input;
370
371    // Validate workflow for security, correctness, and configuration
372    let validation_result = crate::validation::validate_workflow(&execution_graph);
373
374    // Log warnings (but don't fail)
375    for warning in &validation_result.warnings {
376        tracing::warn!(
377            tenant_id = %tenant_id,
378            scenario_id = %scenario_id,
379            version = version,
380            warning = %warning,
381            "Workflow validation warning"
382        );
383    }
384
385    // Fail on errors
386    if validation_result.has_errors() {
387        let error_messages: Vec<String> = validation_result
388            .errors
389            .iter()
390            .map(|e| e.to_string())
391            .collect();
392
393        let warning_note = if validation_result.has_warnings() {
394            format!(
395                "\n\nAdditionally, {} warning(s) were found.",
396                validation_result.warnings.len()
397            )
398        } else {
399            String::new()
400        };
401
402        return Err(io::Error::new(
403            io::ErrorKind::InvalidData,
404            format!(
405                "Workflow validation failed with {} error(s):\n\n{}{}",
406                validation_result.errors.len(),
407                error_messages.join("\n\n"),
408                warning_note
409            ),
410        ));
411    }
412
413    // Get native library paths
414    let native_libs = get_native_libs()?;
415
416    // Create build directory
417    let setup_start = std::time::Instant::now();
418    let build_dir = get_rustc_compile_dir(&tenant_id, &scenario_id, version);
419    fs::create_dir_all(&build_dir)?;
420
421    // Convert child scenarios to HashMap<step_id, ExecutionGraph>
422    let child_graphs: HashMap<String, ExecutionGraph> = child_scenarios
423        .iter()
424        .map(|c| (c.step_id.clone(), c.execution_graph.clone()))
425        .collect();
426
427    // Generate the Rust program using AST-based code generation
428    let codegen_start = std::time::Instant::now();
429    let tenant_id_for_codegen = tenant_id.clone();
430    let rust_code = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
431        ast::compile_with_children(
432            &execution_graph,
433            debug_mode,
434            child_graphs,
435            connection_service_url,
436            Some(tenant_id_for_codegen),
437        )
438    }))
439    .map_err(|panic_info| {
440        let panic_msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
441            s.to_string()
442        } else if let Some(s) = panic_info.downcast_ref::<String>() {
443            s.clone()
444        } else {
445            "Unknown panic during code generation".to_string()
446        };
447
448        tracing::error!(
449            tenant_id = %tenant_id,
450            scenario_id = %scenario_id,
451            version = version,
452            error = %panic_msg,
453            "Code generation panicked"
454        );
455
456        io::Error::new(
457            io::ErrorKind::InvalidData,
458            format!("Code generation failed: {}", panic_msg),
459        )
460    })?;
461    let codegen_duration = codegen_start.elapsed();
462    tracing::debug!(
463        codegen_duration_ms = codegen_duration.as_millis() as u64,
464        "Code generation completed"
465    );
466
467    let main_rs_path = build_dir.join("main.rs");
468    fs::write(&main_rs_path, rust_code)?;
469    let setup_duration = setup_start.elapsed();
470    tracing::debug!(
471        setup_duration_ms = setup_duration.as_millis() as u64,
472        "Setup completed (dirs + codegen + write)"
473    );
474
475    // Determine binary output path
476    let binary_output_path = build_dir.join("scenario");
477
478    // Compile with rustc to native binary
479    let compilation_start = std::time::Instant::now();
480    info!(
481        scenario_id = %scenario_id,
482        version = version,
483        mode = "native",
484        "Starting scenario compilation"
485    );
486
487    // Build rustc command for native binary
488    let target = get_host_target();
489    let mut cmd = Command::new("rustc");
490    cmd.arg(format!("--target={}", target))
491        .arg("--crate-type=bin")
492        .arg("--edition=2024")
493        .arg("-C")
494        .arg("opt-level=2")
495        .arg("-C")
496        .arg("codegen-units=16");
497
498    // Use static CRT linking on Linux (musl) for fully static binaries
499    #[cfg(target_os = "linux")]
500    {
501        cmd.arg("-C").arg("target-feature=+crt-static");
502    }
503
504    // Add library search paths
505    let deps_dir = &native_libs.deps_dir;
506    if deps_dir.exists() {
507        cmd.arg("-L")
508            .arg(format!("dependency={}", deps_dir.display()));
509    }
510
511    // Add native library path (parent of scenario lib)
512    if let Some(lib_dir) = native_libs.scenario_lib_path.parent() {
513        cmd.arg("-L").arg(format!("native={}", lib_dir.display()));
514    }
515
516    // Add OpenSSL library paths for macOS (Homebrew)
517    #[cfg(target_os = "macos")]
518    {
519        // Try common Homebrew OpenSSL locations
520        let openssl_paths = [
521            "/opt/homebrew/opt/openssl/lib", // ARM64
522            "/usr/local/opt/openssl/lib",    // Intel
523            "/opt/homebrew/opt/openssl@3/lib",
524            "/usr/local/opt/openssl@3/lib",
525        ];
526        for path in &openssl_paths {
527            if std::path::Path::new(path).exists() {
528                cmd.arg("-L").arg(format!("native={}", path));
529                break;
530            }
531        }
532    }
533
534    // Add extern crate for the unified workflow stdlib library
535    let stdlib_name = crate::agents_library::get_stdlib_name();
536    cmd.arg("--extern").arg(format!(
537        "{}={}",
538        stdlib_name,
539        native_libs.scenario_lib_path.display()
540    ));
541
542    // Determine the dylib extension for the current platform
543    #[cfg(target_os = "macos")]
544    let dylib_ext = "dylib";
545    #[cfg(target_os = "linux")]
546    let dylib_ext = "so";
547    #[cfg(target_os = "windows")]
548    let dylib_ext = "dll";
549
550    // Add ALL dependency rlibs AND dylibs as extern crates (needed for transitive dependency resolution)
551    // Proc-macro crates are compiled as dylibs, not rlibs
552    if deps_dir.exists() {
553        if let Ok(entries) = fs::read_dir(deps_dir) {
554            for entry in entries.flatten() {
555                let path = entry.path();
556                let ext = path.extension().and_then(|s| s.to_str());
557
558                // Accept both rlibs and dylibs (proc-macros)
559                if ext != Some("rlib") && ext != Some(dylib_ext) {
560                    continue;
561                }
562
563                if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
564                    // Extract crate name from filename (e.g., "libserde-abc123.rlib" -> "serde")
565                    if let Some(crate_name_part) = filename.strip_prefix("lib") {
566                        if let Some(crate_name) = crate_name_part.split('-').next() {
567                            // Convert hyphens to underscores for crate names
568                            let extern_name = crate_name.replace('-', "_");
569                            cmd.arg("--extern")
570                                .arg(format!("{}={}", extern_name, path.display()));
571                        }
572                    }
573                }
574            }
575        }
576    }
577
578    // Output
579    cmd.arg("-o").arg(&binary_output_path);
580
581    // Input
582    cmd.arg(&main_rs_path);
583
584    tracing::info!(
585        tenant_id = %tenant_id,
586        scenario_id = %scenario_id,
587        version = version,
588        "Invoking rustc for native compilation"
589    );
590    let rustc_start = std::time::Instant::now();
591    let output = cmd.output().map_err(|e| {
592        io::Error::other(format!(
593            "Failed to execute rustc: {}. Make sure rustc is installed with {} target.",
594            e, target
595        ))
596    })?;
597    let rustc_duration = rustc_start.elapsed();
598    tracing::info!(
599        rustc_duration_ms = rustc_duration.as_millis() as u64,
600        "Rustc compilation completed"
601    );
602
603    // Check if compilation was successful
604    let stderr = String::from_utf8_lossy(&output.stderr);
605    if !output.status.success() {
606        let stdout = String::from_utf8_lossy(&output.stdout);
607        // Log the full error for debugging
608        tracing::error!(
609            stderr = %stderr,
610            stdout = %stdout,
611            "Rustc compilation failed"
612        );
613
614        // Parse and provide user-friendly error message
615        let user_message = parse_rustc_error(&stderr, target);
616        return Err(io::Error::other(user_message));
617    }
618
619    // Verify the binary was created
620    if !binary_output_path.exists() {
621        return Err(io::Error::other(format!(
622            "Compilation appeared to succeed but binary was not found at {:?}",
623            binary_output_path
624        )));
625    }
626
627    // Get binary size and calculate checksum
628    let io_start = std::time::Instant::now();
629    let binary_metadata = fs::metadata(&binary_output_path).map_err(|e| {
630        io::Error::other(format!(
631            "Failed to stat binary at {:?}: {}",
632            binary_output_path, e
633        ))
634    })?;
635    let binary_size = binary_metadata.len() as usize;
636
637    // Calculate checksum by reading in chunks (more memory efficient)
638    let mut hasher = Sha256::new();
639    let mut file = fs::File::open(&binary_output_path)?;
640    std::io::copy(&mut file, &mut hasher)?;
641    let binary_checksum = format!("{:x}", hasher.finalize());
642
643    let io_duration = io_start.elapsed();
644    tracing::debug!(
645        io_duration_ms = io_duration.as_millis() as u64,
646        binary_size_bytes = binary_size,
647        "Checksum calculated"
648    );
649
650    // Detect side effects from the scenario JSON file if it exists
651    let scenario_json_path = get_scenario_json_path(&tenant_id, &scenario_id, version);
652    let has_side_effects = if scenario_json_path.exists() {
653        let json_content = fs::read_to_string(&scenario_json_path)?;
654        let workflow: Value = serde_json::from_str(&json_content).map_err(|e| {
655            io::Error::new(
656                io::ErrorKind::InvalidData,
657                format!("Failed to parse scenario JSON: {}", e),
658            )
659        })?;
660        workflow_has_side_effects(&workflow)
661    } else {
662        false
663    };
664
665    let compilation_duration = compilation_start.elapsed();
666    info!(
667        tenant_id = %tenant_id,
668        scenario_id = %scenario_id,
669        version = version,
670        binary_size_bytes = binary_size,
671        compilation_duration_ms = compilation_duration.as_millis() as u64,
672        has_side_effects = has_side_effects,
673        "Scenario compiled successfully"
674    );
675
676    // Convert child scenarios to dependencies
677    let child_dependencies: Vec<ChildDependency> = child_scenarios
678        .iter()
679        .map(|c| ChildDependency {
680            step_id: c.step_id.clone(),
681            child_scenario_id: c.scenario_id.clone(),
682            child_version_requested: c.version_requested.clone(),
683            child_version_resolved: c.version_resolved,
684        })
685        .collect();
686
687    Ok(NativeCompilationResult {
688        binary_path: binary_output_path,
689        binary_size,
690        binary_checksum,
691        build_dir,
692        has_side_effects,
693        child_dependencies,
694    })
695}
696
697/// Translate (generate code only, no compilation)
698///
699/// # Arguments
700/// * `tenant_id` - Tenant identifier
701/// * `scenario_id` - Scenario identifier
702/// * `version` - Version number
703/// * `execution_graph` - The execution graph
704/// * `debug_mode` - Whether to include debug instrumentation
705///
706/// # Returns
707/// Path to the build directory containing generated main.rs
708pub fn translate_scenario(
709    tenant_id: &str,
710    scenario_id: &str,
711    version: u32,
712    execution_graph: &ExecutionGraph,
713    debug_mode: bool,
714) -> io::Result<PathBuf> {
715    // Create build directory
716    let build_dir = get_rustc_compile_dir(tenant_id, scenario_id, version);
717    fs::create_dir_all(&build_dir)?;
718
719    // Generate the Rust program using AST-based code generation
720    let rust_code = ast::compile(execution_graph, debug_mode);
721
722    // Write main.rs
723    let main_rs_path = build_dir.join("main.rs");
724    fs::write(&main_rs_path, rust_code)?;
725
726    info!(
727        "Generated Rust code for scenario {}/{} v{} at {:?}",
728        tenant_id, scenario_id, version, main_rs_path
729    );
730
731    Ok(build_dir)
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    #[test]
739    fn test_workflow_has_side_effects_empty() {
740        let workflow: Value = serde_json::json!({
741            "steps": {}
742        });
743        assert!(!workflow_has_side_effects(&workflow));
744    }
745
746    #[test]
747    fn test_workflow_has_side_effects_http() {
748        let workflow: Value = serde_json::json!({
749            "steps": {
750                "step1": {
751                    "stepType": "Agent",
752                    "operatorId": "http",
753                    "operationId": "http-request"
754                }
755            }
756        });
757        assert!(workflow_has_side_effects(&workflow));
758    }
759
760    #[test]
761    fn test_workflow_has_side_effects_pure() {
762        let workflow: Value = serde_json::json!({
763            "steps": {
764                "step1": {
765                    "stepType": "Agent",
766                    "operatorId": "transform",
767                    "operationId": "map"
768                }
769            }
770        });
771        assert!(!workflow_has_side_effects(&workflow));
772    }
773}