From bd204895d515a4413c93c0db3d9b4ba6863bbac3 Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 7 Jun 2026 17:51:51 +0200 Subject: [PATCH] we are all at v0.7.5 welcome to bulk post, tui-pages support for canvas crate, moved validation-core crate --- .gitignore | 2 + .gitmodules | 2 +- Cargo.lock | 382 ++++++++++++-- Cargo.toml | 5 +- canvas | 1 - client | 2 +- common/proto/tables_data.proto | 41 ++ common/src/proto/descriptor.bin | Bin 74355 -> 76527 bytes common/src/proto/komp_ac.tables_data.rs | 136 +++++ server | 2 +- tui-canvas | 1 + tui-pages | 2 +- tui-pages_migration_for_client_guide.md | 33 ++ validation-core/Cargo.toml | 18 - .../docs/validation_architecture.md | 481 ------------------ validation-core/src/config.rs | 293 ----------- validation-core/src/lib.rs | 15 - validation-core/src/rules/character_limits.rs | 452 ---------------- validation-core/src/rules/display_mask.rs | 348 ------------- validation-core/src/rules/mod.rs | 7 - validation-core/src/rules/pattern_rules.rs | 330 ------------ validation-core/src/set.rs | 150 ------ 22 files changed, 557 insertions(+), 2146 deletions(-) delete mode 160000 canvas create mode 160000 tui-canvas create mode 100644 tui-pages_migration_for_client_guide.md delete mode 100644 validation-core/Cargo.toml delete mode 100644 validation-core/docs/validation_architecture.md delete mode 100644 validation-core/src/config.rs delete mode 100644 validation-core/src/lib.rs delete mode 100644 validation-core/src/rules/character_limits.rs delete mode 100644 validation-core/src/rules/display_mask.rs delete mode 100644 validation-core/src/rules/mod.rs delete mode 100644 validation-core/src/rules/pattern_rules.rs delete mode 100644 validation-core/src/set.rs diff --git a/.gitignore b/.gitignore index 1ba8016..53004bd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ canvas/*.toml .aider* .codex TODO.md +tui-pages-cli/ +tui-canvas-validation-core/ diff --git a/.gitmodules b/.gitmodules index cdaca34..08e7749 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,7 +2,7 @@ path = client url = git@gitlab.com:filipriec/komp_ac_client.git [submodule "canvas"] - path = canvas + path = tui-canvas url = git@gitlab.com:filipriec/tui-canvas.git [submodule "server"] path = server diff --git a/Cargo.lock b/Cargo.lock index f16e59a..847155d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,26 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -479,42 +499,30 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" -[[package]] -name = "canvas" -version = "0.6.15" -dependencies = [ - "anyhow", - "async-trait", - "crossterm", - "derivative", - "once_cell", - "ratatui", - "regex", - "ropey", - "serde", - "syntect", - "thiserror 2.0.12", - "tokio", - "tokio-test", - "toml", - "tracing", - "tracing-subscriber", - "unicode-width 0.2.0", - "validation-core", -] - [[package]] name = "cassowary" version = "0.3.0" @@ -571,7 +579,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -586,11 +594,10 @@ dependencies = [ [[package]] name = "client" -version = "0.6.15" +version = "0.7.5" dependencies = [ "anyhow", "async-trait", - "canvas", "common", "crossterm", "dirs", @@ -616,11 +623,21 @@ dependencies = [ "tonic", "tracing", "tracing-subscriber", + "tui-pages", "unicode-segmentation", "unicode-width 0.2.0", "uuid", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "codegen" version = "0.2.0" @@ -642,7 +659,7 @@ dependencies = [ [[package]] name = "common" -version = "0.6.15" +version = "0.7.5" dependencies = [ "prost 0.13.5", "prost-build 0.14.1", @@ -808,6 +825,7 @@ dependencies = [ "mio", "parking_lot", "rustix 0.38.44", + "serde", "signal-hook", "signal-hook-mio", "winapi", @@ -987,6 +1005,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.9.1", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1041,6 +1069,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etcetera" version = "0.8.0" @@ -1085,6 +1119,21 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1298,6 +1347,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.0.8", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -1356,6 +1415,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1733,6 +1803,20 @@ dependencies = [ "version_check", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2043,6 +2127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -2057,6 +2142,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multimap" version = "0.10.1" @@ -2210,6 +2305,79 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.1", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.1", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -2432,6 +2600,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.9.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.9.0" @@ -2655,6 +2836,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.1" @@ -3117,7 +3310,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "search" -version = "0.6.15" +version = "0.7.5" dependencies = [ "anyhow", "common", @@ -3216,7 +3409,7 @@ dependencies = [ [[package]] name = "server" -version = "0.6.15" +version = "0.7.5" dependencies = [ "anyhow", "bcrypt", @@ -3253,9 +3446,9 @@ dependencies = [ "tonic-reflection", "tracing", "tracing-subscriber", + "tui-canvas-validation-core", "unicode-width 0.2.0", "uuid", - "validation-core", "validator", ] @@ -3336,6 +3529,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simdutf8" version = "0.1.5" @@ -4101,6 +4300,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.41" @@ -4438,6 +4651,53 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a" +[[package]] +name = "tui-canvas" +version = "0.7.5" +dependencies = [ + "anyhow", + "arboard", + "async-trait", + "crossterm", + "derivative", + "once_cell", + "ratatui", + "regex", + "ropey", + "serde", + "syntect", + "thiserror 2.0.12", + "tokio", + "tokio-test", + "toml", + "tracing", + "tracing-subscriber", + "tui-canvas-validation-core", + "unicode-width 0.2.0", +] + +[[package]] +name = "tui-canvas-validation-core" +version = "0.7.5" +dependencies = [ + "regex", + "serde", + "thiserror 2.0.12", + "unicode-width 0.2.0", +] + +[[package]] +name = "tui-pages" +version = "0.7.5" +dependencies = [ + "crossterm", + "ratatui", + "serde", + "toml", + "tracing", + "tui-canvas", +] + [[package]] name = "typed-arena" version = "2.0.2" @@ -4547,16 +4807,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "validation-core" -version = "0.6.15" -dependencies = [ - "regex", - "serde", - "thiserror 2.0.12", - "unicode-width 0.2.0", -] - [[package]] name = "validator" version = "0.20.0" @@ -4709,6 +4959,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "which" version = "7.0.3" @@ -4770,7 +5026,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -4803,13 +5059,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[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.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -4818,7 +5080,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -5081,6 +5343,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.0.8", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xdg" version = "3.0.0" @@ -5227,3 +5506,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index cc7dbb4..5209425 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [workspace] -members = ["client", "server", "common", "search", "canvas", "validation-core"] +members = ["client", "server", "common", "search", "tui-canvas", "tui-canvas/tui-canvas-validation-core", "tui-pages" ] resolver = "2" [workspace.package] # TODO: idk how to do the name, fix later # name = "komp_ac" -version = "0.6.15" +version = "0.7.5" edition = "2021" license = "GPL-3.0-or-later" authors = ["Filip Priečinský "] @@ -53,4 +53,3 @@ toml = "0.8.20" unicode-width = "0.2.0" common = { path = "./common" } -validation-core = { path = "./validation-core" } diff --git a/canvas b/canvas deleted file mode 160000 index e6c942d..0000000 --- a/canvas +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e6c942dd412e7b7ea8ae04412528d225ec794017 diff --git a/client b/client index 768592e..69dfbff 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 768592e67334b1a5155584c35dd7a08df4b0eac9 +Subproject commit 69dfbffd99c12945b839d3c4e033b4b10f01fef2 diff --git a/common/proto/tables_data.proto b/common/proto/tables_data.proto index b0a72c0..6c56bfc 100644 --- a/common/proto/tables_data.proto +++ b/common/proto/tables_data.proto @@ -23,6 +23,17 @@ service TablesData { // - If the physical table is missing but the definition exists, returns INTERNAL rpc PostTableData(PostTableDataRequest) returns (PostTableDataResponse); + // Insert multiple rows by applying PostTableData behavior to each row. + // + // Behavior: + // - Accepts 1..10,000 rows in one gRPC request + // - Processes rows in request order + // - Each row is inserted through the same validation, script execution, + // typed binding, database insert, and indexing path as PostTableData + // - Stops at the first failing row and returns that row's gRPC error code + // with row index context; rows inserted before the failure remain inserted + rpc PostTableDataBulk(PostTableDataBulkRequest) returns (PostTableDataBulkResponse); + // Update existing row data with strict type binding and script validation. // // Behavior: @@ -124,6 +135,36 @@ message PostTableDataResponse { int64 inserted_id = 3; } +// One row in a bulk insert request. +message PostTableDataBulkRow { + // Required. Same data payload as PostTableDataRequest.data. + map data = 1; +} + +// Bulk insert request. +message PostTableDataBulkRequest { + // Required. Profile (PostgreSQL schema) name that owns the table. + string profile_name = 1; + + // Required. Logical table (definition) name within the profile. + string table_name = 2; + + // Required. Rows to insert. Must contain at least 1 and at most 10,000 rows. + repeated PostTableDataBulkRow rows = 3; +} + +// Bulk insert response. +message PostTableDataBulkResponse { + // True if all rows were inserted successfully. + bool success = 1; + + // Human-readable message. + string message = 2; + + // Per-row responses from the underlying PostTableData logic, in request order. + repeated PostTableDataResponse responses = 3; +} + // Update an existing row. message PutTableDataRequest { // Required. Profile (schema) name. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index ac5dc14718f51edfc5cabf374762aa86ceb61a0a..41dd8e3d1abb5d34116ce1b1f0f8223860d0fbd4 100644 GIT binary patch delta 3773 zcmZu!e{56N752G)esQi7ATK}h8n~nh6o{SBrfO7%D$1DJW~C@$uw@mJle~ai$9A7> zeoewfL}aR%RIsWCT7`y0r79X~ru@PFU|ofVM74?yl};^{IwUq)=msii6;l}OJMX=l zh|$0DzI)Gi?m6E%_gv@pAB~%z7*n5pC610vfBx0)sAY;|!^T{pxG6Q1v44;%rk*Wj zUhU8AtaSgYqw?Oh+A58Ez8-4)70H%|j4Nxey)bW$G@RVdLQ7bMAB#O+S$ng&GIg!y z(SY3VZMGdHx$t3n?)aL9=VY~G7xKAm!PY)jRDOG-{qRk5LI2!V+v`HsSC)KTKRtQ< zYMAa;4&B(%_inTbd1HgDvLY1vg{X9V^Ap-M zz4x1Dqx^AL8fHk46>bxo<7dO_d4%K?)mSN0Oy@JU;w;tBZk5XCGrQB-ZR&xu)sVeC zwIiK#R57RQ)bMt;PfDqt4c4EQDxua658L@-L9I$AS9N!GcX#_<(^*x{Wo@;sf8#pk z_+hj8MkhCH7og0nS+J>c&WP=>$@<_Zl`g2X7qLB}irbxBY1?-EwN)WCW~&{kOnM|$ zOy{zlsxa)N^F?LvvWH9Fid0G!%e(U+4yCgrNUc-BI5m_i*n!VZmCBCbd&J(wndei* z?J8AxPyvo+pqR@SRI2EmJeqb2MKzjAXP5&yJSUD_EIHW%EMSJkWd$$ova%fq>J8^c zYz}B=8b>h4I~2>=qP?rQS_{FY;dt%QoMZc^;hYk_9eXSVJ+&56*2oCgl?!V&Yb1bL zx-|-0;rVT=H_3ewuLrQ%HT~;YGg|0#e^>N)eFyR&SBN@kyo?9+69k|_SDdI?j(zXd9#hA_f{#@15;6DB6Z2D^KMP^af{ zy5P(Yl1Ro^tA4xnwNl!#N0MrQ2Laz}T;JW9Txx`GFa~{NnHS-R0|nRg$n6@!^Fe~_ z!7c(lNYK3vzW%hKDyPs^m+EEzGeUCN$An#O8MVH9$Bbl18D3Qb<5Jto@-=)y+T{l9Dn)#|>>KgA- z^n0=!fk#;GN%wt8oloE!8Bgkr0bx0*`xX#xC%JEXJt8(wx~(zl0g8Sz;;uJ;wGyy@JqS|@#kl`@_PoKJNQmrUQL>L;J zCQMhp6M&$e*7qEcZTX%nXkc3A2Ne1cooJ~a=P@n#m3^QI@v0af@cj894l-GkGu(r{ zvpvYaL`puD`_3&_Q%4UB5kD;bb_0Z)GrHXX5yTnYZYC#p)}P2vam-i%`>d7?&@%`o zG9cJzxx-d?F+sL-ZbH&h9Lutu)3O1Ac#fi$Cj}sg=cutsE=TTFmiZBdK5-``wbxg{ z@FR*Y@@ikjX`c6Mk8ijl9Ds0niOcaMbg8z?FDUe-d%T9a>#Je-g}xKj?DDd& z3%=pr%}rT=8^q->)Mxab=FjvRu)!z!1DE{(ET{)hx?Zce0ie zJ^03fSG6pFAYRp10uaQjd?nT(2WgqtDRkS7&75O>;8260EVz`}}{7`|$!=-%ta~ z`xNc)f~nzP_6he7l2-Nr!0A2_3~NA`?i0b23kawCL@>H)IGS3Paw0S82m=hxSNdySygoye-r~tv75T~*hMHc)IE>XZ1 delta 2063 zcmYL~T}T{P6vyY>xidR^TTPt(@FP(-scwmJ)g&7#DFq3bS`sQ$YCj(mXlT+0S2VVQ zrB)E_Yq9M`uviF!MZwrbwg|Qa`eK6!N`tMic?~7d7GFweOaEtPuKINTd(ZFO^FQbA z`o}Nw;3s+S*T>@PzT*Dl@2U9dlYedcZ03_cKP=w*D=F0OWfk)svL4aQ)WMtdO7YP_ zwR~p6QPQ;p`F2y0o{o!l;?VWgv&!Mm1ymwylnr&RTRjm0gtJ_6<)05I&wS2JMQMT$ z(yc}T%DwQadSC|-RHt~kZo}56Qq)`=`fr}vZQz}UciO-^5AU=Yz@1a!ozl0v*1AYi zy9CtbR!6r;KwUK*Mgi)2@eQ>cLXL0ul!l2~sLcV@;H*!7Y17o% zzC(Z~+!s$oI05KOSF7K=06;^9(iKVX%9es?igI|oh2}Fxfhe@*R0G;1Uk+Oz>n)EO zDu=y#)rum-ZI0-t9yR5;MQ99QM7RMDg=!&>fW*eAair{_VAtC1Mg>s3Lmv8{MnK#jC&_k2W}H^C@1vw6t!MJ>_8Bjtbkca z%w&SFkVx7rv60RI#Nnj=F-3WRFefLEK)8d%VnPu1AkpRd02IEQ))mL7EiVMT$!UVQ zFcYmoddf(E(^OHZK9>OrIa4YeqY44kcA*HH4N<&&NUS!JoJ-hk29vRUSCjRPzE?#j z5}c?B5Ah0W+U$Q;m#gUV1;prPmatx~0g236!hR!(lCy*bKOcZNoYSW&=sAGwf6n-a z_{5)lJjJObIoqFEwN0WR_q`Ptxc z_;Ts1B(>%df?F~kAi*t>R~2}G1h+)Vlxl_t$9GrA`c7X@QYL{P5V=BLqk0<^9rm!Q zXOh&PM~srIW)YCcTs4b;M9EcN#6^^JeRqwlA9Q_+Iub4t*C=}1E|)BYV?m6PCF2ef znI$t8NR%v@vAAquJsb;Sfa^yfk7eDA1*ZVlc`WB(!sW4S=xmyDdBgxW%v>M=ZkV?X z32=ko_De7k^WFPo{j8_c)R~MS@jgG$ZT#lS7`xak?WXA}~jO}V2p8IO_}sS9rb4+(0bYI1`=eM RyzmPP39?M3-|NUZ{y%2*`z!ze diff --git a/common/src/proto/komp_ac.tables_data.rs b/common/src/proto/komp_ac.tables_data.rs index a738033..55931fe 100644 --- a/common/src/proto/komp_ac.tables_data.rs +++ b/common/src/proto/komp_ac.tables_data.rs @@ -55,6 +55,42 @@ pub struct PostTableDataResponse { #[prost(int64, tag = "3")] pub inserted_id: i64, } +/// One row in a bulk insert request. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PostTableDataBulkRow { + /// Required. Same data payload as PostTableDataRequest.data. + #[prost(map = "string, message", tag = "1")] + pub data: ::std::collections::HashMap< + ::prost::alloc::string::String, + ::prost_types::Value, + >, +} +/// Bulk insert request. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PostTableDataBulkRequest { + /// Required. Profile (PostgreSQL schema) name that owns the table. + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + /// Required. Logical table (definition) name within the profile. + #[prost(string, tag = "2")] + pub table_name: ::prost::alloc::string::String, + /// Required. Rows to insert. Must contain at least 1 and at most 10,000 rows. + #[prost(message, repeated, tag = "3")] + pub rows: ::prost::alloc::vec::Vec, +} +/// Bulk insert response. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct PostTableDataBulkResponse { + /// True if all rows were inserted successfully. + #[prost(bool, tag = "1")] + pub success: bool, + /// Human-readable message. + #[prost(string, tag = "2")] + pub message: ::prost::alloc::string::String, + /// Per-row responses from the underlying PostTableData logic, in request order. + #[prost(message, repeated, tag = "3")] + pub responses: ::prost::alloc::vec::Vec, +} /// Update an existing row. #[derive(Clone, PartialEq, ::prost::Message)] pub struct PutTableDataRequest { @@ -300,6 +336,44 @@ pub mod tables_data_client { ); self.inner.unary(req, path, codec).await } + /// Insert multiple rows by applying PostTableData behavior to each row. + /// + /// Behavior: + /// - Accepts 1..10,000 rows in one gRPC request + /// - Processes rows in request order + /// - Each row is inserted through the same validation, script execution, + /// typed binding, database insert, and indexing path as PostTableData + /// - Stops at the first failing row and returns that row's gRPC error code + /// with row index context; rows inserted before the failure remain inserted + pub async fn post_table_data_bulk( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.tables_data.TablesData/PostTableDataBulk", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.tables_data.TablesData", + "PostTableDataBulk", + ), + ); + self.inner.unary(req, path, codec).await + } /// Update existing row data with strict type binding and script validation. /// /// Behavior: @@ -507,6 +581,22 @@ pub mod tables_data_server { tonic::Response, tonic::Status, >; + /// Insert multiple rows by applying PostTableData behavior to each row. + /// + /// Behavior: + /// - Accepts 1..10,000 rows in one gRPC request + /// - Processes rows in request order + /// - Each row is inserted through the same validation, script execution, + /// typed binding, database insert, and indexing path as PostTableData + /// - Stops at the first failing row and returns that row's gRPC error code + /// with row index context; rows inserted before the failure remain inserted + async fn post_table_data_bulk( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// Update existing row data with strict type binding and script validation. /// /// Behavior: @@ -708,6 +798,52 @@ pub mod tables_data_server { }; Box::pin(fut) } + "/komp_ac.tables_data.TablesData/PostTableDataBulk" => { + #[allow(non_camel_case_types)] + struct PostTableDataBulkSvc(pub Arc); + impl< + T: TablesData, + > tonic::server::UnaryService + for PostTableDataBulkSvc { + type Response = super::PostTableDataBulkResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::post_table_data_bulk(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = PostTableDataBulkSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/komp_ac.tables_data.TablesData/PutTableData" => { #[allow(non_camel_case_types)] struct PutTableDataSvc(pub Arc); diff --git a/server b/server index 24339bf..271caf1 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 24339bf7afa6b05a8c2f9e13c45bcb7fa281c184 +Subproject commit 271caf181d0789ef2ee46628784e2256b9150334 diff --git a/tui-canvas b/tui-canvas new file mode 160000 index 0000000..5330366 --- /dev/null +++ b/tui-canvas @@ -0,0 +1 @@ +Subproject commit 533036667de86f6ddc149cd2762fb48f60f0aa2a diff --git a/tui-pages b/tui-pages index aa05de8..dd82769 160000 --- a/tui-pages +++ b/tui-pages @@ -1 +1 @@ -Subproject commit aa05de8dc85e3ff6c5ca253b0fcae21461c80a16 +Subproject commit dd827694b908df61ab53338de7b8560c3d6c124e diff --git a/tui-pages_migration_for_client_guide.md b/tui-pages_migration_for_client_guide.md new file mode 100644 index 0000000..563f328 --- /dev/null +++ b/tui-pages_migration_for_client_guide.md @@ -0,0 +1,33 @@ + + 1. Action System + tui-pages intentionally does not provide ActionResolution like client/src/action_engine/action_decider/ + handler.rs:23. Replace it in the client adapter/handler, not in tui-pages. + + Best path: keep the routing logic as app code inside TuiActionHandler::handle_action, or keep a private helper + equivalent to ActionDecider::resolve. ctx.current_view and ctx.focus from tui-pages/src/runtime/mod.rs:181 give + you enough data to route page/canvas/global actions. This is a client philosophy change: routing becomes part of + the app handler, while tui-pages only provides focus/view/input context. + 2. Overlay Types + No tui-pages change needed. Your old OverlayKind maps naturally to the generic O parameter. + + Define something like client-side enum ClientOverlay { CommandBar, SearchPalette, FindFilePalette, Sidebar, + Picker }, then use FocusTarget::Overlay(ClientOverlay::CommandBar) etc. DialogButton(usize) should probably + become FocusTarget::ModalItem(usize) if you use the modal path, because tui-pages separates simple named overlays + from modal item focus in tui-pages/src/focus/target.rs:19. + 3. String vs Type-Based Modes + This is not a real incompatibility. ModeId is just a typed wrapper over strings and implements AsRef/ + From in tui-pages/src/runtime/mod.rs:13. Your current KeyMode::as_str() values already match the tui- + pages::modes constants. + + The actual migration is moving mode calculation from FocusTarget::mode_hint() in client/src/focus_manager/ + target.rs:50 into PageSpec::modes(...). Since page_spec(view, state, focus) receives focus, you can preserve the + same behavior there. Watch one behavior: the old client converts plain insert-mode chars into + CanvasAction::InsertChar in client/src/input_pipeline/pipeline.rs:90; tui-pages returns PipelineResponse::Type + instead. Preserve that in TuiActionHandler::handle_text. + 4. Page Identification + PageIdentifier should likely disappear. It duplicates AppView. tui-pages already gives ctx.current_view, so + checks like PageIdentifier::Form(_) become matches!(ctx.current_view, AppView::Form(_)). + + This is a natural cleanup, not a tui-pages gap. The only case to keep a separate identifier is if you + intentionally want to collapse several AppView variants into one routing bucket. Even then, make it a client- + local helper derived from AppView, not a runtime concept. diff --git a/validation-core/Cargo.toml b/validation-core/Cargo.toml deleted file mode 100644 index e6570ee..0000000 --- a/validation-core/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "validation-core" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -description = "Shared validation primitives, rules, and sets." -repository.workspace = true - -[dependencies] -serde = { workspace = true } -thiserror = { workspace = true } -unicode-width = { workspace = true } -regex = { workspace = true, optional = true } - -[features] -default = [] -regex = ["dep:regex"] diff --git a/validation-core/docs/validation_architecture.md b/validation-core/docs/validation_architecture.md deleted file mode 100644 index 656d961..0000000 --- a/validation-core/docs/validation_architecture.md +++ /dev/null @@ -1,481 +0,0 @@ -# Validation - -This document is the frontend guide for the validation system. - -The important idea: reusable validation is built from **rules** and **sets**. -The frontend creates and manages those. When a set is applied to a table field, -the server resolves it into the existing `FieldValidation` shape, and the form -runtime continues to work through the normal table-validation flow. - -## Ownership - -```mermaid -flowchart LR - Core[validation-core
validation meaning
rule/set merge rules] - Server[server
stores rules/sets
applies sets
enforces writes] - Common[common/proto
gRPC contract] - Client[client/frontend
rule/set UI
calls gRPC] - Canvas[canvas
field editing
mask display
local feedback] - - Server --> Core - Canvas --> Core - Client --> Common - Server --> Common - Client --> Canvas -``` - -`server` stores simple serializable settings. `validation-core` owns how those -settings combine. `canvas` uses resolved field validation to guide editing. - -## Terms - -| Term | Meaning | -| --- | --- | -| `FieldValidation` | Existing per-column validation config from `common/proto/table_validation.proto`. This is what forms/canvas already consume. | -| `ValidationRule` | One named reusable fragment, for example `digits-only`, `phone-length`, or `required`. Stored by the server as a `FieldValidation` fragment with no meaningful `dataKey`. | -| `ValidationSet` | Ordered collection of rule names, for example `phone = [required, phone-length, digits-only, phone-mask]`. | -| Applied validation | A resolved snapshot of a set written to `table_validation_rules` for a concrete `(table, dataKey)`. | -| Snapshot | Applying a set copies the resolved config to a field. Later edits to the set do not automatically update fields that were already applied. | - -## What Backend Enforces - -Backend write validation enforces only server-relevant parts: - -| FieldValidation part | Backend | Canvas/frontend | -| --- | --- | --- | -| `required` | Yes | Yes | -| `limits` | Yes | Yes | -| `pattern` | Yes | Yes | -| `allowed_values` | Yes | Yes | -| `mask` | Partly: raw value length/literals | Yes: display/editing mask | -| `formatter` | No | Yes | -| `external_validation_enabled` | No | Yes/UI hint | - -`mask` is visual metadata, but the backend still uses it to reject incorrectly -submitted raw values. Example: if the mask is `(###) ###-####`, the backend -expects the stored value to be raw digits, not `(123) 456-7890`. - -## Main User Flow - -```mermaid -sequenceDiagram - participant UI as Frontend UI - participant API as TableValidationService - participant DB as Server DB - participant Form as Existing Form Runtime - - UI->>API: UpsertValidationRule(required) - UI->>API: UpsertValidationRule(digits-only) - UI->>API: UpsertValidationRule(phone-length) - UI->>API: UpsertValidationSet(phone: [required, phone-length, digits-only]) - UI->>API: ApplyValidationSet(profile, table, dataKey, phone) - API->>DB: write resolved FieldValidation snapshot - Form->>API: GetTableValidation(profile, table) - API->>Form: resolved FieldValidation for dataKey -``` - -After `ApplyValidationSet`, the existing form code does not need to know that a -set was used. It receives normal `FieldValidation`. - -## API - -All APIs live on `TableValidationService`. - -### Rules - -Create or update one reusable rule: - -```text -UpsertValidationRule(UpsertValidationRuleRequest) -``` - -Request shape: - -```text -profileName: string -rule: - name: string - description: optional string - validation: FieldValidation -``` - -Frontend rules: - -- `rule.name` is required and unique inside a profile. -- `rule.validation.dataKey` is ignored by the server. -- A rule should usually configure one logical fragment. -- Examples: `required`, `phone-length`, `digits-only`, `phone-mask`. - -List rules: - -```text -ListValidationRules({ profileName }) -``` - -Delete rule: - -```text -DeleteValidationRule({ profileName, name }) -``` - -Deleting a rule removes it from future reusable composition. Already applied -field snapshots are not changed. - -### Sets - -Create or update one reusable set: - -```text -UpsertValidationSet(UpsertValidationSetRequest) -``` - -Request shape: - -```text -profileName: string -set: - name: string - description: optional string - ruleItems: repeated ValidationSetRuleItem -``` - -Frontend rules: - -- `set.name` is required and unique inside a profile. -- `ruleItems` must contain at least one item. -- `ruleItems` are ordered. -- Every global rule reference must already exist. -- Duplicate rule names in the same set are rejected. -- Conflicting singleton fragments are rejected. - -Singleton fragments are: - -```text -limits -allowed_values -mask -formatter -``` - -That means a set cannot currently contain two rules that both define `limits`. -Pattern rules are additive: multiple rules with `pattern` are merged into one -combined pattern. - -List sets: - -```text -ListValidationSets({ profileName }) -``` - -Response includes each set plus `resolvedValidation`, so the frontend can show -what the set expands to. - -Delete set: - -```text -DeleteValidationSet({ profileName, name }) -``` - -Deleting a set does not change already applied fields. - -### Apply Set To Field - -Apply a reusable set to one field: - -```text -ApplyValidationSet(ApplyValidationSetRequest) -``` - -Request shape: - -```text -profileName: string -tableName: string -dataKey: string -setName: string -``` - -Server behavior: - -1. Loads the set. -2. Loads its ordered rules. -3. Resolves/merges them through `validation-core`. -4. Validates that `dataKey` exists in the table definition. -5. Writes the resolved config into existing `table_validation_rules`. - -This is a snapshot. If the user later edits the `phone` set, fields that already -used `phone` keep their old resolved config until the set is applied again. - -## FieldValidation Guide - -Rules and direct field validation both use `FieldValidation`. - -### Required - -```text -required: true -``` - -Backend rejects missing or empty values. - -### Limits - -```text -limits: - min: 10 - max: 10 - warnAt: optional - countMode: CHARS | BYTES | DISPLAY_WIDTH -``` - -Backend enforces `min` and `max`. `warnAt` is mainly UI feedback. - -### Pattern - -Pattern rules validate characters at positions. - -Example digits-only: - -```text -pattern: - rules: - - position: - kind: PATTERN_POSITION_FROM - start: 0 - constraint: - kind: CHARACTER_CONSTRAINT_NUMERIC -``` - -Useful constraints: - -```text -CHARACTER_CONSTRAINT_ALPHABETIC -CHARACTER_CONSTRAINT_NUMERIC -CHARACTER_CONSTRAINT_ALPHANUMERIC -CHARACTER_CONSTRAINT_EXACT -CHARACTER_CONSTRAINT_ONE_OF -CHARACTER_CONSTRAINT_REGEX -``` - -Pattern fragments from multiple rules are merged. - -### Allowed Values - -```text -allowed_values: - values: ["open", "closed"] - allow_empty: false - case_insensitive: true -``` - -Backend rejects values not in the list. - -### Mask - -```text -mask: - pattern: "(###) ###-####" - input_char: "#" - template_char: "_" -``` - -Canvas uses this for display/editing. Backend expects raw values without mask -literals. - -### External Validation - -```text -external_validation_enabled: true -``` - -This is a frontend/UI hint. Backend stores it but does not perform external -validation. - -## Recommended Frontend Screens - -### Rule List - -Show all rules for a profile. - -Actions: - -```text -create rule -edit rule -delete rule -preview rule config -``` - -### Rule Editor - -Build a `ValidationRuleDefinition`. - -Recommended UI: - -```text -name -description -required toggle -limits section -pattern section -allowed values section -mask section -external validation toggle -``` - -For v1, encourage one fragment per rule. Example: create `phone-length` and -`digits-only` separately, instead of one huge rule. - -### Set List - -Show all sets for a profile. - -Use `ListValidationSets`, because it returns `resolvedValidation`. - -Actions: - -```text -create set -edit set -delete set -preview resolved validation -``` - -### Set Editor - -Build a `ValidationSetDefinition`. - -Recommended UI: - -```text -name -description -ordered global/inline rule item picker -resolved preview -``` - -When rule ordering changes, call `UpsertValidationSet` and then refresh -`ListValidationSets`. - -### Apply Set - -On the table/field validation screen, add: - -```text -Apply validation set -``` - -Flow: - -1. Load sets with `ListValidationSets`. -2. User selects a set. -3. Call `ApplyValidationSet(profileName, tableName, dataKey, setName)`. -4. Refresh `GetTableValidation(profileName, tableName)`. - -The field should now behave exactly like a directly configured field validation. - -## Example: Phone - -Create rule `required`: - -```text -validation: - required: true -``` - -Create rule `phone-length`: - -```text -validation: - limits: - min: 10 - max: 10 - countMode: CHARS -``` - -Create rule `digits-only`: - -```text -validation: - pattern: - rules: - - position: - kind: PATTERN_POSITION_FROM - start: 0 - constraint: - kind: CHARACTER_CONSTRAINT_NUMERIC -``` - -Create rule `phone-mask`: - -```text -validation: - mask: - pattern: "(###) ###-####" - input_char: "#" -``` - -Create set `phone`: - -```text -ruleItems: - - globalRuleName: required - - globalRuleName: phone-length - - globalRuleName: digits-only - - globalRuleName: phone-mask -``` - -Apply set: - -```text -profileName: "default" -tableName: "customers" -dataKey: "customer_phone" -setName: "phone" -``` - -Then refresh: - -```text -GetTableValidation(default, customers) -``` - -The response contains a normal `FieldValidation` for `customer_phone`. - -## Important UX Notes - -- Applying a set is not a live link. -- Editing a rule or set does not mutate fields where it was already applied. -- To update a field after set changes, apply the set again. -- If a set has conflicting singleton rules, the server rejects it. -- For now, the system does not store field metadata like `sourceSetName` on - applied fields. The field only stores the resolved validation snapshot. - -## Files - -Core model: - -```text -validation-core/src/set.rs -validation-core/src/config.rs -``` - -Wire contract: - -```text -common/proto/table_validation.proto -``` - -Server implementation: - -```text -server/src/table_validation/get/service.rs -server/src/table_validation/post/repo.rs -server/src/table_validation/config.rs -``` - -Storage: - -```text -server/migrations/20260506170000_create_validation_rules_and_sets.sql -``` diff --git a/validation-core/src/config.rs b/validation-core/src/config.rs deleted file mode 100644 index c6ed9ac..0000000 --- a/validation-core/src/config.rs +++ /dev/null @@ -1,293 +0,0 @@ -use crate::rules::{ - CharacterFilter, CharacterLimits, DisplayMask, PatternFilters, PositionFilter, PositionRange, -}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use thiserror::Error; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AllowedValues { - pub values: Vec, - pub allow_empty: bool, - pub case_insensitive: bool, -} - -impl AllowedValues { - pub fn new(values: Vec) -> Self { - Self { - values, - allow_empty: true, - case_insensitive: false, - } - } - - pub fn allow_empty(mut self, allow_empty: bool) -> Self { - self.allow_empty = allow_empty; - self - } - - pub fn case_insensitive(mut self, case_insensitive: bool) -> Self { - self.case_insensitive = case_insensitive; - self - } - - pub fn matches(&self, text: &str) -> bool { - if self.case_insensitive { - self.values - .iter() - .any(|allowed| allowed.eq_ignore_ascii_case(text)) - } else { - self.values.iter().any(|allowed| allowed == text) - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum CharacterFilterSettings { - Alphabetic, - Numeric, - Alphanumeric, - Exact(char), - OneOf(Vec), - Regex(String), -} - -impl CharacterFilterSettings { - pub fn resolve(&self) -> CharacterFilter { - match self { - Self::Alphabetic => CharacterFilter::Alphabetic, - Self::Numeric => CharacterFilter::Numeric, - Self::Alphanumeric => CharacterFilter::Alphanumeric, - Self::Exact(ch) => CharacterFilter::Exact(*ch), - Self::OneOf(chars) => CharacterFilter::OneOf(chars.clone()), - Self::Regex(pattern) => { - #[cfg(feature = "regex")] - { - match regex::Regex::new(pattern) { - Ok(regex) => CharacterFilter::Custom(Arc::new(move |ch| { - regex.is_match(&ch.to_string()) - })), - Err(_) => CharacterFilter::Custom(Arc::new(|_| false)), - } - } - #[cfg(not(feature = "regex"))] - { - let _ = pattern; - CharacterFilter::Custom(Arc::new(|_| false)) - } - } - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PositionFilterSettings { - pub positions: PositionRange, - pub filter: CharacterFilterSettings, -} - -impl PositionFilterSettings { - pub fn resolve(&self) -> PositionFilter { - PositionFilter::new(self.positions.clone(), self.filter.resolve()) - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PatternSettings { - pub filters: Vec, - pub description: Option, -} - -impl PatternSettings { - pub fn resolve(&self) -> PatternFilters { - PatternFilters::new().add_filters( - self.filters - .iter() - .map(PositionFilterSettings::resolve) - .collect(), - ) - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ValidationSettings { - pub required: bool, - pub character_limits: Option, - pub pattern: Option, - pub allowed_values: Option, - pub display_mask: Option, - pub external_validation_enabled: bool, -} - -impl ValidationSettings { - pub fn resolve(&self) -> ValidationConfig { - ValidationConfig { - required: self.required, - character_limits: self.character_limits.clone(), - pattern_filters: self.pattern.as_ref().map(PatternSettings::resolve), - allowed_values: self.allowed_values.clone(), - display_mask: self.display_mask.clone(), - external_validation_enabled: self.external_validation_enabled, - } - } - - pub fn merge_rules<'a>( - rules: impl IntoIterator, - ) -> Result { - let mut merged = ValidationSettings::default(); - - for rule in rules { - merged.merge_rule(rule)?; - } - - Ok(merged) - } - - pub fn merge_rule(&mut self, rule: &ValidationSettings) -> Result<(), ValidationMergeError> { - self.required |= rule.required; - self.external_validation_enabled |= rule.external_validation_enabled; - - merge_singleton( - "character_limits", - &mut self.character_limits, - &rule.character_limits, - )?; - merge_singleton( - "allowed_values", - &mut self.allowed_values, - &rule.allowed_values, - )?; - merge_singleton("display_mask", &mut self.display_mask, &rule.display_mask)?; - - if let Some(pattern) = &rule.pattern { - match &mut self.pattern { - Some(existing) => { - existing.filters.extend(pattern.filters.clone()); - if existing.description.is_none() { - existing.description = pattern.description.clone(); - } - } - None => self.pattern = Some(pattern.clone()), - } - } - - Ok(()) - } -} - -fn merge_singleton( - field_name: &'static str, - target: &mut Option, - source: &Option, -) -> Result<(), ValidationMergeError> { - if let Some(source) = source { - if target.is_some() { - return Err(ValidationMergeError::DuplicateSingleton { field_name }); - } - - *target = Some(source.clone()); - } - - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum ValidationMergeError { - #[error("validation set contains more than one rule configuring {field_name}")] - DuplicateSingleton { field_name: &'static str }, -} - -#[derive(Debug, Clone, Default)] -pub struct ValidationConfig { - pub required: bool, - pub character_limits: Option, - pub pattern_filters: Option, - pub allowed_values: Option, - pub display_mask: Option, - pub external_validation_enabled: bool, -} - -impl ValidationConfig { - pub fn validate_content(&self, text: &str) -> ValidationResult { - if text.is_empty() { - if self.required { - return ValidationResult::error("Value required"); - } - - if let Some(allowed_values) = &self.allowed_values { - if !allowed_values.allow_empty { - return ValidationResult::error("Empty value is not allowed"); - } - } - - return ValidationResult::Valid; - } - - if let Some(limits) = &self.character_limits { - if let Some(result) = limits.validate_content(text) { - if !result.is_acceptable() { - return result; - } - } - } - - if let Some(pattern_filters) = &self.pattern_filters { - if let Err(message) = pattern_filters.validate_text(text) { - return ValidationResult::error(message); - } - } - - if let Some(allowed_values) = &self.allowed_values { - if !allowed_values.matches(text) { - return ValidationResult::error("Value must be one of the allowed options"); - } - } - - ValidationResult::Valid - } - - pub fn has_validation(&self) -> bool { - self.required - || self.character_limits.is_some() - || self.pattern_filters.is_some() - || self.allowed_values.is_some() - || self.display_mask.is_some() - || self.external_validation_enabled - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ValidationResult { - Valid, - Warning { message: String }, - Error { message: String }, -} - -impl ValidationResult { - pub fn is_acceptable(&self) -> bool { - matches!(self, Self::Valid | Self::Warning { .. }) - } - - pub fn is_error(&self) -> bool { - matches!(self, Self::Error { .. }) - } - - pub fn message(&self) -> Option<&str> { - match self { - Self::Valid => None, - Self::Warning { message } | Self::Error { message } => Some(message), - } - } - - pub fn warning(message: impl Into) -> Self { - Self::Warning { - message: message.into(), - } - } - - pub fn error(message: impl Into) -> Self { - Self::Error { - message: message.into(), - } - } -} diff --git a/validation-core/src/lib.rs b/validation-core/src/lib.rs deleted file mode 100644 index f048e16..0000000 --- a/validation-core/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod config; -pub mod rules; -pub mod set; - -pub use config::{ - AllowedValues, CharacterFilterSettings, PatternSettings, PositionFilterSettings, - ValidationConfig, ValidationMergeError, ValidationResult, ValidationSettings, -}; -pub use rules::{ - count_text, CharacterFilter, CharacterLimits, CountMode, DisplayMask, LimitCheckResult, - MaskDisplayMode, PatternFilters, PositionFilter, PositionRange, -}; -pub use set::{ - AppliedValidation, ValidationRule, ValidationSet, ValidationSetItem, ValidationSetResolveError, -}; diff --git a/validation-core/src/rules/character_limits.rs b/validation-core/src/rules/character_limits.rs deleted file mode 100644 index 0179d1d..0000000 --- a/validation-core/src/rules/character_limits.rs +++ /dev/null @@ -1,452 +0,0 @@ -// src/validation/limits.rs -//! Character limits validation implementation - -use crate::ValidationResult; -use serde::{Deserialize, Serialize}; -use unicode_width::UnicodeWidthStr; - -/// Character limits configuration for a field -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CharacterLimits { - /// Maximum number of characters allowed (None = unlimited) - max_length: Option, - - /// Minimum number of characters required (None = no minimum) - min_length: Option, - - /// Warning threshold (warn when approaching max limit) - warning_threshold: Option, - - /// Count mode: characters vs display width - count_mode: CountMode, -} - -/// How to count characters for limit checking -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] -pub enum CountMode { - /// Count actual characters (default) - #[default] - Characters, - - /// Count display width (useful for CJK characters) - DisplayWidth, - - /// Count bytes (rarely used, but available) - Bytes, -} - -/// Result of a character limit check -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LimitCheckResult { - /// Within limits - Ok, - - /// Approaching limit (warning) - Warning { current: usize, max: usize }, - - /// At or exceeding limit (error) - Exceeded { current: usize, max: usize }, - - /// Below minimum length - TooShort { current: usize, min: usize }, -} - -impl CharacterLimits { - /// Create new character limits with just max length - pub fn new(max_length: usize) -> Self { - Self { - max_length: Some(max_length), - min_length: None, - warning_threshold: None, - count_mode: CountMode::default(), - } - } - - /// Create new character limits with min and max - pub fn new_range(min_length: usize, max_length: usize) -> Self { - Self { - max_length: Some(max_length), - min_length: Some(min_length), - warning_threshold: None, - count_mode: CountMode::default(), - } - } - - /// Create new character limits with just minimum length - pub fn new_min(min_length: usize) -> Self { - Self { - max_length: None, - min_length: Some(min_length), - warning_threshold: None, - count_mode: CountMode::default(), - } - } - - /// Create new character limits with only a warning threshold. - pub fn new_warning(threshold: usize) -> Self { - Self { - max_length: None, - min_length: None, - warning_threshold: Some(threshold), - count_mode: CountMode::default(), - } - } - - /// Set warning threshold (when to show warning before hitting limit) - pub fn with_warning_threshold(mut self, threshold: usize) -> Self { - self.warning_threshold = Some(threshold); - self - } - - /// Set count mode (characters vs display width vs bytes) - pub fn with_count_mode(mut self, mode: CountMode) -> Self { - self.count_mode = mode; - self - } - - /// Get maximum length - pub fn max_length(&self) -> Option { - self.max_length - } - - /// Get minimum length - pub fn min_length(&self) -> Option { - self.min_length - } - - /// Get warning threshold - pub fn warning_threshold(&self) -> Option { - self.warning_threshold - } - - /// Get count mode - pub fn count_mode(&self) -> CountMode { - self.count_mode - } - - /// Count characters/width/bytes according to the configured mode - fn count(&self, text: &str) -> usize { - match self.count_mode { - CountMode::Characters => text.chars().count(), - CountMode::DisplayWidth => text.width(), - CountMode::Bytes => text.len(), - } - } - - /// Check if inserting a character would exceed limits - pub fn validate_insertion( - &self, - current_text: &str, - position: usize, - character: char, - ) -> Option { - let mut new_text = String::with_capacity(current_text.len() + character.len_utf8()); - let mut chars = current_text.chars(); - - let clamped_pos = position.min(current_text.chars().count()); - for _ in 0..clamped_pos { - if let Some(ch) = chars.next() { - new_text.push(ch); - } - } - - new_text.push(character); - - for ch in chars { - new_text.push(ch); - } - - let new_count = self.count(&new_text); - let current_count = self.count(current_text); - - if let Some(max) = self.max_length { - if new_count > max { - return Some(ValidationResult::error(format!( - "Character limit exceeded: {new_count}/{max}" - ))); - } - - if let Some(warning_threshold) = self.warning_threshold { - if new_count >= warning_threshold && current_count < warning_threshold { - return Some(ValidationResult::warning(format!( - "Approaching character limit: {new_count}/{max}" - ))); - } - } - } - - None // No validation issues - } - - /// Validate the current content - pub fn validate_content(&self, text: &str) -> Option { - let count = self.count(text); - - if let Some(min) = self.min_length { - if count < min { - return Some(ValidationResult::warning(format!( - "Minimum length not met: {count}/{min}" - ))); - } - } - - if let Some(max) = self.max_length { - if count > max { - return Some(ValidationResult::error(format!( - "Character limit exceeded: {count}/{max}" - ))); - } - - if let Some(warning_threshold) = self.warning_threshold { - if count >= warning_threshold { - return Some(ValidationResult::warning(format!( - "Approaching character limit: {count}/{max}" - ))); - } - } - } - - None // No validation issues - } - - /// Get the current status of the text against limits - pub fn check_limits(&self, text: &str) -> LimitCheckResult { - let count = self.count(text); - - if let Some(max) = self.max_length { - if count > max { - return LimitCheckResult::Exceeded { - current: count, - max, - }; - } - - if let Some(warning_threshold) = self.warning_threshold { - if count >= warning_threshold { - return LimitCheckResult::Warning { - current: count, - max, - }; - } - } - } - - // Check min length - if let Some(min) = self.min_length { - if count < min { - return LimitCheckResult::TooShort { - current: count, - min, - }; - } - } - - LimitCheckResult::Ok - } - - /// Get a human-readable status string - pub fn status_text(&self, text: &str) -> Option { - match self.check_limits(text) { - LimitCheckResult::Ok => { - // Show current/max if we have a max limit - self.max_length - .map(|max| format!("{}/{}", self.count(text), max)) - } - LimitCheckResult::Warning { current, max } => { - Some(format!("{current}/{max} (approaching limit)")) - } - LimitCheckResult::Exceeded { current, max } => { - Some(format!("{current}/{max} (exceeded)")) - } - LimitCheckResult::TooShort { current, min } => Some(format!("{current}/{min} minimum")), - } - } - pub fn allows_field_switch(&self, text: &str) -> bool { - if let Some(min) = self.min_length { - let count = self.count(text); - // Allow switching if field is empty OR meets minimum requirement - count == 0 || count >= min - } else { - true // No minimum requirement, always allow switching - } - } - - /// Get reason why field switching is not allowed (if any) - pub fn field_switch_block_reason(&self, text: &str) -> Option { - if let Some(min) = self.min_length { - let count = self.count(text); - if count > 0 && count < min { - return Some(format!( - "Field must be empty or have at least {min} characters (currently: {count})" - )); - } - } - None - } -} - -pub fn count_text(text: &str, mode: CountMode) -> usize { - match mode { - CountMode::Characters => text.chars().count(), - CountMode::DisplayWidth => text.width(), - CountMode::Bytes => text.len(), - } -} - -impl Default for CharacterLimits { - fn default() -> Self { - Self { - max_length: Some(30), // Default 30 character limit as specified - min_length: None, - warning_threshold: None, - count_mode: CountMode::default(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_character_limits_creation() { - let limits = CharacterLimits::new(10); - assert_eq!(limits.max_length(), Some(10)); - assert_eq!(limits.min_length(), None); - - let range_limits = CharacterLimits::new_range(5, 15); - assert_eq!(range_limits.min_length(), Some(5)); - assert_eq!(range_limits.max_length(), Some(15)); - } - - #[test] - fn test_default_limits() { - let limits = CharacterLimits::default(); - assert_eq!(limits.max_length(), Some(30)); - } - - #[test] - fn test_character_counting() { - let limits = CharacterLimits::new(5); - - // Test character mode (default) - assert_eq!(limits.count("hello"), 5); - assert_eq!(limits.count("héllo"), 5); // Accented character counts as 1 - - // Test display width mode - let limits = limits.with_count_mode(CountMode::DisplayWidth); - assert_eq!(limits.count("hello"), 5); - - // Test bytes mode - let limits = limits.with_count_mode(CountMode::Bytes); - assert_eq!(limits.count("hello"), 5); - assert_eq!(limits.count("héllo"), 6); // é takes 2 bytes in UTF-8 - } - - #[test] - fn test_insertion_validation() { - let limits = CharacterLimits::new(5); - - // Valid insertion - let result = limits.validate_insertion("test", 4, 'x'); - assert!(result.is_none()); // No validation issues - - // Invalid insertion (would exceed limit) - let result = limits.validate_insertion("tests", 5, 'x'); - assert!(result.is_some()); - assert!(!result.unwrap().is_acceptable()); - } - - #[test] - fn test_content_validation() { - let limits = CharacterLimits::new_range(3, 10); - - // Too short - let result = limits.validate_content("hi"); - assert!(result.is_some()); - assert!(result.unwrap().is_acceptable()); // Warning, not error - - // Just right - let result = limits.validate_content("hello"); - assert!(result.is_none()); - - // Too long - let result = limits.validate_content("hello world!"); - assert!(result.is_some()); - assert!(!result.unwrap().is_acceptable()); // Error - } - - #[test] - fn test_warning_threshold() { - let limits = CharacterLimits::new(10).with_warning_threshold(8); - - // Below warning threshold - let result = limits.validate_insertion("123456", 6, 'x'); - assert!(result.is_none()); - - // At warning threshold - let result = limits.validate_insertion("1234567", 7, 'x'); - assert!(result.is_some()); // This brings us to 8 chars - assert!(result.unwrap().is_acceptable()); // Warning, not error - - let result = limits.validate_insertion("12345678", 8, 'x'); - assert!(result.is_none()); - } - - #[test] - fn test_status_text() { - let limits = CharacterLimits::new(10); - - assert_eq!(limits.status_text("hello"), Some("5/10".to_string())); - - let limits = limits.with_warning_threshold(8); - assert_eq!( - limits.status_text("12345678"), - Some("8/10 (approaching limit)".to_string()) - ); - assert_eq!( - limits.status_text("1234567890x"), - Some("11/10 (exceeded)".to_string()) - ); - } - - #[test] - fn test_field_switch_blocking() { - let limits = CharacterLimits::new_range(3, 10); - - // Empty field: should allow switching - assert!(limits.allows_field_switch("")); - assert!(limits.field_switch_block_reason("").is_none()); - - // Field with content below minimum: should block switching - assert!(!limits.allows_field_switch("hi")); - assert!(limits.field_switch_block_reason("hi").is_some()); - assert!(limits - .field_switch_block_reason("hi") - .unwrap() - .contains("at least 3 characters")); - - // Field meeting minimum: should allow switching - assert!(limits.allows_field_switch("hello")); - assert!(limits.field_switch_block_reason("hello").is_none()); - - // Field exceeding maximum: should still allow switching (validation shows error but doesn't block) - assert!(limits.allows_field_switch("this is way too long")); - assert!(limits - .field_switch_block_reason("this is way too long") - .is_none()); - } - - #[test] - fn test_field_switch_no_minimum() { - let limits = CharacterLimits::new(10); // Only max, no minimum - - // Should always allow switching when there's no minimum - assert!(limits.allows_field_switch("")); - assert!(limits.allows_field_switch("a")); - assert!(limits.allows_field_switch("hello")); - - assert!(limits.field_switch_block_reason("").is_none()); - assert!(limits.field_switch_block_reason("a").is_none()); - } -} diff --git a/validation-core/src/rules/display_mask.rs b/validation-core/src/rules/display_mask.rs deleted file mode 100644 index 1c43e47..0000000 --- a/validation-core/src/rules/display_mask.rs +++ /dev/null @@ -1,348 +0,0 @@ -// src/validation/mask.rs -//! Pure display mask system - user-defined patterns only - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub enum MaskDisplayMode { - /// Only show separators as user types - /// Example: "" → "", "123" → "123", "12345" → "(123) 45" - #[default] - Dynamic, - - /// Show full template with placeholders from start - /// Example: "" → "(___) ___-____", "123" → "(123) ___-____" - Template { - /// Character to use as placeholder for empty input positions - placeholder: char, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DisplayMask { - /// Mask pattern like "##-##-####" where # = input position, others are visual separators - pattern: String, - /// Character used to represent input positions (usually '#') - input_char: char, - /// How to display the mask (dynamic vs template) - display_mode: MaskDisplayMode, -} - -impl DisplayMask { - /// Create a new display mask with dynamic mode (current behavior) - /// - /// # Arguments - /// * `pattern` - The mask pattern (e.g., "##-##-####", "(###) ###-####") - /// * `input_char` - Character representing input positions (usually '#') - /// - /// # Examples - /// ``` - /// use validation_core::DisplayMask; - /// - /// // Phone number format - /// let phone_mask = DisplayMask::new("(###) ###-####", '#'); - /// - /// // Date format - /// let date_mask = DisplayMask::new("##/##/####", '#'); - /// - /// // Custom business format - /// let employee_id = DisplayMask::new("EMP-####-##", '#'); - /// ``` - pub fn new(pattern: impl Into, input_char: char) -> Self { - Self { - pattern: pattern.into(), - input_char, - display_mode: MaskDisplayMode::Dynamic, - } - } - - /// Set the display mode for this mask - /// - /// # Examples - /// ``` - /// use validation_core::{DisplayMask, MaskDisplayMode}; - /// - /// let dynamic_mask = DisplayMask::new("##-##", '#') - /// .with_mode(MaskDisplayMode::Dynamic); - /// - /// let template_mask = DisplayMask::new("##-##", '#') - /// .with_mode(MaskDisplayMode::Template { placeholder: '_' }); - /// ``` - pub fn with_mode(mut self, mode: MaskDisplayMode) -> Self { - self.display_mode = mode; - self - } - - /// Set template mode with custom placeholder - /// - /// # Examples - /// ``` - /// use validation_core::DisplayMask; - /// - /// let phone_template = DisplayMask::new("(###) ###-####", '#') - /// .with_template('_'); // Shows "(___) ___-____" when empty - /// - /// let date_dots = DisplayMask::new("##/##/####", '#') - /// .with_template('•'); // Shows "••/••/••••" when empty - /// ``` - pub fn with_template(self, placeholder: char) -> Self { - self.with_mode(MaskDisplayMode::Template { placeholder }) - } - - /// Apply mask to raw input, showing visual separators and handling display mode - pub fn apply_to_display(&self, raw_input: &str) -> String { - match &self.display_mode { - MaskDisplayMode::Dynamic => self.apply_dynamic(raw_input), - MaskDisplayMode::Template { placeholder } => { - self.apply_template(raw_input, *placeholder) - } - } - } - - /// Dynamic mode - only show separators as user types - fn apply_dynamic(&self, raw_input: &str) -> String { - if raw_input.is_empty() { - return String::new(); - } - - let mut result = String::new(); - let mut raw_chars = raw_input.chars(); - - for pattern_char in self.pattern.chars() { - if pattern_char == self.input_char { - // Input position - take from raw input - if let Some(input_char) = raw_chars.next() { - result.push(input_char); - } else { - // No more input - stop here in dynamic mode - break; - } - } else { - // Visual separator - always show - result.push(pattern_char); - } - } - - // Append any remaining raw characters that don't fit the pattern - for remaining_char in raw_chars { - result.push(remaining_char); - } - - result - } - - /// Template mode - show full pattern with placeholders - fn apply_template(&self, raw_input: &str, placeholder: char) -> String { - let mut result = String::new(); - let mut raw_chars = raw_input.chars().peekable(); - - for pattern_char in self.pattern.chars() { - if pattern_char == self.input_char { - // Input position - take from raw input or use placeholder - if let Some(input_char) = raw_chars.next() { - result.push(input_char); - } else { - // No more input - use placeholder to show template - result.push(placeholder); - } - } else { - // Visual separator - always show in template mode - result.push(pattern_char); - } - } - - // In template mode, we don't append extra characters beyond the pattern - // This keeps the template consistent - result - } - - /// Check if a display position should accept cursor/input - pub fn is_input_position(&self, display_position: usize) -> bool { - self.pattern - .chars() - .nth(display_position) - .map(|c| c == self.input_char) - .unwrap_or(true) // Beyond pattern = accept input - } - - /// Map display position to raw position - pub fn display_pos_to_raw_pos(&self, display_pos: usize) -> usize { - let mut raw_pos = 0; - - for (i, pattern_char) in self.pattern.chars().enumerate() { - if i >= display_pos { - break; - } - if pattern_char == self.input_char { - raw_pos += 1; - } - } - - raw_pos - } - - /// Map raw position to display position - pub fn raw_pos_to_display_pos(&self, raw_pos: usize) -> usize { - let mut input_positions_seen = 0; - - for (display_pos, pattern_char) in self.pattern.chars().enumerate() { - if pattern_char == self.input_char { - if input_positions_seen == raw_pos { - return display_pos; - } - input_positions_seen += 1; - } - } - - // Beyond pattern, return position after pattern - self.pattern.len() + (raw_pos - input_positions_seen) - } - - /// Find next input position at or after the given display position - pub fn next_input_position(&self, display_pos: usize) -> usize { - for (i, pattern_char) in self.pattern.chars().enumerate().skip(display_pos) { - if pattern_char == self.input_char { - return i; - } - } - // Beyond pattern = all positions are input positions - display_pos.max(self.pattern.len()) - } - - /// Find previous input position at or before the given display position - pub fn prev_input_position(&self, display_pos: usize) -> Option { - // Collect pattern chars with indices first, then search backwards - let pattern_chars: Vec<(usize, char)> = self.pattern.chars().enumerate().collect(); - - // Search backwards from display_pos - for &(i, pattern_char) in pattern_chars.iter().rev() { - if i <= display_pos && pattern_char == self.input_char { - return Some(i); - } - } - None - } - - /// Get the display mode - pub fn display_mode(&self) -> &MaskDisplayMode { - &self.display_mode - } - - /// Check if this mask uses template mode - pub fn is_template_mode(&self) -> bool { - matches!(self.display_mode, MaskDisplayMode::Template { .. }) - } - - /// Get the pattern string - pub fn pattern(&self) -> &str { - &self.pattern - } - - /// Get the input placeholder character - pub fn input_char(&self) -> char { - self.input_char - } - - /// Get the position of the first input character in the pattern - pub fn first_input_position(&self) -> usize { - for (pos, ch) in self.pattern.chars().enumerate() { - if ch == self.input_char { - return pos; - } - } - 0 - } -} - -impl Default for DisplayMask { - fn default() -> Self { - Self::new("", '#') - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_user_defined_phone_mask() { - // User creates their own phone mask - let dynamic = DisplayMask::new("(###) ###-####", '#'); - let template = DisplayMask::new("(###) ###-####", '#').with_template('_'); - - // Dynamic mode - assert_eq!(dynamic.apply_to_display(""), ""); - assert_eq!(dynamic.apply_to_display("1234567890"), "(123) 456-7890"); - - // Template mode - assert_eq!(template.apply_to_display(""), "(___) ___-____"); - assert_eq!(template.apply_to_display("123"), "(123) ___-____"); - } - - #[test] - fn test_user_defined_date_mask() { - // User creates their own date formats - let us_date = DisplayMask::new("##/##/####", '#'); - let eu_date = DisplayMask::new("##.##.####", '#'); - let iso_date = DisplayMask::new("####-##-##", '#'); - - assert_eq!(us_date.apply_to_display("12252024"), "12/25/2024"); - assert_eq!(eu_date.apply_to_display("25122024"), "25.12.2024"); - assert_eq!(iso_date.apply_to_display("20241225"), "2024-12-25"); - } - - #[test] - fn test_user_defined_business_formats() { - // User creates custom business formats - let employee_id = DisplayMask::new("EMP-####-##", '#'); - let product_code = DisplayMask::new("###-###-###", '#'); - let invoice = DisplayMask::new("INV####/##", '#'); - - assert_eq!(employee_id.apply_to_display("123456"), "EMP-1234-56"); - assert_eq!(product_code.apply_to_display("123456789"), "123-456-789"); - assert_eq!(invoice.apply_to_display("123456"), "INV1234/56"); - } - - #[test] - fn test_custom_input_characters() { - // User can define their own input character - let mask_with_x = DisplayMask::new("XXX-XX-XXXX", 'X'); - let mask_with_hash = DisplayMask::new("###-##-####", '#'); - let mask_with_n = DisplayMask::new("NNN-NN-NNNN", 'N'); - - assert_eq!(mask_with_x.apply_to_display("123456789"), "123-45-6789"); - assert_eq!(mask_with_hash.apply_to_display("123456789"), "123-45-6789"); - assert_eq!(mask_with_n.apply_to_display("123456789"), "123-45-6789"); - } - - #[test] - fn test_custom_placeholders() { - // User can define custom placeholder characters - let underscores = DisplayMask::new("##-##", '#').with_template('_'); - let dots = DisplayMask::new("##-##", '#').with_template('•'); - let dashes = DisplayMask::new("##-##", '#').with_template('-'); - - assert_eq!(underscores.apply_to_display(""), "__-__"); - assert_eq!(dots.apply_to_display(""), "••-••"); - assert_eq!(dashes.apply_to_display(""), "-----"); // Note: dashes blend with separator - } - - #[test] - fn test_position_mapping_user_patterns() { - let custom = DisplayMask::new("ABC-###-XYZ", '#'); - - // Position mapping should work correctly with any pattern - assert_eq!(custom.raw_pos_to_display_pos(0), 4); // First # at position 4 - assert_eq!(custom.raw_pos_to_display_pos(1), 5); // Second # at position 5 - assert_eq!(custom.raw_pos_to_display_pos(2), 6); // Third # at position 6 - - assert_eq!(custom.display_pos_to_raw_pos(4), 0); // Position 4 -> first input - assert_eq!(custom.display_pos_to_raw_pos(5), 1); // Position 5 -> second input - assert_eq!(custom.display_pos_to_raw_pos(6), 2); // Position 6 -> third input - - assert!(!custom.is_input_position(0)); // A - assert!(!custom.is_input_position(3)); // - - assert!(custom.is_input_position(4)); // # - assert!(!custom.is_input_position(8)); // Y - } -} diff --git a/validation-core/src/rules/mod.rs b/validation-core/src/rules/mod.rs deleted file mode 100644 index b5920b4..0000000 --- a/validation-core/src/rules/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod character_limits; -pub mod display_mask; -pub mod pattern_rules; - -pub use character_limits::{count_text, CharacterLimits, CountMode, LimitCheckResult}; -pub use display_mask::{DisplayMask, MaskDisplayMode}; -pub use pattern_rules::{CharacterFilter, PatternFilters, PositionFilter, PositionRange}; diff --git a/validation-core/src/rules/pattern_rules.rs b/validation-core/src/rules/pattern_rules.rs deleted file mode 100644 index 2c9f869..0000000 --- a/validation-core/src/rules/pattern_rules.rs +++ /dev/null @@ -1,330 +0,0 @@ -// src/validation/patterns.rs -//! Position-based pattern filtering for validation - -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -/// A filter that applies to specific character positions in a field -#[derive(Debug, Clone)] -pub struct PositionFilter { - /// Which positions this filter applies to - pub positions: PositionRange, - /// What type of character filter to apply - pub filter: CharacterFilter, -} - -/// Defines which character positions a filter applies to -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum PositionRange { - /// Single position (e.g., position 3 only) - Single(usize), - /// Range of positions (e.g., positions 0-2, inclusive) - Range(usize, usize), - /// From position onwards (e.g., position 4 and beyond) - From(usize), - /// Multiple specific positions (e.g., positions 0, 2, 5) - Multiple(Vec), -} - -/// Types of character filters that can be applied -pub enum CharacterFilter { - /// Allow only alphabetic characters (a-z, A-Z) - Alphabetic, - /// Allow only numeric characters (0-9) - Numeric, - /// Allow alphanumeric characters (a-z, A-Z, 0-9) - Alphanumeric, - /// Allow only exact character match - Exact(char), - /// Allow any character from the provided set - OneOf(Vec), - /// Custom user-defined filter function - Custom(Arc bool + Send + Sync>), -} - -// Manual implementations for Debug and Clone -impl std::fmt::Debug for CharacterFilter { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CharacterFilter::Alphabetic => write!(f, "Alphabetic"), - CharacterFilter::Numeric => write!(f, "Numeric"), - CharacterFilter::Alphanumeric => write!(f, "Alphanumeric"), - CharacterFilter::Exact(ch) => write!(f, "Exact('{ch}')"), - CharacterFilter::OneOf(chars) => write!(f, "OneOf({chars:?})"), - CharacterFilter::Custom(_) => write!(f, "Custom()"), - } - } -} - -impl Clone for CharacterFilter { - fn clone(&self) -> Self { - match self { - CharacterFilter::Alphabetic => CharacterFilter::Alphabetic, - CharacterFilter::Numeric => CharacterFilter::Numeric, - CharacterFilter::Alphanumeric => CharacterFilter::Alphanumeric, - CharacterFilter::Exact(ch) => CharacterFilter::Exact(*ch), - CharacterFilter::OneOf(chars) => CharacterFilter::OneOf(chars.clone()), - CharacterFilter::Custom(func) => CharacterFilter::Custom(Arc::clone(func)), - } - } -} - -impl PositionRange { - /// Check if a position is included in this range - pub fn contains(&self, position: usize) -> bool { - match self { - PositionRange::Single(pos) => position == *pos, - PositionRange::Range(start, end) => position >= *start && position <= *end, - PositionRange::From(start) => position >= *start, - PositionRange::Multiple(positions) => positions.contains(&position), - } - } - - /// Get all positions up to a given length that this range covers - pub fn positions_up_to(&self, max_length: usize) -> Vec { - match self { - PositionRange::Single(pos) => { - if *pos < max_length { - vec![*pos] - } else { - vec![] - } - } - PositionRange::Range(start, end) => { - let actual_end = (*end).min(max_length.saturating_sub(1)); - if *start <= actual_end { - (*start..=actual_end).collect() - } else { - vec![] - } - } - PositionRange::From(start) => { - if *start < max_length { - (*start..max_length).collect() - } else { - vec![] - } - } - PositionRange::Multiple(positions) => positions - .iter() - .filter(|&&pos| pos < max_length) - .copied() - .collect(), - } - } -} - -impl CharacterFilter { - /// Test if a character passes this filter - pub fn accepts(&self, ch: char) -> bool { - match self { - CharacterFilter::Alphabetic => ch.is_alphabetic(), - CharacterFilter::Numeric => ch.is_numeric(), - CharacterFilter::Alphanumeric => ch.is_alphanumeric(), - CharacterFilter::Exact(expected) => ch == *expected, - CharacterFilter::OneOf(chars) => chars.contains(&ch), - CharacterFilter::Custom(func) => func(ch), - } - } - - /// Get a human-readable description of this filter - pub fn description(&self) -> String { - match self { - CharacterFilter::Alphabetic => "alphabetic characters (a-z, A-Z)".to_string(), - CharacterFilter::Numeric => "numeric characters (0-9)".to_string(), - CharacterFilter::Alphanumeric => "alphanumeric characters (a-z, A-Z, 0-9)".to_string(), - CharacterFilter::Exact(ch) => format!("exactly '{ch}'"), - CharacterFilter::OneOf(chars) => { - let char_list: String = chars.iter().collect(); - format!("one of: {char_list}") - } - CharacterFilter::Custom(_) => "custom filter".to_string(), - } - } -} - -impl PositionFilter { - /// Create a new position filter - pub fn new(positions: PositionRange, filter: CharacterFilter) -> Self { - Self { positions, filter } - } - - /// Validate a character at a specific position - pub fn validate_position(&self, position: usize, character: char) -> bool { - if self.positions.contains(position) { - self.filter.accepts(character) - } else { - true // Position not covered by this filter, allow any character - } - } - - /// Get error message for invalid character at position - pub fn error_message(&self, position: usize, character: char) -> Option { - if self.positions.contains(position) && !self.filter.accepts(character) { - Some(format!( - "Position {} requires {} but got '{}'", - position, - self.filter.description(), - character - )) - } else { - None - } - } -} - -/// A collection of position filters for a field -#[derive(Debug, Clone, Default)] -pub struct PatternFilters { - filters: Vec, -} - -impl PatternFilters { - /// Create empty pattern filters - pub fn new() -> Self { - Self::default() - } - - /// Add a position filter - pub fn add_filter(mut self, filter: PositionFilter) -> Self { - self.filters.push(filter); - self - } - - /// Add multiple filters - pub fn add_filters(mut self, filters: Vec) -> Self { - self.filters.extend(filters); - self - } - - /// Validate a character at a specific position against all applicable filters - pub fn validate_char_at_position( - &self, - position: usize, - character: char, - ) -> Result<(), String> { - for filter in &self.filters { - if let Some(error) = filter.error_message(position, character) { - return Err(error); - } - } - Ok(()) - } - - /// Validate entire text against all filters - pub fn validate_text(&self, text: &str) -> Result<(), String> { - for (position, character) in text.char_indices() { - self.validate_char_at_position(position, character)? - } - Ok(()) - } - - /// Check if any filters are configured - pub fn has_filters(&self) -> bool { - !self.filters.is_empty() - } - - /// Get all configured filters - pub fn filters(&self) -> &[PositionFilter] { - &self.filters - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_position_range_contains() { - assert!(PositionRange::Single(3).contains(3)); - assert!(!PositionRange::Single(3).contains(2)); - - assert!(PositionRange::Range(1, 4).contains(3)); - assert!(!PositionRange::Range(1, 4).contains(5)); - - assert!(PositionRange::From(2).contains(5)); - assert!(!PositionRange::From(2).contains(1)); - - assert!(PositionRange::Multiple(vec![0, 2, 5]).contains(2)); - assert!(!PositionRange::Multiple(vec![0, 2, 5]).contains(3)); - } - - #[test] - fn test_position_range_positions_up_to() { - assert_eq!(PositionRange::Single(3).positions_up_to(5), vec![3]); - assert_eq!(PositionRange::Single(5).positions_up_to(3), vec![]); - - assert_eq!(PositionRange::Range(1, 3).positions_up_to(5), vec![1, 2, 3]); - assert_eq!(PositionRange::Range(1, 5).positions_up_to(3), vec![1, 2]); - - assert_eq!(PositionRange::From(2).positions_up_to(5), vec![2, 3, 4]); - - assert_eq!( - PositionRange::Multiple(vec![0, 2, 5]).positions_up_to(4), - vec![0, 2] - ); - } - - #[test] - fn test_character_filter_accepts() { - assert!(CharacterFilter::Alphabetic.accepts('a')); - assert!(CharacterFilter::Alphabetic.accepts('Z')); - assert!(!CharacterFilter::Alphabetic.accepts('1')); - - assert!(CharacterFilter::Numeric.accepts('5')); - assert!(!CharacterFilter::Numeric.accepts('a')); - - assert!(CharacterFilter::Alphanumeric.accepts('a')); - assert!(CharacterFilter::Alphanumeric.accepts('5')); - assert!(!CharacterFilter::Alphanumeric.accepts('-')); - - assert!(CharacterFilter::Exact('x').accepts('x')); - assert!(!CharacterFilter::Exact('x').accepts('y')); - - assert!(CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('b')); - assert!(!CharacterFilter::OneOf(vec!['a', 'b', 'c']).accepts('d')); - } - - #[test] - fn test_position_filter_validation() { - let filter = PositionFilter::new(PositionRange::Range(0, 1), CharacterFilter::Alphabetic); - - assert!(filter.validate_position(0, 'A')); - assert!(filter.validate_position(1, 'b')); - assert!(!filter.validate_position(0, '1')); - assert!(filter.validate_position(2, '1')); // Position 2 not covered, allow anything - } - - #[test] - fn test_pattern_filters_validation() { - let patterns = PatternFilters::new() - .add_filter(PositionFilter::new( - PositionRange::Range(0, 1), - CharacterFilter::Alphabetic, - )) - .add_filter(PositionFilter::new( - PositionRange::Range(2, 4), - CharacterFilter::Numeric, - )); - - // Valid pattern: AB123 - assert!(patterns.validate_text("AB123").is_ok()); - - // Invalid: number in alphabetic position - assert!(patterns.validate_text("A1123").is_err()); - - // Invalid: letter in numeric position - assert!(patterns.validate_text("AB1A3").is_err()); - } - - #[test] - fn test_custom_filter() { - let pattern = PatternFilters::new().add_filter(PositionFilter::new( - PositionRange::From(0), - CharacterFilter::Custom(Arc::new(|c| c.is_lowercase())), - )); - - assert!(pattern.validate_text("hello").is_ok()); - assert!(pattern.validate_text("Hello").is_err()); // Uppercase not allowed - } -} diff --git a/validation-core/src/set.rs b/validation-core/src/set.rs deleted file mode 100644 index 6e34625..0000000 --- a/validation-core/src/set.rs +++ /dev/null @@ -1,150 +0,0 @@ -use crate::{ValidationConfig, ValidationMergeError, ValidationSettings}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ValidationRule { - pub name: String, - pub description: Option, - pub settings: ValidationSettings, -} - -impl ValidationRule { - pub fn resolve(&self) -> ValidationConfig { - self.settings.resolve() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ValidationSet { - pub name: String, - pub description: Option, - pub items: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ValidationSetItem { - GlobalRuleRef(String), - InlineRule { - name: Option, - validation: ValidationSettings, - }, -} - -impl ValidationSet { - pub fn resolve_settings_with_rules<'a>( - &'a self, - rules: impl Fn(&str) -> Option<&'a ValidationRule>, - ) -> Result { - let settings = self.items.iter().map(|item| match item { - ValidationSetItem::GlobalRuleRef(name) => { - rules(name).map(|rule| &rule.settings).ok_or_else(|| { - ValidationSetResolveError::MissingGlobalRule { name: name.clone() } - }) - } - ValidationSetItem::InlineRule { validation, .. } => Ok(validation), - }); - - let settings = settings.collect::, _>>()?; - Ok(ValidationSettings::merge_rules(settings)?) - } - - pub fn resolve_with_rules<'a>( - &'a self, - rules: impl Fn(&str) -> Option<&'a ValidationRule>, - ) -> Result { - Ok(self.resolve_settings_with_rules(rules)?.resolve()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum ValidationSetResolveError { - #[error("validation set references missing global rule '{name}'")] - MissingGlobalRule { name: String }, - #[error(transparent)] - Merge(#[from] ValidationMergeError), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppliedValidation { - pub set_name: Option, - pub settings: ValidationSettings, -} - -impl AppliedValidation { - pub fn resolve(&self) -> ValidationConfig { - self.settings.resolve() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - CharacterFilterSettings, CharacterLimits, PatternSettings, PositionFilterSettings, - PositionRange, - }; - - #[test] - fn validation_set_merges_rule_fragments() { - let set = ValidationSet { - name: "phone".to_string(), - description: None, - items: vec![ - ValidationSetItem::InlineRule { - name: Some("phone-length".to_string()), - validation: ValidationSettings { - character_limits: Some(CharacterLimits::new_range(10, 15)), - ..ValidationSettings::default() - }, - }, - ValidationSetItem::InlineRule { - name: Some("digits-only".to_string()), - validation: ValidationSettings { - pattern: Some(PatternSettings { - filters: vec![PositionFilterSettings { - positions: PositionRange::From(0), - filter: CharacterFilterSettings::Numeric, - }], - description: None, - }), - ..ValidationSettings::default() - }, - }, - ], - }; - - let settings = set - .resolve_settings_with_rules(|_| None) - .expect("set should resolve"); - - assert!(settings.character_limits.is_some()); - assert_eq!(settings.pattern.expect("pattern").filters.len(), 1); - } - - #[test] - fn validation_set_rejects_duplicate_singleton_rules() { - let set = ValidationSet { - name: "conflict".to_string(), - description: None, - items: vec![ - ValidationSetItem::InlineRule { - name: Some("short".to_string()), - validation: ValidationSettings { - character_limits: Some(CharacterLimits::new(10)), - ..ValidationSettings::default() - }, - }, - ValidationSetItem::InlineRule { - name: Some("long".to_string()), - validation: ValidationSettings { - character_limits: Some(CharacterLimits::new(20)), - ..ValidationSettings::default() - }, - }, - ], - }; - - assert!(set.resolve_settings_with_rules(|_| None).is_err()); - } -}