From 5f3bf6dd04ae445473fb99ce080d380ba33787b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 2 Oct 2026 17:18:30 +0000 Subject: [PATCH] feat: add dc_rulekit v0.1 rules engine (Rust + Dart) Introduce business-agnostic on-device rules core as publishable crate dc_rulekit with plugin registry, proposal workflow, engine dry-run, and audit hooks. Mirror semantics in pure Dart package dc_rulekit. Include toy demo plugins, ADR 0001, CI, and documentation noting GitHub repo name rulekit vs published dc_rulekit. Co-authored-by: Uday Chauhan --- .github/workflows/ci.yml | 26 + .gitignore | 4 + CHANGELOG.md | 13 + Cargo.lock | 534 ++++++++++++++++++ Cargo.toml | 19 + LICENSE | 21 + README.md | 103 +++- crates/dc_rulekit/Cargo.toml | 19 + crates/dc_rulekit/README.md | 5 + crates/dc_rulekit/src/audit.rs | 13 + crates/dc_rulekit/src/context.rs | 28 + crates/dc_rulekit/src/engine.rs | 124 ++++ crates/dc_rulekit/src/error.rs | 48 ++ crates/dc_rulekit/src/lib.rs | 25 + crates/dc_rulekit/src/model.rs | 152 +++++ crates/dc_rulekit/src/plugin.rs | 68 +++ crates/dc_rulekit/src/proposal.rs | 126 +++++ crates/dc_rulekit/src/store.rs | 103 ++++ crates/dc_rulekit/tests/engine_tests.rs | 264 +++++++++ crates/dc_rulekit_demo_plugins/Cargo.toml | 11 + crates/dc_rulekit_demo_plugins/src/lib.rs | 196 +++++++ .../ADR/0001-agnostic-core-vs-host-plugins.md | 56 ++ examples/README.md | 9 + packages/dc_rulekit/analysis_options.yaml | 5 + packages/dc_rulekit/lib/dc_rulekit.dart | 8 + packages/dc_rulekit/lib/src/audit.dart | 10 + packages/dc_rulekit/lib/src/context.dart | 21 + packages/dc_rulekit/lib/src/engine.dart | 90 +++ packages/dc_rulekit/lib/src/error.dart | 76 +++ packages/dc_rulekit/lib/src/model.dart | 232 ++++++++ packages/dc_rulekit/lib/src/plugin.dart | 48 ++ packages/dc_rulekit/lib/src/proposal.dart | 80 +++ packages/dc_rulekit/lib/src/store.dart | 61 ++ packages/dc_rulekit/pubspec.lock | 389 +++++++++++++ packages/dc_rulekit/pubspec.yaml | 11 + packages/dc_rulekit/test/engine_test.dart | 143 +++++ 36 files changed, 3140 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 crates/dc_rulekit/Cargo.toml create mode 100644 crates/dc_rulekit/README.md create mode 100644 crates/dc_rulekit/src/audit.rs create mode 100644 crates/dc_rulekit/src/context.rs create mode 100644 crates/dc_rulekit/src/engine.rs create mode 100644 crates/dc_rulekit/src/error.rs create mode 100644 crates/dc_rulekit/src/lib.rs create mode 100644 crates/dc_rulekit/src/model.rs create mode 100644 crates/dc_rulekit/src/plugin.rs create mode 100644 crates/dc_rulekit/src/proposal.rs create mode 100644 crates/dc_rulekit/src/store.rs create mode 100644 crates/dc_rulekit/tests/engine_tests.rs create mode 100644 crates/dc_rulekit_demo_plugins/Cargo.toml create mode 100644 crates/dc_rulekit_demo_plugins/src/lib.rs create mode 100644 docs/ADR/0001-agnostic-core-vs-host-plugins.md create mode 100644 examples/README.md create mode 100644 packages/dc_rulekit/analysis_options.yaml create mode 100644 packages/dc_rulekit/lib/dc_rulekit.dart create mode 100644 packages/dc_rulekit/lib/src/audit.dart create mode 100644 packages/dc_rulekit/lib/src/context.dart create mode 100644 packages/dc_rulekit/lib/src/engine.dart create mode 100644 packages/dc_rulekit/lib/src/error.dart create mode 100644 packages/dc_rulekit/lib/src/model.dart create mode 100644 packages/dc_rulekit/lib/src/plugin.dart create mode 100644 packages/dc_rulekit/lib/src/proposal.dart create mode 100644 packages/dc_rulekit/lib/src/store.dart create mode 100644 packages/dc_rulekit/pubspec.lock create mode 100644 packages/dc_rulekit/pubspec.yaml create mode 100644 packages/dc_rulekit/test/engine_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c340777 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --all + + dart: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + - run: dart pub get + working-directory: packages/dc_rulekit + - run: dart analyze + working-directory: packages/dc_rulekit + - run: dart test + working-directory: packages/dc_rulekit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05acc25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +**/.dart_tool/ +.packages +.pub-cache/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8bc199e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2026-10-02 + +### Added + +- Rust crate **`dc_rulekit`**: rule model, plugin registry, in-memory/file stores, proposal workflow, engine with dry-run, audit hook. +- Dart package **`dc_rulekit`**: pure-Dart mirror of core semantics for Flutter hosts. +- Toy plugins in **`dc_rulekit_demo_plugins`** (`demo.when.*`, `demo.then.*`). +- ADR 0001 (agnostic core vs host plugins). +- Unit tests for Rust and Dart. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8bbd3fe --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,534 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "dc_rulekit" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "tempfile", + "thiserror", + "uuid", +] + +[[package]] +name = "dc_rulekit_demo_plugins" +version = "0.1.0" +dependencies = [ + "dc_rulekit", + "serde_json", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom 0.2.17", + "serde", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3a63027 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[workspace] +resolver = "2" +members = [ + "crates/dc_rulekit", + "crates/dc_rulekit_demo_plugins", +] +default-members = ["crates/dc_rulekit"] + +[workspace.package] +edition = "2021" +license = "MIT" +repository = "https://github.com/DevelopersCoffee/rulekit" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +uuid = { version = "=1.11.0", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3412229 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DevelopersCoffee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, rename, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 913032d..cc1ebc3 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,105 @@ Business-agnostic on-device rules engine for DevelopersCoffee apps. -Host applications register condition/action plugins; the core stays domain-free. +> **Published package name:** [`dc_rulekit`](https://crates.io/crates/dc_rulekit) (Rust) and [`dc_rulekit`](https://pub.dev/packages/dc_rulekit) (Dart). +> **This repository** is named `rulekit` on GitHub — same project, different registry names (crates.io `rulekit` / `rule_kit` are taken). + +Host applications register **condition** and **action** plugins; the core validates, stores, and evaluates rules without domain-specific effects. + +## Concepts + +- **Rule**: JSON document with `when` (conditions) and `then` (actions), optional `trigger`, `source` (`static` | `llm`). +- **Plugins**: String ids → host `ConditionEvaluator` / `ActionHandler`. +- **Lifecycle**: `propose` → `approve` → active rule in store. +- **Evaluate**: Ordered conditions (all must pass), ordered actions, optional **dry-run** and **audit receipt** hook. + +See [docs/ADR/0001-agnostic-core-vs-host-plugins.md](docs/ADR/0001-agnostic-core-vs-host-plugins.md). + +## Rust quickstart + +```toml +[dependencies] +dc_rulekit = "0.1" +``` + +```rust +use dc_rulekit::{ + Action, Condition, Engine, EvalContext, EvaluateOptions, PluginRegistry, + ProposalStore, Rule, RuleSource, RuleStore, +}; +use dc_rulekit_demo_plugins::{register_all, PLUGIN_ALWAYS, PLUGIN_LOG}; +use serde_json::json; + +let mut registry = PluginRegistry::new(); +register_all(&mut registry); +let engine = Engine::new(®istry); + +let mut rule = Rule::new("demo.app/hello", "Hello", RuleSource::Static); +rule.when.push(Condition { + id: "always".into(), + plugin: PLUGIN_ALWAYS.into(), + params: json!({}), +}); +rule.then.push(Action { + id: "log".into(), + plugin: PLUGIN_LOG.into(), + params: json!({ "message": "hello from dc_rulekit" }), +}); + +let mut proposals = ProposalStore::in_memory(); +let mut active = RuleStore::in_memory(); +let proposal = proposals.propose(rule).unwrap(); +let active_rule = proposals.approve(&proposal.proposal_id, &mut active).unwrap(); + +let receipt = engine + .evaluate(&active_rule, &EvalContext::new("demo.app"), EvaluateOptions::default()) + .unwrap(); +assert!(receipt.matched); +``` + +Run tests: + +```bash +cargo test +``` + +Run the demo quickstart (Rust): + +```bash +cargo test -p dc_rulekit_demo_plugins quickstart_runs +``` + +## Dart quickstart + +```yaml +dependencies: + dc_rulekit: ^0.1.0 +``` + +```dart +import 'package:dc_rulekit/dc_rulekit.dart'; + +final registry = PluginRegistry() + ..registerCondition(/* host ConditionEvaluator */) + ..registerAction(/* host ActionHandler */); + +final engine = Engine(registry); +// Same propose → approve → evaluate flow as Rust. +``` + +```bash +cd packages/dc_rulekit && dart pub get && dart test +``` + +## Layout + +| Path | Description | +|------|-------------| +| `crates/dc_rulekit` | Rust core (crates.io: `dc_rulekit`) | +| `crates/dc_rulekit_demo_plugins` | Toy plugins for examples/tests | +| `packages/dc_rulekit` | Dart package (pub.dev: `dc_rulekit`) | +| `docs/ADR/` | Architecture decisions | + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/crates/dc_rulekit/Cargo.toml b/crates/dc_rulekit/Cargo.toml new file mode 100644 index 0000000..a8e2953 --- /dev/null +++ b/crates/dc_rulekit/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "dc_rulekit" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Business-agnostic on-device rules engine with host-supplied plugins" +readme = "README.md" +keywords = ["rules", "engine", "plugins"] +categories = ["config"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/dc_rulekit/README.md b/crates/dc_rulekit/README.md new file mode 100644 index 0000000..1554b89 --- /dev/null +++ b/crates/dc_rulekit/README.md @@ -0,0 +1,5 @@ +# dc_rulekit + +Core on-device rules engine (Rust). Published crate name: **`dc_rulekit`**. Repository: [DevelopersCoffee/rulekit](https://github.com/DevelopersCoffee/rulekit). + +See the repository root README for quickstart. diff --git a/crates/dc_rulekit/src/audit.rs b/crates/dc_rulekit/src/audit.rs new file mode 100644 index 0000000..5ab5cce --- /dev/null +++ b/crates/dc_rulekit/src/audit.rs @@ -0,0 +1,13 @@ +use crate::model::AuditReceipt; + +/// Host hook invoked after each evaluation with an opaque receipt payload. +pub trait AuditHook: Send + Sync { + fn on_receipt(&self, receipt: &AuditReceipt); +} + +#[derive(Default)] +pub struct NoopAuditHook; + +impl AuditHook for NoopAuditHook { + fn on_receipt(&self, _receipt: &AuditReceipt) {} +} diff --git a/crates/dc_rulekit/src/context.rs b/crates/dc_rulekit/src/context.rs new file mode 100644 index 0000000..4275168 --- /dev/null +++ b/crates/dc_rulekit/src/context.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Host-provided evaluation context (domain-free bag of facts). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EvalContext { + #[serde(default)] + pub app_namespace: String, + #[serde(default)] + pub facts: HashMap, + #[serde(default)] + pub event: Option, +} + +impl EvalContext { + pub fn new(app_namespace: impl Into) -> Self { + Self { + app_namespace: app_namespace.into(), + facts: HashMap::new(), + event: None, + } + } + + pub fn with_fact(mut self, key: impl Into, value: serde_json::Value) -> Self { + self.facts.insert(key.into(), value); + self + } +} diff --git a/crates/dc_rulekit/src/engine.rs b/crates/dc_rulekit/src/engine.rs new file mode 100644 index 0000000..80188fd --- /dev/null +++ b/crates/dc_rulekit/src/engine.rs @@ -0,0 +1,124 @@ +use crate::audit::AuditHook; +use crate::context::EvalContext; +use crate::error::{Result, RulekitError}; +use crate::model::{ + ActionOutcome, AuditReceipt, ConditionOutcome, Rule, +}; +use crate::plugin::PluginRegistry; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EvaluateOptions { + pub dry_run: bool, +} + +impl Default for EvaluateOptions { + fn default() -> Self { + Self { dry_run: false } + } +} + +pub struct Engine<'a> { + registry: &'a PluginRegistry, +} + +impl<'a> Engine<'a> { + pub fn new(registry: &'a PluginRegistry) -> Self { + Self { registry } + } + + /// Validate plugins and evaluate `when` conditions (all must pass), then run `then` actions. + pub fn evaluate( + &self, + rule: &Rule, + ctx: &EvalContext, + options: EvaluateOptions, + ) -> Result { + rule.validate_schema()?; + if !rule.enabled { + return Err(RulekitError::DisabledRule { + rule_id: rule.id.clone(), + }); + } + self.registry.validate_rule_plugins(rule)?; + + let mut condition_results = Vec::with_capacity(rule.when.len()); + let mut all_passed = true; + + for condition in &rule.when { + let evaluator = self.registry.get_condition(&condition.plugin)?; + let passed = evaluator.evaluate(&condition.params, ctx)?; + if !passed { + all_passed = false; + condition_results.push(ConditionOutcome { + condition_id: condition.id.clone(), + plugin: condition.plugin.clone(), + passed: false, + detail: None, + }); + // Fail-fast on first failing condition (ordered evaluation). + break; + } + condition_results.push(ConditionOutcome { + condition_id: condition.id.clone(), + plugin: condition.plugin.clone(), + passed: true, + detail: None, + }); + } + + let mut action_outcomes = Vec::new(); + if all_passed { + for action in &rule.then { + let handler = self.registry.get_action(&action.plugin)?; + let (executed, skipped_dry_run, result) = if options.dry_run && !handler.is_pure() { + (false, true, None) + } else { + match handler.execute(&action.params, ctx) { + Ok(value) => (true, false, Some(value)), + Err(RulekitError::ActionDenied { + reason, .. + }) => { + return Err(RulekitError::ActionDenied { + rule_id: rule.id.clone(), + action_id: action.id.clone(), + plugin_id: action.plugin.clone(), + reason, + }); + } + Err(e) => return Err(e), + } + }; + action_outcomes.push(ActionOutcome { + action_id: action.id.clone(), + plugin: action.plugin.clone(), + executed, + skipped_dry_run, + result, + }); + } + } + + Ok(AuditReceipt { + rule_id: rule.id.clone(), + matched: all_passed, + dry_run: options.dry_run, + condition_results, + action_outcomes, + opaque: serde_json::json!({}), + }) + } + + pub fn evaluate_with_audit( + &self, + rule: &Rule, + ctx: &EvalContext, + options: EvaluateOptions, + hook: &H, + opaque: serde_json::Value, + ) -> Result { + let mut receipt = self.evaluate(rule, ctx, options)?; + receipt.opaque = opaque; + hook.on_receipt(&receipt); + Ok(receipt) + } +} diff --git a/crates/dc_rulekit/src/error.rs b/crates/dc_rulekit/src/error.rs new file mode 100644 index 0000000..8bbb4de --- /dev/null +++ b/crates/dc_rulekit/src/error.rs @@ -0,0 +1,48 @@ +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RulekitError { + #[error("unknown condition plugin: {plugin_id}")] + UnknownConditionPlugin { plugin_id: String }, + + #[error("unknown action plugin: {plugin_id}")] + UnknownActionPlugin { plugin_id: String }, + + #[error("rule is disabled: {rule_id}")] + DisabledRule { rule_id: String }, + + #[error("condition failed: rule={rule_id}, condition={condition_id}, plugin={plugin_id}")] + ConditionFailed { + rule_id: String, + condition_id: String, + plugin_id: String, + }, + + #[error("action denied: rule={rule_id}, action={action_id}, plugin={plugin_id}, reason={reason}")] + ActionDenied { + rule_id: String, + action_id: String, + plugin_id: String, + reason: String, + }, + + #[error("schema version mismatch: expected={expected}, found={found}")] + SchemaVersionMismatch { expected: u32, found: u32 }, + + #[error("rule not found: {rule_id}")] + RuleNotFound { rule_id: String }, + + #[error("proposal not found: {proposal_id}")] + ProposalNotFound { proposal_id: String }, + + #[error("invalid proposal state: {message}")] + InvalidProposalState { message: String }, + + #[error("store error: {message}")] + StoreError { message: String }, + + #[error("evaluation error: {message}")] + EvaluationError { message: String }, +} + +pub type Result = std::result::Result; diff --git a/crates/dc_rulekit/src/lib.rs b/crates/dc_rulekit/src/lib.rs new file mode 100644 index 0000000..43c921e --- /dev/null +++ b/crates/dc_rulekit/src/lib.rs @@ -0,0 +1,25 @@ +//! Business-agnostic on-device rules engine. +//! +//! Host applications register [`ConditionEvaluator`] and [`ActionHandler`] plugins by string id. +//! The core validates, stores, and evaluates rules without domain-specific effects. + +mod audit; +mod context; +mod engine; +mod error; +mod model; +mod plugin; +mod proposal; +mod store; + +pub use audit::{AuditHook, NoopAuditHook}; +pub use context::EvalContext; +pub use engine::{Engine, EvaluateOptions}; +pub use error::{Result, RulekitError}; +pub use model::{ + Action, ActionOutcome, AuditReceipt, Condition, ConditionOutcome, ProposalStatus, Rule, + RuleProposal, RuleSource, Trigger, CURRENT_SCHEMA_VERSION, +}; +pub use plugin::{ActionHandler, ConditionEvaluator, PluginRegistry}; +pub use proposal::ProposalStore; +pub use store::RuleStore; diff --git a/crates/dc_rulekit/src/model.rs b/crates/dc_rulekit/src/model.rs new file mode 100644 index 0000000..02d2d3e --- /dev/null +++ b/crates/dc_rulekit/src/model.rs @@ -0,0 +1,152 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Current rule document schema version written by this crate. +pub const CURRENT_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RuleSource { + Static, + Llm, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Trigger { + Manual, + Event { + #[serde(default)] + topic: String, + }, + Schedule { + /// Cron-like or ISO8601 duration string — interpreted by the host scheduler (not v0.1 runner). + expression: String, + }, +} + +impl Default for Trigger { + fn default() -> Self { + Self::Manual + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Condition { + pub id: String, + pub plugin: String, + #[serde(default)] + pub params: serde_json::Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Action { + pub id: String, + pub plugin: String, + #[serde(default)] + pub params: serde_json::Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Rule { + pub schema_version: u32, + pub id: String, + pub title: String, + pub source: RuleSource, + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub trigger: Trigger, + #[serde(default)] + pub when: Vec, + #[serde(default)] + pub then: Vec, +} + +fn default_enabled() -> bool { + true +} + +impl Rule { + pub fn new(id: impl Into, title: impl Into, source: RuleSource) -> Self { + Self { + schema_version: CURRENT_SCHEMA_VERSION, + id: id.into(), + title: title.into(), + source, + enabled: true, + trigger: Trigger::default(), + when: Vec::new(), + then: Vec::new(), + } + } + + pub fn validate_schema(&self) -> crate::error::Result<()> { + if self.schema_version != CURRENT_SCHEMA_VERSION { + return Err(crate::error::RulekitError::SchemaVersionMismatch { + expected: CURRENT_SCHEMA_VERSION, + found: self.schema_version, + }); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProposalStatus { + Proposed, + Approved, + Rejected, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RuleProposal { + pub proposal_id: String, + pub status: ProposalStatus, + pub proposed_at: DateTime, + pub rule: Rule, +} + +impl RuleProposal { + pub fn new(rule: Rule) -> Self { + Self { + proposal_id: Uuid::new_v4().to_string(), + status: ProposalStatus::Proposed, + proposed_at: Utc::now(), + rule, + } + } +} + +/// Opaque audit/receipt payload emitted after evaluation (host interprets bytes/JSON). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AuditReceipt { + pub rule_id: String, + pub matched: bool, + pub dry_run: bool, + pub condition_results: Vec, + pub action_outcomes: Vec, + #[serde(default)] + pub opaque: serde_json::Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConditionOutcome { + pub condition_id: String, + pub plugin: String, + pub passed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ActionOutcome { + pub action_id: String, + pub plugin: String, + pub executed: bool, + pub skipped_dry_run: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, +} diff --git a/crates/dc_rulekit/src/plugin.rs b/crates/dc_rulekit/src/plugin.rs new file mode 100644 index 0000000..c96fc1d --- /dev/null +++ b/crates/dc_rulekit/src/plugin.rs @@ -0,0 +1,68 @@ +use crate::context::EvalContext; +use crate::error::{Result, RulekitError}; +use serde_json::Value; + +/// Evaluates a single condition plugin invocation. +pub trait ConditionEvaluator: Send + Sync { + fn plugin_id(&self) -> &str; + fn evaluate(&self, params: &Value, ctx: &EvalContext) -> Result; +} + +/// Executes a single action plugin invocation. +pub trait ActionHandler: Send + Sync { + fn plugin_id(&self) -> &str; + /// When `true`, dry-run may invoke the handler (no side effects). When `false`, dry-run skips execution and records the planned action. + fn is_pure(&self) -> bool; + fn execute(&self, params: &Value, ctx: &EvalContext) -> Result; +} + +/// Registry of host-supplied condition and action plugins (fail-closed on unknown ids). +#[derive(Default)] +pub struct PluginRegistry { + conditions: std::collections::HashMap>, + actions: std::collections::HashMap>, +} + +impl PluginRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register_condition(&mut self, evaluator: Box) { + let id = evaluator.plugin_id().to_string(); + self.conditions.insert(id, evaluator); + } + + pub fn register_action(&mut self, handler: Box) { + let id = handler.plugin_id().to_string(); + self.actions.insert(id, handler); + } + + pub fn get_condition(&self, plugin_id: &str) -> Result<&dyn ConditionEvaluator> { + self.conditions + .get(plugin_id) + .map(|b| b.as_ref()) + .ok_or_else(|| RulekitError::UnknownConditionPlugin { + plugin_id: plugin_id.to_string(), + }) + } + + pub fn get_action(&self, plugin_id: &str) -> Result<&dyn ActionHandler> { + self.actions + .get(plugin_id) + .map(|b| b.as_ref()) + .ok_or_else(|| RulekitError::UnknownActionPlugin { + plugin_id: plugin_id.to_string(), + }) + } + + pub fn validate_rule_plugins(&self, rule: &crate::model::Rule) -> Result<()> { + for c in &rule.when { + self.get_condition(&c.plugin)?; + } + for a in &rule.then { + self.get_action(&a.plugin)?; + } + Ok(()) + } +} diff --git a/crates/dc_rulekit/src/proposal.rs b/crates/dc_rulekit/src/proposal.rs new file mode 100644 index 0000000..1f42a9c --- /dev/null +++ b/crates/dc_rulekit/src/proposal.rs @@ -0,0 +1,126 @@ +use crate::error::{Result, RulekitError}; +use crate::model::{ProposalStatus, Rule, RuleProposal}; +use crate::store::RuleStore; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Proposal workflow: propose → approve → durable active rule in [`RuleStore`]. +#[derive(Debug, Default)] +pub struct ProposalStore { + proposals: HashMap, + path: Option, +} + +impl ProposalStore { + pub fn in_memory() -> Self { + Self { + proposals: HashMap::new(), + path: None, + } + } + + pub fn with_file_path(path: impl Into) -> Self { + Self { + proposals: HashMap::new(), + path: Some(path.into()), + } + } + + pub fn load_from_disk(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let mut store = Self::with_file_path(path.clone()); + if path.exists() { + let data = fs::read_to_string(&path).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + let proposals: Vec = + serde_json::from_str(&data).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + for p in proposals { + p.rule.validate_schema()?; + store.proposals.insert(p.proposal_id.clone(), p); + } + } + Ok(store) + } + + fn persist(&self) -> Result<()> { + let Some(path) = &self.path else { + return Ok(()); + }; + let proposals: Vec<&RuleProposal> = self.proposals.values().collect(); + let data = serde_json::to_string_pretty(&proposals).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + } + } + fs::write(path, data).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + Ok(()) + } + + pub fn propose(&mut self, rule: Rule) -> Result { + rule.validate_schema()?; + let proposal = RuleProposal::new(rule); + self.proposals + .insert(proposal.proposal_id.clone(), proposal.clone()); + self.persist()?; + Ok(proposal) + } + + pub fn get(&self, proposal_id: &str) -> Result<&RuleProposal> { + self.proposals + .get(proposal_id) + .ok_or_else(|| RulekitError::ProposalNotFound { + proposal_id: proposal_id.to_string(), + }) + } + + pub fn reject(&mut self, proposal_id: &str) -> Result { + let proposal = self.proposals.get_mut(proposal_id).ok_or_else(|| { + RulekitError::ProposalNotFound { + proposal_id: proposal_id.to_string(), + } + })?; + if proposal.status != ProposalStatus::Proposed { + return Err(RulekitError::InvalidProposalState { + message: format!("cannot reject proposal in state {:?}", proposal.status), + }); + } + proposal.status = ProposalStatus::Rejected; + let out = proposal.clone(); + self.persist()?; + Ok(out) + } + + /// Approve a proposal and persist the rule into the active store. + pub fn approve( + &mut self, + proposal_id: &str, + active_store: &mut RuleStore, + ) -> Result { + let proposal = self.proposals.get_mut(proposal_id).ok_or_else(|| { + RulekitError::ProposalNotFound { + proposal_id: proposal_id.to_string(), + } + })?; + if proposal.status != ProposalStatus::Proposed { + return Err(RulekitError::InvalidProposalState { + message: format!("cannot approve proposal in state {:?}", proposal.status), + }); + } + proposal.status = ProposalStatus::Approved; + let rule = proposal.rule.clone(); + active_store.upsert(rule.clone())?; + self.persist()?; + Ok(rule) + } +} diff --git a/crates/dc_rulekit/src/store.rs b/crates/dc_rulekit/src/store.rs new file mode 100644 index 0000000..847e173 --- /dev/null +++ b/crates/dc_rulekit/src/store.rs @@ -0,0 +1,103 @@ +use crate::error::{Result, RulekitError}; +use crate::model::Rule; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// In-memory rule store with optional JSON file persistence. +#[derive(Debug, Default)] +pub struct RuleStore { + rules: HashMap, + path: Option, +} + +impl RuleStore { + pub fn in_memory() -> Self { + Self { + rules: HashMap::new(), + path: None, + } + } + + pub fn with_file_path(path: impl Into) -> Self { + Self { + rules: HashMap::new(), + path: Some(path.into()), + } + } + + pub fn load_from_disk(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let mut store = Self::with_file_path(path.clone()); + if path.exists() { + let data = fs::read_to_string(&path).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + let rules: Vec = serde_json::from_str(&data).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + for rule in rules { + rule.validate_schema()?; + store.rules.insert(rule.id.clone(), rule); + } + } + Ok(store) + } + + pub fn persist(&self) -> Result<()> { + let Some(path) = &self.path else { + return Ok(()); + }; + let rules: Vec<&Rule> = self.rules.values().collect(); + let data = serde_json::to_string_pretty(&rules).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + } + } + fs::write(path, data).map_err(|e| RulekitError::StoreError { + message: e.to_string(), + })?; + Ok(()) + } + + pub fn upsert(&mut self, rule: Rule) -> Result<()> { + rule.validate_schema()?; + self.rules.insert(rule.id.clone(), rule); + self.persist() + } + + pub fn get(&self, rule_id: &str) -> Result<&Rule> { + self.rules + .get(rule_id) + .ok_or_else(|| RulekitError::RuleNotFound { + rule_id: rule_id.to_string(), + }) + } + + pub fn remove(&mut self, rule_id: &str) -> Result { + let rule = self.rules.remove(rule_id).ok_or_else(|| RulekitError::RuleNotFound { + rule_id: rule_id.to_string(), + })?; + self.persist()?; + Ok(rule) + } + + pub fn list(&self) -> Vec<&Rule> { + let mut v: Vec<_> = self.rules.values().collect(); + v.sort_by(|a, b| a.id.cmp(&b.id)); + v + } + + pub fn len(&self) -> usize { + self.rules.len() + } + + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } +} diff --git a/crates/dc_rulekit/tests/engine_tests.rs b/crates/dc_rulekit/tests/engine_tests.rs new file mode 100644 index 0000000..4da5855 --- /dev/null +++ b/crates/dc_rulekit/tests/engine_tests.rs @@ -0,0 +1,264 @@ +use dc_rulekit::{ + Action, ActionHandler, AuditHook, Condition, ConditionEvaluator, Engine, EvaluateOptions, + EvalContext, PluginRegistry, ProposalStore, Rule, RuleSource, RuleStore, RulekitError, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +struct AlwaysCondition; +impl ConditionEvaluator for AlwaysCondition { + fn plugin_id(&self) -> &str { + "demo.when.always" + } + fn evaluate(&self, _params: &Value, _ctx: &EvalContext) -> dc_rulekit::Result { + Ok(true) + } +} + +struct NeverCondition; +impl ConditionEvaluator for NeverCondition { + fn plugin_id(&self) -> &str { + "demo.when.never" + } + fn evaluate(&self, _params: &Value, _ctx: &EvalContext) -> dc_rulekit::Result { + Ok(false) + } +} + +struct LogAction { + log: Arc>>, +} +impl ActionHandler for LogAction { + fn plugin_id(&self) -> &str { + "demo.then.log" + } + fn is_pure(&self) -> bool { + false + } + fn execute(&self, params: &Value, _ctx: &EvalContext) -> dc_rulekit::Result { + let msg = params + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("log"); + self.log.lock().unwrap().push(msg.to_string()); + Ok(json!({ "logged": msg })) + } +} + +struct PureEchoAction; +impl ActionHandler for PureEchoAction { + fn plugin_id(&self) -> &str { + "demo.then.echo" + } + fn is_pure(&self) -> bool { + true + } + fn execute(&self, params: &Value, _ctx: &EvalContext) -> dc_rulekit::Result { + Ok(params.clone()) + } +} + +struct CapturingAudit { + receipts: Arc>>, +} +impl AuditHook for CapturingAudit { + fn on_receipt(&self, receipt: &dc_rulekit::AuditReceipt) { + self.receipts.lock().unwrap().push(receipt.clone()); + } +} + +fn registry_with_demo() -> (PluginRegistry, Arc>>) { + let log = Arc::new(Mutex::new(Vec::new())); + let mut reg = PluginRegistry::new(); + reg.register_condition(Box::new(AlwaysCondition)); + reg.register_condition(Box::new(NeverCondition)); + reg.register_action(Box::new(LogAction { + log: Arc::clone(&log), + })); + reg.register_action(Box::new(PureEchoAction)); + (reg, log) +} + +#[test] +fn evaluate_runs_actions_when_conditions_pass() { + let (reg, log) = registry_with_demo(); + let engine = Engine::new(®); + let mut rule = Rule::new("demo.app/rule-1", "Test", RuleSource::Static); + rule.when.push(Condition { + id: "c1".into(), + plugin: "demo.when.always".into(), + params: json!({}), + }); + rule.then.push(Action { + id: "a1".into(), + plugin: "demo.then.log".into(), + params: json!({ "message": "hello" }), + }); + + let receipt = engine + .evaluate(&rule, &EvalContext::new("demo.app"), EvaluateOptions::default()) + .unwrap(); + assert!(receipt.matched); + assert_eq!(log.lock().unwrap().len(), 1); +} + +#[test] +fn dry_run_skips_impure_actions() { + let (reg, log) = registry_with_demo(); + let engine = Engine::new(®); + let mut rule = Rule::new("demo.app/rule-2", "Dry", RuleSource::Static); + rule.when.push(Condition { + id: "c1".into(), + plugin: "demo.when.always".into(), + params: json!({}), + }); + rule.then.push(Action { + id: "a1".into(), + plugin: "demo.then.log".into(), + params: json!({}), + }); + + let receipt = engine + .evaluate( + &rule, + &EvalContext::new("demo.app"), + EvaluateOptions { dry_run: true }, + ) + .unwrap(); + assert!(receipt.matched); + assert!(receipt.action_outcomes[0].skipped_dry_run); + assert!(!receipt.action_outcomes[0].executed); + assert!(log.lock().unwrap().is_empty()); +} + +#[test] +fn dry_run_runs_pure_actions() { + let (reg, _log) = registry_with_demo(); + let engine = Engine::new(®); + let mut rule = Rule::new("demo.app/rule-3", "Pure dry", RuleSource::Static); + rule.when.push(Condition { + id: "c1".into(), + plugin: "demo.when.always".into(), + params: json!({}), + }); + rule.then.push(Action { + id: "a1".into(), + plugin: "demo.then.echo".into(), + params: json!({ "x": 1 }), + }); + + let receipt = engine + .evaluate( + &rule, + &EvalContext::new("demo.app"), + EvaluateOptions { dry_run: true }, + ) + .unwrap(); + assert!(receipt.action_outcomes[0].executed); +} + +#[test] +fn unknown_plugin_fail_closed() { + let (reg, _) = registry_with_demo(); + let engine = Engine::new(®); + let mut rule = Rule::new("demo.app/rule-4", "Bad", RuleSource::Static); + rule.when.push(Condition { + id: "c1".into(), + plugin: "unknown.plugin".into(), + params: json!({}), + }); + + let err = engine + .evaluate(&rule, &EvalContext::new("demo.app"), EvaluateOptions::default()) + .unwrap_err(); + assert!(matches!( + err, + RulekitError::UnknownConditionPlugin { .. } + )); +} + +#[test] +fn proposal_approve_and_store_roundtrip() { + let mut proposals = ProposalStore::in_memory(); + let mut active = RuleStore::in_memory(); + let mut rule = Rule::new("demo.app/rule-prop", "Prop", RuleSource::Static); + rule.when.push(Condition { + id: "c1".into(), + plugin: "demo.when.always".into(), + params: json!({}), + }); + + let proposal = proposals.propose(rule).unwrap(); + assert_eq!(proposal.status, dc_rulekit::ProposalStatus::Proposed); + + let active_rule = proposals.approve(&proposal.proposal_id, &mut active).unwrap(); + assert_eq!(active_rule.id, "demo.app/rule-prop"); + assert!(active.get("demo.app/rule-prop").is_ok()); +} + +#[test] +fn store_file_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rules.json"); + let mut rule = Rule::new("demo.app/file", "File", RuleSource::Static); + rule.then.push(Action { + id: "a1".into(), + plugin: "demo.then.log".into(), + params: json!({}), + }); + + { + let mut store = RuleStore::with_file_path(&path); + store.upsert(rule.clone()).unwrap(); + } + let store = RuleStore::load_from_disk(&path).unwrap(); + assert_eq!(store.get("demo.app/file").unwrap().title, "File"); +} + +#[test] +fn audit_hook_receives_opaque_payload() { + let (reg, _) = registry_with_demo(); + let engine = Engine::new(®); + let mut rule = Rule::new("demo.app/audit", "Audit", RuleSource::Static); + rule.when.push(Condition { + id: "c1".into(), + plugin: "demo.when.always".into(), + params: json!({}), + }); + rule.then.push(Action { + id: "a1".into(), + plugin: "demo.then.echo".into(), + params: json!({}), + }); + + let audit = CapturingAudit { + receipts: Arc::new(Mutex::new(Vec::new())), + }; + let opaque = json!({ "host": "payload", "trace": "abc" }); + engine + .evaluate_with_audit( + &rule, + &EvalContext::new("demo.app"), + EvaluateOptions::default(), + &audit, + opaque.clone(), + ) + .unwrap(); + + let captured = audit.receipts.lock().unwrap(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].opaque, opaque); +} + +#[test] +fn disabled_rule_errors() { + let (reg, _) = registry_with_demo(); + let engine = Engine::new(®); + let mut rule = Rule::new("demo.app/dis", "Dis", RuleSource::Static); + rule.enabled = false; + + let err = engine + .evaluate(&rule, &EvalContext::new("demo.app"), EvaluateOptions::default()) + .unwrap_err(); + assert!(matches!(err, RulekitError::DisabledRule { .. })); +} diff --git a/crates/dc_rulekit_demo_plugins/Cargo.toml b/crates/dc_rulekit_demo_plugins/Cargo.toml new file mode 100644 index 0000000..908a723 --- /dev/null +++ b/crates/dc_rulekit_demo_plugins/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "dc_rulekit_demo_plugins" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Toy condition/action plugins for dc_rulekit examples" +publish = false + +[dependencies] +dc_rulekit = { path = "../dc_rulekit" } +serde_json = { workspace = true } diff --git a/crates/dc_rulekit_demo_plugins/src/lib.rs b/crates/dc_rulekit_demo_plugins/src/lib.rs new file mode 100644 index 0000000..cb33619 --- /dev/null +++ b/crates/dc_rulekit_demo_plugins/src/lib.rs @@ -0,0 +1,196 @@ +//! Toy plugins for dc_rulekit quickstarts (not for production domain logic). + +use dc_rulekit::{ActionHandler, ConditionEvaluator, EvalContext, Result}; +use serde_json::{json, Value}; + +pub const PLUGIN_ALWAYS: &str = "demo.when.always"; +pub const PLUGIN_THRESHOLD: &str = "demo.when.threshold"; +pub const PLUGIN_COUNTER_GTE: &str = "demo.when.counter_gte"; +pub const PLUGIN_LOG: &str = "demo.then.log"; +pub const PLUGIN_INCREMENT: &str = "demo.then.increment_counter"; + +/// Always passes. +pub struct WhenAlways; + +impl ConditionEvaluator for WhenAlways { + fn plugin_id(&self) -> &str { + PLUGIN_ALWAYS + } + fn evaluate(&self, _params: &Value, _ctx: &EvalContext) -> Result { + Ok(true) + } +} + +/// Passes when `ctx.facts[key] >= params.min` (numeric). +pub struct WhenThreshold; + +impl ConditionEvaluator for WhenThreshold { + fn plugin_id(&self) -> &str { + PLUGIN_THRESHOLD + } + fn evaluate(&self, params: &Value, ctx: &EvalContext) -> Result { + let key = params + .get("key") + .and_then(|v| v.as_str()) + .unwrap_or("value"); + let min = params.get("min").and_then(|v| v.as_f64()).unwrap_or(0.0); + let actual = ctx + .facts + .get(key) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + Ok(actual >= min) + } +} + +/// Passes when counter fact >= params.threshold (alias for threshold with `counter` key default). +pub struct WhenCounterGte; + +impl ConditionEvaluator for WhenCounterGte { + fn plugin_id(&self) -> &str { + PLUGIN_COUNTER_GTE + } + fn evaluate(&self, params: &Value, ctx: &EvalContext) -> Result { + let key = params + .get("key") + .and_then(|v| v.as_str()) + .unwrap_or("counter"); + let threshold = params + .get("threshold") + .and_then(|v| v.as_f64()) + .unwrap_or(1.0); + let actual = ctx + .facts + .get(key) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + Ok(actual >= threshold) + } +} + +/// Logs `message` to stderr (side effect); returns `{ "logged": message }`. +pub struct ThenLog; + +impl ActionHandler for ThenLog { + fn plugin_id(&self) -> &str { + PLUGIN_LOG + } + fn is_pure(&self) -> bool { + false + } + fn execute(&self, params: &Value, _ctx: &EvalContext) -> Result { + let message = params + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("dc_rulekit demo log"); + eprintln!("[dc_rulekit-demo] {}", message); + Ok(json!({ "logged": message })) + } +} + +/// Pure planner: returns increment instruction (host may apply); does not mutate ctx. +pub struct ThenIncrementCounter; + +impl ActionHandler for ThenIncrementCounter { + fn plugin_id(&self) -> &str { + PLUGIN_INCREMENT + } + fn is_pure(&self) -> bool { + true + } + fn execute(&self, params: &Value, _ctx: &EvalContext) -> Result { + let key = params + .get("key") + .and_then(|v| v.as_str()) + .unwrap_or("counter"); + let by = params.get("by").and_then(|v| v.as_f64()).unwrap_or(1.0); + Ok(json!({ "increment": { "key": key, "by": by } })) + } +} + +/// Register all demo plugins on a registry. +pub fn register_all(registry: &mut dc_rulekit::PluginRegistry) { + registry.register_condition(Box::new(WhenAlways)); + registry.register_condition(Box::new(WhenThreshold)); + registry.register_condition(Box::new(WhenCounterGte)); + registry.register_action(Box::new(ThenLog)); + registry.register_action(Box::new(ThenIncrementCounter)); +} + +/// End-to-end quickstart: propose static rule → approve → evaluate → audit. +pub fn run_quickstart_example() -> Result { + use dc_rulekit::{ + Action, AuditHook, Condition, Engine, EvaluateOptions, ProposalStore, Rule, RuleSource, + RuleStore, + }; + use std::sync::{Arc, Mutex}; + + struct CaptureHook(Arc>>); + impl AuditHook for CaptureHook { + fn on_receipt(&self, receipt: &dc_rulekit::AuditReceipt) { + self.0.lock().unwrap().push(receipt.clone()); + } + } + + let mut registry = dc_rulekit::PluginRegistry::new(); + register_all(&mut registry); + let engine = Engine::new(®istry); + + let mut rule = Rule::new("demo.app/quickstart", "Quickstart", RuleSource::Static); + rule.when.push(Condition { + id: "always".into(), + plugin: PLUGIN_ALWAYS.into(), + params: json!({}), + }); + rule.then.push(Action { + id: "log".into(), + plugin: PLUGIN_LOG.into(), + params: json!({ "message": "approved rule fired" }), + }); + + let mut proposals = ProposalStore::in_memory(); + let mut active = RuleStore::in_memory(); + let proposal = proposals.propose(rule)?; + let active_rule = proposals.approve(&proposal.proposal_id, &mut active)?; + + let hook_storage = Arc::new(Mutex::new(Vec::new())); + let hook = CaptureHook(Arc::clone(&hook_storage)); + let opaque = json!({ "example": "quickstart", "version": 1 }); + + let receipt = engine.evaluate_with_audit( + &active_rule, + &EvalContext::new("demo.app"), + EvaluateOptions::default(), + &hook, + opaque, + )?; + + let hooks = hook_storage.lock().unwrap(); + Ok(json!({ + "proposal_id": proposal.proposal_id, + "rule_id": active_rule.id, + "matched": receipt.matched, + "audit_count": hooks.len(), + "opaque": hooks.first().map(|r| r.opaque.clone()), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quickstart_runs() { + let out = run_quickstart_example().unwrap(); + assert_eq!(out.get("matched").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(out.get("audit_count").and_then(|v| v.as_u64()), Some(1)); + } + + #[test] + fn threshold_condition() { + let eval = WhenThreshold; + let ctx = EvalContext::new("demo.app").with_fact("value", json!(5)); + assert!(eval.evaluate(&json!({ "key": "value", "min": 3 }), &ctx).unwrap()); + assert!(!eval.evaluate(&json!({ "key": "value", "min": 10 }), &ctx).unwrap()); + } +} diff --git a/docs/ADR/0001-agnostic-core-vs-host-plugins.md b/docs/ADR/0001-agnostic-core-vs-host-plugins.md new file mode 100644 index 0000000..9cf29a5 --- /dev/null +++ b/docs/ADR/0001-agnostic-core-vs-host-plugins.md @@ -0,0 +1,56 @@ +# ADR 0001: Agnostic core vs host plugins + +## Status + +Accepted (v0.1.0) + +## Context + +DevelopersCoffee needs an on-device rules framework usable across multiple apps (Flutter hosts, future native tools). Product-specific semantics (storage cleanup, billing, etc.) must not leak into the shared engine. Crates.io names `rulekit` and `rule_kit` are taken; the published Rust crate is **`dc_rulekit`**, aligned with the Dart package **`dc_rulekit`**. The GitHub repository remains **`DevelopersCoffee/rulekit`**. + +## Decision + +### Agnostic core + +The core exposes only: + +- **Rule document model**: `Rule`, `Condition`, `Action`, `Trigger` (`manual` | `schedule` | `event`), `RuleSource` (`static` | `llm`), `schema_version`. +- **Lifecycle**: `ProposalStore` (`propose` → `approve` / `reject`) → durable **active** rules in `RuleStore`. +- **Evaluation**: `Engine` evaluates `when` conditions in order (all must pass; fail-fast), then runs `then` actions in order. +- **Extensions**: `PluginRegistry` maps string plugin ids to host-supplied `ConditionEvaluator` / `ActionHandler`. +- **Audit**: `AuditHook` receives an `AuditReceipt` with structured outcomes plus an **opaque** JSON map the host interprets. +- **Dry-run**: Impure actions (`is_pure == false`) are not executed; outcomes record `skipped_dry_run`. Pure actions may run under dry-run. + +The engine never performs domain effects; it only calls registered plugins. + +### Fail-closed sandbox + +If a rule references an unknown condition or action plugin id, evaluation fails before side effects (`UnknownConditionPlugin` / `UnknownActionPlugin`). Disabled rules reject evaluation (`DisabledRule`). Schema version mismatches reject load/evaluate (`SchemaVersionMismatch`). + +### Multi-app namespacing + +Rule ids and plugin ids are opaque strings. Conventions (not enforced by the engine): `app.namespace/rule-id` and `app.when.*` / `app.then.*`. Toy plugins use the `demo.*` prefix in `dc_rulekit_demo_plugins`. + +### Rust + Dart split (v0.1) + +| Layer | Choice | +|-------|--------| +| Canonical on-device core | **Rust** crate `dc_rulekit` (publishable to crates.io) | +| Flutter / Dart hosts | **Pure Dart** package `dc_rulekit` mirroring schema and semantics | +| FFI | **Deferred** — `flutter_rust_bridge` / `uniffi` adds build complexity for v0.1; ADR revisits when a single binary core is required on mobile | + +Both implementations share the same JSON rule shape and error semantics so hosts can migrate to FFI later without changing rule documents. + +### Schema versioning + +`schema_version` on each rule must match `CURRENT_SCHEMA_VERSION` (Rust) / `currentSchemaVersion` (Dart). Bump the constant and migration story when breaking JSON fields. + +### Scheduling + +`Trigger.schedule` stores a host-defined expression string. **No cron runner** in v0.1; the host scheduler invokes evaluation. + +## Consequences + +- Cruftkit and other products are **consumers** only; no Cruftkit adapter in the core repo. +- Demo plugins live in `crates/dc_rulekit_demo_plugins` and Dart tests inline toy plugins — not shipped as production domain logic. +- Documentation and README refer to the published name **`dc_rulekit`** while linking to the **`rulekit`** GitHub repo. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..463971b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,9 @@ +# Examples + +Toy plugins live in `crates/dc_rulekit_demo_plugins`. Run the end-to-end quickstart: + +```bash +cargo test -p dc_rulekit_demo_plugins quickstart_runs -- --nocapture +``` + +For Flutter/Dart, see `packages/dc_rulekit/test/engine_test.dart` for inline demo plugins. diff --git a/packages/dc_rulekit/analysis_options.yaml b/packages/dc_rulekit/analysis_options.yaml new file mode 100644 index 0000000..1eb34bb --- /dev/null +++ b/packages/dc_rulekit/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:lints/recommended.yaml + +linter: + rules: + prefer_single_quotes: true diff --git a/packages/dc_rulekit/lib/dc_rulekit.dart b/packages/dc_rulekit/lib/dc_rulekit.dart new file mode 100644 index 0000000..c5de9bd --- /dev/null +++ b/packages/dc_rulekit/lib/dc_rulekit.dart @@ -0,0 +1,8 @@ +export 'src/audit.dart'; +export 'src/context.dart'; +export 'src/engine.dart'; +export 'src/error.dart'; +export 'src/model.dart'; +export 'src/plugin.dart'; +export 'src/proposal.dart'; +export 'src/store.dart'; diff --git a/packages/dc_rulekit/lib/src/audit.dart b/packages/dc_rulekit/lib/src/audit.dart new file mode 100644 index 0000000..3175e9c --- /dev/null +++ b/packages/dc_rulekit/lib/src/audit.dart @@ -0,0 +1,10 @@ +import 'model.dart'; + +abstract class AuditHook { + void onReceipt(AuditReceipt receipt); +} + +class NoopAuditHook implements AuditHook { + @override + void onReceipt(AuditReceipt receipt) {} +} diff --git a/packages/dc_rulekit/lib/src/context.dart b/packages/dc_rulekit/lib/src/context.dart new file mode 100644 index 0000000..041d1cd --- /dev/null +++ b/packages/dc_rulekit/lib/src/context.dart @@ -0,0 +1,21 @@ +class EvalContext { + EvalContext({ + this.appNamespace = '', + Map? facts, + this.event, + }) : facts = facts ?? {}; + + final String appNamespace; + final Map facts; + final Map? event; + + EvalContext withFact(String key, dynamic value) { + final next = Map.from(facts); + next[key] = value; + return EvalContext( + appNamespace: appNamespace, + facts: next, + event: event, + ); + } +} diff --git a/packages/dc_rulekit/lib/src/engine.dart b/packages/dc_rulekit/lib/src/engine.dart new file mode 100644 index 0000000..6464525 --- /dev/null +++ b/packages/dc_rulekit/lib/src/engine.dart @@ -0,0 +1,90 @@ +import 'audit.dart'; +import 'context.dart'; +import 'error.dart'; +import 'model.dart'; +import 'plugin.dart'; + +class EvaluateOptions { + const EvaluateOptions({this.dryRun = false}); + final bool dryRun; +} + +class Engine { + Engine(this.registry); + final PluginRegistry registry; + + AuditReceipt evaluate(Rule rule, EvalContext ctx, [EvaluateOptions options = const EvaluateOptions()]) { + rule.validateSchema(); + if (!rule.enabled) throw DisabledRule(rule.id); + registry.validateRulePlugins(rule); + + final conditionResults = []; + var allPassed = true; + + for (final condition in rule.when) { + final evaluator = registry.getCondition(condition.plugin); + final passed = evaluator.evaluate(condition.params, ctx); + conditionResults.add(ConditionOutcome( + conditionId: condition.id, + plugin: condition.plugin, + passed: passed, + )); + if (!passed) { + allPassed = false; + break; + } + } + + final actionOutcomes = []; + if (allPassed) { + for (final action in rule.then) { + final handler = registry.getAction(action.plugin); + if (options.dryRun && !handler.isPure) { + actionOutcomes.add(ActionOutcome( + actionId: action.id, + plugin: action.plugin, + executed: false, + skippedDryRun: true, + )); + continue; + } + final result = handler.execute(action.params, ctx); + actionOutcomes.add(ActionOutcome( + actionId: action.id, + plugin: action.plugin, + executed: true, + skippedDryRun: false, + result: result, + )); + } + } + + return AuditReceipt( + ruleId: rule.id, + matched: allPassed, + dryRun: options.dryRun, + conditionResults: conditionResults, + actionOutcomes: actionOutcomes, + ); + } + + AuditReceipt evaluateWithAudit( + Rule rule, + EvalContext ctx, + EvaluateOptions options, + AuditHook hook, + Map opaque, + ) { + final receipt = evaluate(rule, ctx, options); + final withOpaque = AuditReceipt( + ruleId: receipt.ruleId, + matched: receipt.matched, + dryRun: receipt.dryRun, + conditionResults: receipt.conditionResults, + actionOutcomes: receipt.actionOutcomes, + opaque: opaque, + ); + hook.onReceipt(withOpaque); + return withOpaque; + } +} diff --git a/packages/dc_rulekit/lib/src/error.dart b/packages/dc_rulekit/lib/src/error.dart new file mode 100644 index 0000000..d4dfb25 --- /dev/null +++ b/packages/dc_rulekit/lib/src/error.dart @@ -0,0 +1,76 @@ +sealed class RulekitException implements Exception { + const RulekitException(this.message); + final String message; + @override + String toString() => message; +} + +class UnknownConditionPlugin extends RulekitException { + UnknownConditionPlugin(this.pluginId) : super('unknown condition plugin: $pluginId'); + final String pluginId; +} + +class UnknownActionPlugin extends RulekitException { + UnknownActionPlugin(this.pluginId) : super('unknown action plugin: $pluginId'); + final String pluginId; +} + +class DisabledRule extends RulekitException { + DisabledRule(this.ruleId) : super('rule is disabled: $ruleId'); + final String ruleId; +} + +class ConditionFailed extends RulekitException { + ConditionFailed({ + required this.ruleId, + required this.conditionId, + required this.pluginId, + }) : super( + 'condition failed: rule=$ruleId, condition=$conditionId, plugin=$pluginId', + ); + final String ruleId; + final String conditionId; + final String pluginId; +} + +class ActionDenied extends RulekitException { + ActionDenied({ + required this.ruleId, + required this.actionId, + required this.pluginId, + required this.reason, + }) : super( + 'action denied: rule=$ruleId, action=$actionId, plugin=$pluginId, reason=$reason', + ); + final String ruleId; + final String actionId; + final String pluginId; + final String reason; +} + +class SchemaVersionMismatch extends RulekitException { + SchemaVersionMismatch({required this.expected, required this.found}) + : super('schema version mismatch: expected=$expected, found=$found'); + final int expected; + final int found; +} + +class RuleNotFound extends RulekitException { + RuleNotFound(this.ruleId) : super('rule not found: $ruleId'); + final String ruleId; +} + +class ProposalNotFound extends RulekitException { + ProposalNotFound(this.proposalId) : super('proposal not found: $proposalId'); + final String proposalId; +} + +class InvalidProposalState extends RulekitException { + InvalidProposalState(this.detail) : super('invalid proposal state: $detail'); + final String detail; +} + +class StoreError extends RulekitException { + StoreError(this.detail) : super('store error: $detail'); + final String detail; +} diff --git a/packages/dc_rulekit/lib/src/model.dart b/packages/dc_rulekit/lib/src/model.dart new file mode 100644 index 0000000..e07b1a4 --- /dev/null +++ b/packages/dc_rulekit/lib/src/model.dart @@ -0,0 +1,232 @@ +import 'error.dart'; + +const currentSchemaVersion = 1; + +enum RuleSource { static, llm } + +enum TriggerType { manual, event, schedule } + +class Trigger { + const Trigger.manual() : type = TriggerType.manual, topic = null, expression = null; + const Trigger.event({this.topic = ''}) + : type = TriggerType.event, + expression = null; + const Trigger.schedule({required this.expression}) + : type = TriggerType.schedule, + topic = null; + + final TriggerType type; + final String? topic; + final String? expression; + + Map toJson() => switch (type) { + TriggerType.manual => {'type': 'manual'}, + TriggerType.event => {'type': 'event', 'topic': topic ?? ''}, + TriggerType.schedule => {'type': 'schedule', 'expression': expression}, + }; + + factory Trigger.fromJson(Map json) { + final t = json['type'] as String? ?? 'manual'; + return switch (t) { + 'event' => Trigger.event(topic: json['topic'] as String? ?? ''), + 'schedule' => Trigger.schedule(expression: json['expression'] as String? ?? ''), + _ => const Trigger.manual(), + }; + } +} + +class Condition { + Condition({required this.id, required this.plugin, this.params = const {}}); + final String id; + final String plugin; + final Map params; + + Map toJson() => { + 'id': id, + 'plugin': plugin, + 'params': params, + }; + + factory Condition.fromJson(Map json) => Condition( + id: json['id'] as String, + plugin: json['plugin'] as String, + params: Map.from(json['params'] as Map? ?? {}), + ); +} + +class Action { + Action({required this.id, required this.plugin, this.params = const {}}); + final String id; + final String plugin; + final Map params; + + Map toJson() => { + 'id': id, + 'plugin': plugin, + 'params': params, + }; + + factory Action.fromJson(Map json) => Action( + id: json['id'] as String, + plugin: json['plugin'] as String, + params: Map.from(json['params'] as Map? ?? {}), + ); +} + +class Rule { + Rule({ + required this.id, + required this.title, + required this.source, + this.schemaVersion = currentSchemaVersion, + this.enabled = true, + this.trigger = const Trigger.manual(), + this.when = const [], + this.then = const [], + }); + + final int schemaVersion; + final String id; + final String title; + final RuleSource source; + final bool enabled; + final Trigger trigger; + final List when; + final List then; + + void validateSchema() { + if (schemaVersion != currentSchemaVersion) { + throw SchemaVersionMismatch( + expected: currentSchemaVersion, + found: schemaVersion, + ); + } + } + + Map toJson() => { + 'schema_version': schemaVersion, + 'id': id, + 'title': title, + 'source': source.name, + 'enabled': enabled, + 'trigger': trigger.toJson(), + 'when': when.map((c) => c.toJson()).toList(), + 'then': then.map((a) => a.toJson()).toList(), + }; + + factory Rule.fromJson(Map json) => Rule( + schemaVersion: json['schema_version'] as int? ?? currentSchemaVersion, + id: json['id'] as String, + title: json['title'] as String, + source: RuleSource.values.byName(json['source'] as String? ?? 'static'), + enabled: json['enabled'] as bool? ?? true, + trigger: Trigger.fromJson( + Map.from(json['trigger'] as Map? ?? {'type': 'manual'}), + ), + when: (json['when'] as List? ?? []) + .map((e) => Condition.fromJson(Map.from(e as Map))) + .toList(), + then: (json['then'] as List? ?? []) + .map((e) => Action.fromJson(Map.from(e as Map))) + .toList(), + ); +} + +enum ProposalStatus { proposed, approved, rejected } + +class RuleProposal { + RuleProposal({ + required this.proposalId, + required this.status, + required this.proposedAt, + required this.rule, + }); + + final String proposalId; + final ProposalStatus status; + final DateTime proposedAt; + final Rule rule; + + Map toJson() => { + 'proposal_id': proposalId, + 'status': status.name, + 'proposed_at': proposedAt.toUtc().toIso8601String(), + 'rule': rule.toJson(), + }; + + factory RuleProposal.fromJson(Map json) => RuleProposal( + proposalId: json['proposal_id'] as String, + status: ProposalStatus.values.byName(json['status'] as String), + proposedAt: DateTime.parse(json['proposed_at'] as String), + rule: Rule.fromJson(Map.from(json['rule'] as Map)), + ); +} + +class ConditionOutcome { + ConditionOutcome({ + required this.conditionId, + required this.plugin, + required this.passed, + this.detail, + }); + final String conditionId; + final String plugin; + final bool passed; + final Map? detail; + + Map toJson() => { + 'condition_id': conditionId, + 'plugin': plugin, + 'passed': passed, + if (detail != null) 'detail': detail, + }; +} + +class ActionOutcome { + ActionOutcome({ + required this.actionId, + required this.plugin, + required this.executed, + required this.skippedDryRun, + this.result, + }); + final String actionId; + final String plugin; + final bool executed; + final bool skippedDryRun; + final Map? result; + + Map toJson() => { + 'action_id': actionId, + 'plugin': plugin, + 'executed': executed, + 'skipped_dry_run': skippedDryRun, + if (result != null) 'result': result, + }; +} + +class AuditReceipt { + AuditReceipt({ + required this.ruleId, + required this.matched, + required this.dryRun, + required this.conditionResults, + required this.actionOutcomes, + this.opaque = const {}, + }); + final String ruleId; + final bool matched; + final bool dryRun; + final List conditionResults; + final List actionOutcomes; + final Map opaque; + + Map toJson() => { + 'rule_id': ruleId, + 'matched': matched, + 'dry_run': dryRun, + 'condition_results': conditionResults.map((c) => c.toJson()).toList(), + 'action_outcomes': actionOutcomes.map((a) => a.toJson()).toList(), + 'opaque': opaque, + }; +} diff --git a/packages/dc_rulekit/lib/src/plugin.dart b/packages/dc_rulekit/lib/src/plugin.dart new file mode 100644 index 0000000..3c35923 --- /dev/null +++ b/packages/dc_rulekit/lib/src/plugin.dart @@ -0,0 +1,48 @@ +import 'error.dart'; +import 'model.dart'; +import 'context.dart'; + +abstract class ConditionEvaluator { + String get pluginId; + bool evaluate(Map params, EvalContext ctx); +} + +abstract class ActionHandler { + String get pluginId; + bool get isPure; + Map execute(Map params, EvalContext ctx); +} + +class PluginRegistry { + final _conditions = {}; + final _actions = {}; + + void registerCondition(ConditionEvaluator evaluator) { + _conditions[evaluator.pluginId] = evaluator; + } + + void registerAction(ActionHandler handler) { + _actions[handler.pluginId] = handler; + } + + ConditionEvaluator getCondition(String pluginId) { + final e = _conditions[pluginId]; + if (e == null) throw UnknownConditionPlugin(pluginId); + return e; + } + + ActionHandler getAction(String pluginId) { + final h = _actions[pluginId]; + if (h == null) throw UnknownActionPlugin(pluginId); + return h; + } + + void validateRulePlugins(Rule rule) { + for (final c in rule.when) { + getCondition(c.plugin); + } + for (final a in rule.then) { + getAction(a.plugin); + } + } +} diff --git a/packages/dc_rulekit/lib/src/proposal.dart b/packages/dc_rulekit/lib/src/proposal.dart new file mode 100644 index 0000000..6691fd2 --- /dev/null +++ b/packages/dc_rulekit/lib/src/proposal.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'error.dart'; +import 'model.dart'; +import 'store.dart'; + +class ProposalStore { + ProposalStore({this.filePath}); + + final String? filePath; + final _proposals = {}; + + RuleProposal propose(Rule rule) { + rule.validateSchema(); + final proposal = RuleProposal( + proposalId: _uuid(), + status: ProposalStatus.proposed, + proposedAt: DateTime.now().toUtc(), + rule: rule, + ); + _proposals[proposal.proposalId] = proposal; + _persist(); + return proposal; + } + + RuleProposal get(String proposalId) { + final p = _proposals[proposalId]; + if (p == null) throw ProposalNotFound(proposalId); + return p; + } + + RuleProposal reject(String proposalId) { + final proposal = get(proposalId); + if (proposal.status != ProposalStatus.proposed) { + throw InvalidProposalState('cannot reject proposal in state ${proposal.status.name}'); + } + final updated = RuleProposal( + proposalId: proposal.proposalId, + status: ProposalStatus.rejected, + proposedAt: proposal.proposedAt, + rule: proposal.rule, + ); + _proposals[proposalId] = updated; + _persist(); + return updated; + } + + Rule approve(String proposalId, RuleStore activeStore) { + final proposal = get(proposalId); + if (proposal.status != ProposalStatus.proposed) { + throw InvalidProposalState('cannot approve proposal in state ${proposal.status.name}'); + } + final updated = RuleProposal( + proposalId: proposal.proposalId, + status: ProposalStatus.approved, + proposedAt: proposal.proposedAt, + rule: proposal.rule, + ); + _proposals[proposalId] = updated; + activeStore.upsert(proposal.rule); + _persist(); + return proposal.rule; + } + + void _persist() { + final path = filePath; + if (path == null) return; + final file = File(path); + file.parent.createSync(recursive: true); + final data = _proposals.values.map((p) => p.toJson()).toList(); + file.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data)); + } + + String _uuid() { + // Lightweight v4-like id without extra deps. + final now = DateTime.now().microsecondsSinceEpoch; + return 'prop-$now-${now.hashCode.abs()}'; + } +} diff --git a/packages/dc_rulekit/lib/src/store.dart b/packages/dc_rulekit/lib/src/store.dart new file mode 100644 index 0000000..fd13cc6 --- /dev/null +++ b/packages/dc_rulekit/lib/src/store.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'error.dart'; +import 'model.dart'; + +class RuleStore { + RuleStore({this.filePath}); + + final String? filePath; + final _rules = {}; + + static RuleStore loadFromDisk(String path) { + final store = RuleStore(filePath: path); + final file = File(path); + if (file.existsSync()) { + final list = jsonDecode(file.readAsStringSync()) as List; + for (final item in list) { + final rule = Rule.fromJson(Map.from(item as Map)); + rule.validateSchema(); + store._rules[rule.id] = rule; + } + } + return store; + } + + void persist() { + final path = filePath; + if (path == null) return; + final file = File(path); + file.parent.createSync(recursive: true); + final data = _rules.values.map((r) => r.toJson()).toList(); + file.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data)); + } + + void upsert(Rule rule) { + rule.validateSchema(); + _rules[rule.id] = rule; + persist(); + } + + Rule get(String ruleId) { + final rule = _rules[ruleId]; + if (rule == null) throw RuleNotFound(ruleId); + return rule; + } + + Rule remove(String ruleId) { + final rule = _rules.remove(ruleId); + if (rule == null) throw RuleNotFound(ruleId); + persist(); + return rule; + } + + List list() { + final rules = _rules.values.toList()..sort((a, b) => a.id.compareTo(b.id)); + return rules; + } + + int get length => _rules.length; +} diff --git a/packages/dc_rulekit/pubspec.lock b/packages/dc_rulekit/pubspec.lock new file mode 100644 index 0000000..cc02a68 --- /dev/null +++ b/packages/dc_rulekit/pubspec.lock @@ -0,0 +1,389 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: fdcd9f70f9eb80df3bc5ed0fa67280df2595fd481948df8a9ab082e6a40ad04b + url: "https://pub.dev" + source: hosted + version: "108.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: a51c769bff3b6dfbe9d60199b8606d808290702a296bef0c26a4ca391d414e46 + url: "https://pub.dev" + source: hosted + version: "14.4.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "404dbf9bce29beafbe9dcb19219447b6f7d49900cd42b8d3b7af102b24508e25" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: de5d145b0afff7921e5e788a880f52d7e5f3ae24068a202f6fd3b58e4ba26323 + url: "https://pub.dev" + source: hosted + version: "1.32.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "0a10344e901e5b2e63819567951cb6a06673ed6b84f40462188ff5a0c41f371f" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + test_core: + dependency: transitive + description: + name: test_core + sha256: "80f3fb49087454e07e7e07c67578cfdd156c8c3a5227d8b3f47c7b2d019c2e93" + url: "https://pub.dev" + source: hosted + version: "0.6.20" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/packages/dc_rulekit/pubspec.yaml b/packages/dc_rulekit/pubspec.yaml new file mode 100644 index 0000000..1a77787 --- /dev/null +++ b/packages/dc_rulekit/pubspec.yaml @@ -0,0 +1,11 @@ +name: dc_rulekit +description: Business-agnostic on-device rules engine (Dart mirror of the Rust dc_rulekit core). +version: 0.1.0 +repository: https://github.com/DevelopersCoffee/rulekit + +environment: + sdk: ^3.5.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.0 diff --git a/packages/dc_rulekit/test/engine_test.dart b/packages/dc_rulekit/test/engine_test.dart new file mode 100644 index 0000000..d78c804 --- /dev/null +++ b/packages/dc_rulekit/test/engine_test.dart @@ -0,0 +1,143 @@ +import 'package:dc_rulekit/dc_rulekit.dart'; +import 'package:test/test.dart'; + +class AlwaysCondition implements ConditionEvaluator { + @override + String get pluginId => 'demo.when.always'; + @override + bool evaluate(Map params, EvalContext ctx) => true; +} + +class LogAction implements ActionHandler { + LogAction(this.log); + final List log; + @override + String get pluginId => 'demo.then.log'; + @override + bool get isPure => false; + @override + Map execute(Map params, EvalContext ctx) { + log.add(params['message'] as String? ?? 'log'); + return {'logged': params['message']}; + } +} + +class PureEcho implements ActionHandler { + @override + String get pluginId => 'demo.then.echo'; + @override + bool get isPure => true; + @override + Map execute(Map params, EvalContext ctx) => params; +} + +void main() { + PluginRegistry registry() { + final reg = PluginRegistry(); + reg.registerCondition(AlwaysCondition()); + reg.registerAction(LogAction([])); + reg.registerAction(PureEcho()); + return reg; + } + + test('evaluate runs actions when conditions pass', () { + final log = []; + final reg = PluginRegistry(); + reg.registerCondition(AlwaysCondition()); + reg.registerAction(LogAction(log)); + final engine = Engine(reg); + final rule = Rule( + id: 'demo.app/rule-1', + title: 'Test', + source: RuleSource.static, + when: [Condition(id: 'c1', plugin: 'demo.when.always')], + then: [ + Action(id: 'a1', plugin: 'demo.then.log', params: {'message': 'hello'}), + ], + ); + final receipt = engine.evaluate(rule, EvalContext(appNamespace: 'demo.app')); + expect(receipt.matched, isTrue); + expect(log, ['hello']); + }); + + test('dry-run skips impure actions', () { + final log = []; + final reg = PluginRegistry(); + reg.registerCondition(AlwaysCondition()); + reg.registerAction(LogAction(log)); + final engine = Engine(reg); + final rule = Rule( + id: 'demo.app/rule-2', + title: 'Dry', + source: RuleSource.static, + when: [Condition(id: 'c1', plugin: 'demo.when.always')], + then: [Action(id: 'a1', plugin: 'demo.then.log')], + ); + final receipt = engine.evaluate( + rule, + EvalContext(appNamespace: 'demo.app'), + const EvaluateOptions(dryRun: true), + ); + expect(receipt.actionOutcomes.first.skippedDryRun, isTrue); + expect(log, isEmpty); + }); + + test('unknown plugin fail-closed', () { + final engine = Engine(registry()); + final rule = Rule( + id: 'demo.app/bad', + title: 'Bad', + source: RuleSource.static, + when: [Condition(id: 'c1', plugin: 'missing.plugin')], + ); + expect( + () => engine.evaluate(rule, EvalContext(appNamespace: 'demo.app')), + throwsA(isA()), + ); + }); + + test('proposal approve lifecycle', () { + final proposals = ProposalStore(); + final active = RuleStore(); + final rule = Rule( + id: 'demo.app/prop', + title: 'Prop', + source: RuleSource.static, + ); + final proposal = proposals.propose(rule); + expect(proposal.status, ProposalStatus.proposed); + proposals.approve(proposal.proposalId, active); + expect(active.get('demo.app/prop').title, 'Prop'); + }); + + test('audit hook receives opaque payload', () { + final reg = PluginRegistry(); + reg.registerCondition(AlwaysCondition()); + reg.registerAction(PureEcho()); + final engine = Engine(reg); + final rule = Rule( + id: 'demo.app/audit', + title: 'Audit', + source: RuleSource.static, + when: [Condition(id: 'c1', plugin: 'demo.when.always')], + then: [Action(id: 'a1', plugin: 'demo.then.echo')], + ); + AuditReceipt? captured; + final hook = _CaptureHook((r) => captured = r); + engine.evaluateWithAudit( + rule, + EvalContext(appNamespace: 'demo.app'), + const EvaluateOptions(), + hook, + {'host': 'payload'}, + ); + expect(captured!.opaque, {'host': 'payload'}); + }); +} + +class _CaptureHook implements AuditHook { + _CaptureHook(this._fn); + final void Function(AuditReceipt) _fn; + @override + void onReceipt(AuditReceipt receipt) => _fn(receipt); +}