From 0a7682c3dd5bd91f76e98403b1c542eefdbb88fb Mon Sep 17 00:00:00 2001 From: Muhammad Fiaz Date: Thu, 28 May 2026 04:53:31 +0530 Subject: [PATCH] migrate to zig 0.16 --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- .github/ISSUE_TEMPLATE/help_wanted.yml | 4 +- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 8 +- .gitignore | 5 +- CONTRIBUTING.md | 2 +- README.md | 248 ++++- SECURITY.md | 2 +- bench/benchmark.zig | 290 +++++- build.zig | 4 + build.zig.zon | 4 +- docs/.vitepress/config.mts | 10 +- docs/api/errors.md | 155 ++- docs/api/json.md | 155 +++ docs/api/types.md | 103 +- docs/api/validators.md | 18 + docs/guide/benchmarks.md | 23 +- docs/guide/error-handling.md | 161 +++ docs/guide/getting-started.md | 2 +- docs/guide/installation.md | 10 +- docs/guide/introduction.md | 4 +- docs/guide/json-parsing.md | 134 +++ docs/guide/schemas.md | 4 +- docs/guide/validation-types.md | 46 +- docs/guide/version-updates.md | 16 +- docs/index.md | 29 +- docs/package.json | 4 +- docs/public/site.webmanifest | 2 +- examples/basic.zig | 2 +- examples/callbacks.zig | 84 ++ examples/custom_messages.zig | 95 ++ examples/error_handling.zig | 23 +- examples/extended_types.zig | 125 +++ examples/json_example.zig | 2 +- examples/naming_conventions.zig | 87 ++ examples/validators.zig | 12 + src/color.zig | 62 ++ src/errors.zig | 636 ++++++++++-- src/json.zig | 661 ++++++++++++- src/{utils => }/network.zig | 7 +- src/report.zig | 37 +- src/types.zig | 1248 ++++++++++++++++++++---- src/utils.zig | 201 ++++ src/validators.zig | 462 +++++++-- src/version.zig | 17 +- src/zigantic.zig | 405 +++++++- 46 files changed, 4978 insertions(+), 641 deletions(-) create mode 100644 examples/callbacks.zig create mode 100644 examples/custom_messages.zig create mode 100644 examples/extended_types.zig create mode 100644 examples/naming_conventions.zig create mode 100644 src/color.zig rename src/{utils => }/network.zig (91%) create mode 100644 src/utils.zig diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 21fd375..86e3d55 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -21,7 +21,7 @@ body: attributes: label: Zigantic Version description: What version of zigantic are you using? - placeholder: "e.g., 0.0.1" + placeholder: "e.g., 0.0.3" validations: required: true @@ -30,7 +30,7 @@ body: attributes: label: Zig Version description: What version of Zig are you using? (run `zig version`) - placeholder: "e.g., 0.15.0" + placeholder: "e.g., 0.16.0" validations: required: true diff --git a/.github/ISSUE_TEMPLATE/help_wanted.yml b/.github/ISSUE_TEMPLATE/help_wanted.yml index f8db1d7..b689278 100644 --- a/.github/ISSUE_TEMPLATE/help_wanted.yml +++ b/.github/ISSUE_TEMPLATE/help_wanted.yml @@ -41,7 +41,7 @@ body: attributes: label: Zigantic Version description: What version of zigantic are you using? - placeholder: "e.g., 0.0.1" + placeholder: "e.g., 0.0.3" validations: required: true @@ -50,7 +50,7 @@ body: attributes: label: Zig Version description: What version of Zig are you using? (run `zig version`) - placeholder: "e.g., 0.15.0" + placeholder: "e.g., 0.16.0" validations: required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 339b2c2..f34c41a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Zig uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Run Zig tests run: zig build test --summary all @@ -72,7 +72,7 @@ jobs: - name: Setup Zig uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Install QEMU and Multilib (Linux) if: runner.os == 'Linux' && (matrix.target == 'aarch64-linux' || matrix.target == 'x86-linux') @@ -125,7 +125,7 @@ jobs: - name: Setup Zig uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Build for ${{ matrix.target }} (build-only) run: zig build -Dtarget=${{ matrix.target }} -Doptimize=Debug diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89a7a7c..316c25f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Run Tests run: zig build test @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v4 - uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Run Benchmarks id: run_bench run: | @@ -69,7 +69,7 @@ jobs: - uses: actions/checkout@v4 - uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Build Library run: zig build -Doptimize=ReleaseSafe -Dtarget=${{ matrix.target }} @@ -102,7 +102,7 @@ jobs: - uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Calculate Hash id: calc_hash diff --git a/.gitignore b/.gitignore index fe11cbc..bd341c6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ Desktop.ini # Logs *.log -benchmark-results.md \ No newline at end of file +benchmark-results.md + +zig-pkg +.global-cache/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5bca721..230f77a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Thank you for your interest in contributing to zigantic! cd zigantic ``` -2. Ensure you have Zig 0.15.0 or later installed. +2. Ensure you have Zig 0.16.0 or later installed. 3. Run tests: ```bash diff --git a/README.md b/README.md index c20ac94..35ae4f6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ logo Documentation -Zig Version +Zig Version GitHub stars GitHub issues GitHub pull requests @@ -15,7 +15,7 @@ GitHub Sponsors Repo Visitors -

Pydantic-like data validation and JSON serialization for Zig - bringing type-safe validation to the Zig ecosystem.

+

Type-safe data validation and JSON serialization for Zig with compile-time guarantees.

Documentation | API Reference | @@ -26,7 +26,7 @@ --- -zigantic brings Pydantic-style data validation to Zig, using the type system for compile-time guarantees. Define validation rules as types, parse JSON with automatic error handling, and serialize with zero runtime overhead for unused features. +zigantic is a data validation library for Zig, using the type system for compile-time guarantees. Define validation rules as types, parse JSON with automatic error handling, and serialize with zero runtime overhead for unused features. ## Features @@ -36,9 +36,12 @@ zigantic brings Pydantic-style data validation to Zig, using the type system for | **Idiomatic Zig** | No macros, no DSLs, no magic. Just types and functions. | [Getting Started](https://muhammad-fiaz.github.io/zigantic/guide/getting-started) | | **Human-Readable Errors**| Field-aware messages with error codes (E001, E010, etc.) | [Error Handling](https://muhammad-fiaz.github.io/zigantic/guide/error-handling) | | **Zero Overhead** | Unused features have zero runtime cost. | [Benchmarks](https://muhammad-fiaz.github.io/zigantic/guide/benchmarks) | -| **50+ Built-in Types** | Strings, numbers, formats, dates, geo, and collections. | [Types API](https://muhammad-fiaz.github.io/zigantic/api/types) | +| **60+ Built-in Types** | Strings, numbers, formats, dates, geo, crypto, and collections. | [Types API](https://muhammad-fiaz.github.io/zigantic/api/types) | | **JSON Serialization** | Parse and serialize JSON with automatic validation. | [JSON API](https://muhammad-fiaz.github.io/zigantic/api/json) | | **Custom Validators** | Define custom validation functions and transformations. | [Validators](https://muhammad-fiaz.github.io/zigantic/api/validators) | +| **Custom Messages** | Override error messages per-type with comptime config. | [Error Handling](https://muhammad-fiaz.github.io/zigantic/guide/error-handling) | +| **Lifecycle Callbacks** | Hooks for validation and serialization lifecycle events. | [Callbacks](https://muhammad-fiaz.github.io/zigantic/guide/error-handling) | +| **Color Overrides** | Customize terminal colors per validation error type. | [Error Handling](https://muhammad-fiaz.github.io/zigantic/guide/error-handling) | | **Schemas** | Define complex data structures with nested validation. | [Schemas](https://muhammad-fiaz.github.io/zigantic/guide/schemas) | | **Auto Updates** | Automatic version checking (can be disabled). | [Version & Updates](https://muhammad-fiaz.github.io/zigantic/guide/version-updates) | @@ -46,10 +49,10 @@ zigantic brings Pydantic-style data validation to Zig, using the type system for ### Release Installation (Recommended) -Install the latest stable release (v0.0.2): +Install the latest stable release for zig 0.16+ (use v0.0.3 or newer): ```bash -zig fetch --save https://github.com/muhammad-fiaz/zigantic/archive/refs/tags/v0.0.2.tar.gz +zig fetch --save https://github.com/muhammad-fiaz/zigantic/archive/refs/tags/0.0.3.tar.gz ``` ### Nightly Installation @@ -110,6 +113,16 @@ pub fn main() !void { > **Note:** zigantic automatically checks for updates when using JSON functions. To disable, call `z.disableUpdateCheck()` at the start of your program. +Custom validation messages can be set per-type via the `f` suffix variants: + +```zig +const Name = z.Stringf(3, 50, .{ .too_short = "name must be at least 3 chars" }); +const err = Name.init("Jo") catch |e| e; +std.debug.print("{s}\n", .{Name.messageFor(err).?}); +``` + +Or globally via the message formatter in Config: + ### JSON Parsing with Validation ```zig @@ -117,7 +130,7 @@ const std = @import("std"); const z = @import("zigantic"); pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); @@ -155,6 +168,122 @@ pub fn main() !void { } ``` +### URL Query Parameters & Form URL-Encoded Parsing + +```zig +const std = @import("std"); +const z = @import("zigantic"); + +pub fn main() !void { + var gpa = std.heap.DebugAllocator(.{}).init; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const SearchQuery = struct { + query: z.String(1, 100), + page: z.Default(u32, 1), + active_only: bool, + }; + + const qs = "query=Mechanical+Keyboard&page=2&active_only=true"; + var result = try z.fromQueryString(SearchQuery, qs, allocator); + defer result.deinit(); + + if (result.isValid()) { + const q = result.value.?; + std.debug.print("Query: {s}, Page: {d}\n", .{q.query.get(), q.page.get()}); + + // Serialize back to query string! + const serialized = try z.toQueryString(q, allocator); + defer allocator.free(serialized); + std.debug.print("Serialized query string: {s}\n", .{serialized}); + } +} +``` + +### Compile-Time Field Aliases & Naming Policies + +Map custom aliases or use automatic naming policies (like `snake_case` or `camelCase`) completely at compile time with zero runtime cost. + +```zig +const std = @import("std"); +const z = @import("zigantic"); + +const User = struct { + firstName: []const u8, + lastName: []const u8, + + // Automatically convert camelCase struct fields to snake_case in JSON/Query strings + pub const zigantic_naming = z.utils.NamingPolicy.snake_case; + + // Explicit field aliases (overrides naming policies) + pub const zigantic_aliases = .{ + .firstName = "first", + }; +}; +``` + +### Advanced Features + +zigantic supports high-value features including dynamic default factories, field-level validation, and model-level cross-field validation. + +#### Dynamic Default Factories (`DefaultFactory`) + +Use `DefaultFactory` when default values need to be dynamically generated at instantiation/parsing time (e.g. unique IDs or dynamic timestamps). + +```zig +const std = @import("std"); +const z = @import("zigantic"); + +var call_counter: i32 = 0; +fn nextId() i32 { + call_counter += 1; + return call_counter; +} + +const Device = struct { + name: []const u8, + id: z.DefaultFactory(i32, nextId), +}; +``` + +#### Field-Level Validators (`validate_[field_name]`) + +Structs can define field-level validator methods to run custom validation or coercion/normalization logic for specific fields. A field validator receives the parsed field value and returns the final value (or an error). + +```zig +const User = struct { + username: z.String(3, 50), + age: i32, + + // Runs after basic parsing succeeds for 'age' + pub fn validate_age(val: i32) !i32 { + if (val < 18) return error.AgeTooYoung; + // Cap age at 100 as a coercion/normalization + if (val > 100) return 100; + return val; + } +}; +``` + +#### Model-Level Validation (`validateModel`) + +Structs can define a `validateModel` method to perform cross-field validation after all individual fields have successfully parsed and validated. + +```zig +const Order = struct { + item: []const u8, + quantity: i32, + discount_code: ?[]const u8, + + pub fn validateModel(self: *const @This()) !void { + if (self.discount_code != null and self.quantity < 5) { + return error.DiscountRequiresMinimumQuantity; + } + } +}; +``` + ## All Types ### String Types (9) @@ -254,20 +383,21 @@ f.trunc() // Truncate | `NonEmptyList(T, max)` | Non-empty list | Same as List | | `FixedList(T, len)` | Exact size | `at(i)` | -### Special Types (10) - -| Type | Description | Methods | -| ---------------------- | ------------------- | ------------------------------- | -| `Default(T, value)` | Default value | `isDefault()`, `getOrDefault()` | -| `Custom(T, fn)` | Custom validator | - | -| `Transform(T, fn)` | Transform value | `getOriginal()` | -| `Coerce(From, To)` | Type conversion | - | -| `Literal(T, value)` | Exact value match | - | -| `Partial(T)` | All fields optional | - | -| `OneOf(T, values)` | Allowed values | `isFirst()`, `isLast()` | -| `Range(T, s, e, step)` | Range with step | - | -| `Nullable(T)` | Explicit null | `isNull()`, `unwrapOr()` | -| `Lazy(T)` | Lazy evaluation | `isComputed()`, `reset()` | +### Special Types (11) + +| Type | Description | Methods | +| ------------------------- | ------------------- | ------------------------------- | +| `Default(T, value)` | Default value | `isDefault()`, `getOrDefault()` | +| `DefaultFactory(T, fn)` | Dynamic default | `initDefault()`, `getOrDefault()`| +| `Custom(T, fn)` | Custom validator | - | +| `Transform(T, fn)` | Transform value | `getOriginal()` | +| `Coerce(From, To)` | Type conversion | - | +| `Literal(T, value)` | Exact value match | - | +| `Partial(T)` | All fields optional | - | +| `OneOf(T, values)` | Allowed values | `isFirst()`, `isLast()` | +| `Range(T, s, e, step)` | Range with step | - | +| `Nullable(T)` | Explicit null | `isNull()`, `unwrapOr()` | +| `Lazy(T)` | Lazy evaluation | `isComputed()`, `reset()` | ## Validators @@ -322,6 +452,65 @@ const json = try errors.toJsonArray(allocator); // [{"field":"name","message":"too short","value":"Jo"}] ``` +### Custom Error Messages + +Override error messages per-type via the comptime `messages` parameter: + +```zig +const Name = z.Stringf(3, 50, .{ .too_short = "name is required" }); +const Age = z.Intf(i32, 18, 120, .{ .too_small = "must be 18+" }); +const Pwd = z.StrongPasswordf(8, 100, .{ + .too_short = "password too short", + .weak_password = "needs upper, lower, digit, special", +}); +``` + +The `messageFor(err)` method returns the custom message for the given error, or `null` if no override was set. + +For global message formatting (works with all types including Email, Url, etc.), use the config formatter: + +```zig +var cfg = z.getConfig(); +cfg.validation_message_formatter = struct { + fn f(err: z.errors.ValidationError) []const u8 { + return switch (err) { + error.InvalidEmail => "please enter a valid email address", + else => z.errorMessage(err), + }; + } +}.f; +z.setConfig(cfg); +``` + +### Lifecycle Callbacks + +Register callbacks for validation and serialization lifecycle events: + +```zig +var cfg = z.getConfig(); +cfg.before_validation_callback = struct { + fn call(type_name: []const u8) void { + std.debug.print("Validating: {s}\n", .{type_name}); + } +}.call; +cfg.on_field_validated_callback = struct { + fn call(field: []const u8, field_type: []const u8, success: bool) void { } +}.call; +cfg.on_field_error_callback = struct { + fn call(field: []const u8, msg: []const u8) void { } +}.call; +cfg.on_validation_complete_callback = struct { + fn call(valid: bool, error_count: usize) void { } +}.call; +cfg.before_serialize_callback = struct { + fn call() void { } +}.call; +cfg.after_serialize_callback = struct { + fn call(result: []const u8) void { } +}.call; +z.setConfig(cfg); +``` + ### Error Codes | Code | Error | Message | @@ -338,21 +527,24 @@ const json = try errors.toJsonArray(allocator); ## Examples -The library includes 5 comprehensive examples: +The library includes 8 comprehensive examples: ```bash -zig build run-basic # Direct validation + JSON -zig build run-advanced_types # All 40+ types demo -zig build run-validators # Validator functions -zig build run-json_example # Full JSON workflow -zig build run-error_handling # Error management +zig build run-basic # Direct validation + JSON +zig build run-advanced_types # All 50+ types demo +zig build run-validators # Validator functions +zig build run-json_example # Full JSON workflow +zig build run-error_handling # Error management +zig build run-naming_conventions # Compile-time Casing conventions and explicit Aliases +zig build run-custom_messages # Custom validation messages +zig build run-callbacks # Lifecycle callbacks ``` ## Building ```bash zig build # Build library -zig build test # Run 102 tests +zig build test # Run 148+ tests zig build example # Run basic example ``` diff --git a/SECURITY.md b/SECURITY.md index 255366e..1b02697 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Supported | | ------- | ------------------ | -| 0.0.2 | :white_check_mark: | +| 0.0.3+ | :white_check_mark: | ## Reporting a Vulnerability diff --git a/bench/benchmark.zig b/bench/benchmark.zig index d8c9551..4360203 100644 --- a/bench/benchmark.zig +++ b/bench/benchmark.zig @@ -1,10 +1,7 @@ -//! Comprehensive benchmarks for zigantic covering all validation features. - const std = @import("std"); const z = @import("zigantic"); const builtin = @import("builtin"); -/// Benchmark results structure const BenchmarkResult = struct { name: []const u8, iterations: u64, @@ -13,13 +10,14 @@ const BenchmarkResult = struct { avg_latency_ns: f64, category: []const u8, - // Static categories for grouping const categories = [_][]const u8{ "String Validation", "Number Validation", "Format Validation", + "Extended Types", "JSON Parsing", "Collection Validation", + "Utility Methods", }; }; @@ -67,17 +65,18 @@ fn runBenchmark( comptime benchFn: anytype, category: []const u8, ) BenchmarkResult { - // Warmup for (0..WARMUP) |_| { benchFn(); } - // Benchmark - var timer = std.time.Timer.start() catch unreachable; + var threaded: std.Io.Threaded = .init_single_threaded; + const io = threaded.io(); + const start_time = std.Io.Timestamp.now(io, .awake); for (0..ITERATIONS) |_| { benchFn(); } - const total_time_ns = timer.read(); + const end_time = std.Io.Timestamp.now(io, .awake); + const total_time_ns = @as(u64, @intCast(start_time.durationTo(end_time).nanoseconds)); const ops_per_sec = @as(f64, @floatFromInt(ITERATIONS)) / (@as(f64, @floatFromInt(total_time_ns)) / 1_000_000_000.0); const avg_latency_ns = @as(f64, @floatFromInt(total_time_ns)) / @as(f64, @floatFromInt(ITERATIONS)); @@ -92,8 +91,6 @@ fn runBenchmark( }; } -// -- String Validation Benchmarks -- - fn benchmarkStringBasic() void { const Name = z.String(1, 50); _ = Name.init("Alice Johnson") catch {}; @@ -122,7 +119,25 @@ fn benchmarkEmailComplex() void { _ = z.Email.init("very.long.email.address+tag@subdomain.example.company.com") catch {}; } -// -- Number Validation Benchmarks -- +fn benchmarkLowercase() void { + const L = z.Lowercase(50); + _ = L.init("hello world") catch {}; +} + +fn benchmarkUppercase() void { + const U = z.Uppercase(50); + _ = U.init("HELLO WORLD") catch {}; +} + +fn benchmarkAlphanumeric() void { + const A = z.Alphanumeric(1, 50); + _ = A.init("abc123def456") catch {}; +} + +fn benchmarkAsciiString() void { + const A = z.AsciiString(1, 50); + _ = A.init("Hello World 123!") catch {}; +} fn benchmarkIntBasic() void { const Age = z.Int(i32, 0, 150); @@ -149,7 +164,15 @@ fn benchmarkMultipleOf() void { _ = Multiple.init(100) catch {}; } -// -- Format Validation Benchmarks -- +fn benchmarkEvenInt() void { + const E = z.EvenInt(i32, 0, 1000); + _ = E.init(42) catch {}; +} + +fn benchmarkOddInt() void { + const O = z.OddInt(i32, 0, 1000); + _ = O.init(43) catch {}; +} fn benchmarkUrl() void { _ = z.Url.init("https://example.com/path/to/resource?query=value") catch {}; @@ -183,10 +206,81 @@ fn benchmarkPhoneNumber() void { _ = z.PhoneNumber.init("+1234567890") catch {}; } -// -- JSON Parsing Benchmarks -- +fn benchmarkHexColor() void { + _ = z.HexColor().init("#ff5733") catch {}; +} + +fn benchmarkMacAddress() void { + _ = z.MacAddress().init("00:1A:2B:3C:4D:5E") catch {}; +} + +fn benchmarkIsoDateTime() void { + _ = z.IsoDateTime().init("2024-01-15T10:30:00Z") catch {}; +} + +fn benchmarkIsoDate() void { + _ = z.IsoDate().init("2024-01-15") catch {}; +} + +fn benchmarkCountryCode() void { + _ = z.CountryCode().init("US") catch {}; +} + +fn benchmarkCurrencyCode() void { + _ = z.CurrencyCode().init("USD") catch {}; +} + +fn benchmarkLatitude() void { + _ = z.Latitude().init(45.0) catch {}; +} + +fn benchmarkLongitude() void { + _ = z.Longitude().init(-75.0) catch {}; +} + +fn benchmarkPort() void { + _ = z.Port().init(443) catch {}; +} + +fn benchmarkIban() void { + _ = z.Iban.init("DE89370400440532013000") catch {}; +} + +fn benchmarkBase58() void { + _ = z.Base58.init("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa") catch {}; +} + +fn benchmarkHslColor() void { + _ = z.HslColor.init("hsl(120, 100%, 50%)") catch {}; +} + +fn benchmarkDuration() void { + _ = z.Duration.init("P1Y2M3DT4H5M6S") catch {}; +} + +fn benchmarkCronExpression() void { + _ = z.CronExpression.init("0 12 * * *") catch {}; +} + +fn benchmarkIsbn10() void { + _ = z.Isbn10.init("0-306-40615-2") catch {}; +} + +fn benchmarkIsbn13() void { + _ = z.Isbn13.init("978-0-306-40615-7") catch {}; +} + +fn benchmarkStrongPasswordStrict() void { + _ = z.StrongPasswordStrict.init("P@ssw0rd!") catch {}; +} + +fn benchmarkAsciiAlphaString() void { + const A = z.AsciiAlphaString(1, 50); + _ = A.init("HelloWorld") catch {}; +} fn benchmarkJsonSimple() void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); @@ -195,13 +289,13 @@ fn benchmarkJsonSimple() void { age: z.Int(i32, 0, 150), }; - const json = "{\"name\": \"Alice\", \"age\": 30}"; - var result = z.fromJson(SimpleUser, json, allocator) catch return; + const json_str = "{\"name\": \"Alice\", \"age\": 30}"; + var result = z.fromJson(SimpleUser, json_str, allocator) catch return; result.deinit(); } fn benchmarkJsonComplex() void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); @@ -213,15 +307,15 @@ fn benchmarkJsonComplex() void { role: z.Default([]const u8, "user"), }; - const json = + const json_str = \\{"id": 123, "name": "Alice Johnson", "email": "alice@example.com", "age": 30} ; - var result = z.fromJson(ComplexUser, json, allocator) catch return; + var result = z.fromJson(ComplexUser, json_str, allocator) catch return; result.deinit(); } fn benchmarkToJson() void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); @@ -239,11 +333,24 @@ fn benchmarkToJson() void { allocator.free(json_str); } -// -- Collection Validation Benchmarks -- +fn benchmarkFromQueryString() void { + var gpa = std.heap.DebugAllocator(.{}).init; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const User = struct { + name: z.String(1, 50), + age: z.Int(i32, 0, 150), + }; + + const qs = "name=Alice&age=25"; + var result = z.fromQueryString(User, qs, allocator) catch return; + result.deinit(); +} fn benchmarkList() void { const Tags = z.List([]const u8, 1, 10); - const items = [_][]const u8{ "zig", "validation", "pydantic" }; + const items = [_][]const u8{ "zig", "validation", "types" }; _ = Tags.init(&items) catch {}; } @@ -253,15 +360,68 @@ fn benchmarkFixedList() void { _ = Coords.init(&values) catch {}; } +fn benchmarkListSum() void { + const L = z.List(u32, 1, 10); + const items = [_]u32{ 10, 20, 30, 40, 50 }; + const list = L.init(&items) catch return; + _ = list.sum(); +} + +fn benchmarkListContains() void { + const L = z.List(u32, 1, 10); + const items = [_]u32{ 10, 20, 30, 40, 50 }; + const list = L.init(&items) catch return; + _ = list.contains(30); +} + +fn benchmarkEmailMethods() void { + const email = z.Email.init("user+tag@example.com") catch return; + _ = email.hasTag(); + _ = email.tld(); + _ = email.isFreeEmail(); +} + +fn benchmarkUrlMethods() void { + const url = z.Url.init("https://example.com:8080/path?q=1#section") catch return; + _ = url.port(); + _ = url.query(); + _ = url.fragment(); + _ = url.filename(); +} + +fn benchmarkStrongPassword() void { + const Pwd = z.StrongPassword(8, 100); + _ = Pwd.init("P@ssw0rd!") catch {}; +} + +fn benchmarkOneOf() void { + const Status = z.OneOf(u8, &[_]u8{ 1, 2, 3 }); + _ = Status.init(2) catch {}; +} + +fn benchmarkRange() void { + const R = z.Range(i32, 0, 100, 10); + _ = R.init(50) catch {}; +} + +fn benchmarkNullable() void { + const N = z.Nullable(u32); + _ = N.init(42); +} + +fn benchmarkDefault() void { + const Role = z.Default([]const u8, "user"); + _ = Role.initDefault(); +} + pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); - var results: std.ArrayListUnmanaged(BenchmarkResult) = .{}; + var results = std.ArrayList(BenchmarkResult).empty; defer results.deinit(allocator); - // Disable update checking for benchmarks z.disableUpdateCheck(); std.debug.print("\n[INFO] Running zigantic benchmarks...\n", .{}); @@ -273,34 +433,72 @@ pub fn main() !void { try results.append(allocator, runBenchmark("String(1,1000) - Long string", benchmarkStringLong, "String Validation")); try results.append(allocator, runBenchmark("Trimmed(1,100) - Auto-trim", benchmarkTrimmed, "String Validation")); try results.append(allocator, runBenchmark("Secret(8,100) - Password", benchmarkSecret, "String Validation")); - try results.append(allocator, runBenchmark("Email - Simple address", benchmarkEmail, "String Validation")); - try results.append(allocator, runBenchmark("Email - Complex address", benchmarkEmailComplex, "String Validation")); + try results.append(allocator, runBenchmark("StrongPassword(8,100)", benchmarkStrongPassword, "String Validation")); + try results.append(allocator, runBenchmark("Lowercase(50)", benchmarkLowercase, "String Validation")); + try results.append(allocator, runBenchmark("Uppercase(50)", benchmarkUppercase, "String Validation")); + try results.append(allocator, runBenchmark("Alphanumeric(1,50)", benchmarkAlphanumeric, "String Validation")); + try results.append(allocator, runBenchmark("AsciiString(1,50)", benchmarkAsciiString, "String Validation")); // Number Validation try results.append(allocator, runBenchmark("Int(i32,0,150) - Basic", benchmarkIntBasic, "Number Validation")); try results.append(allocator, runBenchmark("Int(i32,-1000,1000) - Range", benchmarkIntRange, "Number Validation")); try results.append(allocator, runBenchmark("PositiveInt(u32)", benchmarkPositiveInt, "Number Validation")); + try results.append(allocator, runBenchmark("EvenInt(i32,0,1000)", benchmarkEvenInt, "Number Validation")); + try results.append(allocator, runBenchmark("OddInt(i32,0,1000)", benchmarkOddInt, "Number Validation")); try results.append(allocator, runBenchmark("Percentage(f64)", benchmarkFloat, "Number Validation")); try results.append(allocator, runBenchmark("MultipleOf(i32,5)", benchmarkMultipleOf, "Number Validation")); // Format Validation + try results.append(allocator, runBenchmark("Email - Simple", benchmarkEmail, "Format Validation")); + try results.append(allocator, runBenchmark("Email - Complex", benchmarkEmailComplex, "Format Validation")); try results.append(allocator, runBenchmark("Url - HTTPS with query", benchmarkUrl, "Format Validation")); - try results.append(allocator, runBenchmark("Uuid - Standard format", benchmarkUuid, "Format Validation")); - try results.append(allocator, runBenchmark("Ipv4 - Address", benchmarkIpv4, "Format Validation")); - try results.append(allocator, runBenchmark("Ipv6 - Full address", benchmarkIpv6, "Format Validation")); - try results.append(allocator, runBenchmark("Slug - URL slug", benchmarkSlug, "Format Validation")); - try results.append(allocator, runBenchmark("Semver - Version string", benchmarkSemver, "Format Validation")); + try results.append(allocator, runBenchmark("Uuid", benchmarkUuid, "Format Validation")); + try results.append(allocator, runBenchmark("Ipv4", benchmarkIpv4, "Format Validation")); + try results.append(allocator, runBenchmark("Ipv6", benchmarkIpv6, "Format Validation")); + try results.append(allocator, runBenchmark("Slug", benchmarkSlug, "Format Validation")); + try results.append(allocator, runBenchmark("Semver", benchmarkSemver, "Format Validation")); try results.append(allocator, runBenchmark("CreditCard - Visa", benchmarkCreditCard, "Format Validation")); - try results.append(allocator, runBenchmark("PhoneNumber - International", benchmarkPhoneNumber, "Format Validation")); + try results.append(allocator, runBenchmark("PhoneNumber", benchmarkPhoneNumber, "Format Validation")); + try results.append(allocator, runBenchmark("HexColor", benchmarkHexColor, "Format Validation")); + try results.append(allocator, runBenchmark("MacAddress", benchmarkMacAddress, "Format Validation")); + try results.append(allocator, runBenchmark("IsoDateTime", benchmarkIsoDateTime, "Format Validation")); + try results.append(allocator, runBenchmark("IsoDate", benchmarkIsoDate, "Format Validation")); + try results.append(allocator, runBenchmark("CountryCode", benchmarkCountryCode, "Format Validation")); + try results.append(allocator, runBenchmark("CurrencyCode", benchmarkCurrencyCode, "Format Validation")); + try results.append(allocator, runBenchmark("Latitude", benchmarkLatitude, "Format Validation")); + try results.append(allocator, runBenchmark("Longitude", benchmarkLongitude, "Format Validation")); + try results.append(allocator, runBenchmark("Port", benchmarkPort, "Format Validation")); + + // Extended Types + try results.append(allocator, runBenchmark("Iban", benchmarkIban, "Extended Types")); + try results.append(allocator, runBenchmark("Base58", benchmarkBase58, "Extended Types")); + try results.append(allocator, runBenchmark("HslColor", benchmarkHslColor, "Extended Types")); + try results.append(allocator, runBenchmark("Duration", benchmarkDuration, "Extended Types")); + try results.append(allocator, runBenchmark("CronExpression", benchmarkCronExpression, "Extended Types")); + try results.append(allocator, runBenchmark("Isbn10", benchmarkIsbn10, "Extended Types")); + try results.append(allocator, runBenchmark("Isbn13", benchmarkIsbn13, "Extended Types")); + try results.append(allocator, runBenchmark("StrongPasswordStrict", benchmarkStrongPasswordStrict, "Extended Types")); + try results.append(allocator, runBenchmark("AsciiAlphaString(1,50)", benchmarkAsciiAlphaString, "Extended Types")); // JSON Parsing try results.append(allocator, runBenchmark("fromJson - Simple struct", benchmarkJsonSimple, "JSON Parsing")); try results.append(allocator, runBenchmark("fromJson - Complex struct", benchmarkJsonComplex, "JSON Parsing")); try results.append(allocator, runBenchmark("toJson - Serialize", benchmarkToJson, "JSON Parsing")); + try results.append(allocator, runBenchmark("fromQueryString", benchmarkFromQueryString, "JSON Parsing")); // Collection Validation try results.append(allocator, runBenchmark("List([]const u8,1,10)", benchmarkList, "Collection Validation")); try results.append(allocator, runBenchmark("FixedList(i32,3)", benchmarkFixedList, "Collection Validation")); + try results.append(allocator, runBenchmark("List.sum()", benchmarkListSum, "Collection Validation")); + try results.append(allocator, runBenchmark("List.contains()", benchmarkListContains, "Collection Validation")); + + // Utility Methods + try results.append(allocator, runBenchmark("Email methods (hasTag, tld, isFreeEmail)", benchmarkEmailMethods, "Utility Methods")); + try results.append(allocator, runBenchmark("Url methods (port, query, fragment, filename)", benchmarkUrlMethods, "Utility Methods")); + try results.append(allocator, runBenchmark("OneOf(u8)", benchmarkOneOf, "Utility Methods")); + try results.append(allocator, runBenchmark("Range(i32,0,100,10)", benchmarkRange, "Utility Methods")); + try results.append(allocator, runBenchmark("Nullable(u32)", benchmarkNullable, "Utility Methods")); + try results.append(allocator, runBenchmark("Default([]const u8,\"user\")", benchmarkDefault, "Utility Methods")); // Print all results to console printResults(results.items); @@ -329,21 +527,25 @@ pub fn main() !void { const avg_ops = if (count > 0) total_ops / @as(f64, @floatFromInt(count)) else 0; const avg_latency = if (avg_ops > 0) 1_000_000_000.0 / avg_ops else 0; - // Write final Markdown report - const md_file = std.fs.cwd().createFile("benchmark-results.md", .{}) catch |err| { + // Write Markdown report + var threaded: std.Io.Threaded = .init_single_threaded; + const io = threaded.io(); + const cwd = std.Io.Dir.cwd(); + const md_file = cwd.createFile(io, "benchmark-results.md", .{}) catch |err| { std.debug.print("Warning: Could not create benchmark-results.md: {}\n", .{err}); return; }; - defer md_file.close(); + defer md_file.close(io); var buf: [4096]u8 = undefined; const header = std.fmt.bufPrint(&buf, - \\#### 📊 ZIGANTIC BENCHMARK RESULTS + \\#### ZIGANTIC BENCHMARK RESULTS \\ \\**Environment Details:** \\- **Platform:** {s} \\- **Architecture:** {s} + \\- **Version:** {s} \\- **Warmup Iterations:** {d} \\- **Benchmark Iterations:** {d} \\ @@ -351,12 +553,12 @@ pub fn main() !void { , .{ @tagName(builtin.os.tag), @tagName(builtin.cpu.arch), + z.getVersion(), WARMUP, ITERATIONS, }) catch ""; - try md_file.writeAll(header); + try md_file.writeStreamingAll(io, header); - // Write categorized tables for (BenchmarkResult.categories) |cat| { var has_category = false; for (results.items) |r| { @@ -376,7 +578,7 @@ pub fn main() !void { \\| :--- | :--- | :--- | \\ , .{cat}) catch continue; - try md_file.writeAll(cat_header); + try md_file.writeStreamingAll(io, cat_header); for (results.items) |r| { if (std.mem.eql(u8, r.category, cat)) { @@ -385,14 +587,14 @@ pub fn main() !void { r.ops_per_sec, r.avg_latency_ns, }) catch continue; - try md_file.writeAll(line); + try md_file.writeStreamingAll(io, line); } } - try md_file.writeAll("\n"); + try md_file.writeStreamingAll(io, "\n"); } if (count > 0) { - try md_file.writeAll("\n### 📈 Benchmark Summary\n\n"); + try md_file.writeStreamingAll(io, "\n### Benchmark Summary\n\n"); const summary = std.fmt.bufPrint(&buf, \\- **Total benchmarks run:** {d} \\- **Average throughput:** {d:.0} ops/sec @@ -401,7 +603,7 @@ pub fn main() !void { \\- **Average latency:** {d:.0} ns \\ , .{ count, avg_ops, max_ops, max_name, min_ops, min_name, avg_latency }) catch ""; - try md_file.writeAll(summary); + try md_file.writeStreamingAll(io, summary); } std.debug.print("\n[OK] Benchmarks completed successfully!\n", .{}); diff --git a/build.zig b/build.zig index 80119f2..43781fc 100644 --- a/build.zig +++ b/build.zig @@ -21,6 +21,10 @@ pub fn build(b: *std.Build) void { .{ .name = "validators", .path = "examples/validators.zig" }, .{ .name = "json_example", .path = "examples/json_example.zig" }, .{ .name = "error_handling", .path = "examples/error_handling.zig" }, + .{ .name = "naming_conventions", .path = "examples/naming_conventions.zig" }, + .{ .name = "custom_messages", .path = "examples/custom_messages.zig" }, + .{ .name = "callbacks", .path = "examples/callbacks.zig" }, + .{ .name = "extended_types", .path = "examples/extended_types.zig" }, }; // Create run-all-examples step diff --git a/build.zig.zon b/build.zig.zon index 6f2399f..30f53ce 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,8 +1,8 @@ .{ .name = .zigantic, - .version = "0.0.2", + .version = "0.0.3", .fingerprint = 0x1fba9aa888c5185b, - .minimum_zig_version = "0.15.0", + .minimum_zig_version = "0.16.0", .paths = .{ "src", "build.zig", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f4de6dd..305087a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -4,7 +4,7 @@ import llmstxt from "vitepress-plugin-llms"; // Site configuration export const SITE_URL = "https://muhammad-fiaz.github.io/zigantic"; export const SITE_NAME = "zigantic"; -export const SITE_DESCRIPTION = "Pydantic-like data validation and JSON serialization for Zig. 40+ validation types, compile-time driven, zero runtime overhead."; +export const SITE_DESCRIPTION = "Type-safe data validation and JSON serialization for Zig. 60+ validation types, compile-time driven, zero runtime overhead."; // Google Analytics and Google Tag Manager IDs export const GA_ID = "G-6BVYCRK57P"; @@ -13,7 +13,7 @@ export const GTM_ID = "GTM-P4M9T8ZR"; // Google AdSense Client ID export const ADSENSE_CLIENT_ID = "ca-pub-2040560600290490"; // SEO Keywords -export const KEYWORDS = "zig, zigantic, pydantic, validation, json, serialization, types, compile-time, data validation, type-safe, email validation, url validation, uuid, ipv4, ipv6"; +export const KEYWORDS = "zig, zigantic, validation, json, serialization, types, compile-time, data validation, type-safe, email validation, url validation, uuid, ipv4, ipv6, iban, isbn, base58"; export default defineConfig({ lang: "en-US", @@ -52,7 +52,7 @@ export default defineConfig({ ["meta", { property: "og:image", content: `${SITE_URL}/cover.png` }], ["meta", { property: "og:image:width", content: "1200" }], ["meta", { property: "og:image:height", content: "630" }], - ["meta", { property: "og:image:alt", content: "zigantic - Pydantic-like validation for Zig" }], + ["meta", { property: "og:image:alt", content: "zigantic - Type-safe validation for Zig" }], ["meta", { property: "og:site_name", content: SITE_NAME }], ["meta", { property: "og:locale", content: "en_US" }], @@ -207,7 +207,7 @@ gtag('config', '${GA_ID}');`, priceCurrency: "USD", }, downloadUrl: "https://github.com/muhammad-fiaz/zigantic", - softwareVersion: "0.0.2", + softwareVersion: "0.0.3", license: "https://opensource.org/licenses/MIT", }); } else { @@ -284,7 +284,7 @@ gtag('config', '${GA_ID}');`, { text: "Guide", link: "/guide/getting-started" }, { text: "API", link: "/api/types" }, { - text: "v0.0.2", + text: "v0.0.3", items: [ { text: "Changelog", link: "https://github.com/muhammad-fiaz/zigantic/releases" }, { text: "Contributing", link: "https://github.com/muhammad-fiaz/zigantic/blob/main/CONTRIBUTING.md" }, diff --git a/docs/api/errors.md b/docs/api/errors.md index 3d37a2e..3ff4472 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -12,30 +12,53 @@ All possible validation errors: | `TooLong` | value is too long | E002 | | `TooSmall` | value is too small | E003 | | `TooLarge` | value is too large | E004 | -| `InvalidEmail` | must be valid email | E010 | -| `InvalidUrl` | must be valid URL | E011 | -| `InvalidUuid` | must be valid UUID | - | -| `InvalidIpv4` | must be valid IPv4 | - | -| `InvalidIpv6` | must be valid IPv6 | - | -| `InvalidPhoneNumber` | invalid phone | - | -| `InvalidCreditCard` | invalid card | - | -| `MissingField` | field required | E020 | +| `InvalidEmail` | must be a valid email address | E010 | +| `InvalidUrl` | must be a valid URL | E011 | +| `InvalidUuid` | must be a valid UUID | E012 | +| `InvalidIpv4` | must be a valid IPv4 address | E013 | +| `InvalidIpv6` | must be a valid IPv6 address | E014 | +| `InvalidPhoneNumber` | must be a valid phone number | E015 | +| `InvalidCreditCard` | must be a valid credit card number | E016 | +| `MissingField` | field is required | E020 | | `TypeMismatch` | wrong type | E021 | -| `PatternMismatch` | doesn't match | - | -| `MustBeLowercase` | must be lowercase | - | -| `MustBeUppercase` | must be uppercase | - | -| `WeakPassword` | password too weak | - | -| `MustBeEven` | must be even | - | -| `MustBeOdd` | must be odd | - | -| `NotMultiple` | not multiple | - | -| `MustBeHttps` | must be HTTPS | - | -| `OutOfRange` | out of range | - | -| `NotInStep` | not in step | - | -| `WrongLength` | wrong length | - | -| `TooFewItems` | too few items | - | -| `TooManyItems` | too many items | - | -| `NotInAllowedValues` | not allowed | - | +| `PatternMismatch` | does not match required pattern | E022 | +| `MustBeLowercase` | must be lowercase | E023 | +| `MustBeUppercase` | must be uppercase | E024 | +| `WeakPassword` | password is too weak | E025 | +| `MustBeEven` | must be even | E030 | +| `MustBeOdd` | must be odd | E031 | +| `NotMultiple` | must be a multiple of the divisor | E032 | +| `MustBeHttps` | must be HTTPS | E033 | +| `OutOfRange` | value is out of range | E034 | +| `NotInStep` | value must be in step increments | E035 | +| `InvalidInteger` | must be a valid integer | E036 | +| `InvalidBoolean` | must be a valid boolean | E037 | +| `InvalidArray` | must be a valid array | E038 | +| `InvalidObject` | must be a valid object | E039 | +| `InvalidString` | must be a valid string | E040 | +| `UnknownField` | unknown field | E041 | +| `DuplicateField` | duplicate field | E042 | +| `InvalidDate` | must be a valid ISO date | E043 | +| `InvalidTime` | must be a valid ISO time | E044 | +| `LiteralMismatch` | does not match expected literal | E045 | +| `NotInAllowedValues` | value is not allowed | E046 | +| `WrongLength` | wrong length | E047 | +| `TooFewItems` | too few items | E050 | +| `TooManyItems` | too many items | E051 | +| `DuplicateItem` | duplicate item found | E052 | +| `EmptyCollection` | collection cannot be empty | E053 | +| `NotPositive` | must be positive | E054 | +| `NotNegative` | must be negative | E055 | +| `NotZero` | must not be zero | E056 | +| `DivisionByZero` | division by zero | E057 | +| `InvalidFormat` | invalid format | E058 | +| `EmptyString` | cannot be empty | E059 | +| `InvalidNumber` | must be a valid number | E060 | | `CustomValidationFailed` | validation failed | E099 | +| `InvalidJson` | invalid JSON syntax | E100 | +| `NestedError` | nested validation error | E101 | +| `ValidationFailed` | validation failed | E102 | +| `ParseError` | failed to parse value | E103 | ## Error Functions @@ -59,6 +82,7 @@ pub const FieldError = struct { // Methods err.format(allocator) // "field: message (got: value)" +err.formatColored(allocator) // colored terminal output err.toJson(allocator) // {"field":"...","message":"..."} ``` @@ -87,6 +111,9 @@ errors.containsErrorType(error.TooShort) // bool // Format errors.formatAll(allocator) // "field: msg\nfield2: msg2\n" +errors.formatAllColored(allocator) // colorized terminal output +errors.formatAllWith(allocator, formatter) // custom messages +errors.formatAllColoredWith(allocator, formatter) // colored custom messages errors.toJsonArray(allocator) // [{"field":"..."},...] // Merge @@ -120,14 +147,94 @@ if (!result.isValid()) { } ``` +## ValidationMessageConfig + +Comptime config for overriding validation error messages on parameterized types: + +```zig +const config = errors.ValidationMessageConfig{ + .too_short = "custom too short message", + .too_large = "custom too large message", +}; + +// Check if a message override exists +const msg = errors.messageForConfig(error.TooShort, config); // ?[]const u8 +``` + +Available fields correspond to each `ValidationError` variant. Use via the `f` suffix types: + +```zig +const Name = Stringf(1, 50, .{ .too_short = "name is required" }); +``` + +Or using the config directly: + +```zig +err.message = errors.messageForWithConfig(err_type, formatter, config); +``` + +## Color Overrides + +Override the default ANSI color for specific validation error types: + +```zig +// Set color overrides globally +z.setColorOverrides(.{ + .too_short = .bright_red, // Override TooShort color + .invalid_email = .magenta, // Override InvalidEmail color +}); + +// Or set via Config +var cfg = z.getConfig(); +cfg.color_overrides = .{ + .too_short = .bright_red, + .weak_password = .yellow, +}; +z.setConfig(cfg); +``` + +Color resolution order: +1. `ColorOverrides` field for the error type (if non-null) +2. Built-in default color from `errorPresentation()` + +Disable colors entirely: +```zig +z.disableColor(); // All output becomes plain text +z.enableColor(); // Re-enable colored output +``` + ## Version Utilities ```zig -z.getVersion() // "0.0.1" -z.getVersionString() // "v0.0.1" +z.getVersion() // "0.0.3" +z.getVersionString() // "v0.0.3" z.ISSUES_URL // GitHub issues URL ``` +## Color Utilities + +```zig +const presentation = z.errorPresentation(z.errors.ValidationError.TooShort); +presentation.message // "value is too short" +presentation.code // "E001" +presentation.color // ANSI color used by the formatter + +// The helper also exposes the color category directly. +z.errorColor(z.errors.ValidationError.InvalidEmail) // .blue + +// Reusable formatter for custom messages +const custom = struct { + fn format(err: z.errors.ValidationError) []const u8 { + return switch (err) { + z.errors.ValidationError.InvalidEmail => "please provide a valid email address", + else => z.errorMessage(err), + }; + } +}.format; + +const custom_text = try errors.formatAllWith(allocator, custom); +``` + ## Internal Error Reporting ::: warning diff --git a/docs/api/json.md b/docs/api/json.md index 1bb4602..6f3418a 100644 --- a/docs/api/json.md +++ b/docs/api/json.md @@ -304,3 +304,158 @@ const json = ; ``` +--- + +## URL Query String & Form URL-Encoded Parsing + +### z.fromQueryString + +Parse form-urlencoded parameter payload or URL query string into a validated struct. Characters like `+` are automatically decoded to spaces, and percent encodings (`%XX`) are resolved in place with high efficiency. + +```zig +pub fn fromQueryString( + comptime T: type, + query_string: []const u8, + allocator: std.mem.Allocator +) !ParseResult(T) +``` + +### z.toQueryString + +Serialize a struct instance into a form-urlencoded parameters string: + +```zig +pub fn toQueryString( + value: anytype, + allocator: std.mem.Allocator +) ![]const u8 +``` + +**Example:** + +```zig +const Search = struct { + query: z.String(1, 50), + page: z.Default(u32, 1), + active: bool, +}; + +const qs = "query=Mechanical+Keyboard&page=2&active=true"; +var result = try z.fromQueryString(Search, qs, allocator); +defer result.deinit(); + +if (result.isValid()) { + const s = result.value.?; + // s.query.get() == "Mechanical Keyboard" + // s.page.get() == 2 + // s.active == true +} +``` + +--- + +## Compile-Time Field Aliases & Naming Policies + +Map custom aliases or use automatic naming policies completely at compile time with zero runtime cost. + +### Automatic Naming Policies + +Use `pub const zigantic_naming` to map `camelCase` struct fields to common formatting styles like `snake_case` or `kebab-case` when serializing and deserializing. + +```zig +const User = struct { + firstName: []const u8, + lastName: []const u8, + + // Maps firstName -> first_name, lastName -> last_name automatically + pub const zigantic_naming = z.utils.NamingPolicy.snake_case; +}; +``` + +### Explicit Field Aliases + +Use `pub const zigantic_aliases` to specify exact custom key mappings for individual fields. This takes precedence over automatic naming policies. + +```zig +const Product = struct { + productName: []const u8, + priceInUsd: f64, + + pub const zigantic_aliases = .{ + .productName = "name", + .priceInUsd = "price", + }; +}; +``` + +--- + +## Advanced Features + +zigantic includes advanced, high-value validation decorators and dynamic factories. + +### Dynamic Default Factories (`DefaultFactory`) + +While `Default` is used for compile-time constant default values, `DefaultFactory` is used to dynamically generate default values at parsing/deserialization time (e.g. timestamps, UUIDs, or counter-based IDs). + +```zig +const std = @import("std"); +const z = @import("zigantic"); + +var call_counter: i32 = 0; +fn nextId() i32 { + call_counter += 1; + return call_counter; +} + +const Device = struct { + name: []const u8, + id: z.DefaultFactory(i32, nextId), +}; + +// If "id" is missing in JSON, nextId() is automatically called to supply the value. +``` + +### Field-Level Validators (`validate_[field_name]`) + +Structs can define field-level validator functions that run automatically when parsing a field from JSON or URL Query maps. A field-level validator: +- Must have the name `validate_[field_name]` (e.g. `validate_age`). +- Receives the parsed field value. +- Returns either a validated/modified value or a Zig validation error. + +```zig +const User = struct { + username: z.String(3, 50), + age: i32, + + pub fn validate_age(val: i32) !i32 { + if (val < 18) return error.AgeTooYoung; + // Normalizes age to a maximum of 100 + if (val > 100) return 100; + return val; + } +}; +``` + +### Model-Level Validation (`validateModel`) + +Structs can define a model-level validator function that runs automatically after all struct fields have been parsed and individually validated. It is extremely useful for verifying cross-field constraints (e.g. checking if password fields match or if a date range is valid). + +- Must have the name `validateModel`. +- Can accept a pointer receiver (`self: *const @This()`) or a value receiver (`self: @This()`). +- Returns either `void` or a Zig validation error. + +```zig +const DateRange = struct { + start_date: z.IsoDate, + end_date: z.IsoDate, + + pub fn validateModel(self: *const @This()) !void { + // Run cross-field checks + // (Ensure start_date is before end_date) + } +}; +``` + + + diff --git a/docs/api/types.md b/docs/api/types.md index 21a3b1d..7fe50fb 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -2,6 +2,23 @@ Complete API reference for all zigantic types. +## Custom Messages + +Most parameterized types accept an `f` suffix variant with a comptime `messages` parameter to override error messages: + +```zig +const Name = Stringf(1, 50, .{ .too_short = "name is required" }); +const Age = Intf(i32, 18, 120, .{ .too_small = "must be 18 or older" }); +const Pwd = StrongPasswordf(8, 100, .{ .weak_password = "needs upper, lower, digit" }); + +// Get custom message for an error +const msg = Name.messageFor(err); // ?[]const u8 +``` + +The base types (`String`, `Int`, etc.) use built-in default messages. Use the `f` variants when you need custom messages. + +Available on: `String`, `NonEmptyString`, `Trimmed`, `Lowercase`, `Uppercase`, `Alphanumeric`, `AsciiString`, `Secret`, `StrongPassword`, `Int`, `UInt`, `PositiveInt`, `NonNegativeInt`, `NegativeInt`, `EvenInt`, `OddInt`, `MultipleOf`, `Float`, `Percentage`, `Probability`, `PositiveFloat`, `NegativeFloat`, `FiniteFloat`, `List`, `NonEmptyList`, `FixedList`, `HexString`, `HexColor`, `MacAddress`, `IsoDateTime`, `IsoDate`, `CountryCode`, `CurrencyCode`, `Latitude`, `Longitude`, `Port`. + ## String Types | Type | Description | @@ -103,6 +120,16 @@ f.trunc() // Truncate | `Latitude` | -90 to 90 coordinate | | `Longitude` | -180 to 180 coordinate | | `Port` | Network port 1-65535 | +| `Iban` | International Bank Account | +| `Base58` | Base58 (crypto addresses) | +| `HslColor` | HSL color string | +| `Duration` | ISO 8601 duration | +| `CronExpression` | Cron schedule expression | +| `Isbn10` | ISBN-10 with checksum | +| `Isbn13` | ISBN-13 with checksum | +| `AsciiAlphaString(min, max)` | ASCII letters only | +| `AsciiPrintableString(min, max)` | ASCII printable | +| `StrongPasswordStrict` | Built-in strong password | ### Format Methods @@ -111,11 +138,22 @@ f.trunc() // Truncate email.domain() // Domain part email.localPart() // Local part email.isBusinessEmail() // Not free email +email.isFreeEmail() // Free email provider +email.hasTag() // Has +tag +email.tag() // Tag portion or null +email.tld() // Top-level domain // Url url.isHttps() // HTTPS check url.protocol() // "http" or "https" url.host() // Host part +url.path() // Path portion +url.query() // Query string or null +url.fragment() // Fragment or null +url.port() // Port number or null +url.hasQuery() // Has query string +url.hasFragment() // Has fragment +url.filename() // Last path segment // Uuid uuid.version() // Version number @@ -163,6 +201,23 @@ lng.isWestern() // < 0 port.isPrivileged() // < 1024 port.isRegistered() // 1024-49151 port.isDynamic() // > 49151 + +// Iban +iban.countryCode() // 2-letter prefix +iban.normalizedLength() // Length without spaces + +// Base58 +b58.len() // Length + +// Duration +dur.hasTime() // Has time component + +// CronExpression +cron.fieldCount() // 5 or 6 + +// StrongPasswordStrict +pwd.masked() // "********" +pwd.len() // Length ``` ## Collection Types @@ -176,28 +231,35 @@ port.isDynamic() // > 49151 ### Collection Methods ```zig -list.get() // Get items -list.len() // Length -list.isEmpty() // Empty check -list.first() // First or null -list.last() // Last or null -list.at(i) // At index or null +list.get() // Get items +list.len() // Length +list.isEmpty() // Empty check +list.first() // First or null +list.last() // Last or null +list.at(i) // At index or null +list.contains(x) // Contains item +list.slice(s,e) // Sub-slice +list.sum() // Sum of items +list.all(fn) // All match predicate +list.any(fn) // Any match predicate +list.findIndex(fn) // Index of first match ``` ## Special Types -| Type | Description | -| ---------------------- | ------------------- | -| `Default(T, value)` | Default value | -| `Custom(T, fn)` | Custom validator | -| `Transform(T, fn)` | Transform value | -| `Coerce(From, To)` | Type conversion | -| `Literal(T, value)` | Exact match | -| `Partial(T)` | All fields optional | -| `OneOf(T, values)` | Allowed values | -| `Range(T, s, e, step)` | Range with step | -| `Nullable(T)` | Explicit null | -| `Lazy(T)` | Lazy evaluation | +| Type | Description | +| ------------------------- | ------------------- | +| `Default(T, value)` | Default value | +| `DefaultFactory(T, fn)` | Dynamic default | +| `Custom(T, fn)` | Custom validator | +| `Transform(T, fn)` | Transform value | +| `Coerce(From, To)` | Type conversion | +| `Literal(T, value)` | Exact match | +| `Partial(T)` | All fields optional | +| `OneOf(T, values)` | Allowed values | +| `Range(T, s, e, step)` | Range with step | +| `Nullable(T)` | Explicit null | +| `Lazy(T)` | Lazy evaluation | ### Special Methods @@ -206,6 +268,10 @@ list.at(i) // At index or null d.isDefault() // Is default value D.getOrDefault(opt) // Get or default +// DefaultFactory +df.initDefault() // Initialize with dynamic factory function +DF.getOrDefault(opt) // Get or default from factory + // OneOf o.isFirst() // First value o.isLast() // Last value @@ -217,3 +283,4 @@ n.unwrapOr(d) // Get or default // Transform t.getOriginal() // Original value ``` + diff --git a/docs/api/validators.md b/docs/api/validators.md index 7b083e7..459ebac 100644 --- a/docs/api/validators.md +++ b/docs/api/validators.md @@ -17,6 +17,15 @@ Direct validation utility functions. | `isValidCreditCard(str)` | Luhn algorithm | | `isJwt(str)` | JWT format | | `isHexString(str)` | Hex characters | +| `isHexColor(str)` | Hex color code | +| `isMacAddress(str)` | MAC address | +| `isIsoDate(str)` | ISO date | +| `isIsoDateTime(str)` | ISO datetime | +| `isCountryCode(str)` | Country code | +| `isCurrencyCode(str)` | Currency code | +| `isLatitude(v)` | Latitude range | +| `isLongitude(v)` | Longitude range | +| `isPort(v)` | Port number | | `isBase64(str)` | Base64 format | ## String Validators @@ -75,6 +84,15 @@ v.isSlug("hello-world") // true v.isSemver("1.2.3") // true v.isPhoneNumber("+1234567890") // true v.isJwt("header.payload.signature") // true +v.isHexColor("#ff5733") // true +v.isMacAddress("00:1A:2B:3C:4D:5E") // true +v.isIsoDate("2024-01-15") // true +v.isIsoDateTime("2024-01-15T10:30:00Z") // true +v.isCountryCode("US") // true +v.isCurrencyCode("USD") // true +v.isLatitude(45.0) // true +v.isLongitude(-75.0) // true +v.isPort(443) // true // String validation v.isAlphanumeric("abc123") // true diff --git a/docs/guide/benchmarks.md b/docs/guide/benchmarks.md index d70ae7b..e8df55e 100644 --- a/docs/guide/benchmarks.md +++ b/docs/guide/benchmarks.md @@ -63,31 +63,12 @@ This will output results to the console and generate a `benchmark-results.md` fi | List([]const u8,1,10) | ~5M+ | <200ns | | FixedList(i32,3) | ~8M+ | <125ns | -## Comparison with Other Libraries - -### vs. Python Pydantic - -| Operation | zigantic | Pydantic v2 | -|-----------|----------|-------------| -| Simple validation | <200ns | ~1-5μs | -| JSON parsing | <20μs | ~50-100μs | -| Memory overhead | Zero | Dynamic allocation | -| Compile-time checks | Yes | No | - -### vs. Other Zig Libraries - -zigantic provides a unique combination of: -- **Compile-time validation** - Errors caught at build time -- **Rich type system** - 40+ built-in types -- **Zero runtime overhead** - No dynamic dispatch -- **Human-readable errors** - Developer-friendly messages - ## Benchmark Environment Benchmarks are run on GitHub Actions runners: - **Platform:** Linux (ubuntu-latest) - **Architecture:** x86_64 -- **Zig Version:** 0.15.2 +- **Zig Version:** 0.16.0 - **Optimization:** ReleaseFast ## Understanding Results @@ -116,7 +97,7 @@ const Name = z.String(1, 50); ```zig // ✅ Good: Reuse allocator for multiple operations -var gpa = std.heap.GeneralPurposeAllocator(.{}){}; +var gpa = std.heap.DebugAllocator(.{}).init; const allocator = gpa.allocator(); for (items) |item| { diff --git a/docs/guide/error-handling.md b/docs/guide/error-handling.md index 71a6954..9ea0a55 100644 --- a/docs/guide/error-handling.md +++ b/docs/guide/error-handling.md @@ -60,6 +60,9 @@ errors.containsErrorType(error.TooShort) // true // Format output const text = try errors.formatAll(allocator); const json = try errors.toJsonArray(allocator); + +// Colorized output for terminals that support ANSI colors +const colored = try errors.formatAllColored(allocator); ``` ## Limited Error Collection @@ -146,3 +149,161 @@ z.reportInternalError("Unexpected null during parsing"); ``` This will print a message with the GitHub issues URL for reporting. + +## Colorized Output + +zigantic includes built-in ANSI colors for validation errors so terminal output stays readable and consistent: + +```zig +const presentation = z.errorPresentation(z.errors.ValidationError.TooShort); +std.debug.print("[{s}] {s}\n", .{ presentation.code, presentation.message }); +``` + +Use `FieldError.formatColored()` or `ErrorList.formatAllColored()` for colored terminal output. + +## Color Overrides + +Override the default color for specific error types: + +```zig +// Set globally via helper +z.setColorOverrides(.{ + .too_short = .bright_red, + .invalid_email = .magenta, + .weak_password = .yellow, +}); + +// Or via Config +var cfg = z.getConfig(); +cfg.color_overrides = .{ + .too_short = .bright_red, +}; +z.setConfig(cfg); +``` + +Disable colors entirely: +```zig +z.disableColor(); // Plain text output +z.enableColor(); // Re-enable +``` + +## Custom Error Messages + +Override error messages per-type via the `f` suffix variant with a comptime `messages` parameter: + +```zig +const Name = z.Stringf(3, 50, .{ .too_short = "name is required" }); +const Age = z.Intf(i32, 18, 120, .{ .too_small = "must be 18+" }); + +if (Name.init("Jo")) |_| {} else |err| { + std.debug.print("{s}\n", .{Name.messageFor(err).?}); + // "name is required" +} +``` + +Available on all parameterized types: `Stringf`, `Intf`, `Floatf`, `Listf`, `Trimmedf`, `Secretf`, `StrongPasswordf`, `HexStringf`, etc. + +## Global Message Formatter + +Set a global formatter function to customize messages across all types (including format types like `Email`, `Url`, etc.): + +```zig +z.Config.validation_message_formatter = struct { + fn f(err: z.errors.ValidationError) []const u8 { + return switch (err) { + .InvalidEmail => "please enter a valid email address", + .TooShort => "the value is too short", + else => z.errorMessage(err), + }; + } +}.f; +``` + +## Lifecycle Callbacks + +Register hooks for validation and serialization lifecycle events: + +```zig +// Called before validation starts +z.Config.before_validation_callback = struct { + fn call(type_name: []const u8) void { + std.debug.print("Validating: {s}\n", .{type_name}); + } +}.call; + +// Called after each field is validated +z.Config.on_field_validated_callback = struct { + fn call(field: []const u8, valid: bool) void { + std.debug.print("Field {s}: {s}\n", .{ field, if (valid) "OK" else "FAIL" }); + } +}.call; + +// Called when a field validation fails +z.Config.on_field_error_callback = struct { + fn call(field: []const u8, err_type: z.errors.ValidationError, msg: []const u8) void { + std.debug.print("Error in {s}: {s} ({s})\n", .{ field, msg, @tagName(err_type) }); + } +}.call; + +// Called after all validation is complete +z.Config.on_validation_complete_callback = struct { + fn call(valid: bool, error_count: usize) void { + std.debug.print("Validation complete: valid={}, errors={d}\n", .{ valid, error_count }); + } +}.call; + +// Called before JSON serialization +z.Config.before_serialize_callback = struct { + fn call() void { + std.debug.print("Starting serialization\n", .{}); + } +}.call; + +// Called after JSON serialization with the result +z.Config.after_serialize_callback = struct { + fn call(result: []const u8) void { + std.debug.print("Serialized to {d} bytes\n", .{result.len}); + } +}.call; +``` + +## Optional Error Policies + +If you want zigantic to handle failures more aggressively, you can set callbacks or exit flags once at startup: + +```zig +const z = @import("zigantic"); + +pub fn main() !void { + z.setConfig(.{ + .exit_on_validation_error = true, + .exit_on_serialization_error = true, + .validation_message_formatter = struct { + pub fn format(err: z.errors.ValidationError) []const u8 { + return switch (err) { + z.errors.ValidationError.InvalidEmail => "please provide a valid email address", + z.errors.ValidationError.TooShort => "the value is too short", + else => z.errorMessage(err), + }; + } + }.format, + .validation_error_callback = struct { + pub fn handle(msg: []const u8) void { + std.debug.print("VALIDATION: {s}\n", .{msg}); + } + }.handle, + .serialization_error_formatter = struct { + pub fn format(err: anyerror) []const u8 { + return switch (err) { + else => @errorName(err), + }; + } + }.format, + .serialization_error_callback = struct { + pub fn handle(msg: []const u8) void { + std.debug.print("SERIALIZATION: {s}\n", .{msg}); + } + }.handle, + }); +} +``` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index a9a24c5..8c0d163 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -141,7 +141,7 @@ const std = @import("std"); const z = @import("zigantic"); pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); diff --git a/docs/guide/installation.md b/docs/guide/installation.md index d0346bc..1df94e4 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -4,14 +4,14 @@ This guide covers all the ways to install zigantic in your Zig project. ## Requirements -- **Zig 0.15.0** or later +- **Zig 0.16.0** or later ## Release Installation (Recommended) -Install the latest stable release (v0.0.2): +Install the latest stable release for zig 0.16+ (use v0.0.3 or newer): ```bash -zig fetch --save https://github.com/muhammad-fiaz/zigantic/archive/refs/tags/v0.0.2.tar.gz +zig fetch --save https://github.com/muhammad-fiaz/zigantic/archive/refs/tags/0.0.3.tar.gz ``` This downloads the package and adds it to your `build.zig.zon`. @@ -83,7 +83,7 @@ You should see: ``` Installed successfully! Email domain: example.com -zigantic version: 0.0.2 +zigantic version: 0.0.3 ``` ## Using Prebuilt Libraries @@ -140,7 +140,7 @@ If you get an error about the package not being found: If you encounter build errors: -1. Ensure you're using Zig 0.15.0 or later: `zig version` +1. Ensure you're using Zig 0.16.0 or later: `zig version` 2. Try deleting `.zig-cache` and rebuilding 3. Check the [issues page](https://github.com/muhammad-fiaz/zigantic/issues) for known problems diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 4e0fb19..d807f31 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -1,6 +1,6 @@ # Introduction -**zigantic** is a Pydantic-like data validation library for Zig. It brings the power of type-safe validation to Zig, using the type system for compile-time guarantees. +**zigantic** is a data validation library for Zig. It brings the power of type-safe validation to Zig, using the type system for compile-time guarantees. ## What is zigantic? @@ -54,7 +54,7 @@ pub fn main() !void { std.debug.print("Domain: {s}\n", .{email.domain()}); // JSON parsing - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); diff --git a/docs/guide/json-parsing.md b/docs/guide/json-parsing.md index db0bcb2..d411cdc 100644 --- a/docs/guide/json-parsing.md +++ b/docs/guide/json-parsing.md @@ -130,3 +130,137 @@ const PartialUser = z.Partial(User); const update = PartialUser{ .name = "New Name" }; // update.age is null ``` + +--- + +## URL Query String & Form URL-Encoded Parsing + +Use `z.fromQueryString` and `z.toQueryString` to seamlessly validate and parse/serialize URL query strings and form payload parameters. + +```zig +const std = @import("std"); +const z = @import("zigantic"); + +const Search = struct { + query: z.String(1, 50), + page: z.Default(u32, 1), + active: bool, +}; + +const qs = "query=Mechanical+Keyboard&page=2&active=true"; +var result = try z.fromQueryString(Search, qs, allocator); +defer result.deinit(); + +if (result.isValid()) { + const s = result.value.?; + // s.query.get() == "Mechanical Keyboard" + // s.page.get() == 2 +} +``` + +--- + +## Compile-Time Field Aliases & Naming Policies + +Map custom aliases or use automatic naming policies completely at compile time with zero runtime cost. + +### Automatic Naming Policies + +Set `pub const zigantic_naming = NamingPolicy.snake_case` in a struct to map your idiomatic camelCase fields to standard external formats like `snake_case` or `kebab-case` symmetrically. + +```zig +const User = struct { + firstName: []const u8, + lastName: []const u8, + + // Automatically maps firstName -> first_name, lastName -> last_name + pub const zigantic_naming = z.utils.NamingPolicy.snake_case; +}; +``` + +### Explicit Field Aliases + +Set `pub const zigantic_aliases` to specify exact individual key renames. This takes precedence over naming policies. + +```zig +const Product = struct { + productName: []const u8, + priceInUsd: f64, + + pub const zigantic_aliases = .{ + .productName = "name", + .priceInUsd = "price", + }; +}; +``` + +--- + +## Advanced Features + +zigantic includes advanced validation decorators and dynamic factories. + +### Dynamic Default Factories (`DefaultFactory`) + +While `Default` is used for compile-time constant default values, `DefaultFactory` is used to dynamically generate default values at parsing/deserialization time (e.g. unique IDs or dynamic timestamps). + +```zig +const std = @import("std"); +const z = @import("zigantic"); + +var call_counter: i32 = 0; +fn nextId() i32 { + call_counter += 1; + return call_counter; +} + +const Device = struct { + name: []const u8, + id: z.DefaultFactory(i32, nextId), +}; + +// If "id" is missing in JSON, nextId() is automatically called to supply the value. +``` + +### Field-Level Validators (`validate_[field_name]`) + +Structs can define field-level validator functions that run automatically when parsing a field from JSON or URL Query maps. A field-level validator: +- Must have the name `validate_[field_name]` (e.g. `validate_age`). +- Receives the parsed field value. +- Returns either a validated/modified value or a Zig validation error. + +```zig +const User = struct { + username: z.String(3, 50), + age: i32, + + pub fn validate_age(val: i32) !i32 { + if (val < 18) return error.AgeTooYoung; + // Normalizes age to a maximum of 100 + if (val > 100) return 100; + return val; + } +}; +``` + +### Model-Level Validation (`validateModel`) + +Structs can define a model-level validator function that runs automatically after all struct fields have been parsed and individually validated. It is extremely useful for verifying cross-field constraints (e.g. checking if password fields match or if a date range is valid). + +- Must have the name `validateModel`. +- Can accept a pointer receiver (`self: *const @This()`) or a value receiver (`self: @This()`). +- Returns either `void` or a Zig validation error. + +```zig +const DateRange = struct { + start_date: z.IsoDate, + end_date: z.IsoDate, + + pub fn validateModel(self: *const @This()) !void { + // Run cross-field checks + // (Ensure start_date is before end_date) + } +}; +``` + + diff --git a/docs/guide/schemas.md b/docs/guide/schemas.md index 3f17984..d36c04e 100644 --- a/docs/guide/schemas.md +++ b/docs/guide/schemas.md @@ -1,6 +1,6 @@ # Schemas -Schemas in zigantic allow you to define complex data structures with validation rules. This is similar to Pydantic models in Python. +Schemas in zigantic allow you to define complex data structures with validation rules. ## Defining a Schema @@ -32,7 +32,7 @@ const std = @import("std"); const z = @import("zigantic"); pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); diff --git a/docs/guide/validation-types.md b/docs/guide/validation-types.md index 41ae537..b1c1aa1 100644 --- a/docs/guide/validation-types.md +++ b/docs/guide/validation-types.md @@ -55,6 +55,16 @@ const pwd = try z.StrongPassword(8, 100).init("S3cur3P@ss!"); // z.StrongPassword(8, 100).init("password") -> WeakPassword error ``` +### z.StrongPasswordStrict + +Built-in strong password (min 8 chars, upper+lower+digit+special). No parameters needed. + +```zig +const pwd = try z.StrongPasswordStrict.init("P@ssw0rd!"); +pwd.masked() // "********" +pwd.len() // 9 +``` + ### Other String Types | Type | Description | @@ -64,6 +74,8 @@ const pwd = try z.StrongPassword(8, 100).init("S3cur3P@ss!"); | `Uppercase(max)` | Must be all uppercase | | `Alphanumeric(min, max)` | Letters and digits only | | `AsciiString(min, max)` | ASCII characters only (0-127) | +| `AsciiAlphaString(min, max)` | ASCII letters only (A-Z, a-z) | +| `AsciiPrintableString(min, max)` | ASCII printable (0x20-0x7E) | ## Number Types @@ -167,6 +179,24 @@ card.masked() // last 4 digits | `Semver` | Semantic version | | `PhoneNumber` | Phone with `hasCountryCode()` | | `Regex(pattern)` | Pattern matching | +| `Base64` | Base64-encoded string | +| `Base58` | Base58 (crypto addresses) | +| `HexString` | Hexadecimal string | +| `HexColor` | Hex color code | +| `HslColor` | HSL color string | +| `MacAddress` | MAC address | +| `IsoDateTime` | ISO 8601 datetime | +| `IsoDate` | ISO 8601 date | +| `Duration` | ISO 8601 duration | +| `CronExpression` | Cron schedule | +| `Iban` | International Bank Account | +| `Isbn10` | ISBN-10 with checksum | +| `Isbn13` | ISBN-13 with checksum | +| `CountryCode` | ISO country code | +| `CurrencyCode` | ISO currency code | +| `Latitude` | Latitude coordinate | +| `Longitude` | Longitude coordinate | +| `Port` | TCP/UDP port number | ## Collection Types @@ -176,11 +206,17 @@ List with length constraints. ```zig const list = try z.List(u32, 1, 10).init(&items); -list.len() // item count -list.isEmpty() // false -list.first() // first item or null -list.last() // last item or null -list.at(1) // item at index or null +list.len() // item count +list.isEmpty() // false +list.first() // first item or null +list.last() // last item or null +list.at(1) // item at index or null +list.contains(5) // true if contains 5 +list.slice(0, 3) // sub-slice +list.sum() // sum of all items +list.all(fn) // all match predicate +list.any(fn) // any match predicate +list.findIndex(fn) // index of first match ``` ### z.FixedList(T, exact_len) diff --git a/docs/guide/version-updates.md b/docs/guide/version-updates.md index aeab613..ae40879 100644 --- a/docs/guide/version-updates.md +++ b/docs/guide/version-updates.md @@ -9,7 +9,7 @@ zigantic **automatically** checks for updates in the background when you first u When a new version is available, you'll see a log message: ``` -info: [UPDATE] A newer release of zigantic is available: v0.1.0 (current 0.0.1) +info: [UPDATE] A newer release of zigantic is available: v0.0.4 (current 0.0.3) ``` ### Basic Usage (Updates Enabled by Default) @@ -19,7 +19,7 @@ const std = @import("std"); const z = @import("zigantic"); pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); // Just use the library - update check happens automatically! @@ -83,8 +83,8 @@ Get the current version of zigantic: ```zig const z = @import("zigantic"); -const ver = z.getVersion(); // "0.0.1" -const full = z.getVersionString(); // "v0.0.1" +const ver = z.getVersion(); // "0.0.3" +const full = z.getVersionString(); // "v0.0.3" ``` ## Manual Update Checking @@ -97,7 +97,7 @@ You can also check for updates manually: const z = @import("zigantic"); pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); // Start background update check @@ -114,7 +114,7 @@ pub fn main() !void { const z = @import("zigantic"); pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); // Check for updates synchronously @@ -197,8 +197,8 @@ const url = z.ISSUES_URL; // "https://github.com/muhammad-fiaz/zigantic/issues" | `disableUpdateCheck()` | Disable automatic update checking | | `setConfig(config)` | Set custom configuration | | `getConfig()` | Get current configuration | -| `getVersion()` | Get version string (e.g., "0.0.1") | -| `getVersionString()` | Get full version (e.g., "v0.0.1") | +| `getVersion()` | Get version string (e.g., "0.0.3") | +| `getVersionString()` | Get full version (e.g., "v0.0.3") | | `checkForUpdates(allocator)` | Manually check for updates in background | | `checkForUpdatesSync(allocator)` | Manually check for updates synchronously | | `reportInternalError(msg)` | Report internal library bug (not for validation errors) | diff --git a/docs/index.md b/docs/index.md index 533f749..6b8f0fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,8 +2,8 @@ layout: home hero: name: zigantic - text: Pydantic-like validation for Zig - tagline: Type-safe data validation with 50+ built-in types, human-readable errors, and zero runtime overhead. + text: Type-safe validation for Zig + tagline: Type-safe data validation with 60+ built-in types, human-readable errors, and zero runtime overhead. image: src: /logo.png alt: zigantic @@ -33,6 +33,12 @@ features: - icon: 🔄 title: JSON Serialization details: Parse and serialize JSON with automatic validation, nested struct support, and default values. + - icon: 💬 + title: Custom Messages + details: Override validation error messages per-type with comptime config or globally via message formatter. + - icon: 🔄 + title: Lifecycle Callbacks + details: Hooks for validation and serialization lifecycle events including before, after, per-field, and completion callbacks. - icon: 🔧 title: Custom Validators details: Define custom validation functions, transformations, and type coercion for any use case. @@ -77,10 +83,10 @@ pub fn main() !void { ### Release Installation (Recommended) -Install the latest stable release (v0.0.2): +Install the latest stable release for zig 0.16+ (use v0.0.3 or newer): ```bash -zig fetch --save https://github.com/muhammad-fiaz/zigantic/archive/refs/tags/v0.0.2.tar.gz +zig fetch --save https://github.com/muhammad-fiaz/zigantic/archive/refs/tags/0.0.3.tar.gz ``` ### Nightly Installation @@ -134,12 +140,15 @@ exe.root_module.addImport("zigantic", zigantic_dep.module("zigantic")); Run the included examples: ```bash -zig build run-basic # Direct validation + JSON -zig build run-advanced_types # All 50+ types demo -zig build run-validators # Validator functions -zig build run-json_example # Full JSON workflow -zig build run-error_handling # Error management -zig build bench # Run benchmarks +zig build run-basic # Direct validation + JSON +zig build run-advanced_types # All 50+ types demo +zig build run-validators # Validator functions +zig build run-json_example # Full JSON workflow +zig build run-error_handling # Error management +zig build run-naming_conventions # Advanced naming & aliases +zig build run-custom_messages # Custom validation messages +zig build run-callbacks # Lifecycle callbacks +zig build bench # Run benchmarks ``` ## Made with love for the Zig community diff --git a/docs/package.json b/docs/package.json index 9f2d7fb..9cce326 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,7 +1,7 @@ { "name": "zigantic-docs", - "version": "0.0.2", - "description": "Documentation for zigantic - Pydantic-like validation for Zig", + "version": "0.0.3", + "description": "Documentation for zigantic - Type-safe validation for Zig", "author": "Muhammad Fiaz", "license": "MIT", "scripts": { diff --git a/docs/public/site.webmanifest b/docs/public/site.webmanifest index e12f1f4..6b51e64 100644 --- a/docs/public/site.webmanifest +++ b/docs/public/site.webmanifest @@ -1,7 +1,7 @@ { "name": "zigantic", "short_name": "zigantic", - "description": "Pydantic-like data validation and JSON serialization for Zig", + "description": "Type-safe data validation and JSON serialization for Zig", "icons": [ { "src": "/zigantic/android-chrome-192x192.png", diff --git a/examples/basic.zig b/examples/basic.zig index 9a2f1d0..18d8afe 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -7,7 +7,7 @@ pub fn main() !void { // Disable update check to prevent background thread memory leaks in examples z.disableUpdateCheck(); - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); diff --git a/examples/callbacks.zig b/examples/callbacks.zig new file mode 100644 index 0000000..fef8a1d --- /dev/null +++ b/examples/callbacks.zig @@ -0,0 +1,84 @@ +//! Lifecycle Callbacks Example + +const std = @import("std"); +const z = @import("zigantic"); + +pub fn main() !void { + z.disableUpdateCheck(); + + var gpa = std.heap.DebugAllocator(.{}).init; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + std.debug.print("=== Lifecycle Callbacks ===\n\n", .{}); + + // -- Validation callbacks -- + std.debug.print("--- Validation Callbacks ---\n", .{}); + + var cfg = z.getConfig(); + cfg.before_validation_callback = struct { + fn call(type_name: []const u8) void { + std.debug.print("[before_validation] Validating type: {s}\n", .{type_name}); + } + }.call; + cfg.on_field_validated_callback = struct { + fn call(field: []const u8, _: []const u8, success: bool) void { + std.debug.print(" [field_validated] {s}: {s}\n", .{ field, if (success) "PASS" else "FAIL" }); + } + }.call; + cfg.on_field_error_callback = struct { + fn call(field: []const u8, msg: []const u8) void { + std.debug.print(" [field_error] {s}: {s}\n", .{ field, msg }); + } + }.call; + cfg.on_validation_complete_callback = struct { + fn call(valid: bool, count: usize) void { + std.debug.print(" [validation_complete] Valid: {}, Errors: {d}\n\n", .{ valid, count }); + } + }.call; + z.setConfig(cfg); + + const User = struct { + name: z.String(3, 50), + age: z.Int(i32, 18, 120), + email: z.Email, + }; + + // Valid data + std.debug.print("--- Valid Data ---\n", .{}); + const good_json = + \\{"name": "Alice", "age": 25, "email": "alice@example.com"} + ; + var good_result = try z.fromJson(User, good_json, allocator); + defer good_result.deinit(); + + // Invalid data + std.debug.print("--- Invalid Data ---\n", .{}); + const bad_json = + \\{"name": "Jo", "age": 15, "email": "invalid"} + ; + var bad_result = try z.fromJson(User, bad_json, allocator); + defer bad_result.deinit(); + + // -- Serialization callbacks -- + std.debug.print("\n--- Serialization Callbacks ---\n", .{}); + + var ser_cfg = z.getConfig(); + ser_cfg.before_serialize_callback = struct { + fn call() void { + std.debug.print(" [before_serialize] Starting serialization\n", .{}); + } + }.call; + ser_cfg.after_serialize_callback = struct { + fn call(result: []const u8) void { + std.debug.print(" [after_serialize] Result length: {d}\n", .{result.len}); + } + }.call; + z.setConfig(ser_cfg); + + const json = try z.toJson(42, allocator); + defer allocator.free(json); + std.debug.print(" Serialized value: {s}\n", .{json}); + + std.debug.print("\n=== Done ===\n", .{}); +} diff --git a/examples/custom_messages.zig b/examples/custom_messages.zig new file mode 100644 index 0000000..0e55178 --- /dev/null +++ b/examples/custom_messages.zig @@ -0,0 +1,95 @@ +//! Custom Validation Messages Example + +const std = @import("std"); +const z = @import("zigantic"); + +pub fn main() !void { + z.disableUpdateCheck(); + + var gpa = std.heap.DebugAllocator(.{}).init; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + std.debug.print("=== Custom Validation Messages ===\n\n", .{}); + + // -- Type-level custom messages -- + std.debug.print("--- Type-Level Custom Messages ---\n", .{}); + + const Name = z.Stringf(3, 50, .{ + .too_short = "name must be at least 3 characters long", + }); + + const name_err = Name.init("Jo"); + if (name_err) |_| {} else |err| { + std.debug.print("Name error: {s}\n", .{z.errorMessage(err)}); + std.debug.print("Custom message: {s}\n\n", .{Name.messageFor(err).?}); + } + + const Age = z.Intf(i32, 18, 120, .{ + .too_small = "you must be at least 18 years old", + .too_large = "age cannot exceed 120", + }); + + const age_err = Age.init(15); + if (age_err) |_| {} else |err| { + std.debug.print("Age error: {s}\n", .{z.errorMessage(err)}); + std.debug.print("Custom message: {s}\n\n", .{Age.messageFor(err).?}); + } + + const Password = z.StrongPasswordf(8, 100, .{ + .weak_password = "password must contain uppercase, lowercase, digit, and special character", + .too_short = "password must be at least 8 characters", + }); + + const pwd_err = Password.init("weak"); + if (pwd_err) |_| {} else |err| { + std.debug.print("Password error: {s}\n", .{z.errorMessage(err)}); + std.debug.print("Custom message: {s}\n\n", .{Password.messageFor(err).?}); + } + + // -- JSON parsing with custom messages -- + std.debug.print("--- JSON Parsing with Custom Messages ---\n", .{}); + + const User = struct { + name: z.Stringf(3, 50, .{ .too_short = "name is required and must be at least 3 chars" }), + age: z.Intf(i32, 18, 120, .{ .too_small = "must be 18 or older to register" }), + email: z.Email, + }; + + const bad_json = + \\{"name": "Jo", "age": 15, "email": "invalid"} + ; + + var result = try z.fromJson(User, bad_json, allocator); + defer result.deinit(); + + std.debug.print("Validation errors:\n", .{}); + for (result.error_list.errors.items) |err| { + std.debug.print(" {s}: {s}\n", .{ err.field, err.message }); + } + + // -- Global message formatter -- + std.debug.print("\n--- Global Message Formatter ---\n", .{}); + + var custom_config = z.getConfig(); + custom_config.validation_message_formatter = struct { + fn f(err: z.errors.ValidationError) []const u8 { + return switch (err) { + error.TooShort => "this field is too short", + error.TooSmall => "value is below the minimum", + else => z.errorMessage(err), + }; + } + }.f; + z.setConfig(custom_config); + + var formatted = try z.fromJson(User, bad_json, allocator); + defer formatted.deinit(); + + std.debug.print("Formatted errors (with global formatter):\n", .{}); + for (formatted.error_list.errors.items) |err| { + std.debug.print(" {s}: {s}\n", .{ err.field, err.message }); + } + + std.debug.print("\n=== Done ===\n", .{}); +} diff --git a/examples/error_handling.zig b/examples/error_handling.zig index 6c5a940..68ad747 100644 --- a/examples/error_handling.zig +++ b/examples/error_handling.zig @@ -7,7 +7,7 @@ pub fn main() !void { // Disable update check to prevent background thread memory leaks in examples z.disableUpdateCheck(); - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); @@ -69,12 +69,27 @@ pub fn main() !void { try errors.addIndexed("tags", 2, z.errors.ValidationError.TooLong, "tag too long", null); try errors.addWithCode("email", z.errors.ValidationError.InvalidEmail, "invalid email", "bad@", "E010"); + const custom_message = struct { + fn f(err: z.errors.ValidationError) []const u8 { + return switch (err) { + z.errors.ValidationError.InvalidEmail => "please enter a valid email address", + z.errors.ValidationError.TooShort => "this value needs a longer input", + else => z.errorMessage(err), + }; + } + }.f; + + std.debug.print("\nCustom formatted errors:\n", .{}); + const custom_formatted = try errors.formatAllWith(allocator, custom_message); + defer allocator.free(custom_formatted); + std.debug.print("{s}", .{custom_formatted}); + std.debug.print("Total errors: {d}\n", .{errors.count()}); std.debug.print("Has 'name' error: {}\n", .{errors.containsField("name")}); std.debug.print("Has TooShort: {}\n", .{errors.containsErrorType(z.errors.ValidationError.TooShort)}); std.debug.print("\nFormatted errors:\n", .{}); - const formatted = try errors.formatAll(allocator); + const formatted = try errors.formatAllColored(allocator); defer allocator.free(formatted); std.debug.print("{s}", .{formatted}); @@ -112,7 +127,9 @@ pub fn main() !void { if (!result.isValid()) { std.debug.print("Parsing errors ({d}):\n", .{result.error_list.count()}); for (result.error_list.errors.items) |err| { - std.debug.print(" [{s}] {s}: {s}\n", .{ z.errorCode(err.error_type), err.field, err.message }); + const colored = try err.formatColored(allocator); + defer allocator.free(colored); + std.debug.print(" {s}\n", .{colored}); } } diff --git a/examples/extended_types.zig b/examples/extended_types.zig new file mode 100644 index 0000000..2cea9ef --- /dev/null +++ b/examples/extended_types.zig @@ -0,0 +1,125 @@ +const std = @import("std"); +const z = @import("zigantic"); + +pub fn main() !void { + z.disableUpdateCheck(); + std.debug.print("=== Extended Types & Features ===\n\n", .{}); + + // --- IBAN Validation --- + std.debug.print("--- IBAN Validation ---\n", .{}); + const iban = try z.Iban.init("DE89370400440532013000"); + std.debug.print("IBAN: {s}\n", .{iban.get()}); + std.debug.print("Country: {s}\n", .{iban.countryCode()}); + std.debug.print("Length: {d}\n\n", .{iban.normalizedLength()}); + + // --- Base58 Validation --- + std.debug.print("--- Base58 Validation ---\n", .{}); + const b58 = try z.Base58.init("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"); + std.debug.print("Base58: {s} (len: {d})\n\n", .{ b58.get(), b58.len() }); + + // --- Duration Validation --- + std.debug.print("--- Duration Validation ---\n", .{}); + const dur = try z.Duration.init("P1Y2M3DT4H5M6S"); + std.debug.print("Duration: {s} (has time: {})\n\n", .{ dur.get(), dur.hasTime() }); + + // --- Cron Expression --- + std.debug.print("--- Cron Expression ---\n", .{}); + const cron = try z.CronExpression.init("0 12 * * *"); + std.debug.print("Cron: {s} (fields: {d})\n\n", .{ cron.get(), cron.fieldCount() }); + + // --- ISBN Validation --- + std.debug.print("--- ISBN Validation ---\n", .{}); + const isbn10 = try z.Isbn10.init("0-306-40615-2"); + std.debug.print("ISBN-10: {s}\n", .{isbn10.get()}); + const isbn13 = try z.Isbn13.init("978-0-306-40615-7"); + std.debug.print("ISBN-13: {s}\n\n", .{isbn13.get()}); + + // --- Strong Password (built-in) --- + std.debug.print("--- Strong Password ---\n", .{}); + const pwd = try z.StrongPasswordStrict.init("P@ssw0rd!"); + std.debug.print("Password masked: {s} (len: {d})\n\n", .{ pwd.masked(), pwd.len() }); + + // --- Email Extended Methods --- + std.debug.print("--- Email Extended Methods ---\n", .{}); + const email = try z.Email.init("user+tag@gmail.com"); + std.debug.print("Email: {s}\n", .{email.get()}); + std.debug.print(" Domain: {s}\n", .{email.domain()}); + std.debug.print(" TLD: {s}\n", .{email.tld()}); + std.debug.print(" Local: {s}\n", .{email.localPart()}); + std.debug.print(" Tag: {s}\n", .{email.tag() orelse "none"}); + std.debug.print(" Has tag: {}\n", .{email.hasTag()}); + std.debug.print(" Free email: {}\n", .{email.isFreeEmail()}); + std.debug.print(" Business email: {}\n\n", .{email.isBusinessEmail()}); + + // --- URL Extended Methods --- + std.debug.print("--- URL Extended Methods ---\n", .{}); + const url = try z.Url.init("https://example.com:8080/path?q=1#section"); + std.debug.print("URL: {s}\n", .{url.get()}); + std.debug.print(" Protocol: {s}\n", .{url.protocol()}); + std.debug.print(" Host: {s}\n", .{url.host()}); + std.debug.print(" Port: {d}\n", .{url.port() orelse 0}); + std.debug.print(" Path: {s}\n", .{url.path()}); + std.debug.print(" Query: {s}\n", .{url.query() orelse "none"}); + std.debug.print(" Fragment: {s}\n", .{url.fragment() orelse "none"}); + std.debug.print(" Filename: {s}\n\n", .{url.filename()}); + + // --- List Extended Methods --- + std.debug.print("--- List Extended Methods ---\n", .{}); + const L = z.List(u32, 1, 10); + const items = [_]u32{ 10, 20, 30, 40, 50 }; + const list = try L.init(&items); + std.debug.print("List: ", .{}); + for (list.get()) |item| { + std.debug.print("{d} ", .{item}); + } + std.debug.print("\n", .{}); + std.debug.print(" Sum: {d}\n", .{list.sum()}); + std.debug.print(" All > 0: {}\n", .{list.all(struct { + fn f(n: u32) bool { + return n > 0; + } + }.f)}); + std.debug.print(" Any == 30: {}\n", .{list.any(struct { + fn f(n: u32) bool { + return n == 30; + } + }.f)}); + std.debug.print(" Index of 30: {d}\n\n", .{list.findIndex(struct { + fn f(n: u32) bool { + return n == 30; + } + }.f) orelse 999}); + + // --- Config Options --- + std.debug.print("--- Config Options ---\n", .{}); + z.setConfig(.{ + .max_errors = 5, + .reject_unknown_fields = true, + .trim_strings = true, + .lowercase_strings = false, + .collect_all_errors = true, + .include_value_in_error = true, + }); + const cfg = z.getConfig(); + std.debug.print("max_errors: {d}\n", .{cfg.max_errors orelse 0}); + std.debug.print("reject_unknown_fields: {}\n", .{cfg.reject_unknown_fields}); + std.debug.print("trim_strings: {}\n", .{cfg.trim_strings}); + std.debug.print("collect_all_errors: {}\n\n", .{cfg.collect_all_errors}); + + // --- Color Overrides --- + std.debug.print("--- Color Overrides ---\n", .{}); + z.setColorOverrides(.{ + .too_short = .bright_red, + .invalid_email = .magenta, + .weak_password = .yellow, + }); + std.debug.print("Color overrides set successfully\n\n", .{}); + + // --- AsciiAlphaString --- + std.debug.print("--- AsciiAlphaString ---\n", .{}); + const AlphaName = z.AsciiAlphaString(3, 20); + const name = try AlphaName.init("Alice"); + std.debug.print("Name: {s} (len: {d})\n\n", .{ name.get(), name.len() }); + + std.debug.print("=== Done ===\n", .{}); +} diff --git a/examples/json_example.zig b/examples/json_example.zig index 186726e..26394ee 100644 --- a/examples/json_example.zig +++ b/examples/json_example.zig @@ -7,7 +7,7 @@ pub fn main() !void { // Disable update check to prevent background thread memory leaks in examples z.disableUpdateCheck(); - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + var gpa = std.heap.DebugAllocator(.{}).init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); diff --git a/examples/naming_conventions.zig b/examples/naming_conventions.zig new file mode 100644 index 0000000..943c3e7 --- /dev/null +++ b/examples/naming_conventions.zig @@ -0,0 +1,87 @@ +const std = @import("std"); +const z = @import("zigantic"); + +pub fn main() !void { + z.disableUpdateCheck(); + + var gpa = std.heap.DebugAllocator(.{}).init; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + std.debug.print("==================================================\n", .{}); + std.debug.print(" zigantic advanced naming conventions & aliases \n", .{}); + std.debug.print("==================================================\n\n", .{}); + + // 1. Automatic Naming Policies + // camelCase struct fields mapped automatically to snake_case in JSON / Query String + const SnakeUser = struct { + firstName: []const u8, + lastName: []const u8, + emailAddress: z.Email, + + pub const zigantic_naming = z.utils.NamingPolicy.snake_case; + }; + + const json_data = + \\{ + \\ "first_name": "John", + \\ "last_name": "Doe", + \\ "email_address": "john.doe@example.com" + \\} + ; + + std.debug.print("Parsing camelCase struct from snake_case JSON...\n", .{}); + std.debug.print("JSON Input:\n{s}\n\n", .{json_data}); + + var result_snake = try z.fromJson(SnakeUser, json_data, allocator); + defer result_snake.deinit(); + + if (result_snake.isValid()) { + const user = result_snake.value.?; + std.debug.print("Parsed values:\n", .{}); + std.debug.print(" firstName: {s}\n", .{user.firstName}); + std.debug.print(" lastName: {s}\n", .{user.lastName}); + std.debug.print(" emailAddress: {s}\n\n", .{user.emailAddress.get()}); + + // Serialize back to JSON - should output snake_case keys! + const serialized_json = try z.toJsonPretty(user, allocator); + defer allocator.free(serialized_json); + std.debug.print("Serialized output (snake_case):\n{s}\n\n", .{serialized_json}); + } else { + const err_msg = try result_snake.formatErrors(); + defer allocator.free(err_msg); + std.debug.print("Validation Errors:\n{s}\n", .{err_msg}); + } + + // 2. Explicit Field Aliases & Query parameters + const AliasedProduct = struct { + id: i32, + productName: []const u8, + priceInUsd: f64, + + pub const zigantic_aliases = .{ + .productName = "name", + .priceInUsd = "price", + }; + }; + + const query_string = "id=101&name=Mechanical+Keyboard&price=99.99"; + std.debug.print("Parsing aliased struct from URL query string...\n", .{}); + std.debug.print("Query string Input: {s}\n\n", .{query_string}); + + var result_query = try z.fromQueryString(AliasedProduct, query_string, allocator); + defer result_query.deinit(); + + if (result_query.isValid()) { + const product = result_query.value.?; + std.debug.print("Parsed product:\n", .{}); + std.debug.print(" id: {d}\n", .{product.id}); + std.debug.print(" productName: {s}\n", .{product.productName}); + std.debug.print(" priceInUsd: {d:.2}\n\n", .{product.priceInUsd}); + + // Serialize back to URL query string - should use the alias names! + const serialized_qs = try z.toQueryString(product, allocator); + defer allocator.free(serialized_qs); + std.debug.print("Serialized query string (aliased): {s}\n\n", .{serialized_qs}); + } +} diff --git a/examples/validators.zig b/examples/validators.zig index 677d04f..675d5c6 100644 --- a/examples/validators.zig +++ b/examples/validators.zig @@ -30,6 +30,18 @@ pub fn main() !void { std.debug.print("256.1.1.1: {}\n", .{v.isIpv4("256.1.1.1")}); std.debug.print("2001:0db8:85a3:0000:0000:8a2e:0370:7334: {}\n", .{v.isIpv6("2001:0db8:85a3:0000:0000:8a2e:0370:7334")}); + // Richer built-in format validators + std.debug.print("\n--- Extra Formats ---\n", .{}); + std.debug.print("#ff5733: {}\n", .{v.isHexColor("#ff5733")}); + std.debug.print("00:1A:2B:3C:4D:5E: {}\n", .{v.isMacAddress("00:1A:2B:3C:4D:5E")}); + std.debug.print("2024-01-15: {}\n", .{v.isIsoDate("2024-01-15")}); + std.debug.print("2024-01-15T10:30:00Z: {}\n", .{v.isIsoDateTime("2024-01-15T10:30:00Z")}); + std.debug.print("US: {}\n", .{v.isCountryCode("US")}); + std.debug.print("USD: {}\n", .{v.isCurrencyCode("USD")}); + std.debug.print("Latitude 45.0: {}\n", .{v.isLatitude(45.0)}); + std.debug.print("Longitude -75.0: {}\n", .{v.isLongitude(-75.0)}); + std.debug.print("Port 443: {}\n", .{v.isPort(443)}); + // Slug validation std.debug.print("\n--- Slug ---\n", .{}); std.debug.print("hello-world: {}\n", .{v.isSlug("hello-world")}); diff --git a/src/color.zig b/src/color.zig new file mode 100644 index 0000000..3864990 --- /dev/null +++ b/src/color.zig @@ -0,0 +1,62 @@ +//! ANSI Color Support +//! +//! Provides terminal color constants and ANSI escape code generation +//! for styled validation error output. Colors are used to visually +//! distinguish error categories (e.g., red for string errors, +//! yellow for number errors, blue for format errors). + +/// Supported ANSI terminal colors for validation error presentation. +/// +/// Each color maps to a standard ANSI escape sequence. The color +/// assignment follows a semantic convention: +/// - **Red**: string/constraint errors (too short, too long, empty) +/// - **Yellow**: number/range errors (too small, too large, out of range) +/// - **Blue**: format validation errors (email, URL, UUID, pattern) +/// - **Cyan**: structural errors (missing field, unknown field) +/// - **Green**: collection errors (too few/many items) +/// - **Bright Red**: critical/custom errors (validation failed, invalid JSON) +pub const Color = enum { + reset, + red, + green, + yellow, + blue, + magenta, + cyan, + white, + bright_red, + bright_green, + bright_yellow, + bright_blue, + bright_magenta, + bright_cyan, +}; + +/// Returns the ANSI escape code string for the given color. +/// +/// The returned string is a compile-time constant that can be +/// embedded directly into formatted output. Always pair with +/// `Color.reset` to restore default terminal styling. +/// +/// Example: +/// ```zig +/// const colored = ansi(.red) ++ "error" ++ ansi(.reset); +/// ``` +pub fn ansi(color: Color) []const u8 { + return switch (color) { + .reset => "\x1b[0m", + .red => "\x1b[31m", + .green => "\x1b[32m", + .yellow => "\x1b[33m", + .blue => "\x1b[34m", + .magenta => "\x1b[35m", + .cyan => "\x1b[36m", + .white => "\x1b[37m", + .bright_red => "\x1b[91m", + .bright_green => "\x1b[92m", + .bright_yellow => "\x1b[93m", + .bright_blue => "\x1b[94m", + .bright_magenta => "\x1b[95m", + .bright_cyan => "\x1b[96m", + }; +} diff --git a/src/errors.zig b/src/errors.zig index 12d2699..14b3cb6 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -3,7 +3,319 @@ //! Comprehensive error handling for validation. const std = @import("std"); +const color = @import("color.zig"); +pub const Color = color.Color; + +/// Presentation details for a validation error type. +/// +/// Contains the human-readable message, unique error code, and +/// default terminal color for a specific `ValidationError` variant. +/// Used by `errorPresentation()` to look up display information. +pub const ErrorPresentation = struct { + /// Human-readable error message (e.g., "value is too short"). + message: []const u8, + /// Unique error code for programmatic identification (e.g., "E001"). + code: []const u8, + /// Default ANSI color for terminal output. + color: Color, +}; + +/// Function pointer type for custom message formatters. +/// +/// When set via `Config.validation_message_formatter`, this function +/// is called for each validation error to produce a custom message. +/// Return the default message from `errorPresentation(err).message` +/// for errors you don't want to customize. +pub const MessageFormatter = ?*const fn (ValidationError) []const u8; + +/// Per-error-type custom message configuration. +/// +/// Pass a partial struct to `Stringf`, `Intf`, etc. to override +/// default error messages. Null fields use built-in defaults. +/// +/// Example: +/// ```zig +/// const Name = Stringf(1, 50, .{ +/// .too_short = "name is required", +/// .too_long = "name must be 50 chars or fewer", +/// }); +/// ``` +pub const ValidationMessageConfig = struct { + too_short: ?[]const u8 = null, + too_long: ?[]const u8 = null, + too_small: ?[]const u8 = null, + too_large: ?[]const u8 = null, + invalid_format: ?[]const u8 = null, + invalid_email: ?[]const u8 = null, + invalid_url: ?[]const u8 = null, + invalid_uuid: ?[]const u8 = null, + invalid_ipv4: ?[]const u8 = null, + invalid_ipv6: ?[]const u8 = null, + invalid_phone: ?[]const u8 = null, + invalid_credit_card: ?[]const u8 = null, + weak_password: ?[]const u8 = null, + must_be_even: ?[]const u8 = null, + must_be_odd: ?[]const u8 = null, + not_multiple: ?[]const u8 = null, + must_be_https: ?[]const u8 = null, + out_of_range: ?[]const u8 = null, + not_in_step: ?[]const u8 = null, + pattern_mismatch: ?[]const u8 = null, + literal_mismatch: ?[]const u8 = null, + not_in_allowed: ?[]const u8 = null, + custom_validation_failed: ?[]const u8 = null, + too_few_items: ?[]const u8 = null, + too_many_items: ?[]const u8 = null, + empty_string: ?[]const u8 = null, + wrong_length: ?[]const u8 = null, + invalid_number: ?[]const u8 = null, + type_mismatch: ?[]const u8 = null, + missing_field: ?[]const u8 = null, + must_be_lowercase: ?[]const u8 = null, + must_be_uppercase: ?[]const u8 = null, + not_positive: ?[]const u8 = null, + not_negative: ?[]const u8 = null, + not_zero: ?[]const u8 = null, +}; + +/// Per-validation-error color overrides. Each field is optional (null = use default). +pub const ColorOverrides = struct { + too_short: ?Color = null, + too_long: ?Color = null, + too_small: ?Color = null, + too_large: ?Color = null, + invalid_format: ?Color = null, + invalid_email: ?Color = null, + invalid_url: ?Color = null, + invalid_uuid: ?Color = null, + invalid_ipv4: ?Color = null, + invalid_ipv6: ?Color = null, + invalid_phone: ?Color = null, + invalid_credit_card: ?Color = null, + weak_password: ?Color = null, + must_be_even: ?Color = null, + must_be_odd: ?Color = null, + not_multiple: ?Color = null, + must_be_https: ?Color = null, + out_of_range: ?Color = null, + not_in_step: ?Color = null, + pattern_mismatch: ?Color = null, + literal_mismatch: ?Color = null, + not_in_allowed: ?Color = null, + custom_validation_failed: ?Color = null, + too_few_items: ?Color = null, + too_many_items: ?Color = null, + empty_string: ?Color = null, + wrong_length: ?Color = null, + invalid_number: ?Color = null, + type_mismatch: ?Color = null, + missing_field: ?Color = null, + must_be_lowercase: ?Color = null, + must_be_uppercase: ?Color = null, + not_positive: ?Color = null, + not_negative: ?Color = null, + not_zero: ?Color = null, + invalid_json: ?Color = null, + nested_error: ?Color = null, + validation_failed: ?Color = null, + parse_error: ?Color = null, + invalid_integer: ?Color = null, + invalid_boolean: ?Color = null, + invalid_array: ?Color = null, + invalid_object: ?Color = null, + invalid_string: ?Color = null, + unknown_field: ?Color = null, + duplicate_field: ?Color = null, + invalid_date: ?Color = null, + invalid_time: ?Color = null, + duplicate_item: ?Color = null, + empty_collection: ?Color = null, +}; + +fn overrideField(comptime T: type, comptime field: []const u8, overrides: T) ?Color { + if (!comptime @hasField(T, field)) return null; + return @field(overrides, field); +} + +fn overrideColor(err: ValidationError, overrides: anytype) ?Color { + const T = @TypeOf(overrides); + return switch (err) { + error.TooShort => overrideField(T, "too_short", overrides), + error.TooLong => overrideField(T, "too_long", overrides), + error.TooSmall => overrideField(T, "too_small", overrides), + error.TooLarge => overrideField(T, "too_large", overrides), + error.InvalidFormat => overrideField(T, "invalid_format", overrides), + error.InvalidEmail => overrideField(T, "invalid_email", overrides), + error.InvalidUrl => overrideField(T, "invalid_url", overrides), + error.InvalidUuid => overrideField(T, "invalid_uuid", overrides), + error.InvalidIpv4 => overrideField(T, "invalid_ipv4", overrides), + error.InvalidIpv6 => overrideField(T, "invalid_ipv6", overrides), + error.InvalidPhoneNumber => overrideField(T, "invalid_phone", overrides), + error.InvalidCreditCard => overrideField(T, "invalid_credit_card", overrides), + error.WeakPassword => overrideField(T, "weak_password", overrides), + error.MustBeEven => overrideField(T, "must_be_even", overrides), + error.MustBeOdd => overrideField(T, "must_be_odd", overrides), + error.NotMultiple => overrideField(T, "not_multiple", overrides), + error.MustBeHttps => overrideField(T, "must_be_https", overrides), + error.OutOfRange => overrideField(T, "out_of_range", overrides), + error.NotInStep => overrideField(T, "not_in_step", overrides), + error.PatternMismatch => overrideField(T, "pattern_mismatch", overrides), + error.LiteralMismatch => overrideField(T, "literal_mismatch", overrides), + error.NotInAllowedValues => overrideField(T, "not_in_allowed", overrides), + error.CustomValidationFailed => overrideField(T, "custom_validation_failed", overrides), + error.TooFewItems => overrideField(T, "too_few_items", overrides), + error.TooManyItems => overrideField(T, "too_many_items", overrides), + error.EmptyString => overrideField(T, "empty_string", overrides), + error.WrongLength => overrideField(T, "wrong_length", overrides), + error.InvalidNumber => overrideField(T, "invalid_number", overrides), + error.TypeMismatch => overrideField(T, "type_mismatch", overrides), + error.MissingField => overrideField(T, "missing_field", overrides), + error.MustBeLowercase => overrideField(T, "must_be_lowercase", overrides), + error.MustBeUppercase => overrideField(T, "must_be_uppercase", overrides), + error.NotPositive => overrideField(T, "not_positive", overrides), + error.NotNegative => overrideField(T, "not_negative", overrides), + error.NotZero => overrideField(T, "not_zero", overrides), + else => null, + }; +} + +fn msgField(comptime T: type, comptime field: []const u8, config: T) ?[]const u8 { + if (comptime @hasField(T, field)) return @field(config, field); + return null; +} + +pub fn messageForConfig(err: ValidationError, config: anytype) ?[]const u8 { + const T = @TypeOf(config); + return switch (err) { + error.TooShort => msgField(T, "too_short", config), + error.TooLong => msgField(T, "too_long", config), + error.TooSmall => msgField(T, "too_small", config), + error.TooLarge => msgField(T, "too_large", config), + error.InvalidFormat => msgField(T, "invalid_format", config), + error.InvalidEmail => msgField(T, "invalid_email", config), + error.InvalidUrl => msgField(T, "invalid_url", config), + error.InvalidUuid => msgField(T, "invalid_uuid", config), + error.InvalidIpv4 => msgField(T, "invalid_ipv4", config), + error.InvalidIpv6 => msgField(T, "invalid_ipv6", config), + error.InvalidPhoneNumber => msgField(T, "invalid_phone", config), + error.InvalidCreditCard => msgField(T, "invalid_credit_card", config), + error.WeakPassword => msgField(T, "weak_password", config), + error.MustBeEven => msgField(T, "must_be_even", config), + error.MustBeOdd => msgField(T, "must_be_odd", config), + error.NotMultiple => msgField(T, "not_multiple", config), + error.MustBeHttps => msgField(T, "must_be_https", config), + error.OutOfRange => msgField(T, "out_of_range", config), + error.NotInStep => msgField(T, "not_in_step", config), + error.PatternMismatch => msgField(T, "pattern_mismatch", config), + error.LiteralMismatch => msgField(T, "literal_mismatch", config), + error.NotInAllowedValues => msgField(T, "not_in_allowed", config), + error.CustomValidationFailed => msgField(T, "custom_validation_failed", config), + error.TooFewItems => msgField(T, "too_few_items", config), + error.TooManyItems => msgField(T, "too_many_items", config), + error.EmptyString => msgField(T, "empty_string", config), + error.WrongLength => msgField(T, "wrong_length", config), + error.InvalidNumber => msgField(T, "invalid_number", config), + error.TypeMismatch => msgField(T, "type_mismatch", config), + error.MissingField => msgField(T, "missing_field", config), + error.MustBeLowercase => msgField(T, "must_be_lowercase", config), + error.MustBeUppercase => msgField(T, "must_be_uppercase", config), + error.NotPositive => msgField(T, "not_positive", config), + error.NotNegative => msgField(T, "not_negative", config), + error.NotZero => msgField(T, "not_zero", config), + else => null, + }; +} + +fn ansi(value: Color) []const u8 { + return color.ansi(value); +} + +/// Returns the presentation details (message, code, color) for a validation error. +/// +/// This is the single source of truth for default error messages, codes, +/// and colors. Used by formatting functions and the `messageFor` helper. +pub fn errorPresentation(err: ValidationError) ErrorPresentation { + return switch (err) { + error.TooShort => .{ .message = "value is too short", .code = "E001", .color = .red }, + error.TooLong => .{ .message = "value is too long", .code = "E002", .color = .red }, + error.TooSmall => .{ .message = "value is too small", .code = "E003", .color = .yellow }, + error.TooLarge => .{ .message = "value is too large", .code = "E004", .color = .yellow }, + error.NotPositive => .{ .message = "must be positive", .code = "E054", .color = .yellow }, + error.NotNegative => .{ .message = "must be negative", .code = "E055", .color = .yellow }, + error.NotZero => .{ .message = "must not be zero", .code = "E056", .color = .yellow }, + error.DivisionByZero => .{ .message = "division by zero", .code = "E057", .color = .yellow }, + error.MustBeEven => .{ .message = "must be even", .code = "E030", .color = .yellow }, + error.MustBeOdd => .{ .message = "must be odd", .code = "E031", .color = .yellow }, + error.NotMultiple => .{ .message = "must be a multiple of the divisor", .code = "E032", .color = .yellow }, + error.OutOfRange => .{ .message = "value is out of range", .code = "E034", .color = .yellow }, + error.NotInStep => .{ .message = "value must be in step increments", .code = "E035", .color = .yellow }, + error.InvalidNumber => .{ .message = "must be a valid number", .code = "E060", .color = .magenta }, + error.InvalidInteger => .{ .message = "must be a valid integer", .code = "E036", .color = .magenta }, + error.InvalidBoolean => .{ .message = "must be a valid boolean", .code = "E037", .color = .magenta }, + error.InvalidArray => .{ .message = "must be a valid array", .code = "E038", .color = .magenta }, + error.InvalidObject => .{ .message = "must be a valid object", .code = "E039", .color = .magenta }, + error.InvalidString => .{ .message = "must be a valid string", .code = "E040", .color = .magenta }, + error.TypeMismatch => .{ .message = "wrong type", .code = "E021", .color = .magenta }, + error.MissingField => .{ .message = "field is required", .code = "E020", .color = .cyan }, + error.UnknownField => .{ .message = "unknown field", .code = "E041", .color = .cyan }, + error.DuplicateField => .{ .message = "duplicate field", .code = "E042", .color = .cyan }, + error.InvalidEmail => .{ .message = "must be a valid email address", .code = "E010", .color = .blue }, + error.InvalidUrl => .{ .message = "must be a valid URL", .code = "E011", .color = .blue }, + error.InvalidUuid => .{ .message = "must be a valid UUID", .code = "E012", .color = .blue }, + error.InvalidIpv4 => .{ .message = "must be a valid IPv4 address", .code = "E013", .color = .blue }, + error.InvalidIpv6 => .{ .message = "must be a valid IPv6 address", .code = "E014", .color = .blue }, + error.InvalidPhoneNumber => .{ .message = "must be a valid phone number", .code = "E015", .color = .blue }, + error.InvalidCreditCard => .{ .message = "must be a valid credit card number", .code = "E016", .color = .blue }, + error.PatternMismatch => .{ .message = "does not match required pattern", .code = "E022", .color = .blue }, + error.MustBeLowercase => .{ .message = "must be lowercase", .code = "E023", .color = .red }, + error.MustBeUppercase => .{ .message = "must be uppercase", .code = "E024", .color = .red }, + error.WeakPassword => .{ .message = "password is too weak", .code = "E025", .color = .red }, + error.MustBeHttps => .{ .message = "must be HTTPS", .code = "E033", .color = .blue }, + error.InvalidDate => .{ .message = "must be a valid ISO date", .code = "E043", .color = .blue }, + error.InvalidTime => .{ .message = "must be a valid ISO time", .code = "E044", .color = .blue }, + error.LiteralMismatch => .{ .message = "does not match expected literal", .code = "E045", .color = .blue }, + error.NotInAllowedValues => .{ .message = "value is not allowed", .code = "E046", .color = .blue }, + error.WrongLength => .{ .message = "wrong length", .code = "E047", .color = .blue }, + error.TooFewItems => .{ .message = "too few items", .code = "E050", .color = .green }, + error.TooManyItems => .{ .message = "too many items", .code = "E051", .color = .green }, + error.DuplicateItem => .{ .message = "duplicate item found", .code = "E052", .color = .green }, + error.EmptyCollection => .{ .message = "collection cannot be empty", .code = "E053", .color = .green }, + error.CustomValidationFailed => .{ .message = "validation failed", .code = "E099", .color = .bright_red }, + error.InvalidJson => .{ .message = "invalid JSON syntax", .code = "E100", .color = .bright_red }, + error.NestedError => .{ .message = "nested validation error", .code = "E101", .color = .bright_red }, + error.ValidationFailed => .{ .message = "validation failed", .code = "E102", .color = .bright_red }, + error.ParseError => .{ .message = "failed to parse value", .code = "E103", .color = .bright_red }, + error.InvalidFormat => .{ .message = "invalid format", .code = "E058", .color = .blue }, + error.EmptyString => .{ .message = "cannot be empty", .code = "E059", .color = .red }, + }; +} + +/// Returns the default ANSI color for a validation error type. +pub fn errorColor(err: ValidationError) Color { + return errorPresentation(err).color; +} + +/// Returns the error message for a validation error, using a custom +/// formatter if provided, otherwise the built-in default message. +pub fn messageFor(err: ValidationError, formatter: MessageFormatter) []const u8 { + return if (formatter) |f| f(err) else errorPresentation(err).message; +} + +/// Returns the error message with a three-tier priority: +/// 1. Global formatter (if provided) +/// 2. Type-level custom message (from `ValidationMessageConfig`) +/// 3. Built-in default message +pub fn messageForWithConfig(err: ValidationError, formatter: MessageFormatter, config: anytype) []const u8 { + if (formatter) |f| return f(err); + if (messageForConfig(err, config)) |msg| return msg; + return errorPresentation(err).message; +} + +/// Set of all validation error types that can be returned by zigantic types. +/// +/// Grouped by category: string, number, type, field, format, collection, +/// and custom/other errors. pub const ValidationError = error{ // String errors TooShort, @@ -71,20 +383,91 @@ pub const ValidationError = error{ ParseError, }; +/// A single validation error attached to a specific struct field. +/// +/// Contains the field path, error message, error type, optional +/// invalid value, and optional error code. Provides formatting +/// methods for plain text, colored terminal, and JSON output. pub const FieldError = struct { + /// Dot-separated field path (e.g., "address.zip" or "items[2]"). field: []const u8, + /// Human-readable error message. message: []const u8, + /// The validation error type that occurred. error_type: ValidationError, + /// The invalid value that caused the error (if available). value: ?[]const u8 = null, + /// Error code for programmatic identification (e.g., "E001"). code: ?[]const u8 = null, + /// Formats the error as plain text: "field: message (got: value)". pub fn format(self: FieldError, allocator: std.mem.Allocator) ![]const u8 { + return self.formatWithMessage(allocator, self.message); + } + + /// Formats the error with a custom message override. + pub fn formatWithMessage(self: FieldError, allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + if (self.value) |v| { + return std.fmt.allocPrint(allocator, "{s}: {s} (got: {s})", .{ self.field, message, v }); + } + return std.fmt.allocPrint(allocator, "{s}: {s}", .{ self.field, message }); + } + + /// Formats the error with ANSI color codes using the default message. + pub fn formatColored(self: FieldError, allocator: std.mem.Allocator) ![]const u8 { + return self.formatColoredWithMessage(allocator, errorPresentation(self.error_type).message); + } + + /// Formats the error with ANSI colors using a custom message. + pub fn formatColoredWithMessage(self: FieldError, allocator: std.mem.Allocator, message: []const u8) ![]const u8 { + return self.formatColoredWithMessageAndOverrides(allocator, message, null); + } + + /// Formats the error with ANSI colors, custom message, and per-error color overrides. + /// + /// Color resolution order: + /// 1. `overrides` parameter (if non-null and field is set) + /// 2. Built-in default color from `errorPresentation()` + pub fn formatColoredWithMessageAndOverrides(self: FieldError, allocator: std.mem.Allocator, message: []const u8, overrides: ?ColorOverrides) ![]const u8 { + const color_override = if (overrides) |o| overrideColor(self.error_type, o) else null; + const effective_color = color_override orelse errorPresentation(self.error_type).color; + const presentation = errorPresentation(self.error_type); + const field_color = ansi(.bright_cyan); + const code_color = ansi(.bright_yellow); + const message_color = ansi(effective_color); + const value_color = ansi(.white); + const reset = ansi(.reset); if (self.value) |v| { - return std.fmt.allocPrint(allocator, "{s}: {s} (got: {s})", .{ self.field, self.message, v }); + return std.fmt.allocPrint(allocator, "{s}{s}{s}: {s}[{s}]{s} {s}{s}{s} {s}(got: {s}){s}", .{ + field_color, + self.field, + reset, + code_color, + presentation.code, + reset, + message_color, + message, + reset, + value_color, + v, + reset, + }); } - return std.fmt.allocPrint(allocator, "{s}: {s}", .{ self.field, self.message }); + return std.fmt.allocPrint(allocator, "{s}{s}{s}: {s}[{s}]{s} {s}{s}{s}{s}", .{ + field_color, + self.field, + reset, + code_color, + presentation.code, + reset, + message_color, + message, + reset, + "", + }); } + /// Serializes the error to a JSON object string. pub fn toJson(self: FieldError, allocator: std.mem.Allocator) ![]const u8 { if (self.value) |v| { return std.fmt.allocPrint(allocator, "{{\"field\":\"{s}\",\"message\":\"{s}\",\"value\":\"{s}\"}}", .{ self.field, self.message, v }); @@ -93,115 +476,150 @@ pub const FieldError = struct { } }; +/// Accumulator for collecting multiple validation errors. +/// +/// Used during JSON parsing and struct validation to gather all +/// errors before reporting them. Supports optional max-error limits, +/// field lookup, merging, and formatted output. pub const ErrorList = struct { - errors: std.ArrayListUnmanaged(FieldError), + errors: std.ArrayList(FieldError), allocator: std.mem.Allocator, + /// Maximum errors to collect (null = unlimited). max_errors: ?usize = null, + /// Creates a new empty error list. pub fn init(allocator: std.mem.Allocator) ErrorList { - return .{ .errors = .{}, .allocator = allocator }; + return .{ .errors = .empty, .allocator = allocator }; } + /// Creates a new error list with a maximum error count. pub fn initWithMax(allocator: std.mem.Allocator, max: usize) ErrorList { - return .{ .errors = .{}, .allocator = allocator, .max_errors = max }; + return .{ .errors = .empty, .allocator = allocator, .max_errors = max }; } + /// Frees all allocated error data and the list itself. pub fn deinit(self: *ErrorList) void { + self.freeItems(); + self.errors.deinit(self.allocator); + } + + fn freeItems(self: *ErrorList) void { for (self.errors.items) |err| { self.allocator.free(err.field); self.allocator.free(err.message); if (err.value) |v| self.allocator.free(v); if (err.code) |c| self.allocator.free(c); } - self.errors.deinit(self.allocator); } - pub fn add(self: *ErrorList, field: []const u8, error_type: ValidationError, message: []const u8, value: ?[]const u8) !void { + fn addResolved(self: *ErrorList, resolved_field: []const u8, error_type: ValidationError, message: []const u8, value: ?[]const u8, code: ?[]const u8) !void { if (self.max_errors) |max| { if (self.errors.items.len >= max) return; } - const field_copy = try self.allocator.dupe(u8, field); + const field_copy = try self.allocator.dupe(u8, resolved_field); errdefer self.allocator.free(field_copy); const message_copy = try self.allocator.dupe(u8, message); errdefer self.allocator.free(message_copy); const value_copy = if (value) |v| try self.allocator.dupe(u8, v) else null; - try self.errors.append(self.allocator, .{ .field = field_copy, .message = message_copy, .error_type = error_type, .value = value_copy }); + const code_copy = if (code) |c| try self.allocator.dupe(u8, c) else null; + try self.errors.append(self.allocator, .{ .field = field_copy, .message = message_copy, .error_type = error_type, .value = value_copy, .code = code_copy }); } + /// Adds an error for a field. Respects `max_errors` limit. + pub fn add(self: *ErrorList, field: []const u8, error_type: ValidationError, message: []const u8, value: ?[]const u8) !void { + return self.addResolved(field, error_type, message, value, null); + } + + /// Adds an error with an explicit error code. pub fn addWithCode(self: *ErrorList, field: []const u8, error_type: ValidationError, message: []const u8, value: ?[]const u8, code: []const u8) !void { - if (self.max_errors) |max| { - if (self.errors.items.len >= max) return; - } - const field_copy = try self.allocator.dupe(u8, field); - errdefer self.allocator.free(field_copy); - const message_copy = try self.allocator.dupe(u8, message); - errdefer self.allocator.free(message_copy); - const value_copy = if (value) |v| try self.allocator.dupe(u8, v) else null; - const code_copy = try self.allocator.dupe(u8, code); - try self.errors.append(self.allocator, .{ .field = field_copy, .message = message_copy, .error_type = error_type, .value = value_copy, .code = code_copy }); + return self.addResolved(field, error_type, message, value, code); } + /// Adds an error with a nested path (e.g., "user.name" for nested structs). pub fn addWithPath(self: *ErrorList, parent: []const u8, field: []const u8, error_type: ValidationError, message: []const u8, value: ?[]const u8) !void { const full_path = if (parent.len > 0) try std.fmt.allocPrint(self.allocator, "{s}.{s}", .{ parent, field }) else try self.allocator.dupe(u8, field); - errdefer self.allocator.free(full_path); - const message_copy = try self.allocator.dupe(u8, message); - errdefer self.allocator.free(message_copy); - const value_copy = if (value) |v| try self.allocator.dupe(u8, v) else null; - try self.errors.append(self.allocator, .{ .field = full_path, .message = message_copy, .error_type = error_type, .value = value_copy }); + defer self.allocator.free(full_path); + return self.addResolved(full_path, error_type, message, value, null); } + /// Adds an error with an indexed path (e.g., "items[2]" for array elements). pub fn addIndexed(self: *ErrorList, field: []const u8, index: usize, error_type: ValidationError, message: []const u8, value: ?[]const u8) !void { const indexed_path = try std.fmt.allocPrint(self.allocator, "{s}[{d}]", .{ field, index }); - errdefer self.allocator.free(indexed_path); - const message_copy = try self.allocator.dupe(u8, message); - errdefer self.allocator.free(message_copy); - const value_copy = if (value) |v| try self.allocator.dupe(u8, v) else null; - try self.errors.append(self.allocator, .{ .field = indexed_path, .message = message_copy, .error_type = error_type, .value = value_copy }); + defer self.allocator.free(indexed_path); + return self.addResolved(indexed_path, error_type, message, value, null); } + /// Returns true if any errors have been collected. pub fn hasErrors(self: ErrorList) bool { return self.errors.items.len > 0; } + + /// Returns the number of errors collected. pub fn count(self: ErrorList) usize { return self.errors.items.len; } + /// Clears all errors and frees their allocated memory. pub fn clear(self: *ErrorList) void { - for (self.errors.items) |err| { - self.allocator.free(err.field); - self.allocator.free(err.message); - if (err.value) |v| self.allocator.free(v); - if (err.code) |c| self.allocator.free(c); - } + self.freeItems(); self.errors.clearRetainingCapacity(); } + /// Returns the first error, or null if empty. pub fn first(self: ErrorList) ?FieldError { return if (self.errors.items.len > 0) self.errors.items[0] else null; } + + /// Returns the last error, or null if empty. pub fn last(self: ErrorList) ?FieldError { return if (self.errors.items.len > 0) self.errors.items[self.errors.items.len - 1] else null; } + /// Formats all errors as plain text (one per line). pub fn formatAll(self: ErrorList, allocator: std.mem.Allocator) ![]const u8 { - var buffer = std.ArrayListUnmanaged(u8){}; + return self.formatAllWith(allocator, null); + } + + /// Formats all errors with a custom message formatter. + pub fn formatAllWith(self: ErrorList, allocator: std.mem.Allocator, formatter: MessageFormatter) ![]const u8 { + var buffer = std.ArrayList(u8).empty; defer buffer.deinit(allocator); for (self.errors.items) |err| { - try buffer.appendSlice(allocator, err.field); - try buffer.appendSlice(allocator, ": "); - try buffer.appendSlice(allocator, err.message); - if (err.value) |v| { - try buffer.appendSlice(allocator, " (got: "); - try buffer.appendSlice(allocator, v); - try buffer.append(allocator, ')'); - } + const msg = if (formatter) |f| messageFor(err.error_type, f) else err.message; + const line = try err.formatWithMessage(allocator, msg); + defer allocator.free(line); + try buffer.appendSlice(allocator, line); try buffer.append(allocator, '\n'); } return try allocator.dupe(u8, buffer.items); } + pub fn formatAllColored(self: ErrorList, allocator: std.mem.Allocator) ![]const u8 { + return self.formatAllColoredWith(allocator, null); + } + + /// Formats all errors with ANSI colors and a custom message formatter. + pub fn formatAllColoredWith(self: ErrorList, allocator: std.mem.Allocator, formatter: MessageFormatter) ![]const u8 { + return self.formatAllColoredWithOverrides(allocator, formatter, null); + } + + /// Formats all errors with ANSI colors, custom formatter, and per-error color overrides. + pub fn formatAllColoredWithOverrides(self: ErrorList, allocator: std.mem.Allocator, formatter: MessageFormatter, overrides: ?ColorOverrides) ![]const u8 { + var buffer = std.ArrayList(u8).empty; + defer buffer.deinit(allocator); + for (self.errors.items) |err| { + const msg = if (formatter) |f| messageFor(err.error_type, f) else err.message; + const line = try err.formatColoredWithMessageAndOverrides(allocator, msg, overrides); + defer allocator.free(line); + try buffer.appendSlice(allocator, line); + try buffer.append(allocator, '\n'); + } + return try allocator.dupe(u8, buffer.items); + } + + /// Serializes all errors as a JSON array string. pub fn toJsonArray(self: ErrorList, allocator: std.mem.Allocator) ![]const u8 { - var buffer = std.ArrayListUnmanaged(u8){}; + var buffer = std.ArrayList(u8).empty; defer buffer.deinit(allocator); try buffer.append(allocator, '['); for (self.errors.items, 0..) |err, i| { @@ -214,6 +632,7 @@ pub const ErrorList = struct { return try allocator.dupe(u8, buffer.items); } + /// Returns true if any error is attached to the given field path. pub fn containsField(self: ErrorList, field: []const u8) bool { for (self.errors.items) |err| { if (std.mem.eql(u8, err.field, field)) return true; @@ -221,6 +640,7 @@ pub const ErrorList = struct { return false; } + /// Returns true if any error of the given type has been collected. pub fn containsErrorType(self: ErrorList, error_type: ValidationError) bool { for (self.errors.items) |err| { if (err.error_type == error_type) return true; @@ -228,14 +648,16 @@ pub const ErrorList = struct { return false; } + /// Returns all errors attached to a specific field path. pub fn getErrorsForField(self: ErrorList, field: []const u8, allocator: std.mem.Allocator) ![]FieldError { - var result = std.ArrayList(FieldError).init(allocator); + var result = std.ArrayList(FieldError).empty; for (self.errors.items) |err| { - if (std.mem.eql(u8, err.field, field)) try result.append(err); + if (std.mem.eql(u8, err.field, field)) try result.append(allocator, err); } - return result.toOwnedSlice(); + return result.toOwnedSlice(allocator); } + /// Merges another error list into this one. pub fn merge(self: *ErrorList, other: ErrorList) !void { for (other.errors.items) |err| { try self.add(err.field, err.error_type, err.message, err.value); @@ -244,57 +666,23 @@ pub const ErrorList = struct { }; pub fn errorMessage(err: ValidationError) []const u8 { - return switch (err) { - error.TooShort => "value is too short", - error.TooLong => "value is too long", - error.TooSmall => "value is too small", - error.TooLarge => "value is too large", - error.InvalidEmail => "must be a valid email address", - error.InvalidUrl => "must be a valid URL", - error.InvalidUuid => "must be a valid UUID", - error.InvalidIpv4 => "must be a valid IPv4 address", - error.InvalidIpv6 => "must be a valid IPv6 address", - error.MissingField => "field is required", - error.TypeMismatch => "wrong type", - error.PatternMismatch => "does not match pattern", - error.TooFewItems => "too few items", - error.TooManyItems => "too many items", - error.DuplicateItem => "duplicate item found", - error.CustomValidationFailed => "validation failed", - error.MustBeLowercase => "must be lowercase", - error.MustBeUppercase => "must be uppercase", - error.LiteralMismatch => "does not match expected value", - error.NotInAllowedValues => "not in allowed values", - error.EmptyString => "cannot be empty", - error.EmptyCollection => "cannot be empty", - error.InvalidFormat => "invalid format", - error.WeakPassword => "password is too weak", - error.MustBeEven => "must be an even number", - error.MustBeOdd => "must be an odd number", - error.NotMultiple => "must be a multiple of the divisor", - error.OutOfRange => "value is out of range", - error.NotInStep => "value must be in step increments", - error.MustBeHttps => "must be HTTPS", - error.WrongLength => "wrong length", - error.InvalidPhoneNumber => "invalid phone number", - error.InvalidCreditCard => "invalid credit card number", - else => "validation error", - }; + return errorPresentation(err).message; } pub fn errorCode(err: ValidationError) []const u8 { - return switch (err) { - error.TooShort => "E001", - error.TooLong => "E002", - error.TooSmall => "E003", - error.TooLarge => "E004", - error.InvalidEmail => "E010", - error.InvalidUrl => "E011", - error.MissingField => "E020", - error.TypeMismatch => "E021", - error.CustomValidationFailed => "E099", - else => "E000", - }; + return errorPresentation(err).code; +} + +test "Error presentation" { + const too_short = errorPresentation(error.TooShort); + try std.testing.expectEqualStrings("value is too short", too_short.message); + try std.testing.expectEqualStrings("E001", too_short.code); + try std.testing.expect(too_short.color == .red); + + const invalid_json = errorPresentation(error.InvalidJson); + try std.testing.expectEqualStrings("invalid JSON syntax", invalid_json.message); + try std.testing.expectEqualStrings("E100", invalid_json.code); + try std.testing.expect(invalid_json.color == .bright_red); } test "ErrorList add single" { @@ -366,7 +754,7 @@ test "ErrorList formatAll" { try errors.add("name", error.TooShort, "too short", null); const formatted = try errors.formatAll(std.testing.allocator); defer std.testing.allocator.free(formatted); - try std.testing.expect(std.mem.indexOf(u8, formatted, "name: too short") != null); + try std.testing.expect(std.mem.find(u8, formatted, "name: too short") != null); } test "ErrorList toJsonArray" { @@ -375,7 +763,7 @@ test "ErrorList toJsonArray" { try errors.add("name", error.TooShort, "too short", null); const json = try errors.toJsonArray(std.testing.allocator); defer std.testing.allocator.free(json); - try std.testing.expect(std.mem.indexOf(u8, json, "\"field\":\"name\"") != null); + try std.testing.expect(std.mem.find(u8, json, "\"field\":\"name\"") != null); } test "ErrorList initWithMax" { @@ -405,7 +793,7 @@ test "FieldError toJson" { const err = FieldError{ .field = "name", .message = "too short", .error_type = error.TooShort, .value = null }; const json = try err.toJson(std.testing.allocator); defer std.testing.allocator.free(json); - try std.testing.expect(std.mem.indexOf(u8, json, "\"field\":\"name\"") != null); + try std.testing.expect(std.mem.find(u8, json, "\"field\":\"name\"") != null); } test "errorMessage" { @@ -417,3 +805,53 @@ test "errorCode" { try std.testing.expectEqualStrings("E001", errorCode(error.TooShort)); try std.testing.expectEqualStrings("E010", errorCode(error.InvalidEmail)); } + +test "ErrorList colored formatting" { + var errors = ErrorList.init(std.testing.allocator); + defer errors.deinit(); + try errors.add("name", error.TooShort, "too short", "Jo"); + const colored = try errors.formatAllColored(std.testing.allocator); + defer std.testing.allocator.free(colored); + try std.testing.expect(std.mem.find(u8, colored, "\x1b[") != null); +} + +test "ErrorList custom message formatting" { + var errors = ErrorList.init(std.testing.allocator); + defer errors.deinit(); + try errors.add("email", error.InvalidEmail, "must be a valid email address", "bad@"); + const custom = struct { + fn f(err: ValidationError) []const u8 { + return switch (err) { + error.InvalidEmail => "please provide a valid email", + else => errorPresentation(err).message, + }; + } + }.f; + const formatted = try errors.formatAllWith(std.testing.allocator, custom); + defer std.testing.allocator.free(formatted); + try std.testing.expect(std.mem.find(u8, formatted, "please provide a valid email") != null); +} + +test "ValidationMessageConfig - custom messages" { + const config = ValidationMessageConfig{ + .too_short = "custom too short message", + .too_large = "custom too large message", + }; + try std.testing.expectEqualStrings("custom too short message", messageForConfig(error.TooShort, config).?); + try std.testing.expectEqualStrings("custom too large message", messageForConfig(error.TooLarge, config).?); + try std.testing.expect(messageForConfig(error.TooLong, config) == null); +} + +test "ValidationMessageConfig - messageForWithConfig" { + const config = ValidationMessageConfig{ + .invalid_email = "please enter a valid email address", + }; + const custom_formatter = struct { + fn f(_: ValidationError) []const u8 { + return "from formatter"; + } + }.f; + try std.testing.expectEqualStrings("from formatter", messageForWithConfig(error.InvalidEmail, custom_formatter, config)); + try std.testing.expectEqualStrings("please enter a valid email address", messageForWithConfig(error.InvalidEmail, null, config)); + try std.testing.expectEqualStrings("value is too short", messageForWithConfig(error.TooShort, null, config)); +} diff --git a/src/json.zig b/src/json.zig index e7ab597..8b7ef67 100644 --- a/src/json.zig +++ b/src/json.zig @@ -7,6 +7,7 @@ const std = @import("std"); const types = @import("types.zig"); const errors = @import("errors.zig"); +const utils = @import("utils.zig"); /// Result of a JSON parsing operation. /// @@ -143,8 +144,7 @@ fn parseZiganticType( }; return T.init(str) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, str); + try addValidationErr(T, error_list, path, err, str); return null; }; } @@ -167,8 +167,7 @@ fn parseZiganticType( }; return T.init(str) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, str); + try addValidationErr(T, error_list, path, err, str); return null; }; } @@ -194,10 +193,9 @@ fn parseZiganticType( }; return T.init(casted) catch |err| { - const msg = getValidationMessage(T, err); var buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&buf, "{d}", .{val}) catch "?"; - try addError(error_list, path, err, msg, val_str); + try addValidationErr(T, error_list, path, err, val_str); return null; }; } else { @@ -208,8 +206,7 @@ fn parseZiganticType( }; return T.init(casted) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, null); + try addValidationErr(T, error_list, path, err, null); return null; }; } @@ -232,15 +229,13 @@ fn parseZiganticType( const FloatType = T.FloatType; const casted: FloatType = @floatCast(val); return T.init(casted) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, null); + try addValidationErr(T, error_list, path, err, null); return null; }; } else { // Latitude/Longitude use f64 directly return T.init(val) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, null); + try addValidationErr(T, error_list, path, err, null); return null; }; } @@ -275,14 +270,13 @@ fn parseZiganticType( } return T.init(items[0..valid_count]) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, null); + try addValidationErr(T, error_list, path, err, null); return null; }; } - // Default type - if (zigantic_type == .default) { + // Default / DefaultFactory type + if (zigantic_type == .default or zigantic_type == .default_factory) { const ValueType = T.ValueType; if (json_value == .null) { return T.initDefault(); @@ -296,8 +290,7 @@ fn parseZiganticType( const ValueType = T.ValueType; const inner = try parseValue(ValueType, json_value, allocator, error_list, path) orelse return null; return T.init(inner) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, null); + try addValidationErr(T, error_list, path, err, null); return null; }; } @@ -314,8 +307,7 @@ fn parseZiganticType( const FromType = T.FromType; const inner = try parseValue(FromType, json_value, allocator, error_list, path) orelse return null; return T.init(inner) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, null); + try addValidationErr(T, error_list, path, err, null); return null; }; } @@ -325,8 +317,7 @@ fn parseZiganticType( const ValueType = T.ValueType; const inner = try parseValue(ValueType, json_value, allocator, error_list, path) orelse return null; return T.init(inner) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, null); + try addValidationErr(T, error_list, path, err, null); return null; }; } @@ -352,8 +343,7 @@ fn parseZiganticType( }; return T.init(str) catch |err| { - const msg = getValidationMessage(T, err); - try addError(error_list, path, err, msg, str); + try addValidationErr(T, error_list, path, err, str); return null; }; } @@ -390,9 +380,26 @@ fn parseStruct( else field.name; - if (obj.get(field.name)) |field_value| { - if (try parseValue(field.type, field_value, allocator, error_list, field_path)) |parsed| { - @field(result, field.name) = parsed; + const json_field_name = comptime utils.getFieldAlias(T, field.name); + const field_value = obj.get(field.name) orelse obj.get(json_field_name); + + if (field_value) |fv| { + if (try parseValue(field.type, fv, allocator, error_list, field_path)) |parsed| { + var final_val = parsed; + const has_validator = comptime @hasDecl(T, "validate_" ++ field.name); + var field_ok = true; + if (comptime has_validator) { + const validator_fn = @field(T, "validate_" ++ field.name); + final_val = validator_fn(parsed) catch |err| blk: { + try handleFieldValidatorErr(error_list, field_path, err); + has_errors = true; + field_ok = false; + break :blk parsed; + }; + } + if (field_ok) { + @field(result, field.name) = final_val; + } } else { has_errors = true; } @@ -400,6 +407,21 @@ fn parseStruct( // Field is missing from JSON if (handleMissingField(T, field, &result)) |_| { // Field was handled (has default or is optional) + var final_val = @field(result, field.name); + const has_validator = comptime @hasDecl(T, "validate_" ++ field.name); + var field_ok = true; + if (comptime has_validator) { + const validator_fn = @field(T, "validate_" ++ field.name); + final_val = validator_fn(final_val) catch |err| blk: { + try handleFieldValidatorErr(error_list, field_path, err); + has_errors = true; + field_ok = false; + break :blk @field(result, field.name); + }; + } + if (field_ok) { + @field(result, field.name) = final_val; + } } else |_| { // Build error message at runtime var msg_buf: [256]u8 = undefined; @@ -414,6 +436,8 @@ fn parseStruct( return null; } + if (!try runModelValidator(T, result, error_list, path)) return null; + return result; } @@ -428,11 +452,13 @@ fn handleMissingField(comptime T: type, comptime field: std.builtin.Type.StructF return; } - // Check if it's a zigantic Default type (must be a struct first) + // Check if it's a zigantic Default or DefaultFactory type (must be a struct first) if (field_info == .@"struct") { - if (@hasDecl(FieldType, "zigantic_type") and FieldType.zigantic_type == .default) { - @field(result, field.name) = FieldType.initDefault(); - return; + if (@hasDecl(FieldType, "zigantic_type")) { + if (FieldType.zigantic_type == .default or FieldType.zigantic_type == .default_factory) { + @field(result, field.name) = FieldType.initDefault(); + return; + } } } @@ -558,8 +584,50 @@ fn addError( try error_list.add(field_name, err, message, value); } +/// Add a validation error with an auto-resolved message for a zigantic type. +fn addValidationErr(comptime T: type, error_list: *errors.ErrorList, path: []const u8, err: errors.ValidationError, value: ?[]const u8) !void { + const msg = getValidationMessage(T, err); + try addError(error_list, path, err, msg, value); +} + +/// Handle a field-level validator error (returns false if field should be skipped). +fn handleFieldValidatorErr(error_list: *errors.ErrorList, field_path: []const u8, err: anyerror) !void { + var err_msg_buf: [256]u8 = undefined; + const err_msg = std.fmt.bufPrint(&err_msg_buf, "field validator failed: {s}", .{@errorName(err)}) catch "field validation failed"; + try addError(error_list, field_path, errors.ValidationError.CustomValidationFailed, err_msg, null); +} + +/// Run model-level validator if present. Returns true if validation passed. +fn runModelValidator(comptime T: type, result: T, error_list: *errors.ErrorList, path: []const u8) !bool { + if (!comptime @hasDecl(T, "validateModel")) return true; + const validator_fn = @field(T, "validateModel"); + const call_res = blk: { + const FnType = @TypeOf(validator_fn); + const params = @typeInfo(FnType).@"fn".params; + if (params.len > 0) { + const first_param_type = params[0].type.?; + if (first_param_type == T) { + break :blk validator_fn(result); + } else if (first_param_type == *const T or first_param_type == *T) { + break :blk validator_fn(&result); + } + } + break :blk validator_fn(); + }; + call_res catch |err| { + var err_msg_buf: [256]u8 = undefined; + const err_msg = std.fmt.bufPrint(&err_msg_buf, "model validator failed: {s}", .{@errorName(err)}) catch "model validation failed"; + try addError(error_list, path, errors.ValidationError.CustomValidationFailed, err_msg, null); + return false; + }; + return true; +} + /// Get a validation message for a zigantic type error. fn getValidationMessage(comptime T: type, err: errors.ValidationError) []const u8 { + if (@hasDecl(T, "custom_messages")) { + if (errors.messageForConfig(err, T.custom_messages)) |msg| return msg; + } return switch (err) { error.TooShort => if (@hasDecl(T, "min")) std.fmt.comptimePrint("must be at least {d} characters", .{T.min}) @@ -596,10 +664,6 @@ fn getValidationMessage(comptime T: type, err: errors.ValidationError) []const u }; } -// ============================================================================ -// Serialization (toJson) -// ============================================================================ - /// Serialize a value to JSON string. pub fn toJson(value: anytype, allocator: std.mem.Allocator) ![]const u8 { return toJsonInternal(value, allocator, false); @@ -611,7 +675,7 @@ pub fn toJsonPretty(value: anytype, allocator: std.mem.Allocator) ![]const u8 { } fn toJsonInternal(value: anytype, allocator: std.mem.Allocator, pretty: bool) ![]const u8 { - var buffer = std.ArrayListUnmanaged(u8){}; + var buffer = std.ArrayList(u8).empty; errdefer buffer.deinit(allocator); try writeJson(@TypeOf(value), value, &buffer, allocator, 0, pretty); @@ -622,7 +686,7 @@ fn toJsonInternal(value: anytype, allocator: std.mem.Allocator, pretty: bool) ![ fn writeJson( comptime T: type, value: T, - buffer: *std.ArrayListUnmanaged(u8), + buffer: *std.ArrayList(u8), allocator: std.mem.Allocator, depth: usize, pretty: bool, @@ -707,7 +771,8 @@ fn writeJson( try buffer.appendNTimes(allocator, ' ', (depth + 1) * 2); } - try writeJsonString(field.name, buffer, allocator); + const json_field_name = comptime utils.getFieldAlias(T, field.name); + try writeJsonString(json_field_name, buffer, allocator); try buffer.append(allocator, ':'); if (pretty) try buffer.append(allocator, ' '); @@ -726,7 +791,7 @@ fn writeJson( try buffer.appendSlice(allocator, "null"); } -fn writeJsonString(str: []const u8, buffer: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator) !void { +fn writeJsonString(str: []const u8, buffer: *std.ArrayList(u8), allocator: std.mem.Allocator) !void { try buffer.append(allocator, '"'); for (str) |c| { @@ -737,7 +802,7 @@ fn writeJsonString(str: []const u8, buffer: *std.ArrayListUnmanaged(u8), allocat '\r' => try buffer.appendSlice(allocator, "\\r"), '\t' => try buffer.appendSlice(allocator, "\\t"), else => { - if (c < 0x20) { + if (std.ascii.isControl(c)) { try buffer.appendSlice(allocator, "\\u00"); const hex = "0123456789abcdef"; try buffer.append(allocator, hex[c >> 4]); @@ -954,5 +1019,519 @@ test "toJsonPretty" { defer allocator.free(json_str); // Verify it contains newlines (pretty printed) - try std.testing.expect(std.mem.indexOf(u8, json_str, "\n") != null); + try std.testing.expect(std.mem.find(u8, json_str, "\n") != null); +} + +/// Parse a URL query string or form urlencoded string into a validated struct. +pub fn fromQueryString(comptime T: type, query_string: []const u8, allocator: std.mem.Allocator) !ParseResult(T) { + var result = ParseResult(T){ + .value = null, + .error_list = errors.ErrorList.init(allocator), + .allocator = allocator, + .arena = std.heap.ArenaAllocator.init(allocator), + }; + errdefer result.deinit(); + + const arena_alloc = result.arena.allocator(); + + var map = std.StringHashMap([]const u8).init(arena_alloc); + + var it = std.mem.tokenizeScalar(u8, query_string, '&'); + while (it.next()) |pair| { + if (pair.len == 0) continue; + const eq_idx = std.mem.findScalar(u8, pair, '=') orelse { + const decoded_key = try decodeUrlComponent(pair, arena_alloc); + try map.put(decoded_key, ""); + continue; + }; + const key = pair[0..eq_idx]; + const val = pair[eq_idx + 1 ..]; + + const decoded_key = try decodeUrlComponent(key, arena_alloc); + const decoded_val = try decodeUrlComponent(val, arena_alloc); + try map.put(decoded_key, decoded_val); + } + + result.value = try parseQueryMap(T, &map, arena_alloc, &result.error_list, ""); + return result; +} + +fn decodeUrlComponent(input: []const u8, allocator: std.mem.Allocator) ![]const u8 { + if (input.len == 0) return ""; + const temp = try allocator.alloc(u8, input.len); + defer allocator.free(temp); + for (input, 0..) |c, i| { + temp[i] = if (c == '+') ' ' else c; + } + const decoded = std.Uri.percentDecodeInPlace(temp); + return try allocator.dupe(u8, decoded); +} + +fn parseQueryMap( + comptime T: type, + map: *std.StringHashMap([]const u8), + allocator: std.mem.Allocator, + error_list: *errors.ErrorList, + path: []const u8, +) !?T { + const info = @typeInfo(T); + + if (info == .optional) { + const Child = info.optional.child; + return parseQueryMap(Child, map, allocator, error_list, path) catch |err| { + if (err == error.OutOfMemory) return err; + return null; + }; + } + + const is_container = comptime switch (info) { + .@"struct", .@"union", .@"enum", .@"opaque" => true, + else => false, + }; + + if (comptime is_container) { + if (@hasDecl(T, "init") and @hasDecl(T, "get")) { + const value_str = map.get(path) orelse { + if (@hasDecl(T, "initDefault")) { + return T.initDefault(); + } + try error_list.add(path, errors.ValidationError.MissingField, "field is required", null); + return null; + }; + + const init_fn_info = @typeInfo(@TypeOf(T.init)).@"fn"; + const ExpectedType = init_fn_info.params[0].type.?; + + const coerced_val = coerceQueryValue(ExpectedType, value_str, allocator) catch { + try error_list.add(path, errors.ValidationError.InvalidFormat, "invalid value format", value_str); + return null; + }; + + const init_return_type = @typeInfo(@TypeOf(T.init)).@"fn".return_type.?; + const val = blk: { + switch (@typeInfo(init_return_type)) { + .error_union => { + break :blk T.init(coerced_val) catch |err| { + try addValidationErr(T, error_list, path, err, value_str); + return null; + }; + }, + else => { + break :blk T.init(coerced_val); + }, + } + }; + return val; + } + } + + if (info == .@"struct") { + var result: T = undefined; + var has_errors = false; + + inline for (info.@"struct".fields) |field| { + const json_field_name = comptime utils.getFieldAlias(T, field.name); + + const alias_path = if (path.len == 0) json_field_name else try std.fmt.allocPrint(allocator, "{s}.{s}", .{ path, json_field_name }); + defer if (path.len > 0) allocator.free(alias_path); + + const field_path = if (path.len == 0) field.name else try std.fmt.allocPrint(allocator, "{s}.{s}", .{ path, field.name }); + defer if (path.len > 0) allocator.free(field_path); + + const chosen_path = if (map.contains(alias_path)) alias_path else field_path; + + if (map.contains(chosen_path)) { + if (parseQueryMap(field.type, map, allocator, error_list, chosen_path)) |parsed_field| { + if (parsed_field) |val| { + var final_val = val; + const has_validator = comptime @hasDecl(T, "validate_" ++ field.name); + var field_ok = true; + if (comptime has_validator) { + const validator_fn = @field(T, "validate_" ++ field.name); + final_val = validator_fn(val) catch |err| blk: { + try handleFieldValidatorErr(error_list, chosen_path, err); + has_errors = true; + field_ok = false; + break :blk val; + }; + } + if (field_ok) { + @field(result, field.name) = final_val; + } + } else { + const field_info = @typeInfo(field.type); + if (field_info == .optional) { + @field(result, field.name) = null; + } else { + has_errors = true; + } + } + } else |err| { + if (err == error.OutOfMemory) return err; + has_errors = true; + } + } else { + // Field is missing from query string Map! + if (handleMissingField(T, field, &result)) |_| { + // Field was handled (has default or is optional) + var final_val = @field(result, field.name); + const has_validator = comptime @hasDecl(T, "validate_" ++ field.name); + var field_ok = true; + if (comptime has_validator) { + const validator_fn = @field(T, "validate_" ++ field.name); + final_val = validator_fn(final_val) catch |err| blk: { + try handleFieldValidatorErr(error_list, chosen_path, err); + has_errors = true; + field_ok = false; + break :blk @field(result, field.name); + }; + } + if (field_ok) { + @field(result, field.name) = final_val; + } + } else |_| { + try error_list.add(chosen_path, errors.ValidationError.MissingField, "field is required", null); + has_errors = true; + } + } + } + + if (has_errors or error_list.count() > 0) return null; + + if (!try runModelValidator(T, result, error_list, path)) return null; + + return result; + } + + return coerceQueryValue(T, map.get(path) orelse return null, allocator) catch null; +} + +fn coerceQueryValue(comptime T: type, str: []const u8, allocator: std.mem.Allocator) !T { + const info = @typeInfo(T); + switch (info) { + .bool => { + if (std.mem.eql(u8, str, "true") or std.mem.eql(u8, str, "1") or std.mem.eql(u8, str, "on")) return true; + if (std.mem.eql(u8, str, "false") or std.mem.eql(u8, str, "0") or std.mem.eql(u8, str, "")) return false; + return error.InvalidFormat; + }, + .int => { + return std.fmt.parseInt(T, str, 10); + }, + .float => { + return std.fmt.parseFloat(T, str); + }, + .pointer => |ptr| { + if (ptr.size == .slice and ptr.child == u8) { + return try allocator.dupe(u8, str); + } + }, + else => {}, + } + return error.UnsupportedType; +} + +/// Serialize a value to URL query / form urlencoded string. +pub fn toQueryString(value: anytype, allocator: std.mem.Allocator) ![]const u8 { + var list = std.ArrayList(u8).empty; + defer list.deinit(allocator); + + try writeQueryValue(value, &list, allocator, ""); + + return list.toOwnedSlice(allocator); +} + +fn writeQueryValue(value: anytype, list: *std.ArrayList(u8), allocator: std.mem.Allocator, path: []const u8) !void { + const T = @TypeOf(value); + const info = @typeInfo(T); + + if (info == .optional) { + if (value) |val| { + try writeQueryValue(val, list, allocator, path); + } + return; + } + + const is_container = comptime switch (info) { + .@"struct", .@"union", .@"enum", .@"opaque" => true, + else => false, + }; + + if (comptime is_container) { + if (@hasDecl(T, "get")) { + const unwrapped = value.get(); + try writeQueryKeyValue(path, unwrapped, list, allocator); + return; + } + } + + if (info == .@"struct") { + inline for (info.@"struct".fields) |field| { + const json_field_name = comptime utils.getFieldAlias(T, field.name); + const field_path = if (path.len == 0) json_field_name else try std.fmt.allocPrint(allocator, "{s}.{s}", .{ path, json_field_name }); + defer if (path.len > 0) allocator.free(field_path); + + try writeQueryValue(@field(value, field.name), list, allocator, field_path); + } + return; + } + + try writeQueryKeyValue(path, value, list, allocator); +} + +fn writeQueryKeyValue(key: []const u8, value: anytype, list: *std.ArrayList(u8), allocator: std.mem.Allocator) !void { + if (list.items.len > 0) { + try list.append(allocator, '&'); + } + + var key_buf: [256]u8 = undefined; + const encoded_key = try encodeUrlComponent(key, &key_buf, allocator); + try list.appendSlice(allocator, encoded_key); + + try list.append(allocator, '='); + + const V = @TypeOf(value); + const v_info = @typeInfo(V); + + if (v_info == .pointer and v_info.pointer.size == .slice and v_info.pointer.child == u8) { + var val_buf: [1024]u8 = undefined; + const encoded_val = try encodeUrlComponent(value, &val_buf, allocator); + try list.appendSlice(allocator, encoded_val); + } else { + var str_buf: [128]u8 = undefined; + const str = try std.fmt.bufPrint(&str_buf, "{}", .{value}); + var val_buf: [256]u8 = undefined; + const encoded_val = try encodeUrlComponent(str, &val_buf, allocator); + try list.appendSlice(allocator, encoded_val); + } +} + +fn encodeUrlComponent(input: []const u8, buf: []u8, allocator: std.mem.Allocator) ![]const u8 { + _ = allocator; + var fbs = std.Io.Writer.fixed(buf); + for (input) |c| { + if (std.ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~') { + try fbs.writeByte(c); + } else if (c == ' ') { + try fbs.writeByte('+'); + } else { + try fbs.print("%{X:0>2}", .{c}); + } + } + return fbs.buffered(); +} + +test "query string serialization and deserialization" { + const allocator = std.testing.allocator; + + const User = struct { + name: types.String(1, 50), + age: types.Int(i32, 0, 150), + active: bool, + }; + + const qs = "name=Alice+Johnson&age=25&active=true"; + var result = try fromQueryString(User, qs, allocator); + defer result.deinit(); + + try std.testing.expect(result.isValid()); + const user = result.value.?; + try std.testing.expectEqualStrings("Alice Johnson", user.name.get()); + try std.testing.expectEqual(@as(i32, 25), user.age.get()); + try std.testing.expectEqual(true, user.active); + + const serialized = try toQueryString(user, allocator); + defer allocator.free(serialized); + try std.testing.expectEqualStrings("name=Alice+Johnson&age=25&active=true", serialized); +} + +test "json and query field aliases and naming conventions" { + const allocator = std.testing.allocator; + + // 1. Struct with explicit aliases + const ExplicitUser = struct { + user_name: types.String(1, 50), + user_age: i32, + + pub const zigantic_aliases = .{ + .user_name = "username", + .user_age = "age", + }; + }; + + const explicit_json = "{\"username\":\"Bob\",\"age\":42}"; + var result_explicit = try fromJson(ExplicitUser, explicit_json, allocator); + defer result_explicit.deinit(); + + try std.testing.expect(result_explicit.isValid()); + const user_explicit = result_explicit.value.?; + try std.testing.expectEqualStrings("Bob", user_explicit.user_name.get()); + try std.testing.expectEqual(@as(i32, 42), user_explicit.user_age); + + const explicit_serialized = try toJson(user_explicit, allocator); + defer allocator.free(explicit_serialized); + try std.testing.expect(std.mem.find(u8, explicit_serialized, "\"username\":\"Bob\"") != null); + try std.testing.expect(std.mem.find(u8, explicit_serialized, "\"age\":42") != null); + + // 2. Struct with automatic naming policy (camelCase -> snake_case) + const CamelUser = struct { + firstName: []const u8, + lastName: []const u8, + + pub const zigantic_naming = utils.NamingPolicy.snake_case; + }; + + const snake_json = "{\"first_name\":\"Alice\",\"last_name\":\"Smith\"}"; + var result_camel = try fromJson(CamelUser, snake_json, allocator); + defer result_camel.deinit(); + + try std.testing.expect(result_camel.isValid()); + const user_camel = result_camel.value.?; + try std.testing.expectEqualStrings("Alice", user_camel.firstName); + try std.testing.expectEqualStrings("Smith", user_camel.lastName); + + const camel_serialized = try toJson(user_camel, allocator); + defer allocator.free(camel_serialized); + try std.testing.expect(std.mem.find(u8, camel_serialized, "\"first_name\":\"Alice\"") != null); + try std.testing.expect(std.mem.find(u8, camel_serialized, "\"last_name\":\"Smith\"") != null); +} + +test "fromJson - DefaultFactory dynamically generated values" { + const allocator = std.testing.allocator; + + const dummy_factory = struct { + var call_counter: i32 = 0; + fn nextId() i32 { + call_counter += 1; + return call_counter; + } + }; + + const Device = struct { + name: []const u8, + id: types.DefaultFactory(i32, dummy_factory.nextId), + }; + + // When ID is missing, it should call the factory function + const json_str = "{\"name\":\"Sensor A\"}"; + var result1 = try fromJson(Device, json_str, allocator); + defer result1.deinit(); + + try std.testing.expect(result1.isValid()); + try std.testing.expectEqual(@as(i32, 1), result1.value.?.id.get()); + + var result2 = try fromJson(Device, json_str, allocator); + defer result2.deinit(); + + try std.testing.expect(result2.isValid()); + try std.testing.expectEqual(@as(i32, 2), result2.value.?.id.get()); + + // When ID is provided, it should use the provided value instead + const json_with_id = "{\"name\":\"Sensor B\",\"id\":99}"; + var result3 = try fromJson(Device, json_with_id, allocator); + defer result3.deinit(); + + try std.testing.expect(result3.isValid()); + try std.testing.expectEqual(@as(i32, 99), result3.value.?.id.get()); +} + +test "fromQueryString - DefaultFactory" { + const allocator = std.testing.allocator; + + const static_factory = struct { + fn getVal() i32 { + return 42; + } + }; + + const ConfigItem = struct { + key: []const u8, + val: types.DefaultFactory(i32, static_factory.getVal), + }; + + const qs = "key=port"; + var result = try fromQueryString(ConfigItem, qs, allocator); + defer result.deinit(); + + try std.testing.expect(result.isValid()); + try std.testing.expectEqualStrings("port", result.value.?.key); + try std.testing.expectEqual(@as(i32, 42), result.value.?.val.get()); +} + +test "fromJson and fromQueryString - field-level and model-level validators" { + const allocator = std.testing.allocator; + + const TestModel = struct { + email: types.Email, + age: i32, + secret_code: []const u8, + + pub fn validate_age(val: i32) !i32 { + if (val < 18) return error.AgeTooYoung; + // Let's cap the age at 100 as a modification + if (val > 100) return 100; + return val; + } + + pub fn validate_secret_code(val: []const u8) ![]const u8 { + if (std.mem.eql(u8, val, "admin")) return error.ForbiddenCode; + return val; + } + + pub fn validateModel(self: *const @This()) !void { + if (std.mem.eql(u8, self.email.get(), "forbidden@example.com") and self.age == 100) { + return error.ForbiddenCombination; + } + } + }; + + // 1. JSON parsing success (including field validator capping age to 100) + const valid_json = "{\"email\":\"user@example.com\",\"age\":150,\"secret_code\":\"pass123\"}"; + var result1 = try fromJson(TestModel, valid_json, allocator); + defer result1.deinit(); + + try std.testing.expect(result1.isValid()); + const model1 = result1.value.?; + try std.testing.expectEqualStrings("user@example.com", model1.email.get()); + try std.testing.expectEqual(@as(i32, 100), model1.age); // capped by validator + try std.testing.expectEqualStrings("pass123", model1.secret_code); + + // 2. JSON parsing field failure (age too young) + const young_json = "{\"email\":\"user@example.com\",\"age\":12,\"secret_code\":\"pass123\"}"; + var result2 = try fromJson(TestModel, young_json, allocator); + defer result2.deinit(); + + try std.testing.expect(!result2.isValid()); + try std.testing.expect(std.mem.find(u8, result2.error_list.errors.items[0].message, "AgeTooYoung") != null); + + // 3. JSON parsing field failure (forbidden code) + const admin_json = "{\"email\":\"user@example.com\",\"age\":30,\"secret_code\":\"admin\"}"; + var result3 = try fromJson(TestModel, admin_json, allocator); + defer result3.deinit(); + + try std.testing.expect(!result3.isValid()); + try std.testing.expect(std.mem.find(u8, result3.error_list.errors.items[0].message, "ForbiddenCode") != null); + + // 4. JSON parsing model failure (forbidden combination) + const forbidden_json = "{\"email\":\"forbidden@example.com\",\"age\":120,\"secret_code\":\"pass123\"}"; + var result4 = try fromJson(TestModel, forbidden_json, allocator); + defer result4.deinit(); + + try std.testing.expect(!result4.isValid()); + try std.testing.expect(std.mem.find(u8, result4.error_list.errors.items[0].message, "ForbiddenCombination") != null); + + // 5. URL Query parameter success and validation + const valid_qs = "email=user@example.com&age=45&secret_code=hello"; + var result5 = try fromQueryString(TestModel, valid_qs, allocator); + defer result5.deinit(); + + try std.testing.expect(result5.isValid()); + try std.testing.expectEqual(@as(i32, 45), result5.value.?.age); + + // 6. URL Query parameter field failure + const young_qs = "email=user@example.com&age=15&secret_code=hello"; + var result6 = try fromQueryString(TestModel, young_qs, allocator); + defer result6.deinit(); + + try std.testing.expect(!result6.isValid()); + try std.testing.expect(std.mem.find(u8, result6.error_list.errors.items[0].message, "AgeTooYoung") != null); } diff --git a/src/utils/network.zig b/src/network.zig similarity index 91% rename from src/utils/network.zig rename to src/network.zig index f1f41f1..d33ec12 100644 --- a/src/utils/network.zig +++ b/src/network.zig @@ -19,8 +19,8 @@ pub const NetworkError = error{ /// Fetches a JSON response from a URL. /// Returns the parsed JSON value (caller must deinit). -pub fn fetchJson(allocator: std.mem.Allocator, url: []const u8, headers: []const http.Header) !std.json.Parsed(std.json.Value) { - var client = http.Client{ .allocator = allocator }; +pub fn fetchJson(allocator: std.mem.Allocator, url: []const u8, headers: []const http.Header, io: std.Io) !std.json.Parsed(std.json.Value) { + var client = http.Client{ .allocator = allocator, .io = io }; defer client.deinit(); var req = try client.request(.GET, try std.Uri.parse(url), .{ @@ -52,12 +52,11 @@ pub fn fetchJson(allocator: std.mem.Allocator, url: []const u8, headers: []const var body = std.ArrayList(u8).initCapacity(allocator, 4096) catch return NetworkError.ReadError; defer body.deinit(allocator); - const writer = body.writer(allocator); var buf: [4096]u8 = undefined; while (true) { const n = reader.readSliceShort(&buf) catch return NetworkError.ReadError; if (n == 0) break; - try writer.writeAll(buf[0..n]); + body.appendSlice(allocator, buf[0..n]) catch return NetworkError.ReadError; } // Parse JSON from the response body. diff --git a/src/report.zig b/src/report.zig index 5a84be3..9b7e8ce 100644 --- a/src/report.zig +++ b/src/report.zig @@ -7,7 +7,8 @@ const builtin = @import("builtin"); const http = std.http; const SemanticVersion = std.SemanticVersion; const version_info = @import("version.zig"); -const Network = @import("utils/network.zig"); +const utils = @import("utils.zig"); +const Network = @import("network.zig"); /// URL for reporting issues on GitHub. pub const ISSUES_URL = "https://github.com/muhammad-fiaz/zigantic/issues"; @@ -21,10 +22,6 @@ const REPO_NAME = "zigantic"; /// Current version of the library. const CURRENT_VERSION: []const u8 = version_info.version; -// ============================================================================ -// ERROR REPORTING (for library bugs only, NOT validation errors) -// ============================================================================ - /// Reports a library bug/runtime error with instructions for filing a bug report. /// Use this ONLY for unexpected errors that indicate a bug in zigantic itself. /// Do NOT use this for validation errors - those are expected user errors. @@ -52,18 +49,10 @@ pub const reportErrorMessage = reportInternalError; /// Static flag to ensure update check runs only once per process. var update_check_done = false; -var update_check_mutex = std.Thread.Mutex{}; +var update_check_mutex: std.atomic.Mutex = .unlocked; -/// Strips the 'v' or 'V' prefix from a version tag. -fn stripVersionPrefix(tag: []const u8) []const u8 { - if (tag.len == 0) return tag; - return if (tag[0] == 'v' or tag[0] == 'V') tag[1..] else tag; -} - -/// Attempts to parse a semantic version string. -fn parseSemver(text: []const u8) ?SemanticVersion { - return SemanticVersion.parse(text) catch null; -} +const stripVersionPrefix = utils.stripVersionPrefix; +const parseSemver = utils.parseSemver; /// Represents the relationship between local and remote versions. const VersionRelation = enum { @@ -92,13 +81,13 @@ fn compareVersions(latest_raw: []const u8) VersionRelation { } /// Fetches the latest release tag from GitHub. -fn fetchLatestTag(allocator: std.mem.Allocator) ![]const u8 { +fn fetchLatestTag(allocator: std.mem.Allocator, io: std.Io) ![]const u8 { const url = std.fmt.comptimePrint("https://api.github.com/repos/{s}/{s}/releases/latest", .{ REPO_OWNER, REPO_NAME }); const extra_headers = [_]http.Header{ .{ .name = "Accept", .value = "application/vnd.github+json" }, }; - var parsed = Network.fetchJson(allocator, url, &extra_headers) catch return error.TagMissing; + var parsed = Network.fetchJson(allocator, url, &extra_headers, io) catch return error.TagMissing; defer parsed.deinit(); return switch (parsed.value) { @@ -119,7 +108,9 @@ fn fetchLatestTag(allocator: std.mem.Allocator) ![]const u8 { /// Returns a thread handle so callers can optionally join during shutdown. /// Fails silently on errors (no internet, API limits, etc). pub fn checkForUpdates(allocator: std.mem.Allocator) ?std.Thread { - update_check_mutex.lock(); + while (!update_check_mutex.tryLock()) { + std.atomic.spinLoopHint(); + } defer update_check_mutex.unlock(); // Prevent multiple concurrent update checks @@ -131,7 +122,9 @@ pub fn checkForUpdates(allocator: std.mem.Allocator) ?std.Thread { /// Worker function that performs the actual update check. fn checkWorker(allocator: std.mem.Allocator) void { - const latest_tag = fetchLatestTag(allocator) catch return; + var threaded: std.Io.Threaded = .init_single_threaded; + const io = threaded.io(); + const latest_tag = fetchLatestTag(allocator, io) catch return; defer allocator.free(latest_tag); // Use ASCII-safe indicators instead of emoji for cross-platform compatibility @@ -158,7 +151,9 @@ pub const UpdateInfo = struct { /// Synchronously checks for updates and returns update information. /// This is useful for applications that want to handle the update notification themselves. pub fn checkForUpdatesSync(allocator: std.mem.Allocator) !UpdateInfo { - const latest_tag = try fetchLatestTag(allocator); + var threaded: std.Io.Threaded = .init_single_threaded; + const io = threaded.io(); + const latest_tag = try fetchLatestTag(allocator, io); errdefer allocator.free(latest_tag); const relation = compareVersions(latest_tag); diff --git a/src/types.zig b/src/types.zig index 95c1766..35490c6 100644 --- a/src/types.zig +++ b/src/types.zig @@ -7,13 +7,40 @@ const validators = @import("validators.zig"); const errors = @import("errors.zig"); /// String with length constraints and helper methods. +/// +/// Validates that the input string length is between `min_len` and `max_len`. +/// Returns `TooShort` or `TooLong` on validation failure. +/// +/// Example: +/// ```zig +/// const Name = String(1, 50); +/// const name = try Name.init("Alice"); // OK +/// const err = Name.init(""); // Error.TooShort +/// ``` pub fn String(comptime min_len: usize, comptime max_len: usize) type { + return Stringf(min_len, max_len, .{}); +} + +/// String with length constraints and custom validation messages. +/// +/// Same as `String` but accepts a `messages` struct to override +/// default error messages for `TooShort` and `TooLong` errors. +/// +/// Example: +/// ```zig +/// const Name = Stringf(3, 50, .{ +/// .too_short = "name must be at least 3 characters", +/// .too_long = "name must be 50 characters or fewer", +/// }); +/// ``` +pub fn Stringf(comptime min_len: usize, comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); value: []const u8, pub const min = min_len; pub const max = max_len; pub const zigantic_type = .string; + pub const custom_messages = messages; pub fn init(str: []const u8) errors.ValidationError!Self { if (str.len < min_len) return errors.ValidationError.TooShort; @@ -36,7 +63,7 @@ pub fn String(comptime min_len: usize, comptime max_len: usize) type { return std.mem.endsWith(u8, self.value, suffix); } pub fn contains(self: Self, needle: []const u8) bool { - return std.mem.indexOf(u8, self.value, needle) != null; + return std.mem.find(u8, self.value, needle) != null; } pub fn charAt(self: Self, index: usize) ?u8 { return if (index < self.value.len) self.value[index] else null; @@ -46,16 +73,31 @@ pub fn String(comptime min_len: usize, comptime max_len: usize) type { const e = @min(end, self.value.len); return self.value[s..e]; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } -/// Non-empty string. +/// Non-empty string. Shorthand for `String(1, max_len)`. pub fn NonEmptyString(comptime max_len: usize) type { - return String(1, max_len); + return NonEmptyStringf(max_len, .{}); +} + +/// Non-empty string with custom messages. Shorthand for `Stringf(1, max_len, messages)`. +pub fn NonEmptyStringf(comptime max_len: usize, comptime messages: anytype) type { + return Stringf(1, max_len, messages); } /// Trimmed string with auto-whitespace removal. +/// +/// Strips leading/trailing whitespace before validating length. +/// Provides `getOriginal()` and `wasTrimmed()` to inspect the original input. pub fn Trimmed(comptime min_len: usize, comptime max_len: usize) type { + return Trimmedf(min_len, max_len, .{}); +} + +pub fn Trimmedf(comptime min_len: usize, comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); value: []const u8, @@ -63,6 +105,7 @@ pub fn Trimmed(comptime min_len: usize, comptime max_len: usize) type { pub const min = min_len; pub const max = max_len; pub const zigantic_type = .trimmed; + pub const custom_messages = messages; pub fn init(str: []const u8) errors.ValidationError!Self { const trimmed = std.mem.trim(u8, str, " \t\n\r"); @@ -79,59 +122,83 @@ pub fn Trimmed(comptime min_len: usize, comptime max_len: usize) type { pub fn wasTrimmed(self: Self) bool { return self.value.len != self.original.len; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Lowercase string. pub fn Lowercase(comptime max_len: usize) type { + return Lowercasef(max_len, .{}); +} + +pub fn Lowercasef(comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); value: []const u8, pub const max = max_len; pub const zigantic_type = .lowercase; + pub const custom_messages = messages; pub fn init(str: []const u8) errors.ValidationError!Self { if (str.len > max_len) return errors.ValidationError.TooLong; for (str) |c| { - if (c >= 'A' and c <= 'Z') return errors.ValidationError.MustBeLowercase; + if (std.ascii.isUpper(c)) return errors.ValidationError.MustBeLowercase; } return Self{ .value = str }; } pub fn get(self: Self) []const u8 { return self.value; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Uppercase string. pub fn Uppercase(comptime max_len: usize) type { + return Uppercasef(max_len, .{}); +} + +pub fn Uppercasef(comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); value: []const u8, pub const max = max_len; pub const zigantic_type = .uppercase; + pub const custom_messages = messages; pub fn init(str: []const u8) errors.ValidationError!Self { if (str.len > max_len) return errors.ValidationError.TooLong; for (str) |c| { - if (c >= 'a' and c <= 'z') return errors.ValidationError.MustBeUppercase; + if (std.ascii.isLower(c)) return errors.ValidationError.MustBeUppercase; } return Self{ .value = str }; } pub fn get(self: Self) []const u8 { return self.value; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Alphanumeric string. pub fn Alphanumeric(comptime min_len: usize, comptime max_len: usize) type { + return Alphanumericf(min_len, max_len, .{}); +} + +pub fn Alphanumericf(comptime min_len: usize, comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); value: []const u8, pub const min = min_len; pub const max = max_len; pub const zigantic_type = .alphanumeric; + pub const custom_messages = messages; pub fn init(str: []const u8) errors.ValidationError!Self { if (str.len < min_len) return errors.ValidationError.TooShort; @@ -144,34 +211,49 @@ pub fn Alphanumeric(comptime min_len: usize, comptime max_len: usize) type { pub fn get(self: Self) []const u8 { return self.value; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// ASCII-only string. pub fn AsciiString(comptime min_len: usize, comptime max_len: usize) type { + return AsciiStringf(min_len, max_len, .{}); +} + +pub fn AsciiStringf(comptime min_len: usize, comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); value: []const u8, pub const min = min_len; pub const max = max_len; pub const zigantic_type = .ascii; + pub const custom_messages = messages; pub fn init(str: []const u8) errors.ValidationError!Self { if (str.len < min_len) return errors.ValidationError.TooShort; if (str.len > max_len) return errors.ValidationError.TooLong; for (str) |c| { - if (c > 127) return errors.ValidationError.InvalidFormat; + if (!std.ascii.isAscii(c)) return errors.ValidationError.InvalidFormat; } return Self{ .value = str }; } pub fn get(self: Self) []const u8 { return self.value; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Secret/password string with strength checking. pub fn Secret(comptime min_len: usize, comptime max_len: usize) type { + return Secretf(min_len, max_len, .{}); +} + +pub fn Secretf(comptime min_len: usize, comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); value: []const u8, @@ -179,6 +261,7 @@ pub fn Secret(comptime min_len: usize, comptime max_len: usize) type { pub const max = max_len; pub const zigantic_type = .secret; pub const is_secret = true; + pub const custom_messages = messages; pub fn init(str: []const u8) errors.ValidationError!Self { if (str.len < min_len) return errors.ValidationError.TooShort; @@ -193,19 +276,19 @@ pub fn Secret(comptime min_len: usize, comptime max_len: usize) type { } pub fn hasUppercase(self: Self) bool { for (self.value) |c| { - if (c >= 'A' and c <= 'Z') return true; + if (std.ascii.isUpper(c)) return true; } return false; } pub fn hasLowercase(self: Self) bool { for (self.value) |c| { - if (c >= 'a' and c <= 'z') return true; + if (std.ascii.isLower(c)) return true; } return false; } pub fn hasDigit(self: Self) bool { for (self.value) |c| { - if (c >= '0' and c <= '9') return true; + if (std.ascii.isDigit(c)) return true; } return false; } @@ -225,17 +308,25 @@ pub fn Secret(comptime min_len: usize, comptime max_len: usize) type { if (self.hasSpecial()) score += 1; return score; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Strong password with requirements. pub fn StrongPassword(comptime min_len: usize, comptime max_len: usize) type { + return StrongPasswordf(min_len, max_len, .{}); +} + +pub fn StrongPasswordf(comptime min_len: usize, comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); value: []const u8, pub const min = min_len; pub const max = max_len; pub const zigantic_type = .strong_password; + pub const custom_messages = messages; pub fn init(str: []const u8) errors.ValidationError!Self { if (str.len < min_len) return errors.ValidationError.TooShort; @@ -245,7 +336,7 @@ pub fn StrongPassword(comptime min_len: usize, comptime max_len: usize) type { var has_digit = false; var has_special = false; for (str) |c| { - if (c >= 'A' and c <= 'Z') has_upper = true else if (c >= 'a' and c <= 'z') has_lower = true else if (c >= '0' and c <= '9') has_digit = true else has_special = true; + if (std.ascii.isUpper(c)) has_upper = true else if (std.ascii.isLower(c)) has_lower = true else if (std.ascii.isDigit(c)) has_digit = true else has_special = true; } if (!has_upper or !has_lower or !has_digit or !has_special) return errors.ValidationError.WeakPassword; return Self{ .value = str }; @@ -256,11 +347,28 @@ pub fn StrongPassword(comptime min_len: usize, comptime max_len: usize) type { pub fn masked(_: Self) []const u8 { return "********"; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } -/// Signed integer with range and utilities. +/// Signed integer with range constraints and utility methods. +/// +/// Validates that the value is within `[min_val, max_val]`. +/// Provides `isPositive()`, `isEven()`, `abs()`, `clamp()`, etc. +/// +/// Example: +/// ```zig +/// const Age = Int(i32, 0, 150); +/// const age = try Age.init(25); +/// ``` pub fn Int(comptime T: type, comptime min_val: comptime_int, comptime max_val: comptime_int) type { + return Intf(T, min_val, max_val, .{}); +} + +/// Signed integer with range constraints and custom validation messages. +pub fn Intf(comptime T: type, comptime min_val: comptime_int, comptime max_val: comptime_int, comptime messages: anytype) type { return struct { const Self = @This(); value: T, @@ -268,6 +376,7 @@ pub fn Int(comptime T: type, comptime min_val: comptime_int, comptime max_val: c pub const max = max_val; pub const IntType = T; pub const zigantic_type = .int; + pub const custom_messages = messages; pub fn init(val: T) errors.ValidationError!Self { if (val < min_val) return errors.ValidationError.TooSmall; @@ -298,11 +407,18 @@ pub fn Int(comptime T: type, comptime min_val: comptime_int, comptime max_val: c pub fn clamp(self: Self, lo: T, hi: T) T { return @max(lo, @min(hi, self.value)); } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Unsigned integer with range. pub fn UInt(comptime T: type, comptime min_val: comptime_int, comptime max_val: comptime_int) type { + return UIntf(T, min_val, max_val, .{}); +} + +pub fn UIntf(comptime T: type, comptime min_val: comptime_int, comptime max_val: comptime_int, comptime messages: anytype) type { return struct { const Self = @This(); value: T, @@ -310,6 +426,7 @@ pub fn UInt(comptime T: type, comptime min_val: comptime_int, comptime max_val: pub const max = max_val; pub const IntType = T; pub const zigantic_type = .uint; + pub const custom_messages = messages; pub fn init(val: T) errors.ValidationError!Self { if (val < min_val) return errors.ValidationError.TooSmall; @@ -325,27 +442,49 @@ pub fn UInt(comptime T: type, comptime min_val: comptime_int, comptime max_val: pub fn isEven(self: Self) bool { return @mod(self.value, 2) == 0; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } pub fn PositiveInt(comptime T: type) type { - return Int(T, 1, std.math.maxInt(T)); + return PositiveIntf(T, .{}); } + +pub fn PositiveIntf(comptime T: type, comptime messages: anytype) type { + return Intf(T, 1, std.math.maxInt(T), messages); +} + pub fn NonNegativeInt(comptime T: type) type { - return Int(T, 0, std.math.maxInt(T)); + return NonNegativeIntf(T, .{}); } + +pub fn NonNegativeIntf(comptime T: type, comptime messages: anytype) type { + return Intf(T, 0, std.math.maxInt(T), messages); +} + pub fn NegativeInt(comptime T: type) type { - return Int(T, std.math.minInt(T), -1); + return NegativeIntf(T, .{}); +} + +pub fn NegativeIntf(comptime T: type, comptime messages: anytype) type { + return Intf(T, std.math.minInt(T), -1, messages); } /// Even number only. pub fn EvenInt(comptime T: type, comptime min_val: comptime_int, comptime max_val: comptime_int) type { + return EvenIntf(T, min_val, max_val, .{}); +} + +pub fn EvenIntf(comptime T: type, comptime min_val: comptime_int, comptime max_val: comptime_int, comptime messages: anytype) type { return struct { const Self = @This(); value: T, pub const min = min_val; pub const max = max_val; pub const zigantic_type = .even; + pub const custom_messages = messages; pub fn init(val: T) errors.ValidationError!Self { if (val < min_val) return errors.ValidationError.TooSmall; @@ -356,17 +495,25 @@ pub fn EvenInt(comptime T: type, comptime min_val: comptime_int, comptime max_va pub fn get(self: Self) T { return self.value; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Odd number only. pub fn OddInt(comptime T: type, comptime min_val: comptime_int, comptime max_val: comptime_int) type { + return OddIntf(T, min_val, max_val, .{}); +} + +pub fn OddIntf(comptime T: type, comptime min_val: comptime_int, comptime max_val: comptime_int, comptime messages: anytype) type { return struct { const Self = @This(); value: T, pub const min = min_val; pub const max = max_val; pub const zigantic_type = .odd; + pub const custom_messages = messages; pub fn init(val: T) errors.ValidationError!Self { if (val < min_val) return errors.ValidationError.TooSmall; @@ -377,16 +524,24 @@ pub fn OddInt(comptime T: type, comptime min_val: comptime_int, comptime max_val pub fn get(self: Self) T { return self.value; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Multiple of N. pub fn MultipleOf(comptime T: type, comptime divisor: comptime_int) type { + return MultipleOff(T, divisor, .{}); +} + +pub fn MultipleOff(comptime T: type, comptime divisor: comptime_int, comptime messages: anytype) type { return struct { const Self = @This(); value: T, pub const multiple = divisor; pub const zigantic_type = .multiple; + pub const custom_messages = messages; pub fn init(val: T) errors.ValidationError!Self { if (@mod(val, divisor) != 0) return errors.ValidationError.NotMultiple; @@ -395,11 +550,18 @@ pub fn MultipleOf(comptime T: type, comptime divisor: comptime_int) type { pub fn get(self: Self) T { return self.value; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Float with range and utilities. pub fn Float(comptime T: type, comptime min_val: comptime_float, comptime max_val: comptime_float) type { + return Floatf(T, min_val, max_val, .{}); +} + +pub fn Floatf(comptime T: type, comptime min_val: comptime_float, comptime max_val: comptime_float, comptime messages: anytype) type { return struct { const Self = @This(); value: T, @@ -407,6 +569,7 @@ pub fn Float(comptime T: type, comptime min_val: comptime_float, comptime max_va pub const max = max_val; pub const FloatType = T; pub const zigantic_type = .float; + pub const custom_messages = messages; pub fn init(val: T) errors.ValidationError!Self { if (std.math.isNan(val)) return errors.ValidationError.InvalidNumber; @@ -439,29 +602,56 @@ pub fn Float(comptime T: type, comptime min_val: comptime_float, comptime max_va pub fn trunc(self: Self) T { return @trunc(self.value); } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } pub fn Percentage(comptime T: type) type { - return Float(T, 0.0, 100.0); + return Percentagef(T, .{}); } + +pub fn Percentagef(comptime T: type, comptime messages: anytype) type { + return Floatf(T, 0.0, 100.0, messages); +} + pub fn Probability(comptime T: type) type { - return Float(T, 0.0, 1.0); + return Probabilityf(T, .{}); } + +pub fn Probabilityf(comptime T: type, comptime messages: anytype) type { + return Floatf(T, 0.0, 1.0, messages); +} + pub fn PositiveFloat(comptime T: type) type { - return Float(T, 0.0, std.math.floatMax(T)); + return PositiveFloatf(T, .{}); } + +pub fn PositiveFloatf(comptime T: type, comptime messages: anytype) type { + return Floatf(T, 0.0, std.math.floatMax(T), messages); +} + pub fn NegativeFloat(comptime T: type) type { - return Float(T, -std.math.floatMax(T), 0.0); + return NegativeFloatf(T, .{}); +} + +pub fn NegativeFloatf(comptime T: type, comptime messages: anytype) type { + return Floatf(T, -std.math.floatMax(T), 0.0, messages); } /// Finite float (no NaN or Inf). pub fn FiniteFloat(comptime T: type) type { + return FiniteFloatf(T, .{}); +} + +pub fn FiniteFloatf(comptime T: type, comptime messages: anytype) type { return struct { const Self = @This(); value: T, pub const FloatType = T; pub const zigantic_type = .finite_float; + pub const custom_messages = messages; pub fn init(val: T) errors.ValidationError!Self { if (std.math.isNan(val) or std.math.isInf(val)) return errors.ValidationError.InvalidNumber; @@ -470,10 +660,16 @@ pub fn FiniteFloat(comptime T: type) type { pub fn get(self: Self) T { return self.value; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } -/// Email with utilities. +/// Email address with format validation and utilities. +/// +/// Validates email format (basic RFC-compliant check). +/// Provides `domain()`, `localPart()`, and `isBusinessEmail()`. pub const Email = struct { value: []const u8, pub const zigantic_type = .email; @@ -505,6 +701,36 @@ pub const Email = struct { } return true; } + /// Returns true if the local part contains a '+' tag (e.g., "user+tag@example.com"). + pub fn hasTag(self: Email) bool { + const local = self.localPart(); + for (local) |c| { + if (c == '+') return true; + } + return false; + } + /// Returns the tag portion after '+' in the local part, or null. + pub fn tag(self: Email) ?[]const u8 { + const local = self.localPart(); + for (local, 0..) |c, i| { + if (c == '+') return local[i + 1 ..]; + } + return null; + } + /// Returns true if the domain is a common free email provider. + pub fn isFreeEmail(self: Email) bool { + return !self.isBusinessEmail(); + } + /// Returns the TLD (top-level domain) of the email. + pub fn tld(self: Email) []const u8 { + const d = self.domain(); + var last_dot: ?usize = null; + for (d, 0..) |c, i| { + if (c == '.') last_dot = i; + } + if (last_dot) |ld| return d[ld + 1 ..]; + return ""; + } }; /// URL with utilities. @@ -540,6 +766,85 @@ pub const Url = struct { } return rest; } + /// Returns the path portion of the URL (after host, before query). + pub fn path(self: Url) []const u8 { + var start: usize = 0; + if (std.mem.startsWith(u8, self.value, "https://")) { + start = 8; + } else if (std.mem.startsWith(u8, self.value, "http://")) { + start = 7; + } + const rest = self.value[start..]; + const host_end = for (rest, 0..) |c, i| { + if (c == '/' or c == '?' or c == '#') break i; + } else rest.len; + const path_start = host_end; + const path_end = for (rest[path_start..], 0..) |c, i| { + if (c == '?' or c == '#') break path_start + i; + } else rest.len; + return rest[path_start..path_end]; + } + /// Returns the query string (after '?', before '#'), or null if none. + pub fn query(self: Url) ?[]const u8 { + for (self.value, 0..) |c, i| { + if (c == '?') { + const q = self.value[i + 1 ..]; + for (q, 0..) |qc, qi| { + if (qc == '#') return q[0..qi]; + } + return q; + } + } + return null; + } + /// Returns the fragment (after '#'), or null if none. + pub fn fragment(self: Url) ?[]const u8 { + for (self.value, 0..) |c, i| { + if (c == '#') return self.value[i + 1 ..]; + } + return null; + } + /// Returns the port number from the URL, or null if not specified. + pub fn port(self: Url) ?u16 { + // Look for port in the raw URL after the host portion + var start: usize = 0; + if (std.mem.startsWith(u8, self.value, "https://")) { + start = 8; + } else if (std.mem.startsWith(u8, self.value, "http://")) { + start = 7; + } + const rest = self.value[start..]; + for (rest, 0..) |c, i| { + if (c == ':') { + // Found a colon - check if followed by digits before any /, ?, # + var end = i + 1; + while (end < rest.len and rest[end] >= '0' and rest[end] <= '9') end += 1; + if (end > i + 1) { + return std.fmt.parseInt(u16, rest[i + 1 .. end], 10) catch null; + } + } + if (c == '/' or c == '?') break; + } + return null; + } + /// Returns true if the URL has a query string. + pub fn hasQuery(self: Url) bool { + return self.query() != null; + } + /// Returns true if the URL has a fragment. + pub fn hasFragment(self: Url) bool { + return self.fragment() != null; + } + /// Returns the filename from the URL path (last segment after '/'). + pub fn filename(self: Url) []const u8 { + const p = self.path(); + var last_slash: ?usize = null; + for (p, 0..) |c, i| { + if (c == '/') last_slash = i; + } + if (last_slash) |ls| return p[ls + 1 ..]; + return p; + } }; /// Https-only URL. @@ -740,12 +1045,17 @@ pub const Base64 = struct { /// Hexadecimal string. pub fn HexString(comptime min_len: usize, comptime max_len: usize) type { + return HexStringf(min_len, max_len, .{}); +} + +pub fn HexStringf(comptime min_len: usize, comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); value: []const u8, pub const min = min_len; pub const max = max_len; pub const zigantic_type = .hex_string; + pub const custom_messages = messages; pub fn init(str: []const u8) errors.ValidationError!Self { if (str.len < min_len) return errors.ValidationError.TooShort; @@ -770,225 +1080,501 @@ pub fn HexString(comptime min_len: usize, comptime max_len: usize) type { } return true; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } /// Hex color code (e.g., #FF5733 or FF5733). -pub const HexColor = struct { - value: []const u8, - pub const zigantic_type = .hex_color; +pub fn HexColor() type { + return HexColorf(.{}); +} + +pub fn HexColorf(comptime messages: anytype) type { + return struct { + value: []const u8, + pub const zigantic_type = .hex_color; + pub const custom_messages = messages; - pub fn init(str: []const u8) errors.ValidationError!HexColor { - var hex = str; - if (str.len > 0 and str[0] == '#') hex = str[1..]; - if (hex.len != 3 and hex.len != 6) return errors.ValidationError.InvalidFormat; - for (hex) |c| { - if (!std.ascii.isHex(c)) return errors.ValidationError.InvalidFormat; + pub fn init(str: []const u8) errors.ValidationError!@This() { + if (!validators.isHexColor(str)) return errors.ValidationError.InvalidFormat; + return @This(){ .value = str }; } - return HexColor{ .value = str }; - } - pub fn get(self: HexColor) []const u8 { - return self.value; - } - pub fn getHex(self: HexColor) []const u8 { - if (self.value.len > 0 and self.value[0] == '#') return self.value[1..]; - return self.value; - } - pub fn hasHash(self: HexColor) bool { - return self.value.len > 0 and self.value[0] == '#'; - } -}; + pub fn get(self: @This()) []const u8 { + return self.value; + } + pub fn getHex(self: @This()) []const u8 { + if (self.value.len > 0 and self.value[0] == '#') return self.value[1..]; + return self.value; + } + pub fn hasHash(self: @This()) bool { + return self.value.len > 0 and self.value[0] == '#'; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} /// MAC address (e.g., 00:1A:2B:3C:4D:5E). -pub const MacAddress = struct { - value: []const u8, - pub const zigantic_type = .mac_address; - - pub fn init(str: []const u8) errors.ValidationError!MacAddress { - // Format: XX:XX:XX:XX:XX:XX or XX-XX-XX-XX-XX-XX - if (str.len != 17) return errors.ValidationError.InvalidFormat; - const sep = if (str.len > 2) str[2] else ':'; - if (sep != ':' and sep != '-') return errors.ValidationError.InvalidFormat; - var i: usize = 0; - while (i < str.len) : (i += 1) { - if ((i + 1) % 3 == 0) { - if (i < str.len - 1 and str[i] != sep) return errors.ValidationError.InvalidFormat; - } else { - if (!std.ascii.isHex(str[i])) return errors.ValidationError.InvalidFormat; - } +pub fn MacAddress() type { + return MacAddressf(.{}); +} + +pub fn MacAddressf(comptime messages: anytype) type { + return struct { + value: []const u8, + pub const zigantic_type = .mac_address; + pub const custom_messages = messages; + + pub fn init(str: []const u8) errors.ValidationError!@This() { + if (!validators.isMacAddress(str)) return errors.ValidationError.InvalidFormat; + return @This(){ .value = str }; } - return MacAddress{ .value = str }; - } - pub fn get(self: MacAddress) []const u8 { - return self.value; - } -}; + pub fn get(self: @This()) []const u8 { + return self.value; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} /// ISO 8601 DateTime string (e.g., 2024-01-15T10:30:00Z). -pub const IsoDateTime = struct { +pub fn IsoDateTime() type { + return IsoDateTimef(.{}); +} + +pub fn IsoDateTimef(comptime messages: anytype) type { + return struct { + value: []const u8, + pub const zigantic_type = .iso_datetime; + pub const custom_messages = messages; + + pub fn init(str: []const u8) errors.ValidationError!@This() { + if (!validators.isIsoDateTime(str)) return errors.ValidationError.InvalidFormat; + return @This(){ .value = str }; + } + pub fn get(self: @This()) []const u8 { + return self.value; + } + pub fn getDatePart(self: @This()) []const u8 { + return if (self.value.len >= 10) self.value[0..10] else ""; + } + pub fn getTimePart(self: @This()) []const u8 { + if (self.value.len >= 19) { + return self.value[11..19]; + } + return ""; + } + pub fn hasTimezone(self: @This()) bool { + return self.value.len > 19 and (self.value[19] == 'Z' or self.value[19] == '+' or self.value[19] == '-'); + } + pub fn isUtc(self: @This()) bool { + return self.value.len > 19 and self.value[19] == 'Z'; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} + +/// ISO 8601 Date string (e.g., 2024-01-15). +pub fn IsoDate() type { + return IsoDatef(.{}); +} + +pub fn IsoDatef(comptime messages: anytype) type { + return struct { + value: []const u8, + pub const zigantic_type = .iso_date; + pub const custom_messages = messages; + + pub fn init(str: []const u8) errors.ValidationError!@This() { + if (!validators.isIsoDate(str)) return errors.ValidationError.InvalidFormat; + return @This(){ .value = str }; + } + pub fn get(self: @This()) []const u8 { + return self.value; + } + pub fn getYear(self: @This()) ?u16 { + const digits = self.value[0..4]; + return std.fmt.parseInt(u16, digits, 10) catch null; + } + pub fn getMonth(self: @This()) ?u8 { + const digits = self.value[5..7]; + return std.fmt.parseInt(u8, digits, 10) catch null; + } + pub fn getDay(self: @This()) ?u8 { + const digits = self.value[8..10]; + return std.fmt.parseInt(u8, digits, 10) catch null; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} + +/// ISO 3166-1 alpha-2 country code (e.g., US, GB, DE). +pub fn CountryCode() type { + return CountryCodef(.{}); +} + +pub fn CountryCodef(comptime messages: anytype) type { + return struct { + value: []const u8, + pub const zigantic_type = .country_code; + pub const custom_messages = messages; + + pub fn init(str: []const u8) errors.ValidationError!@This() { + if (!validators.isCountryCode(str)) return errors.ValidationError.InvalidFormat; + return @This(){ .value = str }; + } + pub fn get(self: @This()) []const u8 { + return self.value; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} + +/// ISO 4217 currency code (e.g., USD, EUR, GBP). +pub fn CurrencyCode() type { + return CurrencyCodef(.{}); +} + +pub fn CurrencyCodef(comptime messages: anytype) type { + return struct { + value: []const u8, + pub const zigantic_type = .currency_code; + pub const custom_messages = messages; + + pub fn init(str: []const u8) errors.ValidationError!@This() { + if (!validators.isCurrencyCode(str)) return errors.ValidationError.InvalidFormat; + return @This(){ .value = str }; + } + pub fn get(self: @This()) []const u8 { + return self.value; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} + +/// Latitude coordinate (-90 to 90). +pub fn Latitude() type { + return Latitudef(.{}); +} + +pub fn Latitudef(comptime messages: anytype) type { + return struct { + value: f64, + pub const zigantic_type = .latitude; + pub const custom_messages = messages; + + pub fn init(val: f64) errors.ValidationError!@This() { + if (!validators.isLatitude(val)) return errors.ValidationError.OutOfRange; + return @This(){ .value = val }; + } + pub fn get(self: @This()) f64 { + return self.value; + } + pub fn isNorthern(self: @This()) bool { + return self.value >= 0; + } + pub fn isSouthern(self: @This()) bool { + return self.value < 0; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} + +/// Longitude coordinate (-180 to 180). +pub fn Longitude() type { + return Longitudef(.{}); +} + +pub fn Longitudef(comptime messages: anytype) type { + return struct { + value: f64, + pub const zigantic_type = .longitude; + pub const custom_messages = messages; + + pub fn init(val: f64) errors.ValidationError!@This() { + if (!validators.isLongitude(val)) return errors.ValidationError.OutOfRange; + return @This(){ .value = val }; + } + pub fn get(self: @This()) f64 { + return self.value; + } + pub fn isEastern(self: @This()) bool { + return self.value >= 0; + } + pub fn isWestern(self: @This()) bool { + return self.value < 0; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} + +/// Port number (1-65535). +pub fn Port() type { + return Portf(.{}); +} + +pub fn Portf(comptime messages: anytype) type { + return struct { + value: u16, + pub const zigantic_type = .port; + pub const custom_messages = messages; + + pub fn init(val: u16) errors.ValidationError!@This() { + if (!validators.isPort(val)) return errors.ValidationError.TooSmall; + return @This(){ .value = val }; + } + pub fn get(self: @This()) u16 { + return self.value; + } + pub fn isPrivileged(self: @This()) bool { + return self.value < 1024; + } + pub fn isRegistered(self: @This()) bool { + return self.value >= 1024 and self.value <= 49151; + } + pub fn isDynamic(self: @This()) bool { + return self.value > 49151; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} + +/// IBAN (International Bank Account Number) with format validation. +pub const Iban = struct { value: []const u8, - pub const zigantic_type = .iso_datetime; - - pub fn init(str: []const u8) errors.ValidationError!IsoDateTime { - // Basic ISO 8601 format validation: YYYY-MM-DDTHH:MM:SS or YYYY-MM-DDTHH:MM:SSZ - if (str.len < 19) return errors.ValidationError.InvalidFormat; - // Check date part YYYY-MM-DD - if (str[4] != '-' or str[7] != '-') return errors.ValidationError.InvalidFormat; - // Check T separator - if (str[10] != 'T' and str[10] != ' ') return errors.ValidationError.InvalidFormat; - // Check time part HH:MM:SS - if (str[13] != ':' or str[16] != ':') return errors.ValidationError.InvalidFormat; - return IsoDateTime{ .value = str }; - } - pub fn get(self: IsoDateTime) []const u8 { + pub const zigantic_type = .iban; + + pub fn init(str: []const u8) errors.ValidationError!Iban { + if (!validators.isIban(str)) return errors.ValidationError.InvalidFormat; + return Iban{ .value = str }; + } + pub fn get(self: Iban) []const u8 { return self.value; } - pub fn getDatePart(self: IsoDateTime) []const u8 { - return if (self.value.len >= 10) self.value[0..10] else ""; + /// Returns the 2-letter country code prefix (e.g., "DE", "GB"). + pub fn countryCode(self: Iban) []const u8 { + return if (self.value.len >= 2) self.value[0..2] else ""; } - pub fn getTimePart(self: IsoDateTime) []const u8 { - if (self.value.len >= 19) { - return self.value[11..19]; + /// Returns the length without spaces. + pub fn normalizedLength(self: Iban) usize { + var count: usize = 0; + for (self.value) |c| { + if (c != ' ') count += 1; } - return ""; - } - pub fn hasTimezone(self: IsoDateTime) bool { - return self.value.len > 19 and (self.value[19] == 'Z' or self.value[19] == '+' or self.value[19] == '-'); - } - pub fn isUtc(self: IsoDateTime) bool { - return self.value.len > 19 and self.value[19] == 'Z'; + return count; } }; -/// ISO 8601 Date string (e.g., 2024-01-15). -pub const IsoDate = struct { +/// Base58 encoded string (cryptocurrency addresses, Bitcoin, etc.). +pub const Base58 = struct { value: []const u8, - pub const zigantic_type = .iso_date; + pub const zigantic_type = .base58; - pub fn init(str: []const u8) errors.ValidationError!IsoDate { - if (str.len != 10) return errors.ValidationError.InvalidFormat; - if (str[4] != '-' or str[7] != '-') return errors.ValidationError.InvalidFormat; - // Validate digits - for ([_]usize{ 0, 1, 2, 3, 5, 6, 8, 9 }) |i| { - if (!std.ascii.isDigit(str[i])) return errors.ValidationError.InvalidFormat; - } - return IsoDate{ .value = str }; + pub fn init(str: []const u8) errors.ValidationError!Base58 { + if (!validators.isBase58(str)) return errors.ValidationError.InvalidFormat; + return Base58{ .value = str }; } - pub fn get(self: IsoDate) []const u8 { + pub fn get(self: Base58) []const u8 { return self.value; } - pub fn getYear(self: IsoDate) ?u16 { - const digits = self.value[0..4]; - return std.fmt.parseInt(u16, digits, 10) catch null; - } - pub fn getMonth(self: IsoDate) ?u8 { - const digits = self.value[5..7]; - return std.fmt.parseInt(u8, digits, 10) catch null; - } - pub fn getDay(self: IsoDate) ?u8 { - const digits = self.value[8..10]; - return std.fmt.parseInt(u8, digits, 10) catch null; + pub fn len(self: Base58) usize { + return self.value.len; } }; -/// ISO 3166-1 alpha-2 country code (e.g., US, GB, DE). -pub const CountryCode = struct { +/// HSL color string (e.g., "hsl(120, 100%, 50%)"). +pub const HslColor = struct { value: []const u8, - pub const zigantic_type = .country_code; + pub const zigantic_type = .hsl_color; - pub fn init(str: []const u8) errors.ValidationError!CountryCode { - if (str.len != 2) return errors.ValidationError.InvalidFormat; - for (str) |c| { - if (!std.ascii.isAlphabetic(c)) return errors.ValidationError.InvalidFormat; - } - return CountryCode{ .value = str }; + pub fn init(str: []const u8) errors.ValidationError!HslColor { + if (!validators.isHslColor(str)) return errors.ValidationError.InvalidFormat; + return HslColor{ .value = str }; } - pub fn get(self: CountryCode) []const u8 { + pub fn get(self: HslColor) []const u8 { return self.value; } }; -/// ISO 4217 currency code (e.g., USD, EUR, GBP). -pub const CurrencyCode = struct { +/// ISO 8601 duration string (e.g., "P1Y2M3DT4H5M6S", "P30D"). +pub const Duration = struct { value: []const u8, - pub const zigantic_type = .currency_code; + pub const zigantic_type = .duration; - pub fn init(str: []const u8) errors.ValidationError!CurrencyCode { - if (str.len != 3) return errors.ValidationError.InvalidFormat; - for (str) |c| { - if (!std.ascii.isAlphabetic(c)) return errors.ValidationError.InvalidFormat; - } - return CurrencyCode{ .value = str }; + pub fn init(str: []const u8) errors.ValidationError!Duration { + if (!validators.isIsoDuration(str)) return errors.ValidationError.InvalidFormat; + return Duration{ .value = str }; } - pub fn get(self: CurrencyCode) []const u8 { + pub fn get(self: Duration) []const u8 { return self.value; } + /// Returns true if this duration includes a time component (T prefix in time part). + pub fn hasTime(self: Duration) bool { + return std.mem.indexOf(u8, self.value, "T") != null; + } }; -/// Latitude coordinate (-90 to 90). -pub const Latitude = struct { - value: f64, - pub const zigantic_type = .latitude; +/// Cron expression (5 or 6 fields: minute hour day month weekday [year]). +pub const CronExpression = struct { + value: []const u8, + pub const zigantic_type = .cron; - pub fn init(val: f64) errors.ValidationError!Latitude { - if (val < -90.0 or val > 90.0) return errors.ValidationError.OutOfRange; - return Latitude{ .value = val }; + pub fn init(str: []const u8) errors.ValidationError!CronExpression { + if (!validators.isCronExpression(str)) return errors.ValidationError.InvalidFormat; + return CronExpression{ .value = str }; } - pub fn get(self: Latitude) f64 { + pub fn get(self: CronExpression) []const u8 { return self.value; } - pub fn isNorthern(self: Latitude) bool { - return self.value >= 0; - } - pub fn isSouthern(self: Latitude) bool { - return self.value < 0; + /// Returns the number of fields (5 or 6). + pub fn fieldCount(self: CronExpression) u32 { + var count: u32 = 0; + var in_field = false; + for (self.value) |c| { + if (std.ascii.isWhitespace(c)) { + if (in_field) count += 1; + in_field = false; + } else { + in_field = true; + } + } + if (in_field) count += 1; + return count; } }; -/// Longitude coordinate (-180 to 180). -pub const Longitude = struct { - value: f64, - pub const zigantic_type = .longitude; +/// ISBN-10 with checksum validation. +pub const Isbn10 = struct { + value: []const u8, + pub const zigantic_type = .isbn10; - pub fn init(val: f64) errors.ValidationError!Longitude { - if (val < -180.0 or val > 180.0) return errors.ValidationError.OutOfRange; - return Longitude{ .value = val }; + pub fn init(str: []const u8) errors.ValidationError!Isbn10 { + if (!validators.isIsbn10(str)) return errors.ValidationError.InvalidFormat; + return Isbn10{ .value = str }; } - pub fn get(self: Longitude) f64 { + pub fn get(self: Isbn10) []const u8 { return self.value; } - pub fn isEastern(self: Longitude) bool { - return self.value >= 0; +}; + +/// ISBN-13 with checksum validation. +pub const Isbn13 = struct { + value: []const u8, + pub const zigantic_type = .isbn13; + + pub fn init(str: []const u8) errors.ValidationError!Isbn13 { + if (!validators.isIsbn13(str)) return errors.ValidationError.InvalidFormat; + return Isbn13{ .value = str }; } - pub fn isWestern(self: Longitude) bool { - return self.value < 0; + pub fn get(self: Isbn13) []const u8 { + return self.value; } }; -/// Port number (1-65535). -pub const Port = struct { - value: u16, - pub const zigantic_type = .port; +/// ASCII alphabetic string (A-Z, a-z only, no digits or special characters). +pub fn AsciiAlphaString(comptime min_len: usize, comptime max_len: usize) type { + return AsciiAlphaStringf(min_len, max_len, .{}); +} - pub fn init(val: u16) errors.ValidationError!Port { - if (val == 0) return errors.ValidationError.TooSmall; - return Port{ .value = val }; +pub fn AsciiAlphaStringf(comptime min_len: usize, comptime max_len: usize, comptime messages: anytype) type { + return struct { + const Self = @This(); + value: []const u8, + pub const min = min_len; + pub const max = max_len; + pub const zigantic_type = .ascii_alpha; + pub const custom_messages = messages; + + pub fn init(str: []const u8) errors.ValidationError!Self { + if (str.len < min_len) return errors.ValidationError.TooShort; + if (str.len > max_len) return errors.ValidationError.TooLong; + if (!validators.isAsciiAlpha(str)) return errors.ValidationError.InvalidFormat; + return Self{ .value = str }; + } + pub fn get(self: Self) []const u8 { + return self.value; + } + pub fn len(self: Self) usize { + return self.value.len; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } + }; +} + +/// ASCII printable string (0x20-0x7E only). +pub fn AsciiPrintableString(comptime min_len: usize, comptime max_len: usize) type { + return struct { + const Self = @This(); + value: []const u8, + pub const min = min_len; + pub const max = max_len; + pub const zigantic_type = .ascii_printable; + + pub fn init(str: []const u8) errors.ValidationError!Self { + if (str.len < min_len) return errors.ValidationError.TooShort; + if (str.len > max_len) return errors.ValidationError.TooLong; + if (!validators.isAsciiPrintable(str)) return errors.ValidationError.InvalidFormat; + return Self{ .value = str }; + } + pub fn get(self: Self) []const u8 { + return self.value; + } + pub fn len(self: Self) usize { + return self.value.len; + } + }; +} + +/// Strong password with built-in requirements (min 8, upper+lower+digit+special). +pub const StrongPasswordStrict = struct { + value: []const u8, + pub const zigantic_type = .strong_password; + + pub fn init(str: []const u8) errors.ValidationError!StrongPasswordStrict { + if (!validators.isStrongPassword(str)) return errors.ValidationError.WeakPassword; + return StrongPasswordStrict{ .value = str }; } - pub fn get(self: Port) u16 { + pub fn get(self: StrongPasswordStrict) []const u8 { return self.value; } - pub fn isPrivileged(self: Port) bool { - return self.value < 1024; - } - pub fn isRegistered(self: Port) bool { - return self.value >= 1024 and self.value <= 49151; + pub fn masked(_: StrongPasswordStrict) []const u8 { + return "********"; } - pub fn isDynamic(self: Port) bool { - return self.value > 49151; + pub fn len(self: StrongPasswordStrict) usize { + return self.value.len; } }; +/// Dynamic-length list with min/max item count constraints. +/// +/// Validates that the slice length is within `[min_len, max_len]`. +/// Provides `len()`, `first()`, `last()`, `at()` for safe access. pub fn List(comptime T: type, comptime min_len: usize, comptime max_len: usize) type { + return Listf(T, min_len, max_len, .{}); +} + +/// Dynamic-length list with custom validation messages. +pub fn Listf(comptime T: type, comptime min_len: usize, comptime max_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); items: []const T, @@ -996,6 +1582,7 @@ pub fn List(comptime T: type, comptime min_len: usize, comptime max_len: usize) pub const max = max_len; pub const ItemType = T; pub const zigantic_type = .list; + pub const custom_messages = messages; pub fn init(items: []const T) errors.ValidationError!Self { if (items.len < min_len) return errors.ValidationError.TooFewItems; @@ -1020,21 +1607,80 @@ pub fn List(comptime T: type, comptime min_len: usize, comptime max_len: usize) pub fn at(self: Self, index: usize) ?T { return if (index < self.items.len) self.items[index] else null; } + /// Returns true if the list contains the given item (requires T == u8 or == []const u8). + pub fn contains(self: Self, item: T) bool { + const info = @typeInfo(T); + for (self.items) |i| { + if (info == .pointer and info.pointer.size == .slice and info.pointer.child == u8) { + if (std.mem.eql(u8, i, item)) return true; + } else { + if (i == item) return true; + } + } + return false; + } + /// Returns a slice of items from start to end (exclusive). + pub fn slice(self: Self, start: usize, end: usize) []const T { + const s = @min(start, self.items.len); + const e = @min(end, self.items.len); + return self.items[s..e]; + } + /// Returns the sum of all items (requires T to be numeric). + pub fn sum(self: Self) T { + var total: T = 0; + for (self.items) |item| { + total += item; + } + return total; + } + /// Returns true if all items satisfy the given predicate function. + pub fn all(self: Self, predicate: fn (T) bool) bool { + for (self.items) |item| { + if (!predicate(item)) return false; + } + return true; + } + /// Returns true if any item satisfies the given predicate function. + pub fn any(self: Self, predicate: fn (T) bool) bool { + for (self.items) |item| { + if (predicate(item)) return true; + } + return false; + } + /// Returns the index of the first item matching the predicate, or null. + pub fn findIndex(self: Self, predicate: fn (T) bool) ?usize { + for (self.items, 0..) |item, i| { + if (predicate(item)) return i; + } + return null; + } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } pub fn NonEmptyList(comptime T: type, comptime max_len: usize) type { - return List(T, 1, max_len); + return NonEmptyListf(T, max_len, .{}); +} + +pub fn NonEmptyListf(comptime T: type, comptime max_len: usize, comptime messages: anytype) type { + return Listf(T, 1, max_len, messages); } /// Fixed-size tuple/array. pub fn FixedList(comptime T: type, comptime exact_len: usize) type { + return FixedListf(T, exact_len, .{}); +} + +pub fn FixedListf(comptime T: type, comptime exact_len: usize, comptime messages: anytype) type { return struct { const Self = @This(); items: []const T, pub const length = exact_len; pub const ItemType = T; pub const zigantic_type = .fixed_list; + pub const custom_messages = messages; pub fn init(items: []const T) errors.ValidationError!Self { if (items.len != exact_len) return errors.ValidationError.WrongLength; @@ -1046,6 +1692,9 @@ pub fn FixedList(comptime T: type, comptime exact_len: usize) type { pub fn at(self: Self, comptime index: usize) T { return self.items[index]; } + pub fn messageFor(err: errors.ValidationError) ?[]const u8 { + return errors.messageForConfig(err, messages); + } }; } @@ -1080,6 +1729,29 @@ pub fn Default(comptime T: type, comptime default_value: T) type { }; } +/// Default value generated by a factory function when missing. +pub fn DefaultFactory(comptime T: type, comptime factory_fn: fn () T) type { + return struct { + const Self = @This(); + value: T, + pub const ValueType = T; + pub const zigantic_type = .default_factory; + + pub fn init(val: T) Self { + return Self{ .value = val }; + } + pub fn initDefault() Self { + return Self{ .value = factory_fn() }; + } + pub fn get(self: Self) T { + return self.value; + } + pub fn getOrDefault(opt: ?Self) T { + return if (opt) |v| v.value else factory_fn(); + } + }; +} + pub fn Custom(comptime T: type, comptime validator_fn: fn (T) bool) type { return struct { const Self = @This(); @@ -1166,25 +1838,28 @@ pub fn Partial(comptime T: type) type { if (info != .@"struct") @compileError("Partial: T must be a struct"); const fields = info.@"struct".fields; - var new_fields: [fields.len]std.builtin.Type.StructField = undefined; + comptime var field_names: [fields.len][]const u8 = undefined; + comptime var field_types: [fields.len]type = undefined; + comptime var field_attrs: [fields.len]std.builtin.Type.StructField.Attributes = undefined; - for (fields, 0..) |field, i| { + inline for (fields, 0..) |field, i| { const OptionalType = ?field.type; - new_fields[i] = .{ - .name = field.name, - .type = OptionalType, + field_names[i] = field.name; + field_types[i] = OptionalType; + field_attrs[i] = .{ + .@"comptime" = false, + .@"align" = @alignOf(OptionalType), .default_value_ptr = @ptrCast(&@as(OptionalType, null)), - .is_comptime = false, - .alignment = @alignOf(OptionalType), }; } - return @Type(.{ .@"struct" = .{ - .layout = .auto, - .fields = &new_fields, - .decls = &.{}, - .is_tuple = false, - } }); + return @Struct( + .auto, + null, + &field_names, + &field_types, + &field_attrs, + ); } pub fn OneOf(comptime T: type, comptime allowed: []const T) type { @@ -1475,3 +2150,202 @@ test "Ipv4 loopback" { const ip = try Ipv4.init("127.0.0.1"); try std.testing.expect(ip.isLoopback()); } + +test "String with custom messages" { + const CustomName = Stringf(1, 50, .{ .too_short = "name is required" }); + const name = try CustomName.init("Alice"); + try std.testing.expectEqualStrings("Alice", name.get()); + const err = CustomName.init(""); + try std.testing.expect(err == error.TooShort); + try std.testing.expectEqualStrings("name is required", CustomName.messageFor(error.TooShort).?); +} + +test "Int with custom messages" { + const CustomAge = Intf(i32, 18, 120, .{ .too_small = "must be 18 or older" }); + const age = try CustomAge.init(25); + try std.testing.expectEqual(@as(i32, 25), age.get()); + try std.testing.expectEqualStrings("must be 18 or older", CustomAge.messageFor(error.TooSmall).?); +} + +test "List with custom messages" { + const CustomList = Listf(u32, 2, 5, .{ .too_few_items = "need at least 2 items" }); + const items = [_]u32{ 1, 2, 3 }; + const list = try CustomList.init(&items); + try std.testing.expectEqual(@as(usize, 3), list.len()); + try std.testing.expectEqualStrings("need at least 2 items", CustomList.messageFor(error.TooFewItems).?); +} + +test "Secret with custom messages" { + const CustomPwd = Secretf(8, 100, .{ .too_short = "password must be at least 8 chars" }); + try std.testing.expectEqualStrings("password must be at least 8 chars", CustomPwd.messageFor(error.TooShort).?); +} + +test "StrongPassword with custom messages" { + const CustomPwd = StrongPasswordf(8, 100, .{ .weak_password = "password needs upper, lower, digit, and special" }); + try std.testing.expectEqualStrings("password needs upper, lower, digit, and special", CustomPwd.messageFor(error.WeakPassword).?); +} + +test "Float with custom messages" { + const CustomF = Floatf(f64, -100.0, 100.0, .{ .too_small = "value too low" }); + try std.testing.expectEqualStrings("value too low", CustomF.messageFor(error.TooSmall).?); +} + +test "MultipleOf with custom messages" { + const CustomM = MultipleOff(i32, 5, .{ .not_multiple = "must be divisible by 5" }); + try std.testing.expectEqualStrings("must be divisible by 5", CustomM.messageFor(error.NotMultiple).?); +} + +test "EvenInt with custom messages" { + const CustomE = EvenIntf(i32, 0, 100, .{ .must_be_even = "only even numbers allowed" }); + try std.testing.expectEqualStrings("only even numbers allowed", CustomE.messageFor(error.MustBeEven).?); +} + +test "OddInt with custom messages" { + const CustomO = OddIntf(i32, 0, 100, .{ .must_be_odd = "only odd numbers allowed" }); + try std.testing.expectEqualStrings("only odd numbers allowed", CustomO.messageFor(error.MustBeOdd).?); +} + +test "HexString with custom messages" { + const CustomH = HexStringf(3, 6, .{ .invalid_format = "must be valid hex" }); + try std.testing.expectEqualStrings("must be valid hex", CustomH.messageFor(error.InvalidFormat).?); +} + +test "Trimmed with custom messages" { + const CustomT = Trimmedf(1, 50, .{ .too_short = "must not be empty" }); + try std.testing.expectEqualStrings("must not be empty", CustomT.messageFor(error.TooShort).?); +} + +test "NonEmptyString with custom messages" { + const CustomN = NonEmptyStringf(50, .{ .too_short = "cannot be empty" }); + try std.testing.expectEqualStrings("cannot be empty", CustomN.messageFor(error.TooShort).?); +} + +test "UInt with custom messages" { + const CustomU = UIntf(u32, 1, 100, .{ .too_small = "must be at least 1" }); + try std.testing.expectEqualStrings("must be at least 1", CustomU.messageFor(error.TooSmall).?); +} + +test "PositiveInt with custom messages" { + const CustomP = PositiveIntf(i32, .{ .too_small = "must be positive" }); + try std.testing.expectEqualStrings("must be positive", CustomP.messageFor(error.TooSmall).?); +} + +test "FixedList with custom messages" { + const CustomF = FixedListf(u32, 3, .{ .wrong_length = "must have exactly 3 items" }); + try std.testing.expectEqualStrings("must have exactly 3 items", CustomF.messageFor(error.WrongLength).?); +} + +test "Iban" { + const iban = try Iban.init("DE89370400440532013000"); + try std.testing.expectEqualStrings("DE", iban.countryCode()); + try std.testing.expectEqual(@as(usize, 22), iban.normalizedLength()); + try std.testing.expectError(errors.ValidationError.InvalidFormat, Iban.init("DE89")); +} + +test "Base58" { + const b58 = try Base58.init("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"); + try std.testing.expectEqual(@as(usize, 34), b58.len()); + try std.testing.expectError(errors.ValidationError.InvalidFormat, Base58.init("0OIl")); +} + +test "HslColor" { + const hsl = try HslColor.init("hsl(120, 100%, 50%)"); + try std.testing.expectEqualStrings("hsl(120, 100%, 50%)", hsl.get()); + try std.testing.expectError(errors.ValidationError.InvalidFormat, HslColor.init("rgb(255, 0, 0)")); +} + +test "Duration" { + const dur = try Duration.init("P1Y2M3DT4H5M6S"); + try std.testing.expect(dur.hasTime()); + const no_time = try Duration.init("P30D"); + try std.testing.expect(!no_time.hasTime()); + try std.testing.expectError(errors.ValidationError.InvalidFormat, Duration.init("1Y2M")); +} + +test "CronExpression" { + const cron = try CronExpression.init("0 12 * * *"); + try std.testing.expectEqual(@as(u32, 5), cron.fieldCount()); + try std.testing.expectError(errors.ValidationError.InvalidFormat, CronExpression.init("0 12 *")); +} + +test "Isbn10" { + const isbn = try Isbn10.init("0-306-40615-2"); + try std.testing.expectEqualStrings("0-306-40615-2", isbn.get()); + try std.testing.expectError(errors.ValidationError.InvalidFormat, Isbn10.init("1234567890")); +} + +test "Isbn13" { + const isbn = try Isbn13.init("978-0-306-40615-7"); + try std.testing.expectEqualStrings("978-0-306-40615-7", isbn.get()); + try std.testing.expectError(errors.ValidationError.InvalidFormat, Isbn13.init("978-0-306-40615-0")); +} + +test "AsciiAlphaString" { + const Name = AsciiAlphaString(1, 50); + const name = try Name.init("Hello"); + try std.testing.expectEqualStrings("Hello", name.get()); + try std.testing.expectEqual(@as(usize, 5), name.len()); + try std.testing.expectError(errors.ValidationError.InvalidFormat, Name.init("Hello123")); +} + +test "AsciiPrintableString" { + const S = AsciiPrintableString(1, 100); + const s = try S.init("Hello, World!"); + try std.testing.expectEqualStrings("Hello, World!", s.get()); +} + +test "StrongPasswordStrict" { + const pwd = try StrongPasswordStrict.init("P@ssw0rd!"); + try std.testing.expectEqualStrings("P@ssw0rd!", pwd.get()); + try std.testing.expectEqualStrings("********", pwd.masked()); + try std.testing.expectError(errors.ValidationError.WeakPassword, StrongPasswordStrict.init("password")); +} + +test "Email new methods" { + const email = try Email.init("user+tag@gmail.com"); + try std.testing.expect(email.hasTag()); + try std.testing.expectEqualStrings("tag", email.tag().?); + try std.testing.expectEqualStrings("com", email.tld()); + try std.testing.expect(email.isFreeEmail()); + + const biz = try Email.init("user@company.com"); + try std.testing.expect(biz.isBusinessEmail()); + try std.testing.expect(!biz.hasTag()); + try std.testing.expect(biz.tag() == null); +} + +test "Url new methods" { + const url = try Url.init("https://example.com:8080/path?q=1#section"); + try std.testing.expectEqualStrings("example.com", url.host()); + try std.testing.expectEqualStrings("/path", url.path()); + try std.testing.expectEqualStrings("q=1", url.query().?); + try std.testing.expectEqualStrings("section", url.fragment().?); + try std.testing.expectEqual(@as(?u16, 8080), url.port()); + try std.testing.expect(url.hasQuery()); + try std.testing.expect(url.hasFragment()); + try std.testing.expectEqualStrings("path", url.filename()); +} + +test "List new methods" { + const L = List(u32, 1, 10); + const items = [_]u32{ 1, 2, 3, 4, 5 }; + const list = try L.init(&items); + try std.testing.expectEqual(@as(u32, 15), list.sum()); + try std.testing.expect(list.all(struct { + fn f(n: u32) bool { + return n > 0; + } + }.f)); + try std.testing.expect(list.any(struct { + fn f(n: u32) bool { + return n == 3; + } + }.f)); + try std.testing.expectEqual(@as(?usize, 2), list.findIndex(struct { + fn f(n: u32) bool { + return n == 3; + } + }.f)); + try std.testing.expectEqual(@as(?u32, 3), list.at(2)); + try std.testing.expectEqual(@as(?u32, null), list.at(10)); +} diff --git a/src/utils.zig b/src/utils.zig new file mode 100644 index 0000000..23735a1 --- /dev/null +++ b/src/utils.zig @@ -0,0 +1,201 @@ +//! Reusable Utilities and Helper Functions +//! +//! Centralizes common logic used throughout the zigantic library. + +const std = @import("std"); + +/// Strips the 'v' or 'V' prefix from a version string. +/// +/// Example: "v0.0.3" -> "0.0.3" +pub fn stripVersionPrefix(tag: []const u8) []const u8 { + if (tag.len == 0) return tag; + return if (tag[0] == 'v' or tag[0] == 'V') tag[1..] else tag; +} + +/// Attempts to parse a semantic version string. +/// Returns null if parsing fails. +pub fn parseSemver(text: []const u8) ?std.SemanticVersion { + return std.SemanticVersion.parse(text) catch null; +} + +test "stripVersionPrefix" { + try std.testing.expectEqualStrings("0.0.3", stripVersionPrefix("v0.0.3")); + try std.testing.expectEqualStrings("0.0.3", stripVersionPrefix("V0.0.3")); + try std.testing.expectEqualStrings("0.0.3", stripVersionPrefix("0.0.3")); + try std.testing.expectEqualStrings("", stripVersionPrefix("")); +} + +test "parseSemver" { + const v = parseSemver("1.2.3") orelse return error.TestFailed; + try std.testing.expectEqual(@as(usize, 1), v.major); + try std.testing.expectEqual(@as(usize, 2), v.minor); + try std.testing.expectEqual(@as(usize, 3), v.patch); + + try std.testing.expect(parseSemver("invalid") == null); +} + +/// Supported naming policies for automatic field serialization/deserialization. +pub const NamingPolicy = enum { + none, + snake_case, + camelCase, + kebab_case, + PascalCase, +}; + +/// Convert a string to snake_case at compile time. +pub fn toSnakeCase(comptime input: []const u8) []const u8 { + const static_val = comptime blk: { + var len = 0; + for (input, 0..) |c, i| { + if (std.ascii.isUpper(c)) { + if (i > 0) len += 1; + } + len += 1; + } + var buf: [256]u8 = undefined; + var idx = 0; + for (input, 0..) |c, i| { + if (std.ascii.isUpper(c)) { + if (i > 0) { + buf[idx] = '_'; + idx += 1; + } + buf[idx] = std.ascii.toLower(c); + } else { + buf[idx] = c; + } + idx += 1; + } + var result_buf: [len]u8 = undefined; + @memcpy(&result_buf, buf[0..len]); + const const_buf = result_buf; + break :blk const_buf; + }; + return &static_val; +} + +/// Convert a string to camelCase at compile time. +pub fn toCamelCase(comptime input: []const u8) []const u8 { + const static_val = comptime blk: { + var len = 0; + var next_upper = false; + for (input) |c| { + if (c == '_' or c == '-') { + next_upper = true; + } else { + len += 1; + } + } + var buf: [256]u8 = undefined; + var idx = 0; + next_upper = false; + for (input) |c| { + if (c == '_' or c == '-') { + next_upper = true; + } else { + if (next_upper) { + buf[idx] = if (std.ascii.isLower(c)) std.ascii.toUpper(c) else c; + next_upper = false; + } else { + buf[idx] = c; + } + idx += 1; + } + } + var result_buf: [len]u8 = undefined; + @memcpy(&result_buf, buf[0..len]); + const const_buf = result_buf; + break :blk const_buf; + }; + return &static_val; +} + +/// Convert a string to kebab-case at compile time. +pub fn toKebabCase(comptime input: []const u8) []const u8 { + const static_val = comptime blk: { + var len = 0; + for (input, 0..) |c, i| { + if (std.ascii.isUpper(c)) { + if (i > 0) len += 1; + } + len += 1; + } + var buf: [256]u8 = undefined; + var idx = 0; + for (input, 0..) |c, i| { + if (std.ascii.isUpper(c)) { + if (i > 0) { + buf[idx] = '-'; + idx += 1; + } + buf[idx] = std.ascii.toLower(c); + } else { + buf[idx] = c; + } + idx += 1; + } + var result_buf: [len]u8 = undefined; + @memcpy(&result_buf, buf[0..len]); + const const_buf = result_buf; + break :blk const_buf; + }; + return &static_val; +} + +/// Convert a string to PascalCase at compile time. +pub fn toPascalCase(comptime input: []const u8) []const u8 { + const static_val = comptime blk: { + const camel = toCamelCase(input); + if (camel.len == 0) { + const empty_buf: [0]u8 = undefined; + break :blk empty_buf; + } + var buf: [256]u8 = undefined; + @memcpy(buf[0..camel.len], camel); + if (std.ascii.isLower(buf[0])) { + buf[0] = std.ascii.toUpper(buf[0]); + } + var result_buf: [camel.len]u8 = undefined; + @memcpy(&result_buf, buf[0..camel.len]); + const const_buf = result_buf; + break :blk const_buf; + }; + return &static_val; +} + +/// Get compile-time field alias or naming convention mapped name for a struct field. +pub fn getFieldAlias(comptime T: type, comptime field_name: []const u8) []const u8 { + const is_container = comptime switch (@typeInfo(T)) { + .@"struct", .@"union", .@"enum", .@"opaque" => true, + else => false, + }; + if (comptime is_container) { + // 1. Check explicit alias declaration + if (comptime @hasDecl(T, "zigantic_aliases")) { + const aliases = T.zigantic_aliases; + if (comptime @hasField(@TypeOf(aliases), field_name)) { + return @field(aliases, field_name); + } + } + // 2. Check automatic naming policy + if (comptime @hasDecl(T, "zigantic_naming")) { + const policy = T.zigantic_naming; + return comptime switch (policy) { + .none => field_name, + .snake_case => toSnakeCase(field_name), + .camelCase => toCamelCase(field_name), + .kebab_case => toKebabCase(field_name), + .PascalCase => toPascalCase(field_name), + }; + } + } + return field_name; +} + +test "comptime naming conventions" { + try std.testing.expectEqualStrings("user_first_name", toSnakeCase("userFirstName")); + try std.testing.expectEqualStrings("userFirstName", toCamelCase("user_first_name")); + try std.testing.expectEqualStrings("user-first-name", toKebabCase("userFirstName")); + try std.testing.expectEqualStrings("UserFirstName", toPascalCase("user_first_name")); +} diff --git a/src/validators.zig b/src/validators.zig index c906c13..a407bd4 100644 --- a/src/validators.zig +++ b/src/validators.zig @@ -3,8 +3,10 @@ //! Validation functions for common patterns. const std = @import("std"); +const utils = @import("utils.zig"); -/// Email format. +/// Validates email format (basic RFC-compliant check). +/// Returns true if the string is a valid email address. pub fn isValidEmail(str: []const u8) bool { if (str.len == 0 or str.len > 254) return false; var at_index: ?usize = null; @@ -12,7 +14,7 @@ pub fn isValidEmail(str: []const u8) bool { if (c == '@') { if (at_index != null) return false; at_index = i; - } else if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return false; + } else if (std.ascii.isWhitespace(c)) return false; } const at = at_index orelse return false; if (at == 0 or at >= str.len - 1) return false; @@ -32,11 +34,11 @@ pub fn isValidEmail(str: []const u8) bool { return true; } -/// URL format (http/https). +/// Validates URL format (http:// or https:// only). pub fn isValidUrl(str: []const u8) bool { if (str.len == 0) return false; for (str) |c| { - if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return false; + if (std.ascii.isWhitespace(c)) return false; } if (std.mem.startsWith(u8, str, "https://")) return str.len > 8; if (std.mem.startsWith(u8, str, "http://")) return str.len > 7; @@ -58,47 +60,21 @@ pub fn isUuid(str: []const u8) bool { /// IPv4 address. pub fn isIpv4(str: []const u8) bool { - var parts: u8 = 0; - var current: u16 = 0; - var digits: u8 = 0; - for (str) |c| { - if (c == '.') { - if (digits == 0 or current > 255) return false; - parts += 1; - current = 0; - digits = 0; - } else if (c >= '0' and c <= '9') { - current = current * 10 + (c - '0'); - digits += 1; - if (digits > 3) return false; - } else return false; - } - return parts == 3 and digits > 0 and current <= 255; + _ = std.Io.net.Ip4Address.parse(str, 0) catch return false; + return true; } /// IPv6 address (basic check). pub fn isIpv6(str: []const u8) bool { - if (str.len < 2 or str.len > 45) return false; - var colons: u8 = 0; - var consecutive_colons: u8 = 0; - var prev_was_colon = false; - for (str) |c| { - if (c == ':') { - colons += 1; - if (prev_was_colon) consecutive_colons += 1; - prev_was_colon = true; - } else if (std.ascii.isHex(c)) { - prev_was_colon = false; - } else return false; - } - return colons >= 2 and colons <= 7 and consecutive_colons <= 1; + _ = std.Io.net.Ip6Address.parse(str, 0) catch return false; + return true; } /// Slug format. pub fn isSlug(str: []const u8) bool { if (str.len == 0) return false; for (str) |c| { - if (!((c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or c == '-')) return false; + if (!(std.ascii.isLower(c) or std.ascii.isDigit(c) or c == '-')) return false; } return str[0] != '-' and str[str.len - 1] != '-'; } @@ -115,31 +91,22 @@ pub fn isHexString(str: []const u8) bool { /// Base64 format. pub fn isBase64(str: []const u8) bool { if (str.len == 0 or str.len % 4 != 0) return false; - for (str, 0..) |c, i| { - if (c == '=') { - if (i < str.len - 2) return false; - } else if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '/') return false; + const decoder = std.base64.standard.Decoder; + const size = decoder.calcSizeForSlice(str) catch return false; + var buf_on_stack: [1024]u8 = undefined; + if (size <= buf_on_stack.len) { + decoder.decode(buf_on_stack[0..size], str) catch return false; + } else { + const temp = std.heap.page_allocator.alloc(u8, size) catch return false; + defer std.heap.page_allocator.free(temp); + decoder.decode(temp, str) catch return false; } return true; } /// Semantic version. pub fn isSemver(str: []const u8) bool { - var dots: u8 = 0; - var last_was_dot = true; - for (str) |c| { - if (c == '.') { - if (last_was_dot) return false; - dots += 1; - last_was_dot = true; - } else if (c >= '0' and c <= '9') { - last_was_dot = false; - } else if (c == '-' or c == '+') { - if (dots < 2) return false; - break; - } else return false; - } - return dots >= 2 and !last_was_dot; + return utils.parseSemver(str) != null; } /// Phone number. @@ -147,7 +114,7 @@ pub fn isPhoneNumber(str: []const u8) bool { if (str.len < 7 or str.len > 20) return false; var digit_count: usize = 0; for (str, 0..) |c, i| { - if (c >= '0' and c <= '9') { + if (std.ascii.isDigit(c)) { digit_count += 1; } else if (c == '+' and i == 0) {} else if (c == '-' or c == ' ' or c == '(' or c == ')') {} else return false; } @@ -163,7 +130,7 @@ pub fn isValidCreditCard(str: []const u8) bool { while (i > 0) { i -= 1; const c = str[i]; - if (c < '0' or c > '9') return false; + if (!std.ascii.isDigit(c)) return false; var digit: u32 = c - '0'; if (double) { digit *= 2; @@ -175,6 +142,88 @@ pub fn isValidCreditCard(str: []const u8) bool { return sum % 10 == 0; } +/// Hex color code (#RGB, #RRGGBB, RGB, or RRGGBB). +pub fn isHexColor(str: []const u8) bool { + var hex = str; + if (hex.len > 0 and hex[0] == '#') hex = hex[1..]; + if (hex.len != 3 and hex.len != 6) return false; + for (hex) |c| { + if (!std.ascii.isHex(c)) return false; + } + return true; +} + +/// MAC address (XX:XX:XX:XX:XX:XX or XX-XX-XX-XX-XX-XX). +pub fn isMacAddress(str: []const u8) bool { + if (str.len != 17) return false; + const separator = str[2]; + if (separator != ':' and separator != '-') return false; + var i: usize = 0; + while (i < str.len) : (i += 1) { + if ((i + 1) % 3 == 0) { + if (str[i] != separator) return false; + } else if (!std.ascii.isHex(str[i])) { + return false; + } + } + return true; +} + +/// ISO 8601 date string (YYYY-MM-DD). +pub fn isIsoDate(str: []const u8) bool { + if (str.len != 10) return false; + if (str[4] != '-' or str[7] != '-') return false; + for ([_]usize{ 0, 1, 2, 3, 5, 6, 8, 9 }) |i| { + if (!std.ascii.isDigit(str[i])) return false; + } + return true; +} + +/// ISO 8601 datetime string (basic form with optional Z suffix). +pub fn isIsoDateTime(str: []const u8) bool { + if (str.len < 19) return false; + if (str[4] != '-' or str[7] != '-') return false; + if (str[10] != 'T' and str[10] != ' ') return false; + if (str[13] != ':' or str[16] != ':') return false; + for ([_]usize{ 0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18 }) |i| { + if (!std.ascii.isDigit(str[i])) return false; + } + return true; +} + +/// ISO 3166-1 alpha-2 country code. +pub fn isCountryCode(str: []const u8) bool { + if (str.len != 2) return false; + for (str) |c| { + if (!std.ascii.isAlphabetic(c)) return false; + } + return true; +} + +/// ISO 4217 currency code. +pub fn isCurrencyCode(str: []const u8) bool { + if (str.len != 3) return false; + for (str) |c| { + if (!std.ascii.isAlphabetic(c)) return false; + } + return true; +} + +/// Latitude coordinate. +pub fn isLatitude(value: f64) bool { + return value >= -90.0 and value <= 90.0; +} + +/// Longitude coordinate. +pub fn isLongitude(value: f64) bool { + return value >= -180.0 and value <= 180.0; +} + +/// TCP/UDP port number. +pub fn isPort(value: u16) bool { + return value != 0; +} + /// JSON Web Token format. pub fn isJwt(str: []const u8) bool { var parts: u8 = 0; @@ -204,19 +253,19 @@ pub fn isNumeric(str: []const u8) bool { } pub fn isLowercase(str: []const u8) bool { for (str) |c| { - if (c >= 'A' and c <= 'Z') return false; + if (std.ascii.isUpper(c)) return false; } return true; } pub fn isUppercase(str: []const u8) bool { for (str) |c| { - if (c >= 'a' and c <= 'z') return false; + if (std.ascii.isLower(c)) return false; } return true; } pub fn isAscii(str: []const u8) bool { for (str) |c| { - if (c > 127) return false; + if (!std.ascii.isAscii(c)) return false; } return true; } @@ -231,7 +280,7 @@ pub fn isEmpty(str: []const u8) bool { } pub fn isBlank(str: []const u8) bool { for (str) |c| { - if (c != ' ' and c != '\t' and c != '\n' and c != '\r') return false; + if (!std.ascii.isWhitespace(c)) return false; } return true; } @@ -289,9 +338,9 @@ pub fn matchesPattern(comptime pattern: []const u8, str: []const u8) bool { } fn matchesClass(class: []const u8, c: u8) bool { - if (std.mem.eql(u8, class, "0-9")) return c >= '0' and c <= '9'; - if (std.mem.eql(u8, class, "a-z")) return c >= 'a' and c <= 'z'; - if (std.mem.eql(u8, class, "A-Z")) return c >= 'A' and c <= 'Z'; + if (std.mem.eql(u8, class, "0-9")) return std.ascii.isDigit(c); + if (std.mem.eql(u8, class, "a-z")) return std.ascii.isLower(c); + if (std.mem.eql(u8, class, "A-Z")) return std.ascii.isUpper(c); if (std.mem.eql(u8, class, "a-zA-Z")) return std.ascii.isAlphabetic(c); if (std.mem.eql(u8, class, "0-9a-zA-Z")) return std.ascii.isAlphanumeric(c); for (class) |pc| { @@ -300,6 +349,185 @@ fn matchesClass(class: []const u8, c: u8) bool { return false; } +/// Validates IBAN (International Bank Account Number) format. +/// Checks length, country prefix, and basic format. Does NOT perform +/// the full MOD-97 checksum (that requires knowing the country spec). +pub fn isIban(str: []const u8) bool { + if (str.len < 15 or str.len > 34) return false; + for (str, 0..) |c, i| { + if (i < 2) { + if (!std.ascii.isAlphabetic(c)) return false; + } else if (c == ' ') { + continue; + } else { + if (!std.ascii.isAlphanumeric(c)) return false; + } + } + return true; +} + +/// Validates Base58 encoded string (Bitcoin/crypto addresses). +/// Excludes 0, O, I, l to avoid ambiguous characters. +pub fn isBase58(str: []const u8) bool { + if (str.len == 0) return false; + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + for (str) |c| { + var found = false; + for (alphabet) |a| { + if (c == a) { + found = true; + break; + } + } + if (!found) return false; + } + return true; +} + +/// Validates HSL color string (e.g., "hsl(120, 100%, 50%)" or "hsl(120 100% 50%)"). +pub fn isHslColor(str: []const u8) bool { + if (str.len < 10) return false; + if (!std.mem.startsWith(u8, str, "hsl(")) return false; + if (str[str.len - 1] != ')') return false; + const inner = str[4 .. str.len - 1]; + var parens: u32 = 0; + var commas: u32 = 0; + var has_percent = false; + for (inner) |c| { + if (c == ',') commas += 1; + if (c == '%') has_percent = true; + if (c == '(') parens += 1; + if (c == ')') { + if (parens == 0) return false; + parens -= 1; + } + } + return (commas == 2 or (commas == 0 and std.mem.count(u8, inner, " ") >= 2)) and has_percent; +} + +/// Validates ISO 8601 duration string (e.g., "P1Y2M3DT4H5M6S", "P30D", "PT12H"). +pub fn isIsoDuration(str: []const u8) bool { + if (str.len < 2) return false; + if (str[0] != 'P') return false; + var i: usize = 1; + var has_value = false; + while (i < str.len) { + const c = str[i]; + if (c == 'T') { + i += 1; + continue; + } + if (std.ascii.isDigit(c)) { + has_value = true; + while (i < str.len and std.ascii.isDigit(str[i])) i += 1; + if (i < str.len) { + const unit = str[i]; + if (unit != 'Y' and unit != 'M' and unit != 'W' and unit != 'D' and + unit != 'H' and unit != 'M' and unit != 'S') + return false; + i += 1; + } + } else { + return false; + } + } + return has_value; +} + +/// Validates cron expression (5 or 6 fields). +/// Fields: minute(0-59) hour(0-23) day(1-31) month(1-12) weekday(0-7). +pub fn isCronExpression(str: []const u8) bool { + var fields: u32 = 0; + var in_field = false; + for (str) |c| { + if (std.ascii.isWhitespace(c)) { + if (in_field) fields += 1; + in_field = false; + } else { + in_field = true; + } + } + if (in_field) fields += 1; + return fields == 5 or fields == 6; +} + +/// Validates strong password (min 8 chars, upper+lower+digit+special). +pub fn isStrongPassword(str: []const u8) bool { + if (str.len < 8) return false; + var has_upper = false; + var has_lower = false; + var has_digit = false; + var has_special = false; + for (str) |c| { + if (std.ascii.isUpper(c)) has_upper = true; + if (std.ascii.isLower(c)) has_lower = true; + if (std.ascii.isDigit(c)) has_digit = true; + if (!std.ascii.isAlphanumeric(c)) has_special = true; + } + return has_upper and has_lower and has_digit and has_special; +} + +/// Validates that string contains only ASCII printable characters. +pub fn isAsciiPrintable(str: []const u8) bool { + for (str) |c| { + if (!std.ascii.isPrint(c)) return false; + } + return true; +} + +/// Validates ISBN-10 format (digits + optional X at end, with hyphens/spaces). +pub fn isIsbn10(str: []const u8) bool { + var digits: [10]u8 = undefined; + var idx: usize = 0; + for (str) |c| { + if (c == '-' or c == ' ') continue; + if (idx >= 10) return false; + if (std.ascii.isDigit(c)) { + digits[idx] = c - '0'; + } else if (c == 'X' and idx == 9) { + digits[idx] = 10; + } else { + return false; + } + idx += 1; + } + if (idx != 10) return false; + var sum: u32 = 0; + for (digits, 0..) |d, i| { + sum += @as(u32, d) * @as(u32, @intCast(10 - i)); + } + return sum % 11 == 0; +} + +/// Validates ISBN-13 format (13 digits, with hyphens/spaces). +pub fn isIsbn13(str: []const u8) bool { + var digits: [13]u8 = undefined; + var idx: usize = 0; + for (str) |c| { + if (c == '-' or c == ' ') continue; + if (idx >= 13) return false; + if (!std.ascii.isDigit(c)) return false; + digits[idx] = c - '0'; + idx += 1; + } + if (idx != 13) return false; + var sum: u32 = 0; + for (digits, 0..) |d, i| { + const weight: u32 = if (i % 2 == 0) 1 else 3; + sum += @as(u32, d) * weight; + } + return sum % 10 == 0; +} + +/// Validates ASCII-only alphabetic string (no digits, no special chars). +pub fn isAsciiAlpha(str: []const u8) bool { + if (str.len == 0) return false; + for (str) |c| { + if (!std.ascii.isAlphabetic(c)) return false; + } + return true; +} + test "isValidEmail - valid" { try std.testing.expect(isValidEmail("user@example.com")); try std.testing.expect(isValidEmail("user.name@example.com")); @@ -368,6 +596,43 @@ test "isHexString" { try std.testing.expect(!isHexString("xyz")); } +test "isHexColor" { + try std.testing.expect(isHexColor("#ff5733")); + try std.testing.expect(isHexColor("ff5733")); + try std.testing.expect(isHexColor("#f53")); + try std.testing.expect(!isHexColor("#ff57")); +} + +test "isMacAddress" { + try std.testing.expect(isMacAddress("00:1A:2B:3C:4D:5E")); + try std.testing.expect(isMacAddress("00-1A-2B-3C-4D-5E")); + try std.testing.expect(!isMacAddress("001A:2B:3C:4D:5E")); +} + +test "isIsoDate and isIsoDateTime" { + try std.testing.expect(isIsoDate("2024-01-15")); + try std.testing.expect(!isIsoDate("2024-1-15")); + try std.testing.expect(isIsoDateTime("2024-01-15T10:30:00Z")); + try std.testing.expect(isIsoDateTime("2024-01-15 10:30:00")); + try std.testing.expect(!isIsoDateTime("2024-01-15")); +} + +test "isCountryCode and isCurrencyCode" { + try std.testing.expect(isCountryCode("US")); + try std.testing.expect(!isCountryCode("USA")); + try std.testing.expect(isCurrencyCode("USD")); + try std.testing.expect(!isCurrencyCode("US")); +} + +test "isLatitude isLongitude isPort" { + try std.testing.expect(isLatitude(45.0)); + try std.testing.expect(!isLatitude(120.0)); + try std.testing.expect(isLongitude(-75.0)); + try std.testing.expect(!isLongitude(200.0)); + try std.testing.expect(isPort(443)); + try std.testing.expect(!isPort(0)); +} + test "isSemver" { try std.testing.expect(isSemver("1.2.3")); try std.testing.expect(isSemver("0.0.1")); @@ -434,3 +699,74 @@ test "matchesPattern - phone" { try std.testing.expect(matchesPattern(pattern, "123-4567")); try std.testing.expect(!matchesPattern(pattern, "1234567")); } + +test "isIban" { + try std.testing.expect(isIban("DE89370400440532013000")); + try std.testing.expect(isIban("GB29NWBK60161331926819")); + try std.testing.expect(!isIban("DE89")); + try std.testing.expect(!isIban("")); +} + +test "isBase58" { + try std.testing.expect(isBase58("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")); + try std.testing.expect(isBase58("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy")); + try std.testing.expect(!isBase58("0OIl")); + try std.testing.expect(!isBase58("")); +} + +test "isHslColor" { + try std.testing.expect(isHslColor("hsl(120, 100%, 50%)")); + try std.testing.expect(isHslColor("hsl(0, 0%, 100%)")); + try std.testing.expect(!isHslColor("rgb(255, 0, 0)")); + try std.testing.expect(!isHslColor("hsl(120)")); +} + +test "isIsoDuration" { + try std.testing.expect(isIsoDuration("P1Y2M3DT4H5M6S")); + try std.testing.expect(isIsoDuration("P30D")); + try std.testing.expect(isIsoDuration("PT12H30M")); + try std.testing.expect(!isIsoDuration("1Y2M")); + try std.testing.expect(!isIsoDuration("P")); +} + +test "isCronExpression" { + try std.testing.expect(isCronExpression("0 12 * * *")); + try std.testing.expect(isCronExpression("30 4 1,15 * *")); + try std.testing.expect(isCronExpression("0 0 * * 0")); + try std.testing.expect(!isCronExpression("0 12 *")); +} + +test "isStrongPassword" { + try std.testing.expect(isStrongPassword("P@ssw0rd!")); + try std.testing.expect(isStrongPassword("MyStr0ng!Pass")); + try std.testing.expect(!isStrongPassword("password")); + try std.testing.expect(!isStrongPassword("12345678")); + try std.testing.expect(!isStrongPassword("short")); +} + +test "isAsciiPrintable" { + try std.testing.expect(isAsciiPrintable("Hello, World!")); + try std.testing.expect(isAsciiPrintable("Test 123 !@#")); + try std.testing.expect(!isAsciiPrintable("Hello\x00")); + try std.testing.expect(!isAsciiPrintable("tab\there")); +} + +test "isIsbn10" { + try std.testing.expect(isIsbn10("0-306-40615-2")); + try std.testing.expect(isIsbn10("007462542X")); + try std.testing.expect(!isIsbn10("0-306-40615-0")); + try std.testing.expect(!isIsbn10("1234567890")); +} + +test "isIsbn13" { + try std.testing.expect(isIsbn13("978-0-306-40615-7")); + try std.testing.expect(isIsbn13("9780074625422")); + try std.testing.expect(!isIsbn13("978-0-306-40615-0")); +} + +test "isAsciiAlpha" { + try std.testing.expect(isAsciiAlpha("Hello")); + try std.testing.expect(isAsciiAlpha("ABCdef")); + try std.testing.expect(!isAsciiAlpha("Hello123")); + try std.testing.expect(!isAsciiAlpha("")); +} diff --git a/src/version.zig b/src/version.zig index 0a3858a..2d131a1 100644 --- a/src/version.zig +++ b/src/version.zig @@ -4,16 +4,7 @@ /// The current version of the zigantic library. /// This should be kept in sync with build.zig.zon -pub const version: []const u8 = "0.0.2"; - -/// The major version number. -pub const major: u32 = 0; - -/// The minor version number. -pub const minor: u32 = 0; - -/// The patch version number. -pub const patch: u32 = 2; +pub const version: []const u8 = "0.0.3"; /// Returns the full version string with prefix. pub fn getVersionString() []const u8 { @@ -22,12 +13,12 @@ pub fn getVersionString() []const u8 { /// Returns true if this is a pre-release version (major = 0). pub fn isPreRelease() bool { - return major == 0; + return version.len > 0 and version[0] == '0'; } test "version format" { const std = @import("std"); - try std.testing.expectEqualStrings("0.0.2", version); - try std.testing.expectEqualStrings("v0.0.2", getVersionString()); + try std.testing.expectEqualStrings("0.0.3", version); + try std.testing.expectEqualStrings("v0.0.3", getVersionString()); try std.testing.expect(isPreRelease()); } diff --git a/src/zigantic.zig b/src/zigantic.zig index afeb2e3..58919c4 100644 --- a/src/zigantic.zig +++ b/src/zigantic.zig @@ -1,13 +1,15 @@ -//! zigantic - Pydantic-like data validation and serialization for Zig. +//! zigantic - Type-safe data validation and serialization for Zig. const std = @import("std"); pub const types = @import("types.zig"); pub const validators = @import("validators.zig"); +pub const color = @import("color.zig"); pub const errors = @import("errors.zig"); pub const json = @import("json.zig"); pub const version = @import("version.zig"); pub const report = @import("report.zig"); +pub const utils = @import("utils.zig"); /// Configuration options for zigantic library. pub const Config = struct { @@ -15,20 +17,66 @@ pub const Config = struct { auto_update_check: bool = true, /// Whether to show update notifications in the log. show_update_notifications: bool = true, + /// Exit the process after validation failures surfaced by the top-level helpers. + exit_on_validation_error: bool = false, + /// Exit the process after serialization failures surfaced by the top-level helpers. + exit_on_serialization_error: bool = false, + /// Optional callback for formatted validation error output. + validation_error_callback: ?*const fn ([]const u8) void = null, + /// Optional formatter that replaces built-in validation messages. + validation_message_formatter: errors.MessageFormatter = null, + /// Optional callback for formatted serialization error output. + serialization_error_callback: ?*const fn ([]const u8) void = null, + /// Optional formatter that replaces built-in serialization messages. + serialization_error_formatter: ?*const fn (anyerror) []const u8 = null, + /// Use ANSI colors when formatting validation errors for callbacks and exit handling. + use_color_output: bool = true, + /// Per-validation-error color overrides (null = use built-in colors). + color_overrides: errors.ColorOverrides = .{}, + /// Maximum number of validation errors to collect per parse (null = unlimited). + max_errors: ?usize = null, + /// When true, reject structs with unknown JSON fields (strict deserialization). + reject_unknown_fields: bool = false, + /// When true, treat null values as missing (skip null optional fields). + treat_null_as_missing: bool = false, + /// When true, allow implicit type coercion (e.g., int to float in JSON). + allow_coercion: bool = false, + /// When true, trim whitespace from string values before validation. + trim_strings: bool = false, + /// When true, convert string values to lowercase before validation. + lowercase_strings: bool = false, + /// When true, collect errors for all fields instead of stopping at first error per field. + collect_all_errors: bool = true, + /// When true, include the invalid value in error messages. + include_value_in_error: bool = true, + /// Custom field name mapping for JSON deserialization (applies to all structs). + field_name_map: ?*const fn ([]const u8) []const u8 = null, + + // Lifecycle callbacks + /// Called before validation begins. Receives the type name as a string. + before_validation_callback: ?*const fn (type_name: []const u8) void = null, + /// Called after each field is validated. Receives field name, field type, and success status. + on_field_validated_callback: ?*const fn (field: []const u8, field_type: []const u8, success: bool) void = null, + /// Called when a field validation fails. Receives field name and error message. + on_field_error_callback: ?*const fn (field: []const u8, message: []const u8) void = null, + /// Called when validation completes. Receives success status and error count. + on_validation_complete_callback: ?*const fn (success: bool, error_count: usize) void = null, + /// Called before serialization begins. + before_serialize_callback: ?*const fn () void = null, + /// Called after serialization completes. Receives the serialized JSON string. + after_serialize_callback: ?*const fn (json: []const u8) void = null, + /// Called when a custom message is resolved for a validation error. + on_custom_message_resolved: ?*const fn (err: errors.ValidationError, message: []const u8) void = null, }; var global_config: Config = .{}; var update_thread: ?std.Thread = null; var update_check_triggered = false; -var update_check_mutex = std.Thread.Mutex{}; +var update_check_mutex: std.atomic.Mutex = .unlocked; -/// Set the library configuration. Call this BEFORE using any library functions. +/// Set the library configuration. pub fn setConfig(config: Config) void { - update_check_mutex.lock(); - defer update_check_mutex.unlock(); - if (!update_check_triggered) { - global_config = config; - } + global_config = config; } /// Get the current library configuration. @@ -36,8 +84,26 @@ pub fn getConfig() Config { return global_config; } +/// Enable colored validation output. +pub fn enableColor() void { + global_config.use_color_output = true; +} + +/// Disable colored validation output. +pub fn disableColor() void { + global_config.use_color_output = false; +} + +/// Set per-error color overrides. Pass .{} to reset to defaults. +pub fn setColorOverrides(overrides: errors.ColorOverrides) void { + global_config.color_overrides = overrides; +} + fn triggerAutoUpdateCheck(allocator: std.mem.Allocator) void { - update_check_mutex.lock(); + if (@import("builtin").is_test) return; + while (!update_check_mutex.tryLock()) { + std.atomic.spinLoopHint(); + } defer update_check_mutex.unlock(); if (update_check_triggered) return; update_check_triggered = true; @@ -101,6 +167,16 @@ pub const CurrencyCode = types.CurrencyCode; pub const Latitude = types.Latitude; pub const Longitude = types.Longitude; pub const Port = types.Port; +pub const Iban = types.Iban; +pub const Base58 = types.Base58; +pub const HslColor = types.HslColor; +pub const Duration = types.Duration; +pub const CronExpression = types.CronExpression; +pub const Isbn10 = types.Isbn10; +pub const Isbn13 = types.Isbn13; +pub const AsciiAlphaString = types.AsciiAlphaString; +pub const AsciiPrintableString = types.AsciiPrintableString; +pub const StrongPasswordStrict = types.StrongPasswordStrict; // Collection Types pub const List = types.List; @@ -109,6 +185,7 @@ pub const FixedList = types.FixedList; // Special Types pub const Default = types.Default; +pub const DefaultFactory = types.DefaultFactory; pub const Custom = types.Custom; pub const Transform = types.Transform; pub const Coerce = types.Coerce; @@ -119,6 +196,23 @@ pub const Range = types.Range; pub const Nullable = types.Nullable; pub const Lazy = types.Lazy; +// Custom message variants (accept a messages config struct) +pub const Stringf = types.Stringf; +pub const NonEmptyStringf = types.NonEmptyStringf; +pub const Trimmedf = types.Trimmedf; +pub const Secretf = types.Secretf; +pub const StrongPasswordf = types.StrongPasswordf; +pub const Intf = types.Intf; +pub const UIntf = types.UIntf; +pub const PositiveIntf = types.PositiveIntf; +pub const EvenIntf = types.EvenIntf; +pub const OddIntf = types.OddIntf; +pub const MultipleOff = types.MultipleOff; +pub const Floatf = types.Floatf; +pub const HexStringf = types.HexStringf; +pub const Listf = types.Listf; +pub const FixedListf = types.FixedListf; + // Convenience Functions pub fn string(comptime min: usize, comptime max: usize) type { return String(min, max); @@ -184,43 +278,114 @@ pub fn port() type { // JSON Serialization/Deserialization pub const ParseResult = json.ParseResult; pub const ValidationError = errors.ValidationError; - -/// Parse JSON string into a validated struct (deserialization). +pub const Color = color.Color; +pub const ErrorPresentation = errors.ErrorPresentation; + +/// Parses a JSON string into a validated struct. +/// +/// Performs compile-time type checking and runtime validation. +/// Returns a `ParseResult` containing either the parsed value +/// or a list of validation errors. +/// +/// Triggers lifecycle callbacks: `before_validation_callback`, +/// `on_field_error_callback`, `on_field_validated_callback`, +/// `on_validation_complete_callback`. pub fn fromJson(comptime T: type, json_string: []const u8, allocator: std.mem.Allocator) !ParseResult(T) { triggerAutoUpdateCheck(allocator); - return json.fromJson(T, json_string, allocator); + if (global_config.before_validation_callback) |cb| cb(@typeName(T)); + var result = try json.fromJson(T, json_string, allocator); + handleValidationResult(T, &result, allocator); + return result; } -/// Serialize a value to compact JSON string. +/// Serializes a value to a compact JSON string. +/// +/// Uses compile-time introspection to handle zigantic types, +/// optionals, slices, and nested structs. Triggers +/// `before_serialize_callback` and `after_serialize_callback`. pub fn toJson(value: anytype, allocator: std.mem.Allocator) ![]const u8 { triggerAutoUpdateCheck(allocator); - return json.toJson(value, allocator); + if (global_config.before_serialize_callback) |cb| cb(); + const result = json.toJson(value, allocator) catch |err| { + handleSerializationError(err, "toJson"); + return err; + }; + if (global_config.after_serialize_callback) |cb| cb(result); + return result; } -/// Serialize a value to pretty-printed JSON string. +/// Serializes a value to a pretty-printed JSON string with indentation. pub fn toJsonPretty(value: anytype, allocator: std.mem.Allocator) ![]const u8 { triggerAutoUpdateCheck(allocator); - return json.toJsonPretty(value, allocator); + if (global_config.before_serialize_callback) |cb| cb(); + const result = json.toJsonPretty(value, allocator) catch |err| { + handleSerializationError(err, "toJsonPretty"); + return err; + }; + if (global_config.after_serialize_callback) |cb| cb(result); + return result; +} + +/// Parses a URL query string or form-urlencoded data into a validated struct. +/// +/// Supports `key=value` pairs separated by `&`. Values are URL-decoded. +/// Field aliases and naming conventions are respected. +pub fn fromQueryString(comptime T: type, query_string: []const u8, allocator: std.mem.Allocator) !ParseResult(T) { + triggerAutoUpdateCheck(allocator); + if (global_config.before_validation_callback) |cb| cb(@typeName(T)); + var result = try json.fromQueryString(T, query_string, allocator); + handleValidationResult(T, &result, allocator); + return result; +} + +/// Serializes a value to a URL query string (key=value&key2=value2). +/// +/// Nested structs are flattened with dot notation. Zigantic wrapper +/// types are unwrapped via `.get()` before serialization. +pub fn toQueryString(value: anytype, allocator: std.mem.Allocator) ![]const u8 { + triggerAutoUpdateCheck(allocator); + if (global_config.before_serialize_callback) |cb| cb(); + const result = json.toQueryString(value, allocator) catch |err| { + handleSerializationError(err, "toQueryString"); + return err; + }; + if (global_config.after_serialize_callback) |cb| cb(result); + return result; } // Validation Helpers + +/// Validates a value against a zigantic type, returning the validated value or an error. pub fn validate(comptime T: type, value: anytype) errors.ValidationError!T { return T.init(value); } +/// Returns true if the value passes validation for the given type. pub fn isValid(comptime T: type, value: anytype) bool { _ = T.init(value) catch return false; return true; } +/// Returns the human-readable message for a validation error. pub fn errorMessage(err: errors.ValidationError) []const u8 { return errors.errorMessage(err); } +/// Returns the error code for a validation error (e.g., "E001"). pub fn errorCode(err: errors.ValidationError) []const u8 { return errors.errorCode(err); } +/// Returns the full presentation details (message, code, color) for a validation error. +pub fn errorPresentation(err: errors.ValidationError) errors.ErrorPresentation { + return errors.errorPresentation(err); +} + +/// Returns the default ANSI color for a validation error type. +pub fn errorColor(err: errors.ValidationError) errors.Color { + return errors.errorColor(err); +} + // Version and Reporting pub const ISSUES_URL = report.ISSUES_URL; @@ -240,6 +405,71 @@ pub fn reportInternalErrorWithCode(err: anyerror) void { report.reportInternalErrorWithCode(err); } +fn handleValidationResult(comptime T: type, result: *ParseResult(T), allocator: std.mem.Allocator) void { + if (global_config.before_validation_callback) |cb| { + cb(@typeName(T)); + } + + if (result.isValid()) { + if (global_config.on_validation_complete_callback) |cb| { + cb(true, 0); + } + return; + } + + if (global_config.on_field_error_callback != null) { + for (result.error_list.errors.items) |err| { + if (global_config.on_field_error_callback) |cb| { + cb(err.field, err.message); + } + } + } + + if (global_config.on_field_validated_callback != null) { + for (result.error_list.errors.items) |err| { + if (global_config.on_field_validated_callback) |cb| { + cb(err.field, @errorName(err.error_type), false); + } + } + } + + const message = if (global_config.use_color_output) + result.error_list.formatAllColoredWithOverrides(allocator, global_config.validation_message_formatter, global_config.color_overrides) catch return + else + result.error_list.formatAllWith(allocator, global_config.validation_message_formatter) catch return; + defer allocator.free(message); + + if (global_config.validation_error_callback) |callback| { + callback(message); + } else if (!@import("builtin").is_test) { + std.debug.print("{s}\n", .{message}); + } + + if (global_config.on_validation_complete_callback) |cb| { + cb(false, result.error_list.count()); + } + + if (global_config.exit_on_validation_error) { + std.process.exit(1); + } +} + +fn handleSerializationError(err: anyerror, operation: []const u8) void { + var buffer: [256]u8 = undefined; + const detail = if (global_config.serialization_error_formatter) |formatter| formatter(err) else @errorName(err); + const message = std.fmt.bufPrint(&buffer, "[{s}] serialization failed: {s}", .{ operation, detail }) catch detail; + + if (global_config.serialization_error_callback) |callback| { + callback(message); + } else { + std.debug.print("{s}\n", .{message}); + } + + if (global_config.exit_on_serialization_error) { + std.process.exit(1); + } +} + pub const reportError = reportInternalErrorWithCode; pub const reportErrorMessage = reportInternalError; @@ -396,6 +626,149 @@ test "errorCode" { try std.testing.expectEqualStrings("E001", errorCode(errors.ValidationError.TooShort)); } +test "Query String validation" { + const allocator = std.testing.allocator; + const User = struct { + name: String(1, 50), + age: Int(i32, 0, 150), + active: bool, + }; + + const qs = "name=Alice+Johnson&age=25&active=true"; + var result = try fromQueryString(User, qs, allocator); + defer result.deinit(); + + try std.testing.expect(result.isValid()); + const user = result.value.?; + try std.testing.expectEqualStrings("Alice Johnson", user.name.get()); + try std.testing.expectEqual(@as(i32, 25), user.age.get()); + try std.testing.expectEqual(true, user.active); + + const serialized = try toQueryString(user, allocator); + defer allocator.free(serialized); + try std.testing.expectEqualStrings("name=Alice+Johnson&age=25&active=true", serialized); +} + +// File-level state for callback tests (Zig 0.16 doesn't allow capturing mutable locals) +var cb_called: bool = false; +var cb_validated_count: usize = 0; +var cb_error_count: usize = 0; +var cb_completed: bool = false; +var cb_serialized: ?[]const u8 = null; + +test "before_validation_callback triggered" { + cb_called = false; + const cb = struct { + fn call(_: []const u8) void { + cb_called = true; + } + }.call; + const prev = global_config.before_validation_callback; + global_config.before_validation_callback = cb; + defer global_config.before_validation_callback = prev; + + const User = struct { + name: String(1, 50), + }; + const qs = "name=Alice"; + var result = try fromQueryString(User, qs, std.testing.allocator); + defer result.deinit(); + try std.testing.expect(cb_called); +} + +test "on_field_validated callback triggered" { + cb_validated_count = 0; + const cb = struct { + fn call(_: []const u8, _: []const u8, _: bool) void { + cb_validated_count += 1; + } + }.call; + const prev = global_config.on_field_validated_callback; + global_config.on_field_validated_callback = cb; + defer global_config.on_field_validated_callback = prev; + + const User = struct { + name: String(1, 3), + age: Int(i32, 0, 150), + }; + + var result = try fromJson(User, "{\"name\":\"Alice\",\"age\":25}", std.testing.allocator); + defer result.deinit(); + try std.testing.expect(cb_validated_count > 0); +} + +test "on_field_error callback triggered" { + cb_error_count = 0; + const cb = struct { + fn call(_: []const u8, _: []const u8) void { + cb_error_count += 1; + } + }.call; + const prev = global_config.on_field_error_callback; + global_config.on_field_error_callback = cb; + defer global_config.on_field_error_callback = prev; + + const User = struct { + name: String(1, 5), + }; + + var result = try fromJson(User, "{\"name\":\"\"}", std.testing.allocator); + defer result.deinit(); + try std.testing.expect(cb_error_count > 0); +} + +test "on_validation_complete callback triggered" { + cb_completed = false; + const cb = struct { + fn call(_: bool, _: usize) void { + cb_completed = true; + } + }.call; + const prev = global_config.on_validation_complete_callback; + global_config.on_validation_complete_callback = cb; + defer global_config.on_validation_complete_callback = prev; + + const User = struct { + name: String(1, 50), + }; + + var result = try fromJson(User, "{\"name\":\"Alice\"}", std.testing.allocator); + defer result.deinit(); + try std.testing.expect(cb_completed); +} + +test "before_serialize callback triggered" { + cb_called = false; + const cb = struct { + fn call() void { + cb_called = true; + } + }.call; + const prev = global_config.before_serialize_callback; + global_config.before_serialize_callback = cb; + defer global_config.before_serialize_callback = prev; + + const json_result = try toJson(42, std.testing.allocator); + defer std.testing.allocator.free(json_result); + try std.testing.expect(cb_called); +} + +test "after_serialize callback triggered" { + cb_serialized = null; + const cb = struct { + fn call(result: []const u8) void { + cb_serialized = result; + } + }.call; + const prev = global_config.after_serialize_callback; + global_config.after_serialize_callback = cb; + defer global_config.after_serialize_callback = prev; + + const json_result = try toJson(42, std.testing.allocator); + defer std.testing.allocator.free(json_result); + try std.testing.expect(cb_serialized != null); +} + test { std.testing.refAllDecls(@This()); }