From 1188c7b97cbe3ad9c7491ec0bda909de47281710 Mon Sep 17 00:00:00 2001 From: ice6 Date: Fri, 2 Jan 2026 11:01:56 +0800 Subject: [PATCH 1/9] poc of dotnet pInvoke --- bindings/dotnet/GoRules.Zen.csproj | 53 ++++ bindings/dotnet/README.md | 227 ++++++++++++++ bindings/dotnet/ZenEngine.Interop.cs | 272 ++++++++++++++++ bindings/dotnet/ZenEngine.cs | 450 +++++++++++++++++++++++++++ 4 files changed, 1002 insertions(+) create mode 100644 bindings/dotnet/GoRules.Zen.csproj create mode 100644 bindings/dotnet/README.md create mode 100644 bindings/dotnet/ZenEngine.Interop.cs create mode 100644 bindings/dotnet/ZenEngine.cs diff --git a/bindings/dotnet/GoRules.Zen.csproj b/bindings/dotnet/GoRules.Zen.csproj new file mode 100644 index 00000000..a4a53968 --- /dev/null +++ b/bindings/dotnet/GoRules.Zen.csproj @@ -0,0 +1,53 @@ + + + + net8.0 + disable + enable + latest + + + GoRules.Zen + 0.1.0 + GoRules + C# bindings for Zen Rules Engine - a high-performance business rules engine + rules-engine;business-rules;decision-engine;zen + MIT + https://github.com/gorules/zen + + + true + + + + + + + + + + + + + + + + + + diff --git a/bindings/dotnet/README.md b/bindings/dotnet/README.md new file mode 100644 index 00000000..a1b2e4b1 --- /dev/null +++ b/bindings/dotnet/README.md @@ -0,0 +1,227 @@ +# GoRules.Zen - C# Bindings for Zen Rules Engine + +C# P/Invoke bindings for the Zen Rules Engine, providing high-performance business rules evaluation. + +## Building + +### Step 1: Build the Native Library + +First, build the Rust C bindings as a shared library: + +```bash +# From the repository root +cd bindings/c + +# Build for your platform +cargo build --release + +# The library will be at: +# - Linux: target/release/libzen_ffi.so +# - macOS: target/release/libzen_ffi.dylib +# - Windows: target/release/zen_ffi.dll +``` + +**Note:** The C bindings produce a static library by default. You need to change `Cargo.toml`: + +```toml +[lib] +crate-type = ["cdylib"] # Change from "staticlib" to "cdylib" +``` + +### Step 2: Copy Native Library + +Copy the built library to the appropriate runtime folder: + +```bash +# Linux +mkdir -p bindings/dotnet/runtimes/linux-x64/native +cp target/release/libzen_ffi.so bindings/dotnet/runtimes/linux-x64/native/ + +# macOS x64 +mkdir -p bindings/dotnet/runtimes/osx-x64/native +cp target/release/libzen_ffi.dylib bindings/dotnet/runtimes/osx-x64/native/ + +# macOS ARM64 +mkdir -p bindings/dotnet/runtimes/osx-arm64/native +cp target/release/libzen_ffi.dylib bindings/dotnet/runtimes/osx-arm64/native/ + +# Windows +mkdir -p bindings/dotnet/runtimes/win-x64/native +cp target/release/zen_ffi.dll bindings/dotnet/runtimes/win-x64/native/ +``` + +### Step 3: Build the C# Library + +```bash +cd bindings/dotnet +dotnet build +``` + +## Usage + +### Basic Expression Evaluation + +```csharp +using GoRules.Zen; + +// Evaluate a simple expression +var result = ZenExpression.Evaluate("a + b", """{"a": 10, "b": 20}"""); +Console.WriteLine(result); // 30 + +// Evaluate a boolean expression +var isValid = ZenExpression.EvaluateUnary("age >= 18", """{"age": 21}"""); +Console.WriteLine(isValid); // true + +// Render a template +var greeting = ZenExpression.RenderTemplate( + "Hello {{ name }}!", + """{"name": "World"}""" +); +Console.WriteLine(greeting); // "Hello World!" +``` + +### Typed Expression Evaluation + +```csharp +using GoRules.Zen; + +var context = new { a = 10, b = 20 }; +var result = ZenExpression.Evaluate("a + b", context); +Console.WriteLine(result); // 30 +``` + +### Decision Evaluation + +```csharp +using GoRules.Zen; + +// Create engine +using var engine = new ZenEngine(); + +// Load decision from JSON +string decisionJson = File.ReadAllText("my-decision.json"); +using var decision = engine.CreateDecision(decisionJson); + +// Evaluate +var result = decision.Evaluate("""{"input": "value"}"""); +Console.WriteLine(result); + +// With options +var options = new EvaluationOptions { Trace = true, MaxDepth = 10 }; +var tracedResult = decision.Evaluate("""{"input": "value"}""", options); +``` + +### Using a Decision Loader + +```csharp +using GoRules.Zen; + +// Create engine with loader callback +using var engine = new ZenEngine( + loader: key => + { + // Load decision JSON by key from database, file, etc. + var path = $"decisions/{key}.json"; + if (File.Exists(path)) + return File.ReadAllText(path); + return null; // Not found + } +); + +// Evaluate by key - loader will be called +var result = engine.Evaluate("my-decision", """{"input": "value"}"""); +``` + +### Custom Node Handler + +```csharp +using GoRules.Zen; +using System.Text.Json; + +using var engine = new ZenEngine( + loader: key => File.ReadAllText($"decisions/{key}.json"), + customNode: request => + { + // Parse the request + var doc = JsonDocument.Parse(request); + var nodeType = doc.RootElement.GetProperty("node") + .GetProperty("kind").GetString(); + + // Handle custom node types + if (nodeType == "myCustomNode") + { + return JsonSerializer.Serialize(new + { + result = new { customOutput = "processed" } + }); + } + + throw new Exception($"Unknown custom node: {nodeType}"); + } +); + +var result = engine.Evaluate("decision-with-custom-node", "{}"); +``` + +### Error Handling + +```csharp +using GoRules.Zen; + +try +{ + var result = ZenExpression.Evaluate("invalid expression !!!", "{}"); +} +catch (ZenException ex) +{ + Console.WriteLine($"Error: {ex.ErrorCode}"); + Console.WriteLine($"Details: {ex.Details}"); +} +``` + +## API Reference + +### ZenExpression (Static Methods) + +| Method | Description | +|--------|-------------| +| `Evaluate(expression, context)` | Evaluate an expression, returns JSON | +| `EvaluateUnary(expression, context)` | Evaluate a boolean expression | +| `RenderTemplate(template, context)` | Render a template string | + +### ZenEngine + +| Method | Description | +|--------|-------------| +| `ZenEngine()` | Create engine without callbacks | +| `ZenEngine(loader, customNode)` | Create engine with callbacks | +| `CreateDecision(json)` | Create decision from JSON | +| `GetDecision(key)` | Get decision via loader | +| `Evaluate(key, context, options)` | Evaluate decision by key | + +### ZenDecision + +| Method | Description | +|--------|-------------| +| `Evaluate(context, options)` | Evaluate the decision | +| `Dispose()` | Free native resources | + +### EvaluationOptions + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Trace` | bool | false | Enable execution trace | +| `MaxDepth` | byte | 0 | Max recursion depth (0 = default) | + +## Platform Support + +| Platform | Architecture | Status | +|----------|--------------|--------| +| Linux | x64 | ✅ | +| macOS | x64 | ✅ | +| macOS | ARM64 | ✅ | +| Windows | x64 | ✅ | + +## License + +MIT diff --git a/bindings/dotnet/ZenEngine.Interop.cs b/bindings/dotnet/ZenEngine.Interop.cs new file mode 100644 index 00000000..76c9080f --- /dev/null +++ b/bindings/dotnet/ZenEngine.Interop.cs @@ -0,0 +1,272 @@ +using System; +using System.Runtime.InteropServices; + +namespace GoRules.Zen.Interop +{ + /// + /// Result type for functions returning strings (char*) + /// + [StructLayout(LayoutKind.Sequential)] + public struct ZenResult_c_char + { + public IntPtr result; // char* - JSON result on success + public byte error; // Error code (0 = success) + public IntPtr details; // char* - Error details JSON when error != 0 + } + + /// + /// Result type for functions returning ZenDecisionStruct* + /// + [StructLayout(LayoutKind.Sequential)] + public struct ZenResult_ZenDecisionStruct + { + public IntPtr result; // ZenDecisionStruct* on success + public byte error; // Error code (0 = success) + public IntPtr details; // char* - Error details JSON when error != 0 + } + + /// + /// Result type for functions returning int* (unary expression) + /// + [StructLayout(LayoutKind.Sequential)] + public struct ZenResult_c_int + { + public IntPtr result; // int* - 0 or 1 + public byte error; // Error code (0 = success) + public IntPtr details; // char* - Error details JSON when error != 0 + } + + /// + /// Options for decision evaluation + /// + [StructLayout(LayoutKind.Sequential)] + public struct ZenEngineEvaluationOptions + { + [MarshalAs(UnmanagedType.U1)] + public bool trace; // Enable execution trace + public byte max_depth; // Maximum recursion depth (0 = default) + } + + /// + /// Result returned from decision loader callback + /// + [StructLayout(LayoutKind.Sequential)] + public struct ZenDecisionLoaderResult + { + public IntPtr content; // char* - JSON decision content (malloc'd) + public IntPtr error; // char* - Error message (malloc'd), NULL on success + } + + /// + /// Result returned from custom node callback + /// + [StructLayout(LayoutKind.Sequential)] + public struct ZenCustomNodeResult + { + public IntPtr content; // char* - JSON response (malloc'd) + public IntPtr error; // char* - Error message (malloc'd), NULL on success + } + + /// + /// Error codes returned by the Zen engine + /// + public enum ZenErrorCode : byte + { + Success = 0, + InvalidArgument = 1, + StringNullError = 2, + StringUtf8Error = 3, + JsonSerializationFailed = 4, + JsonDeserializationFailed = 5, + IsolateError = 6, + EvaluationError = 7, + LoaderKeyNotFound = 8, + LoaderInternalError = 9, + TemplateEngineError = 10 + } + + /// + /// Callback delegate for loading decisions by key + /// + /// Decision identifier + /// Result with JSON content or error + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate ZenDecisionLoaderResult ZenDecisionLoaderCallback(IntPtr key); + + /// + /// Callback delegate for handling custom nodes + /// + /// JSON request string + /// Result with JSON response or error + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate ZenCustomNodeResult ZenCustomNodeCallback(IntPtr request); + + /// + /// Native P/Invoke imports for zen_ffi library + /// + public static class ZenNative + { + private const string LibraryName = "zen_ffi"; + + // ============================================================ + // Engine Lifecycle + // ============================================================ + + /// + /// Create a new ZenEngine without callbacks + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr zen_engine_new(); + + /// + /// Create a new ZenEngine with native callbacks + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr zen_engine_new_native( + ZenDecisionLoaderCallback? loader_callback, + ZenCustomNodeCallback? custom_node_callback); + + /// + /// Free a ZenEngine instance + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern void zen_engine_free(IntPtr engine); + + // ============================================================ + // Decision Management + // ============================================================ + + /// + /// Create a decision from JSON content + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern ZenResult_ZenDecisionStruct zen_engine_create_decision( + IntPtr engine, + [MarshalAs(UnmanagedType.LPUTF8Str)] string content); + + /// + /// Get a decision by key (uses loader callback) + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern ZenResult_ZenDecisionStruct zen_engine_get_decision( + IntPtr engine, + [MarshalAs(UnmanagedType.LPUTF8Str)] string key); + + /// + /// Free a decision instance + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern void zen_decision_free(IntPtr decision); + + // ============================================================ + // Evaluation + // ============================================================ + + /// + /// Evaluate a decision with context + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern ZenResult_c_char zen_decision_evaluate( + IntPtr decision, + [MarshalAs(UnmanagedType.LPUTF8Str)] string context, + ZenEngineEvaluationOptions options); + + /// + /// Evaluate a decision by key using loader + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern ZenResult_c_char zen_engine_evaluate( + IntPtr engine, + [MarshalAs(UnmanagedType.LPUTF8Str)] string key, + [MarshalAs(UnmanagedType.LPUTF8Str)] string context, + ZenEngineEvaluationOptions options); + + // ============================================================ + // Expression Evaluation + // ============================================================ + + /// + /// Evaluate an expression with context + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern ZenResult_c_char zen_evaluate_expression( + [MarshalAs(UnmanagedType.LPUTF8Str)] string expression, + [MarshalAs(UnmanagedType.LPUTF8Str)] string context); + + /// + /// Evaluate a unary (boolean) expression + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern ZenResult_c_int zen_evaluate_unary_expression( + [MarshalAs(UnmanagedType.LPUTF8Str)] string expression, + [MarshalAs(UnmanagedType.LPUTF8Str)] string context); + + /// + /// Render a template with context + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + public static extern ZenResult_c_char zen_evaluate_template( + [MarshalAs(UnmanagedType.LPUTF8Str)] string template, + [MarshalAs(UnmanagedType.LPUTF8Str)] string context); + + // ============================================================ + // Memory Management Helpers + // ============================================================ + + /// + /// Free a C string (cross-platform) + /// + [DllImport("libc", EntryPoint = "free", CallingConvention = CallingConvention.Cdecl)] + private static extern void free_libc(IntPtr ptr); + + [DllImport("msvcrt", EntryPoint = "free", CallingConvention = CallingConvention.Cdecl)] + private static extern void free_msvcrt(IntPtr ptr); + + /// + /// Allocate memory for callback return strings + /// + [DllImport("libc", EntryPoint = "malloc", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr malloc_libc(nuint size); + + [DllImport("msvcrt", EntryPoint = "malloc", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr malloc_msvcrt(nuint size); + + /// + /// Free a pointer allocated by the Rust library + /// + public static void FreeRustString(IntPtr ptr) + { + if (ptr == IntPtr.Zero) return; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + free_msvcrt(ptr); + else + free_libc(ptr); + } + + /// + /// Allocate memory for callback strings (must use matching allocator) + /// + public static IntPtr AllocateCString(string? value) + { + if (value == null) return IntPtr.Zero; + + var bytes = System.Text.Encoding.UTF8.GetBytes(value); + var size = (nuint)(bytes.Length + 1); + + IntPtr ptr; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + ptr = malloc_msvcrt(size); + else + ptr = malloc_libc(size); + + if (ptr == IntPtr.Zero) + throw new OutOfMemoryException("Failed to allocate native memory"); + + Marshal.Copy(bytes, 0, ptr, bytes.Length); + Marshal.WriteByte(ptr, bytes.Length, 0); // null terminator + + return ptr; + } + } +} diff --git a/bindings/dotnet/ZenEngine.cs b/bindings/dotnet/ZenEngine.cs new file mode 100644 index 00000000..7eee8542 --- /dev/null +++ b/bindings/dotnet/ZenEngine.cs @@ -0,0 +1,450 @@ +using System; +using System.Runtime.InteropServices; +using System.Text.Json; +using GoRules.Zen.Interop; + +namespace GoRules.Zen +{ + /// + /// Exception thrown when Zen engine operations fail + /// + public class ZenException : Exception + { + public ZenErrorCode ErrorCode { get; } + public string? Details { get; } + + public ZenException(ZenErrorCode errorCode, string? details = null) + : base(FormatMessage(errorCode, details)) + { + ErrorCode = errorCode; + Details = details; + } + + private static string FormatMessage(ZenErrorCode code, string? details) + { + var message = code switch + { + ZenErrorCode.InvalidArgument => "Invalid argument", + ZenErrorCode.StringNullError => "Null string error", + ZenErrorCode.StringUtf8Error => "UTF-8 encoding error", + ZenErrorCode.JsonSerializationFailed => "JSON serialization failed", + ZenErrorCode.JsonDeserializationFailed => "JSON deserialization failed", + ZenErrorCode.IsolateError => "JavaScript isolate error", + ZenErrorCode.EvaluationError => "Evaluation error", + ZenErrorCode.LoaderKeyNotFound => "Decision key not found", + ZenErrorCode.LoaderInternalError => "Loader internal error", + ZenErrorCode.TemplateEngineError => "Template engine error", + _ => $"Unknown error (code: {(int)code})" + }; + + return details != null ? $"{message}: {details}" : message; + } + } + + /// + /// Options for decision evaluation + /// + public class EvaluationOptions + { + /// + /// Enable execution trace for debugging + /// + public bool Trace { get; set; } = false; + + /// + /// Maximum recursion depth (0 = default) + /// + public byte MaxDepth { get; set; } = 0; + + internal ZenEngineEvaluationOptions ToNative() => new() + { + trace = Trace, + max_depth = MaxDepth + }; + } + + /// + /// Delegate for loading decision content by key + /// + /// Decision identifier + /// JSON decision content, or null if not found + public delegate string? DecisionLoaderDelegate(string key); + + /// + /// Delegate for handling custom nodes + /// + /// JSON request object + /// JSON response object + public delegate string CustomNodeDelegate(string request); + + /// + /// A compiled decision that can be evaluated multiple times + /// + public class ZenDecision : IDisposable + { + private IntPtr _handle; + private bool _disposed; + + internal ZenDecision(IntPtr handle) + { + _handle = handle; + } + + /// + /// Evaluate the decision with the given context + /// + /// JSON context object + /// Evaluation options + /// JSON result + public string Evaluate(string context, EvaluationOptions? options = null) + { + ThrowIfDisposed(); + options ??= new EvaluationOptions(); + + var result = ZenNative.zen_decision_evaluate(_handle, context, options.ToNative()); + return ResultHelper.ExtractString(result); + } + + /// + /// Evaluate the decision with a typed context + /// + public TResult Evaluate(TContext context, EvaluationOptions? options = null) + { + var contextJson = JsonSerializer.Serialize(context); + var resultJson = Evaluate(contextJson, options); + return JsonSerializer.Deserialize(resultJson) + ?? throw new ZenException(ZenErrorCode.JsonDeserializationFailed, "Result was null"); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(ZenDecision)); + } + + public void Dispose() + { + if (!_disposed && _handle != IntPtr.Zero) + { + ZenNative.zen_decision_free(_handle); + _handle = IntPtr.Zero; + _disposed = true; + } + GC.SuppressFinalize(this); + } + + ~ZenDecision() + { + Dispose(); + } + } + + /// + /// Zen Rules Engine - evaluates business rules and decisions + /// + public class ZenEngine : IDisposable + { + private IntPtr _handle; + private bool _disposed; + + // Keep delegates alive to prevent GC + private readonly ZenDecisionLoaderCallback? _nativeLoaderCallback; + private readonly ZenCustomNodeCallback? _nativeCustomNodeCallback; + private readonly DecisionLoaderDelegate? _loaderDelegate; + private readonly CustomNodeDelegate? _customNodeDelegate; + + /// + /// Create a new ZenEngine without callbacks + /// + public ZenEngine() + { + _handle = ZenNative.zen_engine_new(); + if (_handle == IntPtr.Zero) + throw new InvalidOperationException("Failed to create ZenEngine"); + } + + /// + /// Create a new ZenEngine with optional callbacks + /// + /// Callback to load decisions by key + /// Callback to handle custom nodes + public ZenEngine(DecisionLoaderDelegate? loader, CustomNodeDelegate? customNode = null) + { + _loaderDelegate = loader; + _customNodeDelegate = customNode; + + if (loader != null) + _nativeLoaderCallback = CreateNativeLoaderCallback(loader); + + if (customNode != null) + _nativeCustomNodeCallback = CreateNativeCustomNodeCallback(customNode); + + _handle = ZenNative.zen_engine_new_native(_nativeLoaderCallback, _nativeCustomNodeCallback); + if (_handle == IntPtr.Zero) + throw new InvalidOperationException("Failed to create ZenEngine"); + } + + /// + /// Create a decision from JSON content + /// + /// JSON decision definition + /// Compiled decision + public ZenDecision CreateDecision(string content) + { + ThrowIfDisposed(); + + var result = ZenNative.zen_engine_create_decision(_handle, content); + var handle = ResultHelper.ExtractDecision(result); + return new ZenDecision(handle); + } + + /// + /// Get a decision by key using the loader callback + /// + /// Decision identifier + /// Compiled decision + public ZenDecision GetDecision(string key) + { + ThrowIfDisposed(); + + var result = ZenNative.zen_engine_get_decision(_handle, key); + var handle = ResultHelper.ExtractDecision(result); + return new ZenDecision(handle); + } + + /// + /// Evaluate a decision by key + /// + /// Decision identifier + /// JSON context + /// Evaluation options + /// JSON result + public string Evaluate(string key, string context, EvaluationOptions? options = null) + { + ThrowIfDisposed(); + options ??= new EvaluationOptions(); + + var result = ZenNative.zen_engine_evaluate(_handle, key, context, options.ToNative()); + return ResultHelper.ExtractString(result); + } + + /// + /// Evaluate a decision with typed context and result + /// + public TResult Evaluate(string key, TContext context, EvaluationOptions? options = null) + { + var contextJson = JsonSerializer.Serialize(context); + var resultJson = Evaluate(key, contextJson, options); + return JsonSerializer.Deserialize(resultJson) + ?? throw new ZenException(ZenErrorCode.JsonDeserializationFailed, "Result was null"); + } + + private static ZenDecisionLoaderCallback CreateNativeLoaderCallback(DecisionLoaderDelegate loader) + { + return (IntPtr keyPtr) => + { + var result = new ZenDecisionLoaderResult(); + try + { + var key = Marshal.PtrToStringUTF8(keyPtr) ?? ""; + var content = loader(key); + + if (content != null) + result.content = ZenNative.AllocateCString(content); + else + result.error = ZenNative.AllocateCString("Decision not found"); + } + catch (Exception ex) + { + result.error = ZenNative.AllocateCString(ex.Message); + } + return result; + }; + } + + private static ZenCustomNodeCallback CreateNativeCustomNodeCallback(CustomNodeDelegate customNode) + { + return (IntPtr requestPtr) => + { + var result = new ZenCustomNodeResult(); + try + { + var request = Marshal.PtrToStringUTF8(requestPtr) ?? "{}"; + var response = customNode(request); + result.content = ZenNative.AllocateCString(response); + } + catch (Exception ex) + { + result.error = ZenNative.AllocateCString(ex.Message); + } + return result; + }; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(ZenEngine)); + } + + public void Dispose() + { + if (!_disposed && _handle != IntPtr.Zero) + { + ZenNative.zen_engine_free(_handle); + _handle = IntPtr.Zero; + _disposed = true; + } + GC.SuppressFinalize(this); + } + + ~ZenEngine() + { + Dispose(); + } + } + + /// + /// Static helper methods for expression and template evaluation + /// + public static class ZenExpression + { + /// + /// Evaluate an expression with context + /// + /// Expression string (e.g., "a + b") + /// JSON context (e.g., {"a": 1, "b": 2}) + /// JSON result + public static string Evaluate(string expression, string context) + { + var result = ZenNative.zen_evaluate_expression(expression, context); + return ResultHelper.ExtractString(result); + } + + /// + /// Evaluate an expression with typed context + /// + public static TResult Evaluate(string expression, TContext context) + { + var contextJson = JsonSerializer.Serialize(context); + var resultJson = Evaluate(expression, contextJson); + return JsonSerializer.Deserialize(resultJson) + ?? throw new ZenException(ZenErrorCode.JsonDeserializationFailed, "Result was null"); + } + + /// + /// Evaluate a unary (boolean) expression + /// + /// Boolean expression (e.g., "a > 10") + /// JSON context + /// Boolean result + public static bool EvaluateUnary(string expression, string context) + { + var result = ZenNative.zen_evaluate_unary_expression(expression, context); + return ResultHelper.ExtractBool(result); + } + + /// + /// Evaluate a unary expression with typed context + /// + public static bool EvaluateUnary(string expression, TContext context) + { + var contextJson = JsonSerializer.Serialize(context); + return EvaluateUnary(expression, contextJson); + } + + /// + /// Render a template with context + /// + /// Template string (e.g., "Hello {{ name }}") + /// JSON context + /// Rendered result + public static string RenderTemplate(string template, string context) + { + var result = ZenNative.zen_evaluate_template(template, context); + return ResultHelper.ExtractString(result); + } + + /// + /// Render a template with typed context + /// + public static string RenderTemplate(string template, TContext context) + { + var contextJson = JsonSerializer.Serialize(context); + return RenderTemplate(template, contextJson); + } + } + + /// + /// Internal helper for extracting results and handling errors + /// + internal static class ResultHelper + { + public static string ExtractString(ZenResult_c_char result) + { + try + { + if (result.error != 0) + { + var details = result.details != IntPtr.Zero + ? Marshal.PtrToStringUTF8(result.details) + : null; + throw new ZenException((ZenErrorCode)result.error, details); + } + + return result.result != IntPtr.Zero + ? Marshal.PtrToStringUTF8(result.result) ?? "" + : ""; + } + finally + { + ZenNative.FreeRustString(result.result); + ZenNative.FreeRustString(result.details); + } + } + + public static IntPtr ExtractDecision(ZenResult_ZenDecisionStruct result) + { + try + { + if (result.error != 0) + { + var details = result.details != IntPtr.Zero + ? Marshal.PtrToStringUTF8(result.details) + : null; + throw new ZenException((ZenErrorCode)result.error, details); + } + + return result.result; + } + finally + { + ZenNative.FreeRustString(result.details); + } + } + + public static bool ExtractBool(ZenResult_c_int result) + { + try + { + if (result.error != 0) + { + var details = result.details != IntPtr.Zero + ? Marshal.PtrToStringUTF8(result.details) + : null; + throw new ZenException((ZenErrorCode)result.error, details); + } + + if (result.result == IntPtr.Zero) + return false; + + return Marshal.ReadInt32(result.result) != 0; + } + finally + { + ZenNative.FreeRustString(result.result); + ZenNative.FreeRustString(result.details); + } + } + } +} From f4a3af341822c7edc0c496700d92d43830f42f40 Mon Sep 17 00:00:00 2001 From: ice6 Date: Fri, 2 Jan 2026 13:18:56 +0800 Subject: [PATCH 2/9] Add .NET bindings for GoRules.Zen with unit tests and build scripts - Created GoRules.Zen.Tests project for unit testing the Zen Rules Engine bindings. - Implemented TemplateTests and UnaryExpressionTests to validate expression rendering and evaluation. - Updated GoRules.Zen project to target .NET 10.0 and include necessary references. - Added solution file for easier project management in Visual Studio. - Enhanced README.md with detailed usage instructions and examples. - Introduced USAGE.md for comprehensive integration guidance in .NET applications. - Added build.sh script to automate the building of the Rust library and .NET bindings. - Updated ZenEngine.cs to clarify default values for evaluation options. --- Cargo.toml | 3 + bindings/c/Cargo.toml | 2 +- bindings/dotnet/.gitignore | 5 + .../dotnet/GoRules.Zen.Tests/EngineTests.cs | 296 +++++++++ .../GoRules.Zen.Tests/ExpressionTests.cs | 106 +++ .../GoRules.Zen.Tests.csproj | 33 + .../dotnet/GoRules.Zen.Tests/TemplateTests.cs | 59 ++ .../GoRules.Zen.Tests/UnaryExpressionTests.cs | 81 +++ bindings/dotnet/GoRules.Zen.csproj | 8 +- bindings/dotnet/GoRules.Zen.sln | 25 + bindings/dotnet/README.md | 324 +++++++--- bindings/dotnet/USAGE.md | 606 ++++++++++++++++++ bindings/dotnet/ZenEngine.cs | 4 +- bindings/dotnet/build.sh | 80 +++ 14 files changed, 1553 insertions(+), 79 deletions(-) create mode 100644 bindings/dotnet/.gitignore create mode 100644 bindings/dotnet/GoRules.Zen.Tests/EngineTests.cs create mode 100644 bindings/dotnet/GoRules.Zen.Tests/ExpressionTests.cs create mode 100644 bindings/dotnet/GoRules.Zen.Tests/GoRules.Zen.Tests.csproj create mode 100644 bindings/dotnet/GoRules.Zen.Tests/TemplateTests.cs create mode 100644 bindings/dotnet/GoRules.Zen.Tests/UnaryExpressionTests.cs create mode 100644 bindings/dotnet/GoRules.Zen.sln create mode 100644 bindings/dotnet/USAGE.md create mode 100755 bindings/dotnet/build.sh diff --git a/Cargo.toml b/Cargo.toml index 8e5665f0..a2773092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,9 @@ members = [ "core/*", "bindings/*" ] +exclude = [ + "bindings/dotnet" +] [workspace.dependencies] ahash = "0.8" diff --git a/bindings/c/Cargo.toml b/bindings/c/Cargo.toml index 7641efc6..bcf1fc9a 100644 --- a/bindings/c/Cargo.toml +++ b/bindings/c/Cargo.toml @@ -17,7 +17,7 @@ zen-expression = { path = "../../core/expression" } zen-tmpl = { path = "../../core/template" } [lib] -crate-type = ["staticlib"] +crate-type = ["staticlib", "cdylib"] [build-dependencies] cbindgen = "0.28" diff --git a/bindings/dotnet/.gitignore b/bindings/dotnet/.gitignore new file mode 100644 index 00000000..90e21fc7 --- /dev/null +++ b/bindings/dotnet/.gitignore @@ -0,0 +1,5 @@ +bin +bin/ +obj +obj/ +runtimes/ \ No newline at end of file diff --git a/bindings/dotnet/GoRules.Zen.Tests/EngineTests.cs b/bindings/dotnet/GoRules.Zen.Tests/EngineTests.cs new file mode 100644 index 00000000..94ef7775 --- /dev/null +++ b/bindings/dotnet/GoRules.Zen.Tests/EngineTests.cs @@ -0,0 +1,296 @@ +using System; +using Xunit; +using GoRules.Zen; +using GoRules.Zen.Interop; + +namespace GoRules.Zen.Tests; + +public class EngineTests : IDisposable +{ + private ZenEngine? _engine; + + public void Dispose() + { + _engine?.Dispose(); + } + + [Fact] + public void Constructor_Default_CreatesEngine() + { + _engine = new ZenEngine(); + Assert.NotNull(_engine); + } + + [Fact] + public void Constructor_WithLoader_CreatesEngine() + { + _engine = new ZenEngine( + loader: key => null + ); + Assert.NotNull(_engine); + } + + [Fact] + public void Constructor_WithLoaderAndCustomNode_CreatesEngine() + { + _engine = new ZenEngine( + loader: key => null, + customNode: request => """{"result": {}}""" + ); + Assert.NotNull(_engine); + } + + [Fact] + public void CreateDecision_ValidJson_ReturnsDecision() + { + _engine = new ZenEngine(); + + // Minimal valid decision JSON + var decisionJson = """ + { + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "id": "input", + "type": "inputNode", + "position": {"x": 0, "y": 0}, + "name": "Input" + }, + { + "id": "output", + "type": "outputNode", + "position": {"x": 200, "y": 0}, + "name": "Output" + } + ], + "edges": [ + { + "id": "edge1", + "sourceId": "input", + "targetId": "output" + } + ] + } + """; + + using var decision = _engine.CreateDecision(decisionJson); + Assert.NotNull(decision); + } + + [Fact] + public void CreateDecision_InvalidJson_ThrowsZenException() + { + _engine = new ZenEngine(); + + var ex = Assert.Throws(() => + _engine.CreateDecision("not valid json") + ); + Assert.True( + ex.ErrorCode == ZenErrorCode.JsonDeserializationFailed || + ex.ErrorCode == ZenErrorCode.InvalidArgument + ); + } + + [Fact] + public void Decision_Evaluate_ReturnsResult() + { + _engine = new ZenEngine(); + + var decisionJson = """ + { + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "id": "input", + "type": "inputNode", + "position": {"x": 0, "y": 0}, + "name": "Input" + }, + { + "id": "output", + "type": "outputNode", + "position": {"x": 200, "y": 0}, + "name": "Output" + } + ], + "edges": [ + { + "id": "edge1", + "sourceId": "input", + "targetId": "output" + } + ] + } + """; + + using var decision = _engine.CreateDecision(decisionJson); + var result = decision.Evaluate("""{"test": "value"}"""); + + Assert.NotNull(result); + Assert.Contains("result", result); + } + + [Fact] + public void Decision_EvaluateWithTrace_IncludesTrace() + { + _engine = new ZenEngine(); + + var decisionJson = """ + { + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "id": "input", + "type": "inputNode", + "position": {"x": 0, "y": 0}, + "name": "Input" + }, + { + "id": "output", + "type": "outputNode", + "position": {"x": 200, "y": 0}, + "name": "Output" + } + ], + "edges": [ + { + "id": "edge1", + "sourceId": "input", + "targetId": "output" + } + ] + } + """; + + using var decision = _engine.CreateDecision(decisionJson); + var options = new EvaluationOptions { Trace = true, MaxDepth = 5 }; + var result = decision.Evaluate("""{"test": "value"}""", options); + + Assert.NotNull(result); + Assert.Contains("trace", result); + } + + [Fact] + public void GetDecision_WithLoader_CallsLoader() + { + var loaderCalled = false; + var requestedKey = ""; + + var decisionJson = """ + { + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "id": "input", + "type": "inputNode", + "position": {"x": 0, "y": 0}, + "name": "Input" + }, + { + "id": "output", + "type": "outputNode", + "position": {"x": 200, "y": 0}, + "name": "Output" + } + ], + "edges": [ + { + "id": "edge1", + "sourceId": "input", + "targetId": "output" + } + ] + } + """; + + _engine = new ZenEngine( + loader: key => + { + loaderCalled = true; + requestedKey = key; + return decisionJson; + } + ); + + using var decision = _engine.GetDecision("my-decision"); + + Assert.True(loaderCalled); + Assert.Equal("my-decision", requestedKey); + } + + [Fact] + public void GetDecision_LoaderReturnsNull_ThrowsZenException() + { + _engine = new ZenEngine( + loader: key => null + ); + + var ex = Assert.Throws(() => + _engine.GetDecision("missing-decision") + ); + + Assert.True( + ex.ErrorCode == ZenErrorCode.LoaderKeyNotFound || + ex.ErrorCode == ZenErrorCode.LoaderInternalError + ); + } + + [Fact] + public void Evaluate_ByKey_UsesLoaderAndReturnsResult() + { + var decisionJson = """ + { + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "id": "input", + "type": "inputNode", + "position": {"x": 0, "y": 0}, + "name": "Input" + }, + { + "id": "output", + "type": "outputNode", + "position": {"x": 200, "y": 0}, + "name": "Output" + } + ], + "edges": [ + { + "id": "edge1", + "sourceId": "input", + "targetId": "output" + } + ] + } + """; + + _engine = new ZenEngine( + loader: key => key == "test-decision" ? decisionJson : null + ); + + var result = _engine.Evaluate("test-decision", """{"input": "data"}"""); + + Assert.NotNull(result); + Assert.Contains("result", result); + } + + [Fact] + public void Dispose_CalledMultipleTimes_DoesNotThrow() + { + _engine = new ZenEngine(); + _engine.Dispose(); + _engine.Dispose(); // Should not throw + } + + [Fact] + public void Evaluate_AfterDispose_ThrowsObjectDisposedException() + { + _engine = new ZenEngine(); + _engine.Dispose(); + + Assert.Throws(() => + _engine.CreateDecision("{}") + ); + } +} diff --git a/bindings/dotnet/GoRules.Zen.Tests/ExpressionTests.cs b/bindings/dotnet/GoRules.Zen.Tests/ExpressionTests.cs new file mode 100644 index 00000000..ae568784 --- /dev/null +++ b/bindings/dotnet/GoRules.Zen.Tests/ExpressionTests.cs @@ -0,0 +1,106 @@ +using Xunit; +using GoRules.Zen; +using GoRules.Zen.Interop; + +namespace GoRules.Zen.Tests; + +public class ExpressionTests +{ + [Fact] + public void Evaluate_SimpleAddition_ReturnsCorrectResult() + { + var result = ZenExpression.Evaluate("a + b", """{"a": 10, "b": 20}"""); + Assert.Equal("30", result); + } + + [Fact] + public void Evaluate_Multiplication_ReturnsCorrectResult() + { + var result = ZenExpression.Evaluate("a * b", """{"a": 5, "b": 4}"""); + Assert.Equal("20", result); + } + + [Fact] + public void Evaluate_StringConcat_ReturnsCorrectResult() + { + var result = ZenExpression.Evaluate( + "firstName + \" \" + lastName", + """{"firstName": "John", "lastName": "Doe"}""" + ); + Assert.Equal("\"John Doe\"", result); + } + + [Fact] + public void Evaluate_ArrayAccess_ReturnsCorrectResult() + { + var result = ZenExpression.Evaluate( + "items[1]", + """{"items": [10, 20, 30]}""" + ); + Assert.Equal("20", result); + } + + [Fact] + public void Evaluate_ObjectProperty_ReturnsCorrectResult() + { + var result = ZenExpression.Evaluate( + "user.name", + """{"user": {"name": "Alice", "age": 30}}""" + ); + Assert.Equal("\"Alice\"", result); + } + + [Fact] + public void Evaluate_Ternary_ReturnsCorrectResult() + { + var result = ZenExpression.Evaluate( + "age >= 18 ? \"adult\" : \"minor\"", + """{"age": 21}""" + ); + Assert.Equal("\"adult\"", result); + } + + [Fact] + public void Evaluate_Max_ReturnsCorrectResult() + { + var result = ZenExpression.Evaluate("max(items)", """{"items": [5, 10, 3]}"""); + Assert.Equal("10", result); + } + + [Fact] + public void Evaluate_Min_ReturnsCorrectResult() + { + var result = ZenExpression.Evaluate("min(items)", """{"items": [5, 10, 3]}"""); + Assert.Equal("3", result); + } + + [Fact] + public void Evaluate_TypedContext_ReturnsTypedResult() + { + var context = new { a = 15, b = 25 }; + var result = ZenExpression.Evaluate("a + b", context); + Assert.Equal(40, result); + } + + [Fact] + public void Evaluate_InvalidExpression_ThrowsZenException() + { + var ex = Assert.Throws(() => + ZenExpression.Evaluate("invalid syntax !!!", "{}") + ); + Assert.True(ex.ErrorCode == ZenErrorCode.EvaluationError || ex.ErrorCode == ZenErrorCode.IsolateError); + } + + [Fact] + public void Evaluate_InvalidJson_ThrowsZenException() + { + var ex = Assert.Throws(() => + ZenExpression.Evaluate("a + b", "not valid json") + ); + Assert.True( + ex.ErrorCode == ZenErrorCode.JsonDeserializationFailed || + ex.ErrorCode == ZenErrorCode.EvaluationError || + ex.ErrorCode == ZenErrorCode.IsolateError + ); + } +} diff --git a/bindings/dotnet/GoRules.Zen.Tests/GoRules.Zen.Tests.csproj b/bindings/dotnet/GoRules.Zen.Tests/GoRules.Zen.Tests.csproj new file mode 100644 index 00000000..f829a511 --- /dev/null +++ b/bindings/dotnet/GoRules.Zen.Tests/GoRules.Zen.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + disable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/bindings/dotnet/GoRules.Zen.Tests/TemplateTests.cs b/bindings/dotnet/GoRules.Zen.Tests/TemplateTests.cs new file mode 100644 index 00000000..d8002b13 --- /dev/null +++ b/bindings/dotnet/GoRules.Zen.Tests/TemplateTests.cs @@ -0,0 +1,59 @@ +using Xunit; +using GoRules.Zen; + +namespace GoRules.Zen.Tests; + +public class TemplateTests +{ + [Fact] + public void RenderTemplate_SimpleInterpolation_ReturnsCorrectResult() + { + var result = ZenExpression.RenderTemplate( + "Hello {{ name }}!", + """{"name": "World"}""" + ); + Assert.Equal("\"Hello World!\"", result); + } + + [Fact] + public void RenderTemplate_MultipleVariables_ReturnsCorrectResult() + { + var result = ZenExpression.RenderTemplate( + "{{ greeting }}, {{ name }}!", + """{"greeting": "Hi", "name": "Alice"}""" + ); + Assert.Equal("\"Hi, Alice!\"", result); + } + + [Fact] + public void RenderTemplate_NestedProperty_ReturnsCorrectResult() + { + var result = ZenExpression.RenderTemplate( + "User: {{ user.name }} ({{ user.email }})", + """{"user": {"name": "Bob", "email": "bob@example.com"}}""" + ); + Assert.Equal("\"User: Bob (bob@example.com)\"", result); + } + + [Fact] + public void RenderTemplate_WithExpression_ReturnsCorrectResult() + { + var result = ZenExpression.RenderTemplate( + "Total: {{ price * quantity }}", + """{"price": 10, "quantity": 3}""" + ); + Assert.Equal("\"Total: 30\"", result); + } + + [Fact] + public void RenderTemplate_TypedContext_ReturnsCorrectResult() + { + var context = new { productName = "Widget", price = 19.99 }; + var result = ZenExpression.RenderTemplate( + "{{ productName }}: ${{ price }}", + context + ); + Assert.Contains("Widget", result); + Assert.Contains("19.99", result); + } +} diff --git a/bindings/dotnet/GoRules.Zen.Tests/UnaryExpressionTests.cs b/bindings/dotnet/GoRules.Zen.Tests/UnaryExpressionTests.cs new file mode 100644 index 00000000..ad761dfe --- /dev/null +++ b/bindings/dotnet/GoRules.Zen.Tests/UnaryExpressionTests.cs @@ -0,0 +1,81 @@ +using Xunit; +using GoRules.Zen; + +namespace GoRules.Zen.Tests; + +public class UnaryExpressionTests +{ + [Fact] + public void EvaluateUnary_GreaterThan_ReturnsTrue() + { + var result = ZenExpression.EvaluateUnary("> 18", """{"$": 21}"""); + Assert.True(result); + } + + [Fact] + public void EvaluateUnary_GreaterThan_ReturnsFalse() + { + var result = ZenExpression.EvaluateUnary("> 18", """{"$": 16}"""); + Assert.False(result); + } + + [Fact] + public void EvaluateUnary_Equality_ReturnsTrue() + { + var result = ZenExpression.EvaluateUnary( + "== \"active\"", + """{"$": "active"}""" + ); + Assert.True(result); + } + + [Fact] + public void EvaluateUnary_GreaterThanOrEqual_ReturnsTrue() + { + var result = ZenExpression.EvaluateUnary( + ">= 18", + """{"$": 25}""" + ); + Assert.True(result); + } + + [Fact] + public void EvaluateUnary_LessThan_ReturnsTrue() + { + var result = ZenExpression.EvaluateUnary( + "< 100", + """{"$": 50}""" + ); + Assert.True(result); + } + + [Fact] + public void EvaluateUnary_NotEqual_ReturnsTrue() + { + var result = ZenExpression.EvaluateUnary( + "!= \"blocked\"", + """{"$": "active"}""" + ); + Assert.True(result); + } + + [Fact] + public void EvaluateUnary_InArray_ReturnsTrue() + { + var result = ZenExpression.EvaluateUnary( + "in [\"active\", \"pending\"]", + """{"$": "active"}""" + ); + Assert.True(result); + } + + [Fact] + public void EvaluateUnary_NotInArray_ReturnsFalse() + { + var result = ZenExpression.EvaluateUnary( + "in [\"active\", \"pending\"]", + """{"$": "blocked"}""" + ); + Assert.False(result); + } +} diff --git a/bindings/dotnet/GoRules.Zen.csproj b/bindings/dotnet/GoRules.Zen.csproj index a4a53968..3b350ed3 100644 --- a/bindings/dotnet/GoRules.Zen.csproj +++ b/bindings/dotnet/GoRules.Zen.csproj @@ -1,10 +1,11 @@ - net8.0 + net10.0 disable enable latest + false GoRules.Zen @@ -19,6 +20,11 @@ true + + + + + diff --git a/bindings/dotnet/GoRules.Zen.sln b/bindings/dotnet/GoRules.Zen.sln new file mode 100644 index 00000000..fa4dbfb5 --- /dev/null +++ b/bindings/dotnet/GoRules.Zen.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoRules.Zen", "GoRules.Zen.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoRules.Zen.Tests", "GoRules.Zen.Tests\GoRules.Zen.Tests.csproj", "{B2C3D4E5-F678-90AB-CDEF-123456789ABC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F678-90AB-CDEF-123456789ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F678-90AB-CDEF-123456789ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F678-90AB-CDEF-123456789ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F678-90AB-CDEF-123456789ABC}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/bindings/dotnet/README.md b/bindings/dotnet/README.md index a1b2e4b1..72c6deab 100644 --- a/bindings/dotnet/README.md +++ b/bindings/dotnet/README.md @@ -1,39 +1,81 @@ # GoRules.Zen - C# Bindings for Zen Rules Engine -C# P/Invoke bindings for the Zen Rules Engine, providing high-performance business rules evaluation. +C# P/Invoke bindings for the [Zen Rules Engine](https://github.com/gorules/zen), providing high-performance business rules evaluation for .NET applications. + +## Overview + +This package provides native C# bindings to the Zen Rules Engine via P/Invoke, allowing you to: + +- Evaluate expressions with a powerful expression language +- Execute business rules defined in JSON decision graphs +- Use templates for dynamic string rendering +- Integrate custom logic via callbacks + +### Why P/Invoke Instead of UniFFI? + +The Zen project uses [UniFFI](https://mozilla.github.io/uniffi-rs/) for multi-language bindings (Kotlin, Java, Swift). However, UniFFI's C# support has limitations that prevent it from working correctly with this codebase. This P/Invoke implementation provides a direct, reliable alternative by: + +1. Using the existing C FFI bindings (`bindings/c/`) +2. Wrapping them with idiomatic C# classes +3. Handling memory management and marshalling automatically + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Your C# Application │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ GoRules.Zen (C# Wrapper) │ +│ • ZenEngine, ZenDecision, ZenExpression │ +│ • Automatic memory management │ +│ • Type-safe API with generics │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ P/Invoke +┌─────────────────────────────────────────────────────────────┐ +│ libzen_ffi.so / zen_ffi.dll │ +│ • C FFI exports with extern "C" │ +│ • cbindgen-generated headers │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Zen Core (Rust) │ +│ • zen-engine: Decision graph execution │ +│ • zen-expression: Expression language VM │ +│ • zen-template: Template rendering │ +└─────────────────────────────────────────────────────────────┘ +``` ## Building -### Step 1: Build the Native Library +### Prerequisites -First, build the Rust C bindings as a shared library: +- .NET SDK 8.0+ (tested with .NET 10) +- Rust toolchain (for building native library) + +### Step 1: Build the Native Library ```bash # From the repository root cd bindings/c -# Build for your platform -cargo build --release +# Build without Go feature (required for .NET) +cargo build --release --no-default-features -# The library will be at: -# - Linux: target/release/libzen_ffi.so -# - macOS: target/release/libzen_ffi.dylib -# - Windows: target/release/zen_ffi.dll +# Output locations: +# - Linux: ../../target/release/libzen_ffi.so +# - macOS: ../../target/release/libzen_ffi.dylib +# - Windows: ../../target/release/zen_ffi.dll ``` -**Note:** The C bindings produce a static library by default. You need to change `Cargo.toml`: - -```toml -[lib] -crate-type = ["cdylib"] # Change from "staticlib" to "cdylib" -``` - -### Step 2: Copy Native Library - -Copy the built library to the appropriate runtime folder: +### Step 2: Copy Native Library to Runtime Folder ```bash -# Linux +# Linux x64 mkdir -p bindings/dotnet/runtimes/linux-x64/native cp target/release/libzen_ffi.so bindings/dotnet/runtimes/linux-x64/native/ @@ -45,7 +87,7 @@ cp target/release/libzen_ffi.dylib bindings/dotnet/runtimes/osx-x64/native/ mkdir -p bindings/dotnet/runtimes/osx-arm64/native cp target/release/libzen_ffi.dylib bindings/dotnet/runtimes/osx-arm64/native/ -# Windows +# Windows x64 mkdir -p bindings/dotnet/runtimes/win-x64/native cp target/release/zen_ffi.dll bindings/dotnet/runtimes/win-x64/native/ ``` @@ -57,37 +99,87 @@ cd bindings/dotnet dotnet build ``` +### Step 4: Run Tests + +```bash +dotnet test +``` + ## Usage -### Basic Expression Evaluation +### Expression Evaluation ```csharp using GoRules.Zen; -// Evaluate a simple expression +// Simple arithmetic var result = ZenExpression.Evaluate("a + b", """{"a": 10, "b": 20}"""); -Console.WriteLine(result); // 30 +// result = "30" + +// String operations +var greeting = ZenExpression.Evaluate( + "firstName + \" \" + lastName", + """{"firstName": "John", "lastName": "Doe"}""" +); +// greeting = "\"John Doe\"" -// Evaluate a boolean expression -var isValid = ZenExpression.EvaluateUnary("age >= 18", """{"age": 21}"""); -Console.WriteLine(isValid); // true +// Array functions +var max = ZenExpression.Evaluate("max(scores)", """{"scores": [85, 92, 78]}"""); +// max = "92" -// Render a template -var greeting = ZenExpression.RenderTemplate( - "Hello {{ name }}!", - """{"name": "World"}""" +// Ternary expressions +var status = ZenExpression.Evaluate( + "age >= 18 ? \"adult\" : \"minor\"", + """{"age": 21}""" ); -Console.WriteLine(greeting); // "Hello World!" +// status = "\"adult\"" ``` -### Typed Expression Evaluation +### Unary (Boolean) Expressions + +Unary expressions compare a value against an expression. The context value is accessed via `$`: ```csharp using GoRules.Zen; -var context = new { a = 10, b = 20 }; -var result = ZenExpression.Evaluate("a + b", context); -Console.WriteLine(result); // 30 +// Greater than +var isAdult = ZenExpression.EvaluateUnary("> 18", """{"$": 21}"""); +// isAdult = true + +// Equality +var isActive = ZenExpression.EvaluateUnary("== \"active\"", """{"$": "active"}"""); +// isActive = true + +// In array +var isValid = ZenExpression.EvaluateUnary( + "in [\"pending\", \"active\"]", + """{"$": "active"}""" +); +// isValid = true +``` + +### Template Rendering + +```csharp +using GoRules.Zen; + +var result = ZenExpression.RenderTemplate( + "Hello {{ name }}! You have {{ count }} messages.", + """{"name": "Alice", "count": 5}""" +); +// result = "\"Hello Alice! You have 5 messages.\"" +``` + +### Typed Context and Results + +Use generics to work with strongly-typed objects: + +```csharp +using GoRules.Zen; + +var context = new { a = 15, b = 25 }; +int result = ZenExpression.Evaluate("a + b", context); +// result = 40 ``` ### Decision Evaluation @@ -102,38 +194,42 @@ using var engine = new ZenEngine(); string decisionJson = File.ReadAllText("my-decision.json"); using var decision = engine.CreateDecision(decisionJson); -// Evaluate -var result = decision.Evaluate("""{"input": "value"}"""); -Console.WriteLine(result); +// Evaluate with context +var result = decision.Evaluate("""{"customer": {"age": 25}}"""); -// With options +// With tracing enabled var options = new EvaluationOptions { Trace = true, MaxDepth = 10 }; -var tracedResult = decision.Evaluate("""{"input": "value"}""", options); +var tracedResult = decision.Evaluate("""{"customer": {"age": 25}}""", options); ``` -### Using a Decision Loader +### Decision Loader Callback + +Load decisions dynamically from any source: ```csharp using GoRules.Zen; -// Create engine with loader callback using var engine = new ZenEngine( loader: key => { - // Load decision JSON by key from database, file, etc. + // Load from file system var path = $"decisions/{key}.json"; if (File.Exists(path)) return File.ReadAllText(path); - return null; // Not found + + // Or load from database, HTTP, etc. + return null; // Return null if not found } ); -// Evaluate by key - loader will be called -var result = engine.Evaluate("my-decision", """{"input": "value"}"""); +// The loader is called automatically +var result = engine.Evaluate("pricing-rules", """{"product": "widget"}"""); ``` ### Custom Node Handler +Handle custom node types in decision graphs: + ```csharp using GoRules.Zen; using System.Text.Json; @@ -142,52 +238,57 @@ using var engine = new ZenEngine( loader: key => File.ReadAllText($"decisions/{key}.json"), customNode: request => { - // Parse the request var doc = JsonDocument.Parse(request); - var nodeType = doc.RootElement.GetProperty("node") - .GetProperty("kind").GetString(); + var nodeKind = doc.RootElement + .GetProperty("node") + .GetProperty("kind") + .GetString(); - // Handle custom node types - if (nodeType == "myCustomNode") + return nodeKind switch { - return JsonSerializer.Serialize(new - { - result = new { customOutput = "processed" } - }); - } - - throw new Exception($"Unknown custom node: {nodeType}"); + "httpCall" => HandleHttpCall(doc), + "dbLookup" => HandleDbLookup(doc), + _ => throw new NotSupportedException($"Unknown node: {nodeKind}") + }; } ); - -var result = engine.Evaluate("decision-with-custom-node", "{}"); ``` ### Error Handling ```csharp using GoRules.Zen; +using GoRules.Zen.Interop; try { - var result = ZenExpression.Evaluate("invalid expression !!!", "{}"); + var result = ZenExpression.Evaluate("invalid !!!", "{}"); } catch (ZenException ex) { - Console.WriteLine($"Error: {ex.ErrorCode}"); + Console.WriteLine($"Error Code: {ex.ErrorCode}"); Console.WriteLine($"Details: {ex.Details}"); + + // Handle specific errors + if (ex.ErrorCode == ZenErrorCode.EvaluationError) + { + // Handle evaluation error + } } ``` ## API Reference -### ZenExpression (Static Methods) +### ZenExpression (Static Class) | Method | Description | |--------|-------------| -| `Evaluate(expression, context)` | Evaluate an expression, returns JSON | -| `EvaluateUnary(expression, context)` | Evaluate a boolean expression | -| `RenderTemplate(template, context)` | Render a template string | +| `Evaluate(expression, context)` | Evaluate expression, returns JSON string | +| `Evaluate(expression, context)` | Typed evaluation | +| `EvaluateUnary(expression, context)` | Evaluate boolean expression | +| `EvaluateUnary(expression, context)` | Typed unary evaluation | +| `RenderTemplate(template, context)` | Render template string | +| `RenderTemplate(template, context)` | Typed template rendering | ### ZenEngine @@ -195,15 +296,18 @@ catch (ZenException ex) |--------|-------------| | `ZenEngine()` | Create engine without callbacks | | `ZenEngine(loader, customNode)` | Create engine with callbacks | -| `CreateDecision(json)` | Create decision from JSON | -| `GetDecision(key)` | Get decision via loader | +| `CreateDecision(json)` | Create decision from JSON string | +| `GetDecision(key)` | Get decision via loader callback | | `Evaluate(key, context, options)` | Evaluate decision by key | +| `Evaluate(...)` | Typed evaluation | +| `Dispose()` | Free native resources | ### ZenDecision | Method | Description | |--------|-------------| | `Evaluate(context, options)` | Evaluate the decision | +| `Evaluate(...)` | Typed evaluation | | `Dispose()` | Free native resources | ### EvaluationOptions @@ -211,17 +315,87 @@ catch (ZenException ex) | Property | Type | Default | Description | |----------|------|---------|-------------| | `Trace` | bool | false | Enable execution trace | -| `MaxDepth` | byte | 0 | Max recursion depth (0 = default) | +| `MaxDepth` | byte | 5 | Maximum recursion depth | + +### Error Codes (ZenErrorCode) + +| Code | Description | +|------|-------------| +| `Success` | No error | +| `InvalidArgument` | Invalid argument provided | +| `JsonSerializationFailed` | JSON serialization error | +| `JsonDeserializationFailed` | JSON parsing error | +| `IsolateError` | Expression evaluation error | +| `EvaluationError` | Decision evaluation error | +| `LoaderKeyNotFound` | Decision key not found | +| `LoaderInternalError` | Loader callback error | +| `TemplateEngineError` | Template rendering error | + +## Project Structure + +``` +bindings/dotnet/ +├── GoRules.Zen.sln # Solution file +├── GoRules.Zen.csproj # Main library project +├── ZenEngine.cs # High-level wrapper classes +├── ZenEngine.Interop.cs # P/Invoke definitions +├── README.md # This file +├── runtimes/ # Native libraries (per-platform) +│ ├── linux-x64/native/ +│ ├── osx-x64/native/ +│ ├── osx-arm64/native/ +│ └── win-x64/native/ +└── GoRules.Zen.Tests/ # Unit tests + ├── ExpressionTests.cs + ├── UnaryExpressionTests.cs + ├── TemplateTests.cs + └── EngineTests.cs +``` ## Platform Support | Platform | Architecture | Status | |----------|--------------|--------| -| Linux | x64 | ✅ | -| macOS | x64 | ✅ | -| macOS | ARM64 | ✅ | -| Windows | x64 | ✅ | +| Linux | x64 | Tested | +| macOS | x64 | Supported | +| macOS | ARM64 (Apple Silicon) | Supported | +| Windows | x64 | Supported | + +## Building for NuGet Distribution + +```bash +# Build native library for your platform first +cd bindings/c +cargo build --release --no-default-features + +# Copy to runtimes folder +cp ../../target/release/libzen_ffi.so ../dotnet/runtimes/linux-x64/native/ + +# Create NuGet package +cd ../dotnet +dotnet pack -c Release +``` + +The resulting `.nupkg` will include the native library for the current platform. + +## Differences from Other Bindings + +| Feature | Node.js | Python | C# (this) | +|---------|---------|--------|-----------| +| Technology | NAPI-RS | PyO3 | P/Invoke | +| Async Support | Native Promises | asyncio | Sync only* | +| Type Generation | TypeScript | .pyi stubs | Manual | +| Memory Management | Automatic | Automatic | Automatic | + +*Async support can be added by wrapping calls in `Task.Run()`. + +## Contributing + +1. Fork the repository +2. Make changes to the C# bindings in `bindings/dotnet/` +3. Run tests: `dotnet test` +4. Submit a pull request ## License -MIT +MIT License - see [LICENSE](../../LICENSE) for details. diff --git a/bindings/dotnet/USAGE.md b/bindings/dotnet/USAGE.md new file mode 100644 index 00000000..1d2557fd --- /dev/null +++ b/bindings/dotnet/USAGE.md @@ -0,0 +1,606 @@ +# Using GoRules.Zen in Your .NET Project + +This guide explains how to integrate the Zen Rules Engine into your own .NET application. + +## Installation Options + +### Option 1: Reference the Project Directly + +If you have the Zen repository cloned locally: + +```bash +# Add project reference +dotnet add reference /path/to/zen/bindings/dotnet/GoRules.Zen.csproj +``` + +### Option 2: Build and Reference the DLL + +```bash +# Build the library +cd /path/to/zen/bindings/dotnet +dotnet build -c Release + +# Copy the DLL to your project +cp bin/Release/net10.0/GoRules.Zen.dll /path/to/your/project/ + +# Add reference in your .csproj +``` + +```xml + + + GoRules.Zen.dll + + +``` + +### Option 3: NuGet Package (Future) + +```bash +# When published to NuGet +dotnet add package GoRules.Zen +``` + +## Native Library Setup + +The C# bindings require the native Rust library. You must include it with your application. + +### Building the Native Library + +```bash +# From the zen repository root +cd bindings/c + +# Build for your platform (without Go feature) +cargo build --release --no-default-features +``` + +### Output Locations + +| Platform | Library Path | +|----------|--------------| +| Linux | `target/release/libzen_ffi.so` | +| macOS | `target/release/libzen_ffi.dylib` | +| Windows | `target/release/zen_ffi.dll` | + +### Deploying the Native Library + +#### Option A: Place Next to Your Executable + +Copy the native library to your application's output directory: + +```bash +# Linux +cp libzen_ffi.so /path/to/your/app/ + +# macOS +cp libzen_ffi.dylib /path/to/your/app/ + +# Windows +cp zen_ffi.dll /path/to/your/app/ +``` + +#### Option B: Use Runtime Folders (Recommended) + +Create platform-specific runtime folders in your project: + +``` +YourProject/ +├── YourProject.csproj +├── Program.cs +└── runtimes/ + ├── linux-x64/ + │ └── native/ + │ └── libzen_ffi.so + ├── osx-x64/ + │ └── native/ + │ └── libzen_ffi.dylib + ├── osx-arm64/ + │ └── native/ + │ └── libzen_ffi.dylib + └── win-x64/ + └── native/ + └── zen_ffi.dll +``` + +Add to your `.csproj`: + +```xml + + + + + + + + + + + + + +``` + +## Quick Start Example + +### 1. Create a New Project + +```bash +dotnet new console -n ZenDemo +cd ZenDemo +``` + +### 2. Add Reference and Native Library + +```bash +# Add project reference +dotnet add reference /path/to/zen/bindings/dotnet/GoRules.Zen.csproj + +# Copy native library +mkdir -p runtimes/linux-x64/native +cp /path/to/zen/target/release/libzen_ffi.so runtimes/linux-x64/native/ +``` + +### 3. Write Your Code + +```csharp +// Program.cs +using GoRules.Zen; + +// Simple expression evaluation +var sum = ZenExpression.Evaluate("price * quantity", """ +{ + "price": 29.99, + "quantity": 3 +} +"""); +Console.WriteLine($"Total: {sum}"); // Total: 89.97 + +// Boolean check +var isEligible = ZenExpression.EvaluateUnary(">= 18", """{"$": 21}"""); +Console.WriteLine($"Is eligible: {isEligible}"); // Is eligible: True + +// Template rendering +var message = ZenExpression.RenderTemplate( + "Order #{{ orderId }} confirmed for {{ customer.name }}", + """ + { + "orderId": 12345, + "customer": { "name": "Alice" } + } + """ +); +Console.WriteLine(message); // "Order #12345 confirmed for Alice" +``` + +### 4. Run + +```bash +dotnet run +``` + +## Common Use Cases + +### Pricing Rules Engine + +```csharp +using GoRules.Zen; + +public class PricingService +{ + private readonly ZenEngine _engine; + + public PricingService() + { + _engine = new ZenEngine( + loader: key => LoadDecisionFromDatabase(key) + ); + } + + public decimal CalculateDiscount(string customerId, decimal orderTotal) + { + var context = new + { + customerId, + orderTotal, + isNewCustomer = CheckIfNewCustomer(customerId), + loyaltyTier = GetLoyaltyTier(customerId) + }; + + var result = _engine.Evaluate( + "discount-rules", + context + ); + + return result.DiscountPercent; + } + + private string? LoadDecisionFromDatabase(string key) + { + // Load from your database, file system, or API + return File.Exists($"rules/{key}.json") + ? File.ReadAllText($"rules/{key}.json") + : null; + } +} + +public record DiscountResult(decimal DiscountPercent, string Reason); +``` + +### Form Validation + +```csharp +using GoRules.Zen; + +public class ValidationService +{ + public ValidationResult ValidateForm(FormData form) + { + var errors = new List(); + + // Email validation + if (!ZenExpression.EvaluateUnary( + """matches "^[\\w.-]+@[\\w.-]+\\.\\w+$" """, + $$$"""{"$": "{{{form.Email}}}"}""")) + { + errors.Add("Invalid email format"); + } + + // Age validation + if (!ZenExpression.EvaluateUnary( + ">= 18", + $$$"""{"$": {{{form.Age}}}}""")) + { + errors.Add("Must be 18 or older"); + } + + // Password strength + if (!ZenExpression.EvaluateUnary( + ">= 8", + $$$"""{"$": {{{form.Password.Length}}}}""")) + { + errors.Add("Password must be at least 8 characters"); + } + + return new ValidationResult(errors.Count == 0, errors); + } +} +``` + +### Dynamic Feature Flags + +```csharp +using GoRules.Zen; + +public class FeatureFlagService +{ + public bool IsFeatureEnabled(string featureName, UserContext user) + { + var context = new + { + feature = featureName, + user = new + { + id = user.Id, + tier = user.Tier, + region = user.Region, + registrationDate = user.RegisteredAt.ToString("o") + }, + environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + }; + + try + { + using var engine = new ZenEngine( + loader: _ => File.ReadAllText("feature-flags.json") + ); + + var result = engine.Evaluate( + "feature-flags", + context + ); + + return result.Enabled; + } + catch (ZenException) + { + // Default to disabled on error + return false; + } + } +} +``` + +### ASP.NET Core Integration + +```csharp +// Program.cs +using GoRules.Zen; + +var builder = WebApplication.CreateBuilder(args); + +// Register as singleton (thread-safe) +builder.Services.AddSingleton(sp => +{ + return new ZenEngine( + loader: key => + { + var path = Path.Combine("Rules", $"{key}.json"); + return File.Exists(path) ? File.ReadAllText(path) : null; + } + ); +}); + +var app = builder.Build(); + +app.MapPost("/api/evaluate/{ruleKey}", async ( + string ruleKey, + JsonElement context, + ZenEngine engine) => +{ + try + { + var result = engine.Evaluate(ruleKey, context.GetRawText()); + return Results.Ok(JsonDocument.Parse(result).RootElement); + } + catch (ZenException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } +}); + +app.Run(); +``` + +### Batch Processing + +```csharp +using GoRules.Zen; +using System.Collections.Concurrent; + +public class BatchProcessor +{ + public async Task> ProcessBatchAsync( + IEnumerable orders) + { + var results = new ConcurrentBag(); + + // ZenEngine is thread-safe for evaluation + using var engine = new ZenEngine( + loader: key => File.ReadAllText($"rules/{key}.json") + ); + + await Parallel.ForEachAsync(orders, async (order, ct) => + { + await Task.Run(() => + { + var context = JsonSerializer.Serialize(order); + var result = engine.Evaluate("order-processing", context); + results.Add(new ProcessingResult(order.Id, result)); + }, ct); + }); + + return results.ToList(); + } +} +``` + +## Decision JSON Format + +Zen uses a JSON format for decision graphs. Here's a minimal example: + +```json +{ + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "id": "input", + "type": "inputNode", + "position": { "x": 0, "y": 0 }, + "name": "Request" + }, + { + "id": "table1", + "type": "decisionTableNode", + "position": { "x": 200, "y": 0 }, + "name": "Pricing Table", + "content": { + "rules": [ + { + "conditions": [{ "field": "tier", "operator": "==", "value": "gold" }], + "outputs": [{ "field": "discount", "value": "0.20" }] + }, + { + "conditions": [{ "field": "tier", "operator": "==", "value": "silver" }], + "outputs": [{ "field": "discount", "value": "0.10" }] + } + ] + } + }, + { + "id": "output", + "type": "outputNode", + "position": { "x": 400, "y": 0 }, + "name": "Response" + } + ], + "edges": [ + { "id": "e1", "sourceId": "input", "targetId": "table1" }, + { "id": "e2", "sourceId": "table1", "targetId": "output" } + ] +} +``` + +For a visual editor and more complex examples, visit [GoRules Editor](https://editor.gorules.io/). + +## Error Handling Best Practices + +```csharp +using GoRules.Zen; +using GoRules.Zen.Interop; + +public class SafeRulesEvaluator +{ + private readonly ZenEngine _engine; + private readonly ILogger _logger; + + public SafeRulesEvaluator(ILogger logger) + { + _logger = logger; + _engine = new ZenEngine(loader: LoadRule); + } + + public EvaluationResult Evaluate(string ruleKey, object context) + { + try + { + var contextJson = JsonSerializer.Serialize(context); + var result = _engine.Evaluate(ruleKey, contextJson); + + return new EvaluationResult + { + Success = true, + Data = JsonSerializer.Deserialize(result) + }; + } + catch (ZenException ex) when (ex.ErrorCode == ZenErrorCode.LoaderKeyNotFound) + { + _logger.LogWarning("Rule not found: {RuleKey}", ruleKey); + return new EvaluationResult + { + Success = false, + Error = $"Rule '{ruleKey}' not found" + }; + } + catch (ZenException ex) when (ex.ErrorCode == ZenErrorCode.EvaluationError) + { + _logger.LogError(ex, "Evaluation failed for rule: {RuleKey}", ruleKey); + return new EvaluationResult + { + Success = false, + Error = "Rule evaluation failed", + Details = ex.Details + }; + } + catch (ZenException ex) + { + _logger.LogError(ex, "Unexpected Zen error: {ErrorCode}", ex.ErrorCode); + return new EvaluationResult + { + Success = false, + Error = ex.Message + }; + } + } + + private string? LoadRule(string key) + { + // Implement your rule loading logic + return null; + } +} + +public class EvaluationResult +{ + public bool Success { get; init; } + public JsonElement? Data { get; init; } + public string? Error { get; init; } + public string? Details { get; init; } +} +``` + +## Performance Tips + +1. **Reuse ZenEngine instances** - Creating an engine is relatively expensive. Create once and reuse. + +2. **Use decision caching** - The loader is called for each evaluation. Implement caching: + +```csharp +public class CachingDecisionLoader +{ + private readonly ConcurrentDictionary _cache = new(); + + public string? Load(string key) + { + return _cache.GetOrAdd(key, k => + { + var path = $"rules/{k}.json"; + return File.Exists(path) ? File.ReadAllText(path) : null!; + }); + } + + public void InvalidateCache(string? key = null) + { + if (key != null) + _cache.TryRemove(key, out _); + else + _cache.Clear(); + } +} +``` + +3. **Compile decisions once** - For frequently-used decisions, use `CreateDecision` once and reuse: + +```csharp +// Good - compile once +using var decision = engine.CreateDecision(decisionJson); +foreach (var item in items) +{ + decision.Evaluate(JsonSerializer.Serialize(item)); +} + +// Bad - recompiles every time +foreach (var item in items) +{ + engine.Evaluate("my-rule", JsonSerializer.Serialize(item)); +} +``` + +4. **Use appropriate MaxDepth** - Lower values are faster but limit recursion depth. + +## Troubleshooting + +### DllNotFoundException + +**Error:** `Unable to load shared library 'zen_ffi'` + +**Solutions:** +1. Ensure the native library is in the same directory as your executable +2. Check you built with `--no-default-features` flag +3. Verify the library architecture matches your runtime (x64 vs ARM64) + +### DepthLimitExceeded + +**Error:** `Evaluation error: {"type":"DepthLimitExceeded"}` + +**Solution:** Increase `MaxDepth` in evaluation options: + +```csharp +var options = new EvaluationOptions { MaxDepth = 10 }; +decision.Evaluate(context, options); +``` + +### Undefined Symbol Errors + +**Error:** `undefined symbol: zen_engine_go_custom_node_callback` + +**Solution:** Rebuild the native library without the Go feature: + +```bash +cargo build --release --no-default-features +``` + +## Next Steps + +- Read the [README.md](README.md) for API reference +- Explore the [test files](GoRules.Zen.Tests/) for more examples +- Visit [GoRules documentation](https://docs.gorules.io/) for decision modeling +- Try the [visual editor](https://editor.gorules.io/) to create decisions diff --git a/bindings/dotnet/ZenEngine.cs b/bindings/dotnet/ZenEngine.cs index 7eee8542..24532a8d 100644 --- a/bindings/dotnet/ZenEngine.cs +++ b/bindings/dotnet/ZenEngine.cs @@ -52,9 +52,9 @@ public class EvaluationOptions public bool Trace { get; set; } = false; /// - /// Maximum recursion depth (0 = default) + /// Maximum recursion depth (default: 5) /// - public byte MaxDepth { get; set; } = 0; + public byte MaxDepth { get; set; } = 5; internal ZenEngineEvaluationOptions ToNative() => new() { diff --git a/bindings/dotnet/build.sh b/bindings/dotnet/build.sh new file mode 100755 index 00000000..42891ab9 --- /dev/null +++ b/bindings/dotnet/build.sh @@ -0,0 +1,80 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +C_BINDINGS_DIR="$ROOT_DIR/bindings/c" +DOTNET_DIR="$SCRIPT_DIR" + +echo "=== Building Zen Engine .NET Bindings ===" +echo "" + +# Detect platform +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + PLATFORM="linux-x64" + LIB_NAME="libzen_ffi.so" +elif [[ "$OSTYPE" == "darwin"* ]]; then + ARCH=$(uname -m) + if [[ "$ARCH" == "arm64" ]]; then + PLATFORM="osx-arm64" + else + PLATFORM="osx-x64" + fi + LIB_NAME="libzen_ffi.dylib" +elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then + PLATFORM="win-x64" + LIB_NAME="zen_ffi.dll" +else + echo "Unsupported platform: $OSTYPE" + exit 1 +fi + +echo "Platform: $PLATFORM" +echo "Library: $LIB_NAME" +echo "" + +# Step 1: Build Rust library +echo "Step 1: Building Rust C bindings..." +cd "$C_BINDINGS_DIR" +cargo build --release +echo "Done." +echo "" + +# Step 2: Copy native library +echo "Step 2: Copying native library..." +RUNTIME_DIR="$DOTNET_DIR/runtimes/$PLATFORM/native" +mkdir -p "$RUNTIME_DIR" + +SOURCE_LIB="$ROOT_DIR/target/release/$LIB_NAME" +if [[ -f "$SOURCE_LIB" ]]; then + cp "$SOURCE_LIB" "$RUNTIME_DIR/" + echo "Copied $LIB_NAME to $RUNTIME_DIR/" +else + echo "ERROR: Library not found at $SOURCE_LIB" + echo "Make sure Cargo.toml has crate-type = [\"cdylib\"]" + exit 1 +fi +echo "" + +# Step 3: Build .NET library +echo "Step 3: Building .NET library..." +cd "$DOTNET_DIR" +dotnet build -c Release +echo "Done." +echo "" + +# Step 4: Run tests (optional) +if [[ "$1" == "--test" ]]; then + echo "Step 4: Running tests..." + dotnet test -c Release + echo "" +fi + +echo "=== Build Complete ===" +echo "" +echo "Output:" +echo " Library: $DOTNET_DIR/bin/Release/net8.0/GoRules.Zen.dll" +echo " Native: $RUNTIME_DIR/$LIB_NAME" +echo "" +echo "To create NuGet package:" +echo " cd $DOTNET_DIR && dotnet pack -c Release" From b6e206feb78d9349d4a5b584543b433f12c28921 Mon Sep 17 00:00:00 2001 From: ice6 Date: Fri, 2 Jan 2026 13:37:22 +0800 Subject: [PATCH 3/9] Add dynamic pricing and real-time quotation decision models - Introduced a dynamic pricing decision model with multiple adjustment factors based on market demand, time of day, competitor pricing, and customer segments. - Implemented a real-time quotation decision model that calculates insurance premiums based on general liability, commercial property, and professional indemnity coverage. - Each model includes input nodes, decision tables, and output nodes to facilitate complex pricing calculations. --- .../GoRules.Zen.Tests/RealWorldTests.cs | 151 +++++ .../dotnet/decisions/1.company-analysis.json | 574 ++++++++++++++++++ .../dotnet/decisions/2.dynamic-pricing.json | 279 +++++++++ .../decisions/3.real-time-quotation.json | 249 ++++++++ 4 files changed, 1253 insertions(+) create mode 100644 bindings/dotnet/GoRules.Zen.Tests/RealWorldTests.cs create mode 100644 bindings/dotnet/decisions/1.company-analysis.json create mode 100644 bindings/dotnet/decisions/2.dynamic-pricing.json create mode 100644 bindings/dotnet/decisions/3.real-time-quotation.json diff --git a/bindings/dotnet/GoRules.Zen.Tests/RealWorldTests.cs b/bindings/dotnet/GoRules.Zen.Tests/RealWorldTests.cs new file mode 100644 index 00000000..22b369a0 --- /dev/null +++ b/bindings/dotnet/GoRules.Zen.Tests/RealWorldTests.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; +using System.Text.Json; +using Xunit; +using GoRules.Zen; + +namespace GoRules.Zen.Tests; + +public class RealWorldTests : IDisposable +{ + private ZenEngine? _engine; + + public void Dispose() + { + _engine?.Dispose(); + } + + private static string GetDecisionsPath() + { + // Navigate from bin/Debug/net10.0 up to the decisions folder + var testDir = AppContext.BaseDirectory; + var decisionsPath = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "decisions")); + return decisionsPath; + } + + [Fact] + public void CompanyAnalysisTest() + { + _engine = new ZenEngine(); + + var decisionPath = Path.Combine(GetDecisionsPath(), "1.company-analysis.json"); + var decisionJson = File.ReadAllText(decisionPath); + + using var decision = _engine.CreateDecision(decisionJson); + + var context = """ + { + "country": "US", + "dateInc": "2014-12-31T16:00:00.000Z", + "industryType": "HC", + "annualRevenue": 1500000, + "creditRating": 770, + "companySize": "medium" + } + """; + + var resultJson = decision.Evaluate(context); + using var doc = JsonDocument.Parse(resultJson); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("result", out var result), "Result should contain 'result' property"); + + // Verify flag values + Assert.True(result.TryGetProperty("flag", out var flag), "Result should contain 'flag' property"); + Assert.Equal("amber", flag.GetProperty("annualRevenue").GetString()); + Assert.Equal("amber", flag.GetProperty("companySize").GetString()); + Assert.Equal("green", flag.GetProperty("country").GetString()); + Assert.Equal("green", flag.GetProperty("creditRating").GetString()); + Assert.Equal("green", flag.GetProperty("industryType").GetString()); + Assert.Equal("green", flag.GetProperty("years").GetString()); + + // Verify comment values + Assert.True(result.TryGetProperty("comment", out var comment), "Result should contain 'comment' property"); + Assert.Equal("Medium - Established market presence", comment.GetProperty("annualRevenue").GetString()); + Assert.Equal("Moderate risk with more stability", comment.GetProperty("companySize").GetString()); + Assert.Equal("Very Good - Low risk", comment.GetProperty("creditRating").GetString()); + Assert.Equal("Essential services with constant demand", comment.GetProperty("industryType").GetString()); + Assert.Equal("Mature business, proven market resilience and operational stability", comment.GetProperty("years").GetString()); + } + + [Fact] + public void DynamicPricingTest() + { + _engine = new ZenEngine(); + + var decisionPath = Path.Combine(GetDecisionsPath(), "2.dynamic-pricing.json"); + var decisionJson = File.ReadAllText(decisionPath); + + using var decision = _engine.CreateDecision(decisionJson); + + var context = """ + { + "pricing": { + "basePrice": 100, + "demand": "high", + "timeOfDay": "normal", + "competitorPrice": "equal", + "customerSegment": "regular" + } + } + """; + + var resultJson = decision.Evaluate(context); + using var doc = JsonDocument.Parse(resultJson); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("result", out var result), "Result should contain 'result' property"); + + // Verify adjusted price + Assert.Equal(115, result.GetProperty("adjustedPrice").GetDouble()); + + // Verify final adjustment factor + Assert.Equal(1.15, result.GetProperty("finalAdjustmentFactor").GetDouble()); + + // Verify adjustments + Assert.True(result.TryGetProperty("adjustments", out var adjustments), "Result should contain 'adjustments' property"); + Assert.Equal(1, adjustments.GetProperty("competitor").GetDouble()); + Assert.Equal(1.15, adjustments.GetProperty("demand").GetDouble()); + Assert.Equal(1, adjustments.GetProperty("segment").GetDouble()); + Assert.Equal(1, adjustments.GetProperty("time").GetDouble()); + + // Verify pricing passthrough + Assert.True(result.TryGetProperty("pricing", out var pricing), "Result should contain 'pricing' property"); + Assert.Equal(100, pricing.GetProperty("basePrice").GetInt32()); + Assert.Equal("equal", pricing.GetProperty("competitorPrice").GetString()); + Assert.Equal("regular", pricing.GetProperty("customerSegment").GetString()); + Assert.Equal("high", pricing.GetProperty("demand").GetString()); + Assert.Equal("normal", pricing.GetProperty("timeOfDay").GetString()); + } + + [Fact] + public void RealTimeQuotationTest() + { + _engine = new ZenEngine(); + + var decisionPath = Path.Combine(GetDecisionsPath(), "3.real-time-quotation.json"); + var decisionJson = File.ReadAllText(decisionPath); + + using var decision = _engine.CreateDecision(decisionJson); + + var context = """ + { + "generalLiability": 5000000, + "commercialProperty": 1000000, + "professionalIndemnity": 1000000 + } + """; + + var resultJson = decision.Evaluate(context); + using var doc = JsonDocument.Parse(resultJson); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("result", out var result), "Result should contain 'result' property"); + + // Verify quotation values + Assert.Equal("300.3", result.GetProperty("paymentFee").GetString()); + Assert.Equal("7800", result.GetProperty("premium").GetString()); + Assert.Equal("780", result.GetProperty("tax").GetString()); + Assert.Equal("8880.3", result.GetProperty("total").GetString()); + } +} diff --git a/bindings/dotnet/decisions/1.company-analysis.json b/bindings/dotnet/decisions/1.company-analysis.json new file mode 100644 index 00000000..afd2704c --- /dev/null +++ b/bindings/dotnet/decisions/1.company-analysis.json @@ -0,0 +1,574 @@ +{ + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "name": "Input", + "id": "6ea65cab-e11d-4fc7-aa61-4c0ffdfd2237", + "position": { + "x": 30, + "y": 325 + }, + "type": "inputNode" + }, + { + "name": "myResponse", + "id": "6d01c66d-09b6-47c7-96dd-8718e2bc2608", + "position": { + "x": 1035, + "y": 325 + }, + "type": "outputNode" + }, + { + "name": "Annual Revenue", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "04e8b221-0e8c-4755-93f9-90f571b594b2", + "name": "Revenue", + "type": "expression", + "field": "annualRevenue" + } + ], + "outputs": [ + { + "field": "flag.annualRevenue", + "id": "aa30057a-bc56-44b1-b311-bfe8f4a6ecfb", + "name": "Flag", + "type": "expression" + }, + { + "id": "c40cde8f-13ca-4c46-9f86-2abb757d2302", + "type": "expression", + "field": "comment.annualRevenue", + "name": "Comment" + } + ], + "rules": [ + { + "_id": "589652c6-03af-4296-8626-831c7cbe73c6", + "04e8b221-0e8c-4755-93f9-90f571b594b2": "< 500_000", + "aa30057a-bc56-44b1-b311-bfe8f4a6ecfb": "'red'", + "c40cde8f-13ca-4c46-9f86-2abb757d2302": "'Small - Higher operational risk'" + }, + { + "_id": "8e38f0b3-a08a-4826-9cec-ded699ccb458", + "04e8b221-0e8c-4755-93f9-90f571b594b2": ">= 500_000 and < 1_000_000", + "aa30057a-bc56-44b1-b311-bfe8f4a6ecfb": "'amber'", + "c40cde8f-13ca-4c46-9f86-2abb757d2302": "'Growing - Moderate operational risk'" + }, + { + "_id": "e91ef488-362e-43d4-926a-9230c322981e", + "04e8b221-0e8c-4755-93f9-90f571b594b2": ">= 1_000_000 and < 10_000_000", + "aa30057a-bc56-44b1-b311-bfe8f4a6ecfb": "'amber'", + "c40cde8f-13ca-4c46-9f86-2abb757d2302": "'Medium - Established market presence'" + }, + { + "_id": "79f423a3-9839-4c6b-b93a-ed87b777f58f", + "04e8b221-0e8c-4755-93f9-90f571b594b2": ">= 10_000_000 and < 50_000_000", + "aa30057a-bc56-44b1-b311-bfe8f4a6ecfb": "'green'", + "c40cde8f-13ca-4c46-9f86-2abb757d2302": "'Large - Significant market presence, lower risk'" + }, + { + "_id": "876ee33b-8b8b-4256-b459-aff5b92f7832", + "04e8b221-0e8c-4755-93f9-90f571b594b2": ">= 50_000_000", + "aa30057a-bc56-44b1-b311-bfe8f4a6ecfb": "'green'", + "c40cde8f-13ca-4c46-9f86-2abb757d2302": "'Very Large - High financial stability and growth potential'" + } + ] + }, + "id": "33506dc8-37b1-412e-9c46-4e3604128eda", + "position": { + "x": 510, + "y": 265 + }, + "type": "decisionTableNode" + }, + { + "name": "Industry Type", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "3391410a-aa82-48f4-8fcd-6419356be425", + "name": "Industry Type", + "type": "expression", + "field": "industryType" + } + ], + "outputs": [ + { + "field": "flag.industryType", + "id": "9d008f22-b67f-4245-948a-3ef424d86624", + "name": "Flag", + "type": "expression" + }, + { + "id": "287ba32f-9d82-4f76-8f34-40a6cf8b7e28", + "type": "expression", + "field": "comment.industryType", + "name": "Comment" + } + ], + "rules": [ + { + "_id": "4efa5c4f-9629-4aa4-80db-67a94c24c441", + "3391410a-aa82-48f4-8fcd-6419356be425": "'HC'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'green'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Essential services with constant demand'" + }, + { + "_id": "06bc02ae-a79c-4116-80cf-ee6ddc0d8024", + "3391410a-aa82-48f4-8fcd-6419356be425": "'UT'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'green'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Stable demand, regulated revenues'" + }, + { + "_id": "69525927-a566-4fd4-b04e-b147b8a65fac", + "3391410a-aa82-48f4-8fcd-6419356be425": "'CS'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'green'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Essential goods, less economic sensitivity'" + }, + { + "_id": "daecd736-b62a-488e-9743-314994348850", + "3391410a-aa82-48f4-8fcd-6419356be425": "'ES'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'green'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Steady demand, resilient to downturns'" + }, + { + "_id": "81c0308c-0046-4fc8-a768-475e302d258e", + "3391410a-aa82-48f4-8fcd-6419356be425": "'IT'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'green'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'High demand, growth potential'" + }, + { + "_id": "49bed386-06fe-464c-a3d4-02215b3e6e6a", + "3391410a-aa82-48f4-8fcd-6419356be425": "'BF'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'amber'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Economic fluctuations, regulatory changes'" + }, + { + "_id": "c40161f4-789e-45a2-b49f-561f73c7ed9a", + "3391410a-aa82-48f4-8fcd-6419356be425": "'RE'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'amber'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Economic cycle sensitivity, interest rate impacts'" + }, + { + "_id": "72f1ef31-1cb0-41af-a297-ad98cb31a61c", + "3391410a-aa82-48f4-8fcd-6419356be425": "'PS'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'amber'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Fluctuating demand, economic conditions'" + }, + { + "_id": "96412ad2-40b9-48bc-af7b-97ecf125ebef", + "3391410a-aa82-48f4-8fcd-6419356be425": "'MN'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'amber'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Steady goods demand, supply chain risks'" + }, + { + "_id": "d71a1ab5-909f-405e-9d0a-470179f014f1", + "3391410a-aa82-48f4-8fcd-6419356be425": "'RG'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'amber'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Evolving tech, policy reliance'" + }, + { + "_id": "00e9416f-2a40-4912-8d67-73b34dd49084", + "3391410a-aa82-48f4-8fcd-6419356be425": "'OG'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Price volatility, regulatory challenges'" + }, + { + "_id": "2b187dbf-17e6-4c09-a450-8e2e098121e5", + "3391410a-aa82-48f4-8fcd-6419356be425": "'TL'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Economic downturns, global events sensitivity'" + }, + { + "_id": "96e8f462-bf69-4a76-a8ac-7f9c93586a53", + "3391410a-aa82-48f4-8fcd-6419356be425": "'AU'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Cyclical demand, innovation costs'" + }, + { + "_id": "a9476a21-d655-4337-8cc8-035597480c3b", + "3391410a-aa82-48f4-8fcd-6419356be425": "'CN'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Economic sensitivity, regulatory hurdles'" + }, + { + "_id": "0621de3b-74ac-4921-bfab-2cacf4b638b6", + "3391410a-aa82-48f4-8fcd-6419356be425": "'RN'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Economic vulnerability, competitive pressures'" + }, + { + "_id": "1259042d-a279-4349-9353-e7e6fbdb016d", + "3391410a-aa82-48f4-8fcd-6419356be425": "'HP'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Economic fluctuations, consumer behavior shifts'" + }, + { + "_id": "1a1ce846-0ef3-4556-bf57-7f8f6c443edb", + "3391410a-aa82-48f4-8fcd-6419356be425": "'AD'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Government spending dependence, geopolitical tensions'" + }, + { + "_id": "7833c040-9eae-4fd9-a249-bc577e134036", + "3391410a-aa82-48f4-8fcd-6419356be425": "'MI'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Commodity price swings, environmental regulations'" + }, + { + "_id": "070f4263-8a6d-446d-8ee9-26ef42228868", + "3391410a-aa82-48f4-8fcd-6419356be425": "'TC'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'High capital expenditure, rapid tech changes'" + }, + { + "_id": "b482b524-d316-47f4-b645-e4a89940a0b3", + "3391410a-aa82-48f4-8fcd-6419356be425": "'EM'", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Technological shifts, copyright issues'" + }, + { + "_id": "8b31c188-80b7-48b3-b1fe-0c27575bb5c5", + "3391410a-aa82-48f4-8fcd-6419356be425": "", + "9d008f22-b67f-4245-948a-3ef424d86624": "'red'", + "287ba32f-9d82-4f76-8f34-40a6cf8b7e28": "'Unknown'" + } + ] + }, + "id": "d509ba53-5840-4448-96d6-a61f11664edf", + "position": { + "x": 510, + "y": 375 + }, + "type": "decisionTableNode" + }, + { + "name": "Company size", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "9005b1eb-7585-4b32-b2e5-c2ac776c1239", + "name": "Size", + "type": "expression", + "field": "companySize" + } + ], + "outputs": [ + { + "field": "flag.companySize", + "id": "0af18ea6-6993-4c77-9842-a12a94b4b2bf", + "name": "Flag", + "type": "expression" + }, + { + "id": "b6914bf4-ddad-46da-8648-2d85e19dfa1a", + "type": "expression", + "field": "comment.companySize", + "name": "Comment" + } + ], + "rules": [ + { + "_id": "42d78116-1679-4660-b75c-63fcf084bb45", + "9005b1eb-7585-4b32-b2e5-c2ac776c1239": "'small'", + "0af18ea6-6993-4c77-9842-a12a94b4b2bf": "'red'", + "b6914bf4-ddad-46da-8648-2d85e19dfa1a": "'High risk due to limited resources'" + }, + { + "_id": "142e16ba-7da9-4a76-822b-ddbdfd785cb7", + "9005b1eb-7585-4b32-b2e5-c2ac776c1239": "'medium'", + "0af18ea6-6993-4c77-9842-a12a94b4b2bf": "'amber'", + "b6914bf4-ddad-46da-8648-2d85e19dfa1a": "'Moderate risk with more stability'" + }, + { + "_id": "f926d73d-bf5b-4d88-aa65-066a822c4aa7", + "9005b1eb-7585-4b32-b2e5-c2ac776c1239": "'large'", + "0af18ea6-6993-4c77-9842-a12a94b4b2bf": "'amber'", + "b6914bf4-ddad-46da-8648-2d85e19dfa1a": "'Moderate risk with established operations'" + }, + { + "_id": "3762a109-d47e-4024-8342-4cfae91f617b", + "9005b1eb-7585-4b32-b2e5-c2ac776c1239": "'very-large'", + "0af18ea6-6993-4c77-9842-a12a94b4b2bf": "'green'", + "b6914bf4-ddad-46da-8648-2d85e19dfa1a": "'Low risk due to extensive resources'" + }, + { + "_id": "bf723079-1d82-4e3d-b54d-456ed67f9d4d", + "9005b1eb-7585-4b32-b2e5-c2ac776c1239": "", + "0af18ea6-6993-4c77-9842-a12a94b4b2bf": "'red'", + "b6914bf4-ddad-46da-8648-2d85e19dfa1a": "'Unknown'" + } + ] + }, + "id": "50abaf5f-bc26-4739-8900-fee6462078ab", + "position": { + "x": 510, + "y": 485 + }, + "type": "decisionTableNode" + }, + { + "name": "Years In Business", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "609be1b5-cc87-40e1-90d7-1a3dc61f08d7", + "name": "Years In Business", + "type": "expression", + "field": "year(date('now')) - year(date(dateInc))" + } + ], + "outputs": [ + { + "field": "flag.years", + "id": "ee031d89-73c6-4df5-9b62-d6282f4f3130", + "name": "Flag", + "type": "expression" + }, + { + "id": "b04da3d4-c87a-4401-bdfc-6563bb4f01d3", + "type": "expression", + "field": "comment.years", + "name": "Comment" + } + ], + "rules": [ + { + "_id": "a2fdc065-7fd9-4c35-9ead-cd4960a461ee", + "609be1b5-cc87-40e1-90d7-1a3dc61f08d7": "< 3", + "ee031d89-73c6-4df5-9b62-d6282f4f3130": "'red'", + "b04da3d4-c87a-4401-bdfc-6563bb4f01d3": "'Start-up phase, higher operational and financial risk'" + }, + { + "_id": "ac2ed364-9b92-4143-857e-7b1ad3486f26", + "609be1b5-cc87-40e1-90d7-1a3dc61f08d7": ">= 3 and < 5", + "ee031d89-73c6-4df5-9b62-d6282f4f3130": "'amber'", + "b04da3d4-c87a-4401-bdfc-6563bb4f01d3": "'Early growth stage, evolving stability and market presence'" + }, + { + "_id": "57346ed5-d88a-4b0a-804f-73ae12403d06", + "609be1b5-cc87-40e1-90d7-1a3dc61f08d7": ">= 5 and < 10", + "ee031d89-73c6-4df5-9b62-d6282f4f3130": "'green'", + "b04da3d4-c87a-4401-bdfc-6563bb4f01d3": "'Established presence, developing consistency in operations'" + }, + { + "_id": "b412bc19-6925-4ab9-9f24-68b6c5247817", + "609be1b5-cc87-40e1-90d7-1a3dc61f08d7": ">= 10", + "ee031d89-73c6-4df5-9b62-d6282f4f3130": "'green'", + "b04da3d4-c87a-4401-bdfc-6563bb4f01d3": "'Mature business, proven market resilience and operational stability'" + }, + { + "_id": "80e0214b-ec6b-47e0-8aaf-c928ca38aa36", + "609be1b5-cc87-40e1-90d7-1a3dc61f08d7": "", + "ee031d89-73c6-4df5-9b62-d6282f4f3130": "'red'", + "b04da3d4-c87a-4401-bdfc-6563bb4f01d3": "'Unknown'" + } + ] + }, + "id": "9397a084-48a9-405d-99eb-6d3a51f17fb9", + "position": { + "x": 510, + "y": 155 + }, + "type": "decisionTableNode" + }, + { + "name": "Credit Rating", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "b912bacd-490e-456f-82bf-3b9cd32d74b1", + "name": "Credit Score", + "type": "expression", + "field": "creditRating" + } + ], + "outputs": [ + { + "field": "flag.creditRating", + "id": "2d7ec775-ee07-48bb-af67-428cb5ba8a9f", + "name": "Flag", + "type": "expression" + }, + { + "id": "60566eb7-2de0-41f8-9e28-51dd0a13b711", + "type": "expression", + "field": "comment.creditRating", + "name": "Comment" + } + ], + "rules": [ + { + "_id": "a807dcb6-8eb1-4974-b9fb-7536cf89acd3", + "b912bacd-490e-456f-82bf-3b9cd32d74b1": "< 580", + "2d7ec775-ee07-48bb-af67-428cb5ba8a9f": "'red'", + "60566eb7-2de0-41f8-9e28-51dd0a13b711": "'Poor - High risk'" + }, + { + "_id": "493f0316-e913-4cc6-99a3-80cb63668436", + "b912bacd-490e-456f-82bf-3b9cd32d74b1": ">= 580 and < 670", + "2d7ec775-ee07-48bb-af67-428cb5ba8a9f": "'amber'", + "60566eb7-2de0-41f8-9e28-51dd0a13b711": "'Fair - Moderate risk'" + }, + { + "_id": "f10f51ef-703c-4834-a905-f938ab66c5ae", + "b912bacd-490e-456f-82bf-3b9cd32d74b1": ">= 670 and < 740", + "2d7ec775-ee07-48bb-af67-428cb5ba8a9f": "'amber'", + "60566eb7-2de0-41f8-9e28-51dd0a13b711": "'Good - Acceptable risk'" + }, + { + "_id": "848ffa2f-ef92-4ff2-91d2-6656e5b90b11", + "b912bacd-490e-456f-82bf-3b9cd32d74b1": ">= 740 and < 800", + "2d7ec775-ee07-48bb-af67-428cb5ba8a9f": "'green'", + "60566eb7-2de0-41f8-9e28-51dd0a13b711": "'Very Good - Low risk'" + }, + { + "_id": "10ffec1b-323e-4156-a916-f84d3048afe4", + "b912bacd-490e-456f-82bf-3b9cd32d74b1": ">= 800", + "2d7ec775-ee07-48bb-af67-428cb5ba8a9f": "'green'", + "60566eb7-2de0-41f8-9e28-51dd0a13b711": "'Excellent - Very low risk'" + }, + { + "_id": "aa53f673-3891-4fc2-aca0-5bf4b20ccade", + "b912bacd-490e-456f-82bf-3b9cd32d74b1": "", + "2d7ec775-ee07-48bb-af67-428cb5ba8a9f": "'red'", + "60566eb7-2de0-41f8-9e28-51dd0a13b711": "'Unknown'" + } + ] + }, + "id": "31fecb02-40a5-482d-8e9f-94ea3950dd91", + "position": { + "x": 510, + "y": 595 + }, + "type": "decisionTableNode" + }, + { + "name": "Country of inc.", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "239831be-5ee9-4131-bb07-a1d10bcddfe7", + "name": "Country of inc", + "type": "expression", + "field": "country" + } + ], + "outputs": [ + { + "field": "flag.country", + "id": "0f7dbda6-b06c-41f8-a6aa-72372576d0df", + "name": "Flag", + "type": "expression" + } + ], + "rules": [ + { + "_id": "8d30a8af-7555-48ed-aa81-5055c013ec92", + "239831be-5ee9-4131-bb07-a1d10bcddfe7": "'US', 'CA', 'GB', 'IE'", + "0f7dbda6-b06c-41f8-a6aa-72372576d0df": "'green'" + }, + { + "_id": "472a6603-a5ba-4307-ade1-4fa7fac79897", + "239831be-5ee9-4131-bb07-a1d10bcddfe7": "'FR', 'BR', 'DE', 'MX'", + "0f7dbda6-b06c-41f8-a6aa-72372576d0df": "'amber'" + }, + { + "_id": "4a383d72-bb84-4f97-99b2-b9accc11dae0", + "239831be-5ee9-4131-bb07-a1d10bcddfe7": "", + "0f7dbda6-b06c-41f8-a6aa-72372576d0df": "'red'" + } + ] + }, + "id": "331dd332-ea02-4995-88a2-053d39806bf3", + "position": { + "x": 510, + "y": 45 + }, + "type": "decisionTableNode" + } + ], + "edges": [ + { + "id": "b91d1065-5f07-406a-8f1d-33dc12db67a2", + "sourceId": "6ea65cab-e11d-4fc7-aa61-4c0ffdfd2237", + "type": "edge", + "targetId": "d509ba53-5840-4448-96d6-a61f11664edf" + }, + { + "id": "024ff625-17ab-4052-9d4b-2f8299d87e5e", + "sourceId": "6ea65cab-e11d-4fc7-aa61-4c0ffdfd2237", + "type": "edge", + "targetId": "50abaf5f-bc26-4739-8900-fee6462078ab" + }, + { + "id": "3cb5d563-b8b0-4a1f-bebb-0b3393224a2d", + "sourceId": "6ea65cab-e11d-4fc7-aa61-4c0ffdfd2237", + "type": "edge", + "targetId": "33506dc8-37b1-412e-9c46-4e3604128eda" + }, + { + "id": "2052555a-dc68-4c24-8942-f7b38f03b5dd", + "sourceId": "6ea65cab-e11d-4fc7-aa61-4c0ffdfd2237", + "type": "edge", + "targetId": "9397a084-48a9-405d-99eb-6d3a51f17fb9" + }, + { + "id": "23aaf5ac-f4f7-46d6-9560-6409b4446575", + "sourceId": "6ea65cab-e11d-4fc7-aa61-4c0ffdfd2237", + "type": "edge", + "targetId": "31fecb02-40a5-482d-8e9f-94ea3950dd91" + }, + { + "id": "eebbe90f-bb69-4431-b64f-711c801c5913", + "sourceId": "6ea65cab-e11d-4fc7-aa61-4c0ffdfd2237", + "type": "edge", + "targetId": "331dd332-ea02-4995-88a2-053d39806bf3" + }, + { + "id": "8bce7590-a024-490c-a62e-41bedc7a91a5", + "sourceId": "331dd332-ea02-4995-88a2-053d39806bf3", + "type": "edge", + "targetId": "6d01c66d-09b6-47c7-96dd-8718e2bc2608" + }, + { + "id": "d4755f03-3eee-45e7-b4a5-3d3c2c0c6ade", + "sourceId": "9397a084-48a9-405d-99eb-6d3a51f17fb9", + "type": "edge", + "targetId": "6d01c66d-09b6-47c7-96dd-8718e2bc2608" + }, + { + "id": "a866f011-6c68-42e0-8187-b66d426e7f75", + "sourceId": "33506dc8-37b1-412e-9c46-4e3604128eda", + "type": "edge", + "targetId": "6d01c66d-09b6-47c7-96dd-8718e2bc2608" + }, + { + "id": "63f8b150-c212-4dc1-bc1c-9aeabe7a5f71", + "sourceId": "d509ba53-5840-4448-96d6-a61f11664edf", + "type": "edge", + "targetId": "6d01c66d-09b6-47c7-96dd-8718e2bc2608" + }, + { + "id": "ebf77cd9-a9d0-44b3-9645-139c0cb7c515", + "sourceId": "50abaf5f-bc26-4739-8900-fee6462078ab", + "type": "edge", + "targetId": "6d01c66d-09b6-47c7-96dd-8718e2bc2608" + }, + { + "id": "bbeece23-4cd4-4ef1-8042-1c5e33fa100d", + "sourceId": "31fecb02-40a5-482d-8e9f-94ea3950dd91", + "type": "edge", + "targetId": "6d01c66d-09b6-47c7-96dd-8718e2bc2608" + } + ] +} \ No newline at end of file diff --git a/bindings/dotnet/decisions/2.dynamic-pricing.json b/bindings/dotnet/decisions/2.dynamic-pricing.json new file mode 100644 index 00000000..f4139296 --- /dev/null +++ b/bindings/dotnet/decisions/2.dynamic-pricing.json @@ -0,0 +1,279 @@ +{ + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "type": "inputNode", + "id": "input1", + "name": "Input", + "position": { + "x": 105, + "y": 255 + } + }, + { + "type": "decisionTableNode", + "content": { + "hitPolicy": "first", + "rules": [ + { + "_id": "1", + "demandInput": "\"high\"", + "demandOutput": "1.15" + }, + { + "_id": "2", + "demandInput": "\"medium\"", + "demandOutput": "1.0" + }, + { + "_id": "3", + "demandInput": "\"low\"", + "demandOutput": "0.9" + } + ], + "inputs": [ + { + "id": "demandInput", + "name": "Demand Level", + "field": "pricing.demand" + } + ], + "outputs": [ + { + "id": "demandOutput", + "name": "Demand Factor", + "field": "adjustments.demand" + } + ], + "passThrough": true, + "inputField": null, + "outputPath": null, + "executionMode": "single" + }, + "id": "demandTable", + "name": "Market Demand Adjustment", + "position": { + "x": 425, + "y": 110 + } + }, + { + "type": "decisionTableNode", + "content": { + "hitPolicy": "first", + "rules": [ + { + "_id": "1", + "timeInput": "\"peak\"", + "timeOutput": "1.2" + }, + { + "_id": "2", + "timeInput": "\"normal\"", + "timeOutput": "1.0" + }, + { + "_id": "3", + "timeInput": "\"off-peak\"", + "timeOutput": "0.85" + } + ], + "inputs": [ + { + "id": "timeInput", + "name": "Time Period", + "field": "pricing.timeOfDay" + } + ], + "outputs": [ + { + "id": "timeOutput", + "name": "Time Factor", + "field": "adjustments.time" + } + ], + "passThrough": true, + "inputField": null, + "outputPath": null, + "executionMode": "single" + }, + "id": "timeTable", + "name": "Time-based Adjustment", + "position": { + "x": 425, + "y": 205 + } + }, + { + "type": "decisionTableNode", + "content": { + "hitPolicy": "first", + "rules": [ + { + "_id": "1", + "competitorInput": "\"higher\"", + "competitorOutput": "1.05" + }, + { + "_id": "2", + "competitorInput": "\"equal\"", + "competitorOutput": "1.0" + }, + { + "_id": "3", + "competitorInput": "\"lower\"", + "competitorOutput": "0.95" + } + ], + "inputs": [ + { + "id": "competitorInput", + "name": "Competitor Position", + "field": "pricing.competitorPrice" + } + ], + "outputs": [ + { + "id": "competitorOutput", + "name": "Competition Factor", + "field": "adjustments.competitor" + } + ], + "passThrough": true, + "inputField": null, + "outputPath": null, + "executionMode": "single" + }, + "id": "competitorTable", + "name": "Competitor-based Adjustment", + "position": { + "x": 425, + "y": 305 + } + }, + { + "type": "decisionTableNode", + "content": { + "hitPolicy": "first", + "rules": [ + { + "_id": "1", + "segmentInput": "\"premium\"", + "segmentOutput": "0.95" + }, + { + "_id": "2", + "segmentInput": "\"regular\"", + "segmentOutput": "1.0" + }, + { + "_id": "3", + "segmentInput": "\"new\"", + "segmentOutput": "0.9" + } + ], + "inputs": [ + { + "id": "segmentInput", + "name": "Customer Type", + "field": "pricing.customerSegment" + } + ], + "outputs": [ + { + "id": "segmentOutput", + "name": "Segment Factor", + "field": "adjustments.segment" + } + ], + "passThrough": true, + "inputField": null, + "outputPath": null, + "executionMode": "single" + }, + "id": "customerTable", + "name": "Customer Segment Adjustment", + "position": { + "x": 425, + "y": 400 + } + }, + { + "type": "expressionNode", + "content": { + "expressions": [ + { + "id": "factorCalc", + "key": "finalAdjustmentFactor", + "value": "adjustments.demand * adjustments.time * adjustments.competitor * adjustments.segment" + }, + { + "id": "priceCalc", + "key": "adjustedPrice", + "value": "round(pricing.basePrice * $.finalAdjustmentFactor * 100) / 100" + } + ], + "passThrough": true, + "inputField": null, + "outputPath": null, + "executionMode": "single" + }, + "id": "priceCalculation", + "name": "Calculate Final Price", + "position": { + "x": 745, + "y": 255 + } + } + ], + "edges": [ + { + "id": "edge1", + "sourceId": "input1", + "targetId": "demandTable", + "type": "edge" + }, + { + "id": "edge2", + "sourceId": "input1", + "targetId": "timeTable", + "type": "edge" + }, + { + "id": "edge3", + "sourceId": "input1", + "targetId": "competitorTable", + "type": "edge" + }, + { + "id": "edge4", + "sourceId": "input1", + "targetId": "customerTable", + "type": "edge" + }, + { + "id": "e266ae0c-3943-41bb-8b15-7662916b03ac", + "sourceId": "demandTable", + "type": "edge", + "targetId": "priceCalculation" + }, + { + "id": "98fa5b83-c1aa-4d72-83e7-e4b89e28083d", + "sourceId": "timeTable", + "type": "edge", + "targetId": "priceCalculation" + }, + { + "id": "48a7702a-0751-4283-add4-f43a740b3212", + "sourceId": "competitorTable", + "type": "edge", + "targetId": "priceCalculation" + }, + { + "id": "2a2fb756-03a4-4b66-9cad-551bdb180539", + "sourceId": "customerTable", + "type": "edge", + "targetId": "priceCalculation" + } + ] +} \ No newline at end of file diff --git a/bindings/dotnet/decisions/3.real-time-quotation.json b/bindings/dotnet/decisions/3.real-time-quotation.json new file mode 100644 index 00000000..12ede824 --- /dev/null +++ b/bindings/dotnet/decisions/3.real-time-quotation.json @@ -0,0 +1,249 @@ +{ + "contentType": "application/vnd.gorules.decision", + "nodes": [ + { + "name": "Input", + "id": "6ea65cab-e11d-4fc7-aa61-4c0ffdfd2237", + "position": { + "x": 35, + "y": 225 + }, + "type": "inputNode" + }, + { + "name": "General Liability", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "c3847f3e-8d84-476e-a040-6273958f9f84", + "name": "General Liability", + "type": "expression", + "field": "generalLiability" + } + ], + "outputs": [ + { + "field": "glPremium", + "id": "b1097f26-2bce-4cd1-a508-2a36328dc95f", + "name": "Premium", + "type": "expression" + } + ], + "rules": [ + { + "_id": "ea0c1717-adfe-4855-9d09-4d7a224263c0", + "c3847f3e-8d84-476e-a040-6273958f9f84": "1_000_000", + "b1097f26-2bce-4cd1-a508-2a36328dc95f": "800" + }, + { + "_id": "ba174d0c-a9b3-4ca6-a3c7-3614a6ec05ce", + "c3847f3e-8d84-476e-a040-6273958f9f84": "2_000_000", + "b1097f26-2bce-4cd1-a508-2a36328dc95f": "1900" + }, + { + "_id": "914d3636-340a-4856-9e93-ce14748768a9", + "c3847f3e-8d84-476e-a040-6273958f9f84": "5_000_000", + "b1097f26-2bce-4cd1-a508-2a36328dc95f": "3000" + } + ] + }, + "id": "c3bdfa40-9f20-4737-b43c-34ab7f993b6c", + "position": { + "x": 670, + "y": 215 + }, + "type": "decisionTableNode" + }, + { + "name": "Coverage", + "content": { + "statements": [ + { + "id": "98569ede-85f1-4522-9e3f-187edff42ae8", + "condition": "generalLiability > 0" + }, + { + "id": "319b344b-48bc-4a83-a1ef-fcdef2b51c5b", + "condition": "commercialProperty > 0" + }, + { + "id": "e2f07518-3ba9-49fe-90db-683f02d1e585", + "condition": "professionalIndemnity > 0" + } + ], + "hitPolicy": "collect" + }, + "id": "36f72720-ebef-415c-92da-4fe10097271d", + "position": { + "x": 350, + "y": 225 + }, + "type": "switchNode" + }, + { + "name": "Commercial Property", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "c3847f3e-8d84-476e-a040-6273958f9f84", + "name": "Commercial Property", + "type": "expression", + "field": "commercialProperty" + } + ], + "outputs": [ + { + "field": "cpPremium", + "id": "b1097f26-2bce-4cd1-a508-2a36328dc95f", + "name": "Premium", + "type": "expression" + } + ], + "rules": [ + { + "_id": "ea0c1717-adfe-4855-9d09-4d7a224263c0", + "c3847f3e-8d84-476e-a040-6273958f9f84": "500_000", + "b1097f26-2bce-4cd1-a508-2a36328dc95f": "1000" + }, + { + "_id": "ba174d0c-a9b3-4ca6-a3c7-3614a6ec05ce", + "c3847f3e-8d84-476e-a040-6273958f9f84": "1_000_000", + "b1097f26-2bce-4cd1-a508-2a36328dc95f": "4000" + }, + { + "_id": "914d3636-340a-4856-9e93-ce14748768a9", + "c3847f3e-8d84-476e-a040-6273958f9f84": "2_000_000", + "b1097f26-2bce-4cd1-a508-2a36328dc95f": "10000" + } + ] + }, + "id": "9a5a651b-6b68-4cf3-89e3-28076437dc1f", + "position": { + "x": 670, + "y": 320 + }, + "type": "decisionTableNode" + }, + { + "name": "General Liability", + "content": { + "hitPolicy": "first", + "inputs": [ + { + "id": "c3847f3e-8d84-476e-a040-6273958f9f84", + "name": "Professional Indemnity", + "type": "expression", + "field": "professionalIndemnity" + } + ], + "outputs": [ + { + "field": "plPremium", + "id": "b1097f26-2bce-4cd1-a508-2a36328dc95f", + "name": "Premium", + "type": "expression" + } + ], + "rules": [ + { + "_id": "ea0c1717-adfe-4855-9d09-4d7a224263c0", + "c3847f3e-8d84-476e-a040-6273958f9f84": "1_000_000", + "b1097f26-2bce-4cd1-a508-2a36328dc95f": "800" + }, + { + "_id": "ba174d0c-a9b3-4ca6-a3c7-3614a6ec05ce", + "c3847f3e-8d84-476e-a040-6273958f9f84": "2_000_000", + "b1097f26-2bce-4cd1-a508-2a36328dc95f": "1900" + }, + { + "_id": "914d3636-340a-4856-9e93-ce14748768a9", + "c3847f3e-8d84-476e-a040-6273958f9f84": "5_000_000", + "b1097f26-2bce-4cd1-a508-2a36328dc95f": "3000" + } + ] + }, + "id": "50db46e7-6e18-4482-a0a1-5378fd12cd10", + "position": { + "x": 670, + "y": 425 + }, + "type": "decisionTableNode" + }, + { + "name": "myResponse", + "id": "6d01c66d-09b6-47c7-96dd-8718e2bc2608", + "position": { + "x": 1310, + "y": 320 + }, + "type": "outputNode" + }, + { + "id": "46892434-3f7f-4def-b150-9873b8de2cf2", + "type": "functionNode", + "position": { + "x": 990, + "y": 320 + }, + "name": "Aggregator", + "content": { + "source": "import Big from 'big.js';\n\nexport const handler = async (input) => {\n delete input.$nodes;\n \n const totalPremium = Object.entries(input).reduce((acc, [_, value]) => {\n return Big(value).plus(acc);\n }, Big(0)).round(2);\n\n const tax = totalPremium.mul(0.1).round(2);\n\n const paymentFee = totalPremium.plus(tax).mul(0.035).round(2);\n\n return {\n premium: totalPremium.toString(),\n tax: tax.toString(),\n paymentFee: paymentFee.toString(),\n total: totalPremium.plus(tax).plus(paymentFee).toString() \n };\n};\n" + } + } + ], + "edges": [ + { + "id": "4df2ee8c-cdb6-4a51-8af9-0a20f7886aed", + "sourceId": "6ea65cab-e11d-4fc7-aa61-4c0ffdfd2237", + "type": "edge", + "targetId": "36f72720-ebef-415c-92da-4fe10097271d" + }, + { + "id": "b1bf30e9-7d78-4fd1-aad8-5681cb4bc990", + "sourceId": "36f72720-ebef-415c-92da-4fe10097271d", + "type": "edge", + "targetId": "c3bdfa40-9f20-4737-b43c-34ab7f993b6c", + "sourceHandle": "98569ede-85f1-4522-9e3f-187edff42ae8" + }, + { + "id": "956c9295-d30b-4483-9c1b-3ff5f2d570ac", + "sourceId": "36f72720-ebef-415c-92da-4fe10097271d", + "type": "edge", + "targetId": "9a5a651b-6b68-4cf3-89e3-28076437dc1f", + "sourceHandle": "319b344b-48bc-4a83-a1ef-fcdef2b51c5b" + }, + { + "id": "1e90bc85-edc0-4c21-8119-b49bae2336af", + "sourceId": "36f72720-ebef-415c-92da-4fe10097271d", + "type": "edge", + "targetId": "50db46e7-6e18-4482-a0a1-5378fd12cd10", + "sourceHandle": "e2f07518-3ba9-49fe-90db-683f02d1e585" + }, + { + "id": "114e0cf6-db6a-407a-8291-332d52a6a0b8", + "sourceId": "c3bdfa40-9f20-4737-b43c-34ab7f993b6c", + "type": "edge", + "targetId": "46892434-3f7f-4def-b150-9873b8de2cf2" + }, + { + "id": "e6b12841-e535-46a5-9d55-38a3842ce251", + "sourceId": "9a5a651b-6b68-4cf3-89e3-28076437dc1f", + "type": "edge", + "targetId": "46892434-3f7f-4def-b150-9873b8de2cf2" + }, + { + "id": "79b6706c-92a2-4a75-84a2-d76333cc3e2b", + "sourceId": "50db46e7-6e18-4482-a0a1-5378fd12cd10", + "type": "edge", + "targetId": "46892434-3f7f-4def-b150-9873b8de2cf2" + }, + { + "id": "a5d7761b-21ad-4a70-b12b-e8131d42e535", + "sourceId": "46892434-3f7f-4def-b150-9873b8de2cf2", + "type": "edge", + "targetId": "6d01c66d-09b6-47c7-96dd-8718e2bc2608" + } + ] +} \ No newline at end of file From fa073683d49da41a15ddb0c30dbce2734d051e15 Mon Sep 17 00:00:00 2001 From: ice6 Date: Fri, 2 Jan 2026 19:49:35 +0800 Subject: [PATCH 4/9] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Rust=20=E5=86=85?= =?UTF-8?q?=E5=AD=98=E7=AE=A1=E7=90=86=E7=9A=84=20C=20=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=AD=97=E7=AC=A6?= =?UTF-8?q?=E4=B8=B2=E5=92=8C=E6=95=B4=E6=95=B0=E7=9A=84=E5=88=86=E9=85=8D?= =?UTF-8?q?=E4=B8=8E=E9=87=8A=E6=94=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/c/src/lib.rs | 48 +++++++++++++ bindings/c/zen_engine.h | 26 +++++++ bindings/dotnet/.gitignore | 3 +- .../GoRules.Zen.Tests.csproj | 1 + .../GoRules.Zen.Tests/xunit.runner.json | 5 ++ bindings/dotnet/TestProgram.cs | 22 ++++++ bindings/dotnet/ZenEngine.Interop.cs | 67 ++++++++++--------- bindings/dotnet/ZenEngine.cs | 2 +- 8 files changed, 140 insertions(+), 34 deletions(-) create mode 100644 bindings/dotnet/GoRules.Zen.Tests/xunit.runner.json create mode 100644 bindings/dotnet/TestProgram.cs diff --git a/bindings/c/src/lib.rs b/bindings/c/src/lib.rs index f3b36004..76e9aa70 100644 --- a/bindings/c/src/lib.rs +++ b/bindings/c/src/lib.rs @@ -10,3 +10,51 @@ mod languages; mod loader; mod mt; mod result; + +use std::ffi::{c_char, c_int, c_void, CString}; + +/// Allocates a string using Rust's allocator. +/// The caller must free the returned pointer using zen_free_string. +/// Returns null if the input is null or if allocation fails. +#[no_mangle] +pub extern "C" fn zen_alloc_string(ptr: *const c_char, len: usize) -> *mut c_char { + if ptr.is_null() { + return std::ptr::null_mut(); + } + + let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, len) }; + match CString::new(slice) { + Ok(cstring) => cstring.into_raw(), + Err(_) => std::ptr::null_mut(), + } +} + +/// Frees a string that was allocated by Rust. +/// This must be called for any string returned by zen_* functions to avoid memory leaks. +/// This is safe to call with a null pointer. +#[no_mangle] +pub extern "C" fn zen_free_string(ptr: *mut c_char) { + if !ptr.is_null() { + unsafe { + let _ = CString::from_raw(ptr); + } + } +} + +/// Frees an integer pointer that was allocated by Rust. +/// This must be called for ZenResult result field when it's not null. +#[no_mangle] +pub extern "C" fn zen_free_int(ptr: *mut c_int) { + if !ptr.is_null() { + let _ = unsafe { Box::from_raw(ptr) }; + } +} + +/// Generic free function for any Rust-allocated memory. +/// Use zen_free_string for strings returned by Rust functions. +#[no_mangle] +pub extern "C" fn zen_free(ptr: *mut c_void) { + if !ptr.is_null() { + let _ = unsafe { Box::from_raw(ptr as *mut u8) }; + } +} diff --git a/bindings/c/zen_engine.h b/bindings/c/zen_engine.h index fb94c00b..63837d54 100644 --- a/bindings/c/zen_engine.h +++ b/bindings/c/zen_engine.h @@ -60,6 +60,32 @@ typedef struct ZenCustomNodeResult { typedef struct ZenCustomNodeResult (*ZenCustomNodeNativeCallback)(const char *request); +/** + * Allocates a string using Rust's allocator. + * The caller must free the returned pointer using zen_free_string. + * Returns null if the input is null or if allocation fails. + */ +char *zen_alloc_string(const char *ptr, uintptr_t len); + +/** + * Frees a string that was allocated by Rust. + * This must be called for any string returned by zen_* functions to avoid memory leaks. + * This is safe to call with a null pointer. + */ +void zen_free_string(char *ptr); + +/** + * Frees an integer pointer that was allocated by Rust. + * This must be called for ZenResult result field when it's not null. + */ +void zen_free_int(int *ptr); + +/** + * Generic free function for any Rust-allocated memory. + * Use zen_free_string for strings returned by Rust functions. + */ +void zen_free(void *ptr); + /** * Frees ZenDecision */ diff --git a/bindings/dotnet/.gitignore b/bindings/dotnet/.gitignore index 90e21fc7..fa8dbeae 100644 --- a/bindings/dotnet/.gitignore +++ b/bindings/dotnet/.gitignore @@ -2,4 +2,5 @@ bin bin/ obj obj/ -runtimes/ \ No newline at end of file +runtimes/ +Sequence_*.xml \ No newline at end of file diff --git a/bindings/dotnet/GoRules.Zen.Tests/GoRules.Zen.Tests.csproj b/bindings/dotnet/GoRules.Zen.Tests/GoRules.Zen.Tests.csproj index f829a511..5f20a650 100644 --- a/bindings/dotnet/GoRules.Zen.Tests/GoRules.Zen.Tests.csproj +++ b/bindings/dotnet/GoRules.Zen.Tests/GoRules.Zen.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/bindings/dotnet/GoRules.Zen.Tests/xunit.runner.json b/bindings/dotnet/GoRules.Zen.Tests/xunit.runner.json new file mode 100644 index 00000000..dd80f43a --- /dev/null +++ b/bindings/dotnet/GoRules.Zen.Tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/bindings/dotnet/TestProgram.cs b/bindings/dotnet/TestProgram.cs new file mode 100644 index 00000000..f4510944 --- /dev/null +++ b/bindings/dotnet/TestProgram.cs @@ -0,0 +1,22 @@ +using System; +using GoRules.Zen; + +class Program +{ + static void Main() + { + try + { + Console.WriteLine("Testing zen_ffi.dll loading..."); + + // 尝试简单的表达式求值 + var result = ZenExpression.Evaluate("1 + 1", "{}"); + Console.WriteLine($"Result: {result}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + Console.WriteLine($"Stack: {ex.StackTrace}"); + } + } +} diff --git a/bindings/dotnet/ZenEngine.Interop.cs b/bindings/dotnet/ZenEngine.Interop.cs index 76c9080f..e7eeef36 100644 --- a/bindings/dotnet/ZenEngine.Interop.cs +++ b/bindings/dotnet/ZenEngine.Interop.cs @@ -214,59 +214,62 @@ public static extern ZenResult_c_char zen_evaluate_template( // ============================================================ /// - /// Free a C string (cross-platform) + /// Allocate a string using Rust's allocator. + /// The string must be freed using zen_free_string. /// - [DllImport("libc", EntryPoint = "free", CallingConvention = CallingConvention.Cdecl)] - private static extern void free_libc(IntPtr ptr); - - [DllImport("msvcrt", EntryPoint = "free", CallingConvention = CallingConvention.Cdecl)] - private static extern void free_msvcrt(IntPtr ptr); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr zen_alloc_string(IntPtr ptr, nuint len); /// - /// Allocate memory for callback return strings + /// Free a string allocated by Rust (zen_ffi library) /// - [DllImport("libc", EntryPoint = "malloc", CallingConvention = CallingConvention.Cdecl)] - private static extern IntPtr malloc_libc(nuint size); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern void zen_free_string(IntPtr ptr); - [DllImport("msvcrt", EntryPoint = "malloc", CallingConvention = CallingConvention.Cdecl)] - private static extern IntPtr malloc_msvcrt(nuint size); + /// + /// Free an integer pointer allocated by Rust (zen_ffi library) + /// + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern void zen_free_int(IntPtr ptr); /// - /// Free a pointer allocated by the Rust library + /// Free a string pointer allocated by the Rust library. + /// This uses zen_free_string which properly deallocates memory on both Windows and Linux. /// public static void FreeRustString(IntPtr ptr) { if (ptr == IntPtr.Zero) return; + zen_free_string(ptr); + } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - free_msvcrt(ptr); - else - free_libc(ptr); + /// + /// Free an integer pointer allocated by the Rust library. + /// This uses zen_free_int which properly deallocates memory on both Windows and Linux. + /// + public static void FreeRustInt(IntPtr ptr) + { + if (ptr == IntPtr.Zero) return; + zen_free_int(ptr); } /// - /// Allocate memory for callback strings (must use matching allocator) + /// Allocate memory for callback strings using Rust's allocator. + /// This ensures proper memory management when strings are returned to Rust. /// public static IntPtr AllocateCString(string? value) { if (value == null) return IntPtr.Zero; var bytes = System.Text.Encoding.UTF8.GetBytes(value); - var size = (nuint)(bytes.Length + 1); - - IntPtr ptr; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - ptr = malloc_msvcrt(size); - else - ptr = malloc_libc(size); - - if (ptr == IntPtr.Zero) - throw new OutOfMemoryException("Failed to allocate native memory"); - - Marshal.Copy(bytes, 0, ptr, bytes.Length); - Marshal.WriteByte(ptr, bytes.Length, 0); // null terminator - - return ptr; + + // Use a temporary pinned buffer to pass to Rust + unsafe + { + fixed (byte* pBytes = bytes) + { + return zen_alloc_string((IntPtr)pBytes, (nuint)bytes.Length); + } + } } } } diff --git a/bindings/dotnet/ZenEngine.cs b/bindings/dotnet/ZenEngine.cs index 24532a8d..83af5f7e 100644 --- a/bindings/dotnet/ZenEngine.cs +++ b/bindings/dotnet/ZenEngine.cs @@ -442,7 +442,7 @@ public static bool ExtractBool(ZenResult_c_int result) } finally { - ZenNative.FreeRustString(result.result); + ZenNative.FreeRustInt(result.result); ZenNative.FreeRustString(result.details); } } From 4e6f8874737eca5d166ba0810ee27b2c5f941a63 Mon Sep 17 00:00:00 2001 From: ice6 Date: Fri, 2 Jan 2026 19:49:55 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=9E=84=E5=BB=BA=E8=84=9A=E6=9C=AC=E5=92=8C=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E4=BB=A5=E6=94=AF=E6=8C=81=20Windows?= =?UTF-8?q?=E3=80=81Linux=20=E5=92=8C=20macOS=20=E7=9A=84=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=BA=93=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-native.yml | 92 ++++++++++++++++++++++++++++++ bindings/c/build-all-platforms.ps1 | 38 ++++++++++++ bindings/c/build-all-platforms.sh | 36 ++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 .github/workflows/build-native.yml create mode 100644 bindings/c/build-all-platforms.ps1 create mode 100644 bindings/c/build-all-platforms.sh diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml new file mode 100644 index 00000000..a537838d --- /dev/null +++ b/.github/workflows/build-native.yml @@ -0,0 +1,92 @@ +name: Build Native Libraries + +on: + push: + branches: [main] + paths: + - 'bindings/c/**' + - 'bindings/dotnet/**' + - 'core/**' + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build-native: + strategy: + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + lib_name: zen_ffi.dll + runtime: win-x64 + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + lib_name: libzen_ffi.so + runtime: linux-x64 + - os: macos-latest + target: x86_64-apple-darwin + lib_name: libzen_ffi.dylib + runtime: osx-x64 + - os: macos-latest + target: aarch64-apple-darwin + lib_name: libzen_ffi.dylib + runtime: osx-arm64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + with: + targets: ${{ matrix.target }} + + - name: Build native library + run: cargo build --release -p zen-ffi --no-default-features --target ${{ matrix.target }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.runtime }} + path: target/${{ matrix.target }}/release/${{ matrix.lib_name }} + + package-nuget: + needs: build-native + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Copy native libraries to runtime folders + run: | + mkdir -p bindings/dotnet/runtimes/win-x64/native + mkdir -p bindings/dotnet/runtimes/linux-x64/native + mkdir -p bindings/dotnet/runtimes/osx-x64/native + mkdir -p bindings/dotnet/runtimes/osx-arm64/native + + cp artifacts/native-win-x64/zen_ffi.dll bindings/dotnet/runtimes/win-x64/native/ + cp artifacts/native-linux-x64/libzen_ffi.so bindings/dotnet/runtimes/linux-x64/native/ + cp artifacts/native-osx-x64/libzen_ffi.dylib bindings/dotnet/runtimes/osx-x64/native/ + cp artifacts/native-osx-arm64/libzen_ffi.dylib bindings/dotnet/runtimes/osx-arm64/native/ + + - name: Build NuGet package + working-directory: bindings/dotnet + run: dotnet pack -c Release + + - name: Upload NuGet package + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: bindings/dotnet/bin/Release/*.nupkg diff --git a/bindings/c/build-all-platforms.ps1 b/bindings/c/build-all-platforms.ps1 new file mode 100644 index 00000000..e878c511 --- /dev/null +++ b/bindings/c/build-all-platforms.ps1 @@ -0,0 +1,38 @@ +# build-all-platforms.ps1 +# Windows PowerShell 脚本 - 使用 cross 或原生工具链构建 +# 前提: cargo install cross (需要 Docker) + +$ErrorActionPreference = "Stop" + +# 定义目标平台 +$targets = @( + @{ target = "x86_64-pc-windows-msvc"; lib = "zen_ffi.dll"; runtime = "win-x64" } + @{ target = "x86_64-pc-windows-gnu"; lib = "zen_ffi.dll"; runtime = "win-x64-gnu" } +) + +# 如果有 cross 和 Docker,可以添加 Linux 目标 +# @{ target = "x86_64-unknown-linux-gnu"; lib = "libzen_ffi.so"; runtime = "linux-x64" } + +$outputDir = "target/native-libs" +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + +foreach ($t in $targets) { + Write-Host "Building for $($t.target)..." -ForegroundColor Cyan + + # 添加目标工具链 + rustup target add $t.target + + # 构建 + cargo build --release -p zen-ffi --no-default-features --target $t.target + + # 复制产物 + $src = "target/$($t.target)/release/$($t.lib)" + $dst = "$outputDir/$($t.runtime)" + New-Item -ItemType Directory -Force -Path $dst | Out-Null + Copy-Item $src "$dst/$($t.lib)" -Force + + Write-Host " -> $dst/$($t.lib)" -ForegroundColor Green +} + +Write-Host "`nAll builds complete!" -ForegroundColor Green +Get-ChildItem -Recurse $outputDir | Format-Table Name, Length diff --git a/bindings/c/build-all-platforms.sh b/bindings/c/build-all-platforms.sh new file mode 100644 index 00000000..a3fb6133 --- /dev/null +++ b/bindings/c/build-all-platforms.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# build-all-platforms.sh +# 使用 cross 工具交叉编译所有平台 +# 安装: cargo install cross + +set -e + +TARGETS=( + "x86_64-pc-windows-gnu" # Windows x64 + "x86_64-unknown-linux-gnu" # Linux x64 + # macOS 交叉编译需要额外配置,通常在 CI 中原生构建 +) + +OUTPUT_DIR="target/native-libs" +mkdir -p "$OUTPUT_DIR" + +for target in "${TARGETS[@]}"; do + echo "Building for $target..." + cross build --release -p zen-ffi --no-default-features --target "$target" + + # 复制产物 + case "$target" in + *windows*) + cp "target/$target/release/zen_ffi.dll" "$OUTPUT_DIR/zen_ffi-$target.dll" + ;; + *linux*) + cp "target/$target/release/libzen_ffi.so" "$OUTPUT_DIR/libzen_ffi-$target.so" + ;; + *darwin*) + cp "target/$target/release/libzen_ffi.dylib" "$OUTPUT_DIR/libzen_ffi-$target.dylib" + ;; + esac +done + +echo "All builds complete! Output in $OUTPUT_DIR" +ls -la "$OUTPUT_DIR" From fb4ab41b7659c5c1c15c6d6a8d40bb34cd36159e Mon Sep 17 00:00:00 2001 From: ice6 Date: Fri, 2 Jan 2026 20:43:24 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.NET=20=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E7=9A=84=E4=BD=BF=E7=94=A8=E8=AF=B4=E6=98=8E=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=B7=A8=E5=B9=B3=E5=8F=B0=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=BA=93=E8=A7=A3=E6=9E=90=E5=99=A8=E5=92=8C=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E4=BB=A5=E6=94=AF=E6=8C=81=20Windows=20ARM64?= =?UTF-8?q?=20=E5=92=8C=20x64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bindings/dotnet/USAGE.md | 63 +++++++++---- bindings/dotnet/ZenEngine.Interop.cs | 94 ++++++++++++++++++ bindings/dotnet/build.ps1 | 136 +++++++++++++++++++++++++++ bindings/dotnet/build.sh | 29 ++++-- 4 files changed, 298 insertions(+), 24 deletions(-) create mode 100644 bindings/dotnet/build.ps1 diff --git a/bindings/dotnet/USAGE.md b/bindings/dotnet/USAGE.md index 1d2557fd..02643ce0 100644 --- a/bindings/dotnet/USAGE.md +++ b/bindings/dotnet/USAGE.md @@ -65,22 +65,9 @@ cargo build --release --no-default-features ### Deploying the Native Library -#### Option A: Place Next to Your Executable +The library includes a custom native library resolver that automatically searches for the native library in multiple locations. Use the recommended `runtimes/{rid}/native/` folder structure for cross-platform compatibility. -Copy the native library to your application's output directory: - -```bash -# Linux -cp libzen_ffi.so /path/to/your/app/ - -# macOS -cp libzen_ffi.dylib /path/to/your/app/ - -# Windows -cp zen_ffi.dll /path/to/your/app/ -``` - -#### Option B: Use Runtime Folders (Recommended) +#### Recommended: Runtime Folders Structure Create platform-specific runtime folders in your project: @@ -92,13 +79,19 @@ YourProject/ ├── linux-x64/ │ └── native/ │ └── libzen_ffi.so + ├── linux-arm64/ + │ └── native/ + │ └── libzen_ffi.so ├── osx-x64/ │ └── native/ │ └── libzen_ffi.dylib ├── osx-arm64/ │ └── native/ │ └── libzen_ffi.dylib - └── win-x64/ + ├── win-x64/ + │ └── native/ + │ └── zen_ffi.dll + └── win-arm64/ └── native/ └── zen_ffi.dll ``` @@ -107,11 +100,16 @@ Add to your `.csproj`: ```xml - + + + + - + + + + ``` +#### Library Search Order + +The custom resolver searches for the native library in the following order: + +1. `{AssemblyDir}/runtimes/{rid}/native/{libname}` +2. `{BaseDir}/runtimes/{rid}/native/{libname}` +3. `{AssemblyDir}/{libname}` (fallback) +4. `{BaseDir}/{libname}` (fallback) +5. Paths in `LD_LIBRARY_PATH` (Linux) or `DYLD_LIBRARY_PATH` (macOS) + +#### Alternative: Environment Variables + +You can also use environment variables to specify library search paths: + +```bash +# Linux +export LD_LIBRARY_PATH=/path/to/native/libs:$LD_LIBRARY_PATH +dotnet run + +# macOS +export DYLD_LIBRARY_PATH=/path/to/native/libs:$DYLD_LIBRARY_PATH +dotnet run +``` + ## Quick Start Example ### 1. Create a New Project diff --git a/bindings/dotnet/ZenEngine.Interop.cs b/bindings/dotnet/ZenEngine.Interop.cs index e7eeef36..dbe70cc0 100644 --- a/bindings/dotnet/ZenEngine.Interop.cs +++ b/bindings/dotnet/ZenEngine.Interop.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using System.Reflection; using System.Runtime.InteropServices; namespace GoRules.Zen.Interop @@ -108,6 +110,98 @@ public static class ZenNative { private const string LibraryName = "zen_ffi"; + /// + /// Static constructor to register custom native library resolver + /// + static ZenNative() + { + NativeLibrary.SetDllImportResolver(typeof(ZenNative).Assembly, ResolveNativeLibrary); + } + + /// + /// Custom resolver for the native zen_ffi library. + /// Searches in multiple locations to support consistent runtimes/{rid}/native/ structure across all platforms. + /// + private static IntPtr ResolveNativeLibrary(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName != LibraryName) + { + return IntPtr.Zero; // Let default resolver handle other libraries + } + + // Determine platform-specific library name and runtime identifier + string rid; + string libFileName; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + rid = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "win-arm64" : "win-x64"; + libFileName = "zen_ffi.dll"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + rid = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "linux-arm64" : "linux-x64"; + libFileName = "libzen_ffi.so"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + rid = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64"; + libFileName = "libzen_ffi.dylib"; + } + else + { + return IntPtr.Zero; // Unsupported platform + } + + // Get base directories to search + var assemblyLocation = Path.GetDirectoryName(assembly.Location) ?? AppContext.BaseDirectory; + var baseDirectory = AppContext.BaseDirectory; + + // Build list of search paths + var searchPaths = new[] + { + // 1. runtimes/{rid}/native/ relative to assembly location + Path.Combine(assemblyLocation, "runtimes", rid, "native", libFileName), + // 2. runtimes/{rid}/native/ relative to base directory + Path.Combine(baseDirectory, "runtimes", rid, "native", libFileName), + // 3. Direct in assembly directory (fallback) + Path.Combine(assemblyLocation, libFileName), + // 4. Direct in base directory (fallback) + Path.Combine(baseDirectory, libFileName), + }; + + // Try each path + foreach (var path in searchPaths) + { + if (File.Exists(path) && NativeLibrary.TryLoad(path, out var handle)) + { + return handle; + } + } + + // Try LD_LIBRARY_PATH / DYLD_LIBRARY_PATH + var envVar = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? "DYLD_LIBRARY_PATH" + : "LD_LIBRARY_PATH"; + var libraryPath = Environment.GetEnvironmentVariable(envVar); + + if (!string.IsNullOrEmpty(libraryPath)) + { + var paths = libraryPath.Split(Path.PathSeparator); + foreach (var dir in paths) + { + var fullPath = Path.Combine(dir, libFileName); + if (File.Exists(fullPath) && NativeLibrary.TryLoad(fullPath, out var handle)) + { + return handle; + } + } + } + + // Let the default resolver try as last resort + return IntPtr.Zero; + } + // ============================================================ // Engine Lifecycle // ============================================================ diff --git a/bindings/dotnet/build.ps1 b/bindings/dotnet/build.ps1 new file mode 100644 index 00000000..5d17319f --- /dev/null +++ b/bindings/dotnet/build.ps1 @@ -0,0 +1,136 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Build script for Zen Engine .NET Bindings on Windows. + +.DESCRIPTION + This script builds the native Rust library and .NET bindings for the Zen Rules Engine. + +.PARAMETER Test + Run tests after building. + +.PARAMETER Configuration + Build configuration (Debug or Release). Default is Release. + +.EXAMPLE + .\build.ps1 + Build with default settings (Release configuration). + +.EXAMPLE + .\build.ps1 -Test + Build and run tests. + +.EXAMPLE + .\build.ps1 -Configuration Debug -Test + Build in Debug mode and run tests. +#> + +param( + [switch]$Test, + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +# Get script directories +$ScriptDir = $PSScriptRoot +$RootDir = Resolve-Path (Join-Path $ScriptDir "..\..") | Select-Object -ExpandProperty Path +$CBindingsDir = Join-Path $RootDir "bindings\c" +$DotNetDir = $ScriptDir + +Write-Host "=== Building Zen Engine .NET Bindings ===" -ForegroundColor Cyan +Write-Host "" + +# Detect architecture +$Arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture +if ($Arch -eq [System.Runtime.InteropServices.Architecture]::Arm64) { + $Platform = "win-arm64" +} else { + $Platform = "win-x64" +} +$LibName = "zen_ffi.dll" + +Write-Host "Platform: $Platform" -ForegroundColor Green +Write-Host "Architecture: $Arch" -ForegroundColor Green +Write-Host "Library: $LibName" -ForegroundColor Green +Write-Host "Configuration: $Configuration" -ForegroundColor Green +Write-Host "" + +# Step 1: Build Rust library +Write-Host "Step 1: Building Rust C bindings..." -ForegroundColor Yellow +Push-Location $CBindingsDir +try { + cargo build --release --no-default-features + if ($LASTEXITCODE -ne 0) { + throw "Cargo build failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location +} +Write-Host "Done." -ForegroundColor Green +Write-Host "" + +# Step 2: Copy native library +Write-Host "Step 2: Copying native library..." -ForegroundColor Yellow +$RuntimeDir = Join-Path $DotNetDir "runtimes\$Platform\native" +if (-not (Test-Path $RuntimeDir)) { + New-Item -ItemType Directory -Path $RuntimeDir -Force | Out-Null +} + +$SourceLib = Join-Path $RootDir "target\release\$LibName" +if (Test-Path $SourceLib) { + Copy-Item $SourceLib -Destination $RuntimeDir -Force + Write-Host "Copied $LibName to $RuntimeDir\" -ForegroundColor Green +} else { + Write-Host "ERROR: Library not found at $SourceLib" -ForegroundColor Red + Write-Host "Make sure Cargo.toml has crate-type = [""cdylib""]" -ForegroundColor Red + exit 1 +} +Write-Host "" + +# Step 3: Build .NET library +Write-Host "Step 3: Building .NET library..." -ForegroundColor Yellow +Push-Location $DotNetDir +try { + dotnet build -c $Configuration + if ($LASTEXITCODE -ne 0) { + throw "dotnet build failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location +} +Write-Host "Done." -ForegroundColor Green +Write-Host "" + +# Step 4: Run tests (optional) +if ($Test) { + Write-Host "Step 4: Running tests..." -ForegroundColor Yellow + Push-Location $DotNetDir + try { + dotnet test -c $Configuration + if ($LASTEXITCODE -ne 0) { + throw "dotnet test failed with exit code $LASTEXITCODE" + } + } finally { + Pop-Location + } + Write-Host "" +} + +Write-Host "=== Build Complete ===" -ForegroundColor Cyan +Write-Host "" +Write-Host "Output:" -ForegroundColor White +Write-Host " Library: $DotNetDir\bin\$Configuration\net10.0\GoRules.Zen.dll" +Write-Host " Native: $RuntimeDir\$LibName" +Write-Host "" +Write-Host "Supported platforms:" -ForegroundColor White +Write-Host " - linux-x64" +Write-Host " - linux-arm64" +Write-Host " - osx-x64" +Write-Host " - osx-arm64" +Write-Host " - win-x64" +Write-Host " - win-arm64" +Write-Host "" +Write-Host "To create NuGet package:" -ForegroundColor White +Write-Host " cd $DotNetDir; dotnet pack -c $Configuration" diff --git a/bindings/dotnet/build.sh b/bindings/dotnet/build.sh index 42891ab9..766ac960 100755 --- a/bindings/dotnet/build.sh +++ b/bindings/dotnet/build.sh @@ -9,12 +9,16 @@ DOTNET_DIR="$SCRIPT_DIR" echo "=== Building Zen Engine .NET Bindings ===" echo "" -# Detect platform +# Detect platform and architecture +ARCH=$(uname -m) if [[ "$OSTYPE" == "linux-gnu"* ]]; then - PLATFORM="linux-x64" + if [[ "$ARCH" == "aarch64" ]] || [[ "$ARCH" == "arm64" ]]; then + PLATFORM="linux-arm64" + else + PLATFORM="linux-x64" + fi LIB_NAME="libzen_ffi.so" elif [[ "$OSTYPE" == "darwin"* ]]; then - ARCH=$(uname -m) if [[ "$ARCH" == "arm64" ]]; then PLATFORM="osx-arm64" else @@ -22,7 +26,11 @@ elif [[ "$OSTYPE" == "darwin"* ]]; then fi LIB_NAME="libzen_ffi.dylib" elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then - PLATFORM="win-x64" + if [[ "$ARCH" == "aarch64" ]] || [[ "$ARCH" == "arm64" ]]; then + PLATFORM="win-arm64" + else + PLATFORM="win-x64" + fi LIB_NAME="zen_ffi.dll" else echo "Unsupported platform: $OSTYPE" @@ -30,13 +38,14 @@ else fi echo "Platform: $PLATFORM" +echo "Architecture: $ARCH" echo "Library: $LIB_NAME" echo "" # Step 1: Build Rust library echo "Step 1: Building Rust C bindings..." cd "$C_BINDINGS_DIR" -cargo build --release +cargo build --release --no-default-features echo "Done." echo "" @@ -73,8 +82,16 @@ fi echo "=== Build Complete ===" echo "" echo "Output:" -echo " Library: $DOTNET_DIR/bin/Release/net8.0/GoRules.Zen.dll" +echo " Library: $DOTNET_DIR/bin/Release/net10.0/GoRules.Zen.dll" echo " Native: $RUNTIME_DIR/$LIB_NAME" echo "" +echo "Supported platforms:" +echo " - linux-x64" +echo " - linux-arm64" +echo " - osx-x64" +echo " - osx-arm64" +echo " - win-x64" +echo " - win-arm64" +echo "" echo "To create NuGet package:" echo " cd $DOTNET_DIR && dotnet pack -c Release" From 99d8eedc98a6b8929d35387fd76ab55a4e981647 Mon Sep 17 00:00:00 2001 From: ice6 Date: Sat, 3 Jan 2026 05:55:47 +0800 Subject: [PATCH 7/9] chore: delete the useless build script, use github workflows instead. --- bindings/c/build-all-platforms.ps1 | 38 ------------------------------ bindings/c/build-all-platforms.sh | 36 ---------------------------- bindings/dotnet/README.md | 10 ++++---- 3 files changed, 5 insertions(+), 79 deletions(-) delete mode 100644 bindings/c/build-all-platforms.ps1 delete mode 100644 bindings/c/build-all-platforms.sh diff --git a/bindings/c/build-all-platforms.ps1 b/bindings/c/build-all-platforms.ps1 deleted file mode 100644 index e878c511..00000000 --- a/bindings/c/build-all-platforms.ps1 +++ /dev/null @@ -1,38 +0,0 @@ -# build-all-platforms.ps1 -# Windows PowerShell 脚本 - 使用 cross 或原生工具链构建 -# 前提: cargo install cross (需要 Docker) - -$ErrorActionPreference = "Stop" - -# 定义目标平台 -$targets = @( - @{ target = "x86_64-pc-windows-msvc"; lib = "zen_ffi.dll"; runtime = "win-x64" } - @{ target = "x86_64-pc-windows-gnu"; lib = "zen_ffi.dll"; runtime = "win-x64-gnu" } -) - -# 如果有 cross 和 Docker,可以添加 Linux 目标 -# @{ target = "x86_64-unknown-linux-gnu"; lib = "libzen_ffi.so"; runtime = "linux-x64" } - -$outputDir = "target/native-libs" -New-Item -ItemType Directory -Force -Path $outputDir | Out-Null - -foreach ($t in $targets) { - Write-Host "Building for $($t.target)..." -ForegroundColor Cyan - - # 添加目标工具链 - rustup target add $t.target - - # 构建 - cargo build --release -p zen-ffi --no-default-features --target $t.target - - # 复制产物 - $src = "target/$($t.target)/release/$($t.lib)" - $dst = "$outputDir/$($t.runtime)" - New-Item -ItemType Directory -Force -Path $dst | Out-Null - Copy-Item $src "$dst/$($t.lib)" -Force - - Write-Host " -> $dst/$($t.lib)" -ForegroundColor Green -} - -Write-Host "`nAll builds complete!" -ForegroundColor Green -Get-ChildItem -Recurse $outputDir | Format-Table Name, Length diff --git a/bindings/c/build-all-platforms.sh b/bindings/c/build-all-platforms.sh deleted file mode 100644 index a3fb6133..00000000 --- a/bindings/c/build-all-platforms.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -# build-all-platforms.sh -# 使用 cross 工具交叉编译所有平台 -# 安装: cargo install cross - -set -e - -TARGETS=( - "x86_64-pc-windows-gnu" # Windows x64 - "x86_64-unknown-linux-gnu" # Linux x64 - # macOS 交叉编译需要额外配置,通常在 CI 中原生构建 -) - -OUTPUT_DIR="target/native-libs" -mkdir -p "$OUTPUT_DIR" - -for target in "${TARGETS[@]}"; do - echo "Building for $target..." - cross build --release -p zen-ffi --no-default-features --target "$target" - - # 复制产物 - case "$target" in - *windows*) - cp "target/$target/release/zen_ffi.dll" "$OUTPUT_DIR/zen_ffi-$target.dll" - ;; - *linux*) - cp "target/$target/release/libzen_ffi.so" "$OUTPUT_DIR/libzen_ffi-$target.so" - ;; - *darwin*) - cp "target/$target/release/libzen_ffi.dylib" "$OUTPUT_DIR/libzen_ffi-$target.dylib" - ;; - esac -done - -echo "All builds complete! Output in $OUTPUT_DIR" -ls -la "$OUTPUT_DIR" diff --git a/bindings/dotnet/README.md b/bindings/dotnet/README.md index 72c6deab..5961434c 100644 --- a/bindings/dotnet/README.md +++ b/bindings/dotnet/README.md @@ -23,27 +23,27 @@ The Zen project uses [UniFFI](https://mozilla.github.io/uniffi-rs/) for multi-la ``` ┌─────────────────────────────────────────────────────────────┐ -│ Your C# Application │ +│ Your C# Application │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ -│ GoRules.Zen (C# Wrapper) │ +│ GoRules.Zen (C# Wrapper) │ │ • ZenEngine, ZenDecision, ZenExpression │ -│ • Automatic memory management │ +│ • Automatic memory management │ │ • Type-safe API with generics │ └─────────────────────────────────────────────────────────────┘ │ ▼ P/Invoke ┌─────────────────────────────────────────────────────────────┐ -│ libzen_ffi.so / zen_ffi.dll │ +│ libzen_ffi.so / zen_ffi.dll │ │ • C FFI exports with extern "C" │ │ • cbindgen-generated headers │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ -│ Zen Core (Rust) │ +│ Zen Core (Rust) │ │ • zen-engine: Decision graph execution │ │ • zen-expression: Expression language VM │ │ • zen-template: Template rendering │ From 2164231acee68744e4537b18a146a0da37ad634d Mon Sep 17 00:00:00 2001 From: ice6 Date: Sat, 3 Jan 2026 06:00:05 +0800 Subject: [PATCH 8/9] add native support for Windows ARM64 and Linux ARM64 --- bindings/dotnet/GoRules.Zen.csproj | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bindings/dotnet/GoRules.Zen.csproj b/bindings/dotnet/GoRules.Zen.csproj index 3b350ed3..bca18581 100644 --- a/bindings/dotnet/GoRules.Zen.csproj +++ b/bindings/dotnet/GoRules.Zen.csproj @@ -34,6 +34,13 @@ CopyToOutputDirectory="PreserveNewest" Condition="Exists('runtimes/win-x64/native/zen_ffi.dll')" /> + + + + + + Date: Sat, 3 Jan 2026 06:07:51 +0800 Subject: [PATCH 9/9] make the `build-native.yml` github workflow support Linux ARM64 --- .github/workflows/build-native.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index a537838d..cf243651 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -24,6 +24,10 @@ jobs: target: x86_64-unknown-linux-gnu lib_name: libzen_ffi.so runtime: linux-x64 + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + lib_name: libzen_ffi.so + runtime: linux-arm64 - os: macos-latest target: x86_64-apple-darwin lib_name: libzen_ffi.dylib @@ -43,6 +47,12 @@ jobs: with: targets: ${{ matrix.target }} + - name: Install Linux ARM64 toolchain + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + - name: Build native library run: cargo build --release -p zen-ffi --no-default-features --target ${{ matrix.target }} @@ -73,11 +83,13 @@ jobs: run: | mkdir -p bindings/dotnet/runtimes/win-x64/native mkdir -p bindings/dotnet/runtimes/linux-x64/native + mkdir -p bindings/dotnet/runtimes/linux-arm64/native mkdir -p bindings/dotnet/runtimes/osx-x64/native mkdir -p bindings/dotnet/runtimes/osx-arm64/native cp artifacts/native-win-x64/zen_ffi.dll bindings/dotnet/runtimes/win-x64/native/ cp artifacts/native-linux-x64/libzen_ffi.so bindings/dotnet/runtimes/linux-x64/native/ + cp artifacts/native-linux-arm64/libzen_ffi.so bindings/dotnet/runtimes/linux-arm64/native/ cp artifacts/native-osx-x64/libzen_ffi.dylib bindings/dotnet/runtimes/osx-x64/native/ cp artifacts/native-osx-arm64/libzen_ffi.dylib bindings/dotnet/runtimes/osx-arm64/native/