diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 00000000..fd9dcd0c --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,146 @@ +# Architecture + +**Analysis Date:** 2026-03-06 + +## Pattern Overview + +**Overall:** Layered Rust workspace — iCalendar parsing layer, domain logic layer, Redis module integration layer + +**Key Characteristics:** +- Three crates with strict dependency direction: `redical_ical` ← `redical_core` ← `redical_redis` +- `redical_redis` compiles to a `cdylib` loaded into Redis as a native module +- Domain objects (`Calendar`, `Event`) are stored directly in Redis memory via custom `RedisType`; no separate serialization at query time +- iCal text is the wire format for both input (commands) and output (responses) +- Calendar indexes (inverted + geospatial) are maintained incrementally on write, or rebuilt in bulk via `rdcl.cal_idx_rebuild` + +## Layers + +**iCalendar Parsing (`redical_ical`):** +- Purpose: Parse and render RFC 5545 iCalendar text; no domain logic +- Location: `redical_ical/src/` +- Contains: Grammar combinators (`grammar.rs`), `ContentLine` type, property types (`properties/`), value types (`values/`), `ICalendarEntity` and `ICalendarComponent` traits, `ParserInput`/`ParserResult` type aliases +- Depends on: `nom`, `nom_locate`, `chrono`, `chrono-tz`, `itertools` +- Used by: `redical_core`, `redical_redis` + +**Domain Core (`redical_core`):** +- Purpose: Calendar/Event domain model, index structures, occurrence iteration, query execution +- Location: `redical_core/src/` +- Contains: `Calendar`, `Event`, `EventOccurrenceOverride`, `EventInstance`, `EventOccurrenceIterator`, `InvertedCalendarIndex`/`InvertedEventIndex`, `GeoSpatialCalendarIndex`, query subsystem (`queries/`) +- Depends on: `redical_ical`, `rrule`, `rstar`, `geo`, `geohash`, `chrono`, `chrono-tz` +- Used by: `redical_redis` + +**Redis Module (`redical_redis`):** +- Purpose: Expose `rdcl.*` Redis commands; own the Redis data type lifecycle (RDB persistence, copy, free) +- Location: `redical_redis/src/` +- Contains: Command handlers (`commands/`), `CALENDAR_DATA_TYPE` (`datatype/`), RDB serialization via `bincode` (`datatype/rdb_data.rs`), `run_with_timeout` utility, allocator shim +- Depends on: `redical_core`, `redical_ical`, `redis-module`, `redis-module-macros`, `bincode`, `rayon`, `libc` +- Used by: Redis server at runtime (loaded as `.so`) + +## Data Flow + +**Write command (`rdcl.evt_set`):** + +1. Redis routes command to `redical_redis/src/commands/rdcl_evt_set.rs` +2. Command handler parses iCal text via `Event::parse_ical` — run in a timeout-guarded thread (`run_with_timeout`) using the configurable `ical-parser-timeout-ms` limit +3. Parsed `Event` is validated +4. LAST-MODIFIED guard: skip update if incoming event is older than stored event +5. `event.rebuild_indexes()` populates per-event inverted index terms +6. `CalendarIndexUpdater` diffs old vs new index terms and applies incremental updates to calendar-level indexes +7. `calendar.insert_event(event)` stores the domain object in Redis key memory +8. `ctx.replicate_verbatim()` replicates the raw command to replicas +9. Keyspace event published via `ctx.notify_keyspace_event` +10. Serialized iCal lines returned to caller as `RedisValue::Array` + +**Query command (`rdcl.evi_query` / `rdcl.evt_query`):** + +1. Redis routes to command handler in `redical_redis/src/commands/` +2. Handler opens Redis key read-only, retrieves `&Calendar` from `CALENDAR_DATA_TYPE` +3. Query string parsed by `QueryParser` (in `redical_core/src/queries/query_parser.rs`) into a `Query` struct with `WhereConditional`, ordering, range bounds, limit/offset, timezone +4. `query.execute(&calendar)` runs: searches calendar-level inverted/geo indexes to narrow event set, then iterates occurrences via `EventOccurrenceIterator` (backed by `rrule`) merging `EventOccurrenceOverride` data +5. Results returned as iCal-serialized content lines + +**RDB Persistence:** + +1. On `rdb_save`: `Calendar` → `RDBCalendar` (via `TryFrom`) → `bincode::serialize` → saved as Redis string +2. On `rdb_load`: bytes → `bincode::deserialize` → `RDBCalendar` → `Calendar::try_from` (parallel parse via `rayon`) + +**State Management:** +- All calendar state lives in Redis key memory as heap-allocated `Calendar` structs owned by the Redis module type system +- No external database; Redis RDB/AOF provides persistence +- Indexes are in-process data structures inside `Calendar` (`InvertedCalendarIndex`, `GeoSpatialCalendarIndex`) + +## Key Abstractions + +**`ICalendarEntity` trait:** +- Purpose: Defines `parse_ical(input) -> ParserResult` and `render_ical() -> String` — the parse/render contract for all iCal value and property types +- Examples: all types in `redical_ical/src/values/`, `redical_ical/src/properties/` +- Pattern: Implemented on concrete structs; `impl_icalendar_entity_traits!` macro derives `FromStr` and `Display` + +**`ICalendarComponent` trait:** +- Purpose: Render a composite object (Calendar, Event, EventOccurrenceOverride) as a `BTreeSet` +- Examples: `redical_core/src/calendar.rs`, `redical_core/src/event.rs` +- Pattern: `to_content_line_set_with_context(context)` with optional `RenderingContext` for timezone/unit conversion + +**`Calendar` struct:** +- Purpose: Root domain aggregate; owns events and all indexes +- Location: `redical_core/src/calendar.rs` +- Pattern: `BTreeMap>` for events; separate `InvertedCalendarIndex` fields per indexed property (categories, location_type, related_to, class) plus `GeoSpatialCalendarIndex` + +**`InvertedCalendarIndex` / `InvertedEventIndex`:** +- Purpose: Per-property inverted indexes supporting AND/OR/NOT query operations with occurrence-level exceptions +- Location: `redical_core/src/inverted_index.rs` +- Pattern: `IndexedConclusion::Include(exceptions)` / `IndexedConclusion::Exclude(exceptions)` — exceptions are sets of occurrence timestamps that flip the conclusion for specific recurrence instances + +**`GeoSpatialCalendarIndex`:** +- Purpose: R-tree spatial index for geo-distance queries +- Location: `redical_core/src/geo_index.rs` +- Pattern: Backed by `rstar::RTree`; stores `GeomWithData` + +**`Query` trait:** +- Purpose: Polymorphic query execution over `Calendar`; implemented by `EventQuery` and `EventInstanceQuery` +- Location: `redical_core/src/queries/query.rs` +- Pattern: `execute(&Calendar) -> QueryResults`; parsed from iCal-like query text by `QueryParser` + +**`CALENDAR_DATA_TYPE`:** +- Purpose: Redis native type registration; owns `rdb_load`, `rdb_save`, `free`, `copy` C-ABI hooks +- Location: `redical_redis/src/datatype/mod.rs` +- Pattern: `RedisType::new(...)` static; `RDBCalendar` intermediate serde struct for bincode persistence + +## Entry Points + +**Redis module init:** +- Location: `redical_redis/src/lib.rs` +- Triggers: Redis loads `.so` via `MODULE LOAD` +- Responsibilities: Registers all `rdcl.*` commands, `CALENDAR_DATA_TYPE`, keyspace event handlers, and `ical-parser-timeout-ms` config + +**Command handlers:** +- Location: `redical_redis/src/commands/rdcl_*.rs` +- Triggers: Client issues `rdcl.*` Redis command +- Responsibilities: Argument parsing, key access, delegation to `redical_core`, iCal serialization of response, keyspace notification, replication + +**`Event::parse_ical`:** +- Location: `redical_core/src/event.rs` +- Triggers: Called by write command handlers inside `run_with_timeout` +- Responsibilities: Orchestrates property-by-property nom parsing of iCal event text + +## Error Handling + +**Strategy:** `Result` throughout `redical_core` and `redical_ical`; command handlers map `String` errors to `RedisError::String`; hard parse failures use `nom::Err::Failure` (non-recoverable), soft failures use `nom::Err::Error` (recoverable/backtracking) + +**Patterns:** +- `ParserError` carries span, message, and context chain for descriptive error messages +- `convert_error` renders parser errors as single-line strings (Redis-friendly) +- `map_err` / `map_err_message!` macro for enriching recoverable nom errors +- Timeout enforced by `run_with_timeout` returning `TimeoutError`; logged as warning and surfaced as `RedisError::String` + +## Cross-Cutting Concerns + +**Logging:** `ctx.log_debug(...)` and `ctx.log_warning(...)` via `redis_module::Context`; only available inside command handlers in `redical_redis` + +**Validation:** `ICalendarEntity::validate()` on parsed values/properties; `Event::validate()` called before insert in write commands + +**Authentication:** Delegated entirely to Redis (no application-level auth) + +--- + +*Architecture analysis: 2026-03-06* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 00000000..22e6143a --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,181 @@ +# Codebase Concerns + +**Analysis Date:** 2026-03-06 + +## Tech Debt + +**Duplicated query layer (`EventQuery` vs `EventInstanceQuery`):** +- Issue: `event_query.rs` (1346 lines) and `event_instance_query.rs` (1315 lines) are near-identical. Both have the same `IndexAccessor` structs, the same `search_*_index` / `search_not_*_index` methods, and parallel test suites. The only meaningful difference is the output type (`Event` vs `EventInstance`). +- Files: `redical_core/src/queries/event_query.rs`, `redical_core/src/queries/event_instance_query.rs` +- Impact: Every bug fix, index search change, or new property filter must be applied twice. Divergence between the two is likely over time. +- Fix approach: Extract a shared generic query executor parameterised on output type, or use a trait to unify the index accessor logic. + +**`insert_new_where_conditional` marked for cleanup:** +- Issue: The `// TODO: Clean this up!` comment at the trait method in `query.rs` flags this as a known rough spot in the query builder API. +- Files: `redical_core/src/queries/query.rs:64` +- Impact: Query construction is harder to follow and extend correctly. +- Fix approach: Refactor the `insert_new_where_conditional` method into a cleaner builder pattern. + +**Duplicate serialization path in `rdcl_evt_query` and `rdcl_evi_query`:** +- Issue: Both command handlers contain `// TODO: Clean up and properly serialize this griminess` around the result serialization to `RedisValue::Array`. +- Files: `redical_redis/src/commands/rdcl_evt_query.rs:114`, `redical_redis/src/commands/rdcl_evi_query.rs:114` +- Impact: Fragile output format that is hard to change consistently. +- Fix approach: Extract a shared `serialize_query_results` helper function. + +**`timestamp_from_date_string` helper not promoted:** +- Issue: `// TODO: make this a helper` comment exists for a date-string-to-timestamp conversion in `rdcl_evt_prune.rs`. The same pattern is repeated in `rdcl_evo_prune.rs`. +- Files: `redical_redis/src/commands/rdcl_evt_prune.rs:70`, `redical_redis/src/commands/rdcl_evo_prune.rs` +- Impact: Inconsistency risk if parsing logic diverges between prune commands. +- Fix approach: Move to a shared `commands/utils.rs` module. + +**`inverted_index.rs` merge efficiency notes:** +- Issue: `merge_and` and `merge_or` both carry `// TODO: * Iterate on the smallest HashMap for efficiency` and `// TODO: clone()/borrowing etc` comments. These are hot paths executed on every indexed query. +- Files: `redical_core/src/inverted_index.rs:54`, `redical_core/src/inverted_index.rs:81` +- Impact: Unnecessary allocations and suboptimal iteration order on large calendars. +- Fix approach: Iterate over the smaller map in `merge_and`; reduce `clone()` calls by working with references where possible. + +**`inverted_index.rs` lacks tests for merge operations:** +- Issue: `// TODO: Add tests...` appears at lines 303 and 335 for the merge operations. +- Files: `redical_core/src/inverted_index.rs:303`, `redical_core/src/inverted_index.rs:335` +- Impact: Core index merge logic is untested; regressions would be invisible. +- Fix approach: Add unit tests covering `merge_and` / `merge_or` edge cases including empty sets, overlapping conclusions, and exception lists. + +**`rebuild_indexed_geo` and `rebuild_indexed_class` lack tests:** +- Issue: Both methods have `// TODO: Add tests...` comments. +- Files: `redical_core/src/event.rs:577`, `redical_core/src/event.rs:584` +- Impact: Index rebuild correctness for geo and class properties is unverified. + +## Known Bugs + +**UID index query with unrecognised term returns wrong result:** +- Symptoms: Querying for a UID that is not in the index returns an `Include` result containing that UID rather than an empty set. Two separate `// TODO: fix this. Should return an empty event set` comments confirm this is a known incorrect behaviour. +- Files: `redical_core/src/queries/indexed_property_filters.rs:731`, `redical_core/src/queries/indexed_property_filters.rs:1065`, `redical_core/src/queries/event_instance_query.rs:522`, `redical_core/src/queries/event_query.rs:572` +- Trigger: Execute a WHERE UID = "NONEXISTENT_UID" query against a calendar that does not contain that event. +- Workaround: Callers must filter the result set against the events map to discard phantom UIDs. + +**Missing indexed event silently skipped during query execution:** +- Symptoms: When an inverted index contains a UID for which no `Event` object exists in `calendar.events`, the query silently continues rather than surfacing an error. Three separate `// TODO: handle missing indexed event...` comments mark these sites. +- Files: `redical_core/src/queries/event_instance_query.rs:277`, `redical_core/src/queries/event_instance_query.rs:409`, `redical_core/src/queries/event_query.rs:361`, `redical_core/src/queries/event_query.rs:495` +- Trigger: Index/data inconsistency after a failed partial update. +- Workaround: None; the event is silently dropped from results. + +**RECURRENCE-ID `RANGE` parameter not implemented:** +- Symptoms: The `RANGE` parameter on `RECURRENCE-ID` (used to modify `THISANDFUTURE` occurrences) is silently ignored. +- Files: `redical_ical/src/properties/recurrence_id.rs:22` +- Impact: Clients relying on `RANGE=THISANDFUTURE` semantics will have overrides applied only to the single specified occurrence. + +**`RDATE` PERIOD value type not implemented:** +- Symptoms: Parsing `RDATE` with `VALUE=PERIOD` is not supported; the `// TODO: Implement PERIOD VALUE type.` comment confirms this. +- Files: `redical_ical/src/properties/event/rdate.rs:152` +- Impact: iCalendar feeds using period-typed RDATEs will fail to parse. + +## Security Considerations + +**`from_utf8_unchecked` on RDB serialized bytes:** +- Risk: `rdb_save` serializes a `Calendar` to bincode bytes and then calls `std::str::from_utf8_unchecked` before passing the result to `raw::save_string`. Bincode output is arbitrary binary; if it contains non-UTF-8 sequences this is undefined behaviour. +- Files: `redical_redis/src/datatype/mod.rs:80` +- Current mitigation: None. The comment acknowledges this is a workaround for missing `save_string_buffer` in the redis-module crate. +- Recommendations: Track redis-module crate for `save_string_buffer` addition; alternatively encode bytes as base64 before saving. + +**RDB files committed to repository:** +- Risk: `dodgey_dump.rdb`, `dump.rdb`, and `test_dump.rdb` are present at the project root and not consistently excluded from git (`.gitignore` does exclude `**/*.rdb` but these files appear to have been committed at some point). +- Files: `/dodgey_dump.rdb`, `/dump.rdb`, `/test_dump.rdb` +- Current mitigation: `.gitignore` covers `**/*.rdb` so they should not be staged going forward. +- Recommendations: Confirm these files are not tracked in git history; remove if so. + +## Performance Bottlenecks + +**`rdcl.evo_prune` collects all event UIDs before iterating:** +- Problem: Full key collection via `calendar.events.keys().map(String::from).collect()` creates an allocation proportional to the number of events before any pruning begins. +- Files: `redical_redis/src/commands/rdcl_evo_prune.rs:192` +- Cause: Required to avoid borrowing `calendar` mutably while iterating its map. +- Improvement path: Collect only UIDs whose events have overrides in the target range; use a cursor-based approach for very large calendars. + +**Inverted index merge does not iterate smallest map first:** +- Problem: `merge_and` always iterates `events_a` regardless of size; for large calendars with sparse index overlap this wastes work. +- Files: `redical_core/src/inverted_index.rs:54` +- Cause: Acknowledged in TODO comment; not yet fixed. +- Improvement path: Swap iteration to use the smaller of `events_a` / `events_b`. + +**`mem_usage` always returns zero:** +- Problem: The Redis module's `mem_usage` callback always returns `0`, so Redis cannot accurately report memory used by calendar data types via `MEMORY USAGE` or enforce `maxmemory` policies against this data. +- Files: `redical_redis/src/datatype/mod.rs:93` +- Cause: Stub implementation. +- Improvement path: Implement using `std::mem::size_of_val` recursively or a custom `MemoryUsage` trait. + +## Fragile Areas + +**`aof_rewrite` is a hard `todo!()`:** +- Files: `redical_redis/src/datatype/mod.rs:90` +- Why fragile: Calling this function (which Redis may invoke during AOF rewrite) will panic the Redis process. +- Safe modification: Implement AOF rewrite or explicitly return an error; do not leave as `todo!()` in production. +- Test coverage: None. + +**`rdb_load` / `rdb_save` panic on error instead of propagating:** +- Files: `redical_redis/src/datatype/mod.rs:57-74` +- Why fragile: A corrupted or schema-mismatched RDB dump will crash the Redis process on startup via `panic!`. +- Safe modification: Surface errors through the Redis module API (return `null_mut()` with a logged error) rather than panicking. + +**`DateTime::from_utc_timestamp` panics on invalid timestamp:** +- Files: `redical_ical/src/values/date_time.rs:314-320` +- Why fragile: Any code path that produces an out-of-range or ambiguous UTC timestamp will crash at runtime. The comments acknowledge this is not handled well. +- Safe modification: Return `Result` and propagate errors upward. + +**`QueryResultOrdering::partial_cmp` panics on mismatched variants:** +- Files: `redical_core/src/queries/results_ordering.rs:292` +- Why fragile: If query result ordering is ever constructed inconsistently (e.g., mixing geo-distance and dtstart orderings in the same result set), a sort will panic. +- Safe modification: Return `None` from `partial_cmp` on mismatched variants instead of panicking. + +**`event_instance.rs` test panics on missing override:** +- Files: `redical_core/src/event_instance.rs:513` +- Why fragile: `panic!("Expected event to have an occurrence...")` inside a test provides a poor failure message and masks the actual assertion failure. + +## Scaling Limits + +**In-memory calendar model:** +- Current capacity: All events and overrides for a calendar key are held in a single `Calendar` struct in Redis memory. No pagination of the data structure itself. +- Limit: Very large calendars (millions of events/overrides) will consume substantial Redis memory with no eviction strategy. +- Scaling path: Consider sharding calendars or implementing a tiered storage strategy. + +**Fuzz-discovered hang inputs not fully addressed:** +- Current state: 75 hang inputs are stored in `redical_ical/tests/fuzz_finds/hangs/` but the hang test (`parse_ical_fuzzing_hang_test`) is marked `#[ignore]` and will panic if any input takes more than 1 second. +- Files: `redical_ical/tests/fuzz_finds/hangs/`, `redical_ical/tests/fuzzing_hang_tests.rs` +- Risk: Maliciously crafted iCalendar input can trigger parser hangs (CPU spin), acting as a denial-of-service vector against the Redis module. +- Scaling path: Fix the underlying parser backtracking issue that causes hangs; un-ignore the test suite to prevent regressions. + +## Dependencies at Risk + +**`chrono-tz` DST gap handling pending upstream PR:** +- Risk: DST transition gap validation relies on a workaround because the proper fix (chrono-tz PR #188) has not been released. Datetimes in DST gaps are currently rejected entirely rather than adjusted. +- Files: `redical_ical/src/values/tzid.rs:68` +- Impact: Valid iCalendar datetimes that fall in DST transition windows are rejected with an error. +- Migration plan: Watch chrono-tz for PR #188 release; implement proper gap handling once available. + +## Test Coverage Gaps + +**`rebuild_indexed_geo` and `rebuild_indexed_class` untested:** +- What's not tested: Index rebuild logic for `GEO` and `CLASS` properties. +- Files: `redical_core/src/event.rs:578`, `redical_core/src/event.rs:585` +- Risk: Incorrect index state after event mutation would silently affect query results. +- Priority: Medium + +**`inverted_index.rs` merge operations untested:** +- What's not tested: `merge_and` / `merge_or` correctness with edge cases. +- Files: `redical_core/src/inverted_index.rs` +- Risk: Index query results are incorrect; difficult to diagnose because failures surface in query output not in index logic. +- Priority: High + +**`convert_error` function stubbed:** +- What's not tested: The `// TODO: Implement this...` comment at `lib.rs:99` means error conversion is incomplete; error messages returned to Redis clients may be incomplete or misleading. +- Files: `redical_ical/src/lib.rs:99` +- Risk: Users receive poor error feedback for malformed iCalendar input. +- Priority: Low + +**`x_geo` context handling missing:** +- What's not tested: `// TODO: handle context.` at `x_geo.rs:77` means geo query property parsing does not respect rendering context. +- Files: `redical_ical/src/properties/query/x_geo.rs:77` +- Priority: Low + +--- + +*Concerns audit: 2026-03-06* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 00000000..d90ddf1e --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,142 @@ +# Coding Conventions + +**Analysis Date:** 2026-03-06 + +## Naming Patterns + +**Files:** +- `snake_case` for all Rust source files: `date_time.rs`, `inverted_index.rs`, `rdcl_evt_set.rs` +- Redis command handler files are named after the command they implement: `rdcl_evt_set.rs`, `rdcl_evi_query.rs` +- Test helper files named `utils.rs` and `macros.rs` within a `testing/` subdirectory + +**Functions:** +- `snake_case` for all functions: `parse_ical`, `render_ical_with_context`, `build_event_from_ical` +- Parser functions named after the grammar rule they parse: `date_time`, `escaped_char`, `tsafe_char` +- Predicate functions prefixed with `is_`: `is_tsafe_char`, `is_safe_char`, `is_utc`, `is_blank`, `is_present` +- Builder/constructor helpers prefixed with `build_`: `build_event_from_ical`, `build_parsed_rrule_set` +- Extraction helpers prefixed with `extract_`: `extract_all_category_strings`, `extract_geo_point` +- Getter methods prefixed with `get_`: `get_tzid`, `get_date_time`, `get_categories`, `get_utc_timestamp` + +**Types (structs, enums, traits):** +- `PascalCase` for all type names: `DTStartProperty`, `XCategoriesProperty`, `InvertedCalendarIndexTerm` +- Property param structs named `{PropertyName}PropertyParams`: `DTStartPropertyParams`, `XCategoriesPropertyParams` +- Property structs named `{PropertyName}Property`: `DTStartProperty`, `XCategoriesProperty` +- Traits named as adjective/noun forms: `ICalendarEntity`, `ICalendarProperty`, `ICalendarComponent`, `QueryableEntity` + +**Variables:** +- `snake_case` throughout, with descriptive full names: `event_occurrence_override`, `calendar_uid`, `context_adjusted_date_time` +- No abbreviations — `content_line_params` not `clp`, `parsed_event_uid` not `uid` +- Iterator/loop variables take the singular form of the collection name: `for (event_uid, indexed_conclusion) in events` + +**Constants:** +- `SCREAMING_SNAKE_CASE`: `CALENDAR_DATA_TYPE`, `CONFIGURATION_ICAL_PARSER_TIMEOUT_MS` + +## Code Style + +**Formatting:** +- Rust edition 2021 +- `rustfmt.toml` contains only `edition = "2021"` — default rustfmt settings otherwise +- Trailing commas used consistently in function args, struct literals, and macro invocations +- Blank lines used between logical groups within functions and between match arms containing multi-line expressions + +**Whitespace:** +- Blank line after guard clauses / early returns before the next meaningful line +- Blank lines separate match arms that contain multi-line bodies +- Blank line between struct field groups when fields serve different conceptual purposes + +**Alignment:** +- Match arm patterns with similar variants are vertically aligned when their values differ only in the variant: + ```rust + (ValueType::DateTime, DateTime::UtcDateTime(_)) => Ok(()), + (ValueType::DateTime, DateTime::LocalDateTime(_)) => Ok(()), + (ValueType::Date, DateTime::LocalDate(_)) => Ok(()), + ``` + +**Linting:** +- Clippy is used (evidenced by recent commit "Resolve Clippy infractions") +- Mismatched lifetime syntax warnings addressed (recent fix) + +## Import Organization + +**Order:** +1. Standard library (`use std::...`) +2. External crate imports (`use nom::...`, `use chrono::...`) +3. Blank line +4. Internal crate imports (`use crate::...`) + +**Grouping:** Related imports from the same crate are grouped with multi-line `use` blocks rather than repeated single imports: +```rust +use nom::combinator::{recognize, map, map_res, opt, cut}; +use nom::sequence::{pair, preceded}; +``` + +**Path aliases:** None used — full paths throughout. + +## Error Handling + +**Parser errors:** Custom `ParserError` type in `redical_ical/src/lib.rs` wraps `nom` errors with structured span, message, and context fields. + +**Patterns:** +- Parser functions return `ParserResult` which is `nom::IResult` +- Hard failures use `cut()` after the initial identifying tag — prevents backtracking once committed to a parse branch +- Enriched error messages via the `map_err_message!` macro: clears context and sets a human-readable message +- Validation errors propagated via `Result<(), String>` — simple string errors for caller display +- `?` operator used for propagation; `unwrap()` only appears in test utilities and `panic!` in `From` where truly unrecoverable +- Redis command handlers map errors to `RedisError::String(...)` at the boundary + +**Error formatting:** +- `convert_error` produces single-line error strings for Redis-friendliness: `"Error - {message} at \"{span}\" -- Context: {context_chain}"` + +## Logging + +**Framework:** Redis module context logging via `ctx.log_debug(...)` and `ctx.log_warning(...)` + +**Patterns:** +- Debug logs at command entry with key arguments: `"rdcl.evt_set: key: {calendar_uid} event uid: {event_uid}"` +- Warning logs for timeout/unexpected conditions +- No logging in the `redical_core` or `redical_ical` layers — logging is a Redis command layer concern only + +## Comments + +**RFC compliance comments:** iCalendar property files include the RFC 5545 grammar notation as comments above the parser functions and struct definitions. This is the primary documentation format for the parsing layer. + +**Doc comments (`///`):** +- Used for public utility functions and trait methods where behaviour needs elaboration: `/// Converts the DateTime to the provided timezone.` +- Includes doc-test examples in `///` blocks for public parser utilities (`map_err`, `terminated_lookahead`) +- `// TODO:` comments mark known incomplete work (see CONCERNS.md) + +**Inline comments:** Brief explanatory notes on non-obvious logic, e.g. timezone fallback chains, match arm groupings. + +## Traits and Implementations + +**Core trait pattern:** `ICalendarEntity` is the central trait — all parseable/renderable types implement it: +```rust +pub trait ICalendarEntity { + fn parse_ical(input: ParserInput) -> ParserResult where Self: Sized; + fn render_ical_with_context(&self, context: Option<&RenderingContext>) -> String; + fn render_ical(&self) -> String { self.render_ical_with_context(None) } + fn validate(&self) -> Result<(), String> { Ok(()) } +} +``` + +**Macro-generated trait impls:** The `impl_icalendar_entity_traits!(TypeName)` macro generates `FromStr` and `Display` implementations for all entity types. Use this macro for every new iCalendar entity type. + +**`define_property_params_ical_parser!` macro:** Used inside `ICalendarEntity` implementations for property params structs. Generates a `parse_ical` that iterates semicolon-separated params and dispatches to the provided handler closures. + +## Module Design + +**Exports:** Public items are re-exported from `mod.rs` using `pub use submodule::*;` — callers import from the module, not the file. + +**Crate boundaries:** +- `redical_ical` — pure iCalendar parsing/rendering, no Redis or core business logic +- `redical_core` — calendar data model, indexing, querying; depends on `redical_ical` +- `redical_redis` — Redis module command handlers; depends on both above crates + +**Test modules:** +- Unit tests are in `#[cfg(test)] mod tests { ... }` at the bottom of each source file +- Integration test helpers live in `redical_core/src/testing/` (re-exported under `#[cfg(test)]`) +- Integration tests against a live Redis server live in `tests/integration.rs` at the workspace root + +--- + +*Convention analysis: 2026-03-06* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 00000000..15f01806 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,93 @@ +# External Integrations + +**Analysis Date:** 2026-03-06 + +## APIs & External Services + +**Redis Module API:** +- Redis 7.0+ - the process host; RediCal runs inside the Redis server as a native module + - SDK/Client: `redis-module` 2.0.2 (`redical_redis/Cargo.toml`) + - Interface: `redis_module!` macro registers commands and data types with the Redis engine + - Commands exposed: `RDCL.CAL_SET`, `RDCL.CAL_GET`, `RDCL.EVT_SET`, `RDCL.EVT_GET`, `RDCL.EVT_DEL`, `RDCL.EVT_LIST`, `RDCL.EVT_QUERY`, `RDCL.EVT_PRUNE`, `RDCL.EVO_SET`, `RDCL.EVO_GET`, `RDCL.EVO_DEL`, `RDCL.EVO_LIST`, `RDCL.EVO_PRUNE`, `RDCL.EVI_LIST`, `RDCL.EVI_QUERY`, `RDCL.CAL_IDX_DISABLE`, `RDCL.CAL_IDX_REBUILD` + - Source: `redical_redis/src/commands/` + +**iCalendar Standard:** +- RFC 5545 / iCalendar - the data format RediCal parses and serialises + - Parser: custom `nom`-based combinator grammar in `redical_ical/src/grammar.rs` and `redical_ical/src/properties/` + - Recurrence rules: delegated to `rrule` 0.10 crate + +## Data Storage + +**Databases:** +- Redis (embedded) - RediCal IS the storage; it extends Redis with a native `Calendar` data type + - Connection: N/A (runs inside Redis process) + - Client: `redis-module` SDK (`RedisGILGuard`, `Context`) + - Persistence: RDB via `bincode` serialisation in `redical_redis/src/datatype/rdb_data.rs`; AOF supported per `ramp.yml` capabilities + +**File Storage:** +- None (no external file storage) + +**Caching:** +- Internal R*-tree and inverted index structures maintained in-memory within the `Calendar` data type (`redical_core/src/geo_index.rs`, `redical_core/src/inverted_index.rs`) + +## Authentication & Identity + +**Auth Provider:** +- None - authentication is entirely delegated to Redis's own ACL/auth system; the module adds no auth layer + +## Monitoring & Observability + +**Error Tracking:** +- None + +**Logs:** +- Redis module logging API (`ctx.log_notice`, `ctx.log_warning`) used for startup banner and error reporting; logs flow through the Redis server log + +## CI/CD & Deployment + +**Hosting:** +- Docker Hub (`gregjoy/redical`) - container image published on push to `main` and on version tags + - Workflow: `.github/workflows/` (docker-main and docker-tag workflows) + - Secrets required: `DOCKERHUB_USERNAME`, `DOCKERHUB_PASSWORD` + +- GitHub Releases - compiled `.tar.gz` archives uploaded on version tag push + - Targets: `x86_64-unknown-linux-gnu`, `x86_64-unknown-linux-musl`, `x86_64-apple-darwin`, `aarch64-apple-darwin` + - Workflow: `.github/workflows/` (build-release workflow) + +**CI Pipeline:** +- GitHub Actions (`ubuntu-latest`) + - `check` job: `cargo check` + - `clippy` job: `cargo clippy -- -D warnings` + - `unit_test` job: `cargo test` + - `integration_test` job: installs Redis via `ppa:redislabs/redis`, builds module, runs `cargo test --all integration` + - Trigger: every push and pull request + +## Environment Configuration + +**Required env vars:** +- None at runtime (module uses Redis `CONFIG SET`/`CONFIG GET`) +- CI secrets: `DOCKERHUB_USERNAME`, `DOCKERHUB_PASSWORD` (GitHub Actions secrets) + +**Secrets location:** +- GitHub Actions encrypted secrets only; no `.env` files present in repo + +## Webhooks & Callbacks + +**Incoming:** +- Redis keyspace notifications emitted by RediCal commands; configured in test via `notify-keyspace-events Kegd` (`tests/redis_test_config.conf`) +- Clients subscribe to these through standard Redis pub/sub; RediCal does not consume them internally + +**Outgoing:** +- None + +## Fuzzing + +**AFL (American Fuzzy Lop):** +- `redical_ical_afl_fuzz_targets/` - standalone crate with two fuzz targets (`event_properties_afl_fuzz_target`, `query_properties_afl_fuzz_target`) +- Uses `afl` crate; run via `redical_ical_afl_fuzz_targets/start_afl_fuzz.sh` +- Input seeds: `redical_ical_afl_fuzz_targets/input_seeds/` +- Not part of default workspace members or CI; opt-in fuzzing only + +--- + +*Integration audit: 2026-03-06* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 00000000..39e38d3f --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,89 @@ +# Technology Stack + +**Analysis Date:** 2026-03-06 + +## Languages + +**Primary:** +- Rust (edition 2021) - all crates: `redical_core`, `redical_ical`, `redical_redis`, `redical_ical_afl_fuzz_targets` + +## Runtime + +**Environment:** +- Native compiled binary (cdylib shared object: `libredical.so` / `libredical.dylib`) +- Loaded as a Redis module into a running Redis server process + +**Package Manager:** +- Cargo (Rust toolchain `stable`) +- Lockfile: `Cargo.lock` present and committed + +## Frameworks + +**Core:** +- `redis-module` 2.0.2 - Redis module SDK; exposes Rust code as Redis commands and custom data types +- `redis-module-macros` 2.0.2 - Macros companion for `redis-module` + +**Testing:** +- Rust built-in test harness (`cargo test`) - unit and integration tests +- `pretty_assertions_sorted` 1.2.3 - enhanced diff output in test failures + +**Build/Dev:** +- `make` - build orchestration (`Makefile`) +- Docker - multi-stage build (`Dockerfile`); builds release `.so` and embeds into `redis:7.0` image +- `ramp-packer` (Python, pip) - packages module for Redis Enterprise (`ramp.yml`) + +## Key Dependencies + +**Critical:** +- `redis-module` 2.0.2 - entire Redis integration layer; `redical_redis/Cargo.toml` +- `nom` 6.0 (core) / 7.1.3 + `nom_locate` 4.2.0 (ical) - parser combinator for iCalendar grammar; `redical_core/Cargo.toml`, `redical_ical/Cargo.toml` +- `rrule` 0.10 (features: `serde`, `exrule`) - RFC 5545 recurrence rule evaluation; `redical_core/Cargo.toml` +- `chrono` 0.4.19 + `chrono-tz` 0.6.1 - datetime handling with timezone support; all crates +- `rstar` 0.11.0 (features: `serde`) - R*-tree spatial index for geographic queries; `redical_core/Cargo.toml` +- `geo` 0.26.0 + `geohash` 0.13.0 - geometric types and geohash encoding for geo indexing; `redical_core/Cargo.toml` +- `rayon` 1.10.0 - data parallelism for query execution; `redical_redis/Cargo.toml` +- `bincode` 1.3.3 - binary serialisation for Redis RDB persistence; `redical_redis/Cargo.toml` +- `serde` 1.0.162 (features: `derive`) - serialisation throughout; all crates + +**Infrastructure:** +- `redis` 0.23 - Rust Redis client used in integration tests; dev-dependency in workspace root and `redical_core`, `redical_redis` +- `libc` 0.2 - FFI types for Redis allocator interop; `redical_redis/Cargo.toml` +- `lazy_static` 1.4.0 - static initialisation; `redical_core`, `redical_redis` +- `itertools` 0.12.1 - iterator utilities; `redical_ical` +- `unicode-segmentation` 1.10.1 - Unicode-aware string splitting; `redical_core`, `redical_ical` +- `regex` 1.5.5 (features: `perf`, `std`, no default) - pattern matching; `redical_core` +- `num` 0.4.1 - numeric traits; `redical_core` +- `afl` (any) - American Fuzzy Lop fuzzing harness; `redical_ical_afl_fuzz_targets` +- `anyhow` 1 - error handling in tests; dev-dependency + +## Configuration + +**Environment:** +- No application-level environment variables; the module is configured entirely through Redis config commands +- `REDICAL.ICAL-PARSER-TIMEOUT-MS` - iCal parser timeout (default 500ms, range 1–60000ms); set via `CONFIG SET` / `redis.conf` +- Build-time env vars injected by `redical_redis/build.rs`: `GIT_SHA`, `GIT_TAG`, `MODULE_VERSION`, `BUILD_DATE_STRING` + +**Build:** +- `Cargo.toml` (workspace root) - workspace-level dependency versions +- `Makefile` - build targets: `all`, `run`, `test`, `clean`, `distclean`, `deps`, `pack` +- `rustfmt.toml` - Rust formatting (edition = "2021") +- `Dockerfile` - multi-stage: `rust:bookworm` builder + `redis:7.0` base + `debian:bookworm` runtime + +## Platform Requirements + +**Development:** +- Rust stable toolchain +- Redis 7.0+ server for integration tests +- Python 3 + `ramp-packer` pip package for packaging only +- `clang` (required by Dockerfile apt install during build) + +**Production:** +- Redis 7.0.0 minimum (declared in `ramp.yml`) +- Redis Enterprise 6.2.18 minimum pack version +- Deployed as `libredical.so` loaded via `redis-server --loadmodule` +- Docker image published to Docker Hub as `gregjoy/redical` +- Compiled targets: `x86_64-unknown-linux-gnu`, `x86_64-unknown-linux-musl`, `x86_64-apple-darwin`, `aarch64-apple-darwin` + +--- + +*Stack analysis: 2026-03-06* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 00000000..b52fbdc7 --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,235 @@ +# Codebase Structure + +**Analysis Date:** 2026-03-06 + +## Directory Layout + +``` +redical/ # Workspace root +├── Cargo.toml # Workspace manifest + shared dependencies +├── Cargo.lock +├── Makefile # Build / test / run targets +├── Dockerfile # Container build +├── rustfmt.toml # Rust formatting config +├── ramp.yml # Release automation config +│ +├── redical_ical/ # iCalendar parsing crate (no domain logic) +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs # Public API: traits, parser types, macros, helpers +│ ├── grammar.rs # Low-level nom combinators (wsp, contentline, etc.) +│ ├── content_line.rs # ContentLine + ContentLineParams types +│ ├── properties/ +│ │ ├── mod.rs # ICalendarProperty, ICalendarGeoProperty, ICalendarDateTimeProperty traits +│ │ ├── uid.rs +│ │ ├── recurrence_id.rs +│ │ ├── last_modified.rs +│ │ ├── event/ # Event-specific property types (RRULE, DTSTART, GEO, etc.) +│ │ ├── calendar/ # Calendar-level property types (UID) +│ │ └── query/ # Query property types (WHERE, ORDER, RANGE, etc.) +│ └── values/ +│ ├── mod.rs +│ ├── date_time.rs +│ ├── date.rs +│ ├── duration.rs +│ ├── recur.rs +│ ├── text.rs +│ ├── list.rs +│ ├── integer.rs +│ ├── float.rs +│ ├── tzid.rs +│ ├── reltype.rs +│ ├── where_operator.rs +│ ├── where_range_operator.rs +│ └── where_range_property.rs +│ +├── redical_core/ # Domain model + query engine crate +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs # Re-exports all public items +│ ├── calendar.rs # Calendar struct + CalendarIndexUpdater +│ ├── event.rs # Event struct + parse_ical + rebuild_indexes +│ ├── event_diff.rs # Diff logic between event versions +│ ├── event_instance.rs # EventInstance (materialised occurrence) +│ ├── event_occurrence_iterator.rs # Iterator over recurrence occurrences (wraps rrule) +│ ├── event_occurrence_override.rs # EventOccurrenceOverride (per-occurrence patch) +│ ├── inverted_index.rs # InvertedCalendarIndex + InvertedEventIndex + IndexedConclusion +│ ├── geo_index.rs # GeoSpatialCalendarIndex (rstar R-tree) +│ ├── utils.rs # KeyValuePair, UpdatedHashMapMembers, UpdatedSetMembers +│ ├── queries/ +│ │ ├── mod.rs +│ │ ├── query.rs # Query + QueryIndexAccessor traits +│ │ ├── event_query.rs # EventQuery implementation +│ │ ├── event_instance_query.rs # EventInstanceQuery implementation +│ │ ├── query_parser.rs # QueryParser (iCal-like query text → Query struct) +│ │ ├── indexed_property_filters.rs # WhereConditional, WhereOperator, filter types +│ │ ├── results.rs # QueryResults, QueryableEntity trait +│ │ ├── results_ordering.rs # OrderingCondition, QueryResultOrdering +│ │ └── results_range_bounds.rs # LowerBoundRangeCondition, UpperBoundRangeCondition +│ └── testing/ # Test helpers (gated behind #[cfg(test)]) +│ +├── redical_redis/ # Redis module crate (cdylib entry point) +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs # redis_module! macro, command registration, event handlers, config +│ ├── utils.rs # run_with_timeout helper +│ ├── datatype/ +│ │ ├── mod.rs # CALENDAR_DATA_TYPE RedisType + RDB hooks +│ │ └── rdb_data.rs # RDBCalendar/RDBEvent/RDBEventOccurrenceOverride (serde/bincode) +│ └── commands/ +│ ├── mod.rs # Re-exports all command functions +│ ├── rdcl_cal_set.rs # rdcl.cal_set +│ ├── rdcl_cal_get.rs # rdcl.cal_get +│ ├── rdcl_cal_idx_disable.rs # rdcl.cal_idx_disable +│ ├── rdcl_cal_idx_rebuild.rs # rdcl.cal_idx_rebuild +│ ├── rdcl_evt_set.rs # rdcl.evt_set +│ ├── rdcl_evt_get.rs # rdcl.evt_get +│ ├── rdcl_evt_del.rs # rdcl.evt_del +│ ├── rdcl_evt_list.rs # rdcl.evt_list +│ ├── rdcl_evt_query.rs # rdcl.evt_query +│ ├── rdcl_evt_prune.rs # rdcl.evt_prune +│ ├── rdcl_evi_list.rs # rdcl.evi_list +│ ├── rdcl_evi_query.rs # rdcl.evi_query +│ ├── rdcl_evo_get.rs # rdcl.evo_get +│ ├── rdcl_evo_set.rs # rdcl.evo_set +│ ├── rdcl_evo_del.rs # rdcl.evo_del +│ ├── rdcl_evo_list.rs # rdcl.evo_list +│ └── rdcl_evo_prune.rs # rdcl.evo_prune +│ +├── redical_ical_afl_fuzz_targets/ # AFL fuzz testing harness (not in default workspace members) +│ ├── Cargo.toml +│ ├── input_seeds/ +│ └── src/bin/ +│ +├── tests/ # Integration tests (run against live Redis instance) +│ ├── integration.rs # Test functions exercising rdcl.* commands end-to-end +│ ├── macros.rs # Test macros (set_and_assert_calendar!, assert_keyspace_events_published!, etc.) +│ ├── utils.rs # Redis connection helpers, listen_for_keyspace_events +│ └── redis_test_config.conf # Redis config for test instance +│ +└── docs/ # Documentation + ├── commands/ # Per-command documentation + └── docs/ # General documentation +``` + +## Directory Purposes + +**`redical_ical/src/properties/event/`:** +- Purpose: One file per event iCal property (RRULE, DTSTART, DTEND, GEO, CATEGORIES, CLASS, RELATED-TO, LOCATION-TYPE, etc.) +- Key files: each property struct implements `ICalendarEntity` (parse + render) and `ICalendarProperty` (to ContentLine) + +**`redical_ical/src/properties/query/`:** +- Purpose: Query-DSL property types — WHERE clauses, ORDER BY, RANGE bounds, LIMIT, OFFSET, TZID, DISTINCT +- Pattern: Same parse/render traits as event properties + +**`redical_core/src/queries/`:** +- Purpose: Complete query subsystem — parsing query text, filtering via indexes, iterating occurrences, ordering and paginating results +- Key files: `query_parser.rs` (text → struct), `event_instance_query.rs` (the main occurrence query), `indexed_property_filters.rs` (WHERE tree evaluation) + +**`redical_redis/src/commands/`:** +- Purpose: One file per Redis command; command names map directly to file names (e.g. `rdcl.evt_set` → `rdcl_evt_set.rs`) + +**`redical_redis/src/datatype/`:** +- Purpose: Redis native type definition and RDB (persistence) serialization/deserialization +- `rdb_data.rs` defines intermediate `RDB*` structs with `serde` derive; iCal text is the interchange format between `RDBCalendar` and domain structs during load + +**`tests/`:** +- Purpose: End-to-end integration tests; require a running Redis with the module loaded +- Key files: `integration.rs` (test scenarios), `macros.rs` (assertion macros over Redis protocol responses) + +## Key File Locations + +**Entry Points:** +- `redical_redis/src/lib.rs`: Module registration; all commands, data types, event handlers, and config registered here via `redis_module!` macro + +**Configuration:** +- `Cargo.toml` (workspace root): Shared dependency versions for all crates +- `rustfmt.toml`: Formatting rules +- `redical_redis/src/lib.rs`: `ical-parser-timeout-ms` runtime config (default 500ms, range 1–60000) + +**Core Domain:** +- `redical_core/src/calendar.rs`: `Calendar` and `CalendarIndexUpdater` +- `redical_core/src/event.rs`: `Event` including `parse_ical`, `rebuild_indexes`, occurrence iteration delegation +- `redical_core/src/inverted_index.rs`: Index data structures and `IndexedConclusion` merge logic +- `redical_core/src/geo_index.rs`: Geospatial index (`rstar` R-tree) + +**Parsing Foundations:** +- `redical_ical/src/lib.rs`: `ICalendarEntity`, `ICalendarComponent`, `ParserInput`, `ParserResult`, `ParserError`, `map_err`, `terminated_lookahead`, `impl_icalendar_entity_traits!` macro +- `redical_ical/src/grammar.rs`: Fundamental nom combinators used by all property parsers + +**Persistence:** +- `redical_redis/src/datatype/rdb_data.rs`: `RDBCalendar`, `RDBEvent`, `RDBEventOccurrenceOverride` — bincode-serialized persistence structs + +**Testing:** +- `tests/integration.rs`: Integration test functions +- `tests/macros.rs`: `set_and_assert_calendar!`, `assert_keyspace_events_published!`, etc. +- `redical_ical/tests/`: Unit-level parser tests including fuzz regression cases (`tests/fuzz_finds/`) + +## Naming Conventions + +**Files:** +- Snake_case for all `.rs` files +- Command handlers named after the Redis command: `rdcl_evt_set.rs` → `rdcl.evt_set` +- Entity/concept files named after the type they primarily define: `calendar.rs`, `inverted_index.rs` + +**Directories:** +- Snake_case +- Crate roots prefixed with `redical_`: `redical_ical`, `redical_core`, `redical_redis` + +**Types:** +- PascalCase structs and enums: `Calendar`, `EventOccurrenceIterator`, `InvertedCalendarIndexTerm` +- Trait names prefixed with `I` for iCalendar domain traits: `ICalendarEntity`, `ICalendarProperty`, `ICalendarComponent`, `ICalendarGeoProperty`, `ICalendarDateTimeProperty` + +**Commands:** +- Redis command names: `rdcl._` (e.g. `rdcl.evt_set`, `rdcl.evi_query`, `rdcl.evo_del`, `rdcl.cal_idx_rebuild`) + - `evt` = event, `evi` = event instance, `evo` = event occurrence override, `cal` = calendar + +## Where to Add New Code + +**New iCal property type:** +- Parser + renderer: `redical_ical/src/properties/event/` (for event properties) or `redical_ical/src/properties/query/` (for query properties) +- Add to `EventProperty` enum in `redical_ical/src/properties/event/mod.rs` +- Unit tests: inline `#[cfg(test)]` module within the property file + +**New domain index:** +- Index struct: `redical_core/src/` (e.g. a new `*_index.rs` file) +- Add field to `Calendar` in `redical_core/src/calendar.rs` +- Add update methods to `CalendarIndexUpdater` +- Add rebuild logic to `Calendar::rebuild_indexes` +- Add search method to `QueryIndexAccessor` trait in `redical_core/src/queries/query.rs` + +**New Redis command:** +- Handler: `redical_redis/src/commands/rdcl__.rs` +- Register in `redical_redis/src/commands/mod.rs` (mod + pub use) +- Register in `redis_module!` macro in `redical_redis/src/lib.rs` +- Integration test: `tests/integration.rs` + +**New query filter/clause:** +- Query property type: `redical_ical/src/properties/query/` +- Filter evaluation: `redical_core/src/queries/indexed_property_filters.rs` +- Wire into `QueryParser`: `redical_core/src/queries/query_parser.rs` + +**Shared utilities:** +- Core-level helpers: `redical_core/src/utils.rs` +- Redis-layer helpers: `redical_redis/src/utils.rs` + +## Special Directories + +**`redical_ical_afl_fuzz_targets/`:** +- Purpose: AFL++ fuzzing harnesses for the iCal parser +- Generated: No +- Committed: Yes; excluded from default workspace build members + +**`redical_ical/tests/fuzz_finds/`:** +- Purpose: Regression test cases discovered during fuzzing (converted to unit tests) +- Generated: No (manually committed after fuzz runs) +- Committed: Yes + +**`target/`:** +- Purpose: Cargo build output +- Generated: Yes +- Committed: No + +--- + +*Structure analysis: 2026-03-06* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 00000000..996fd1ad --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,219 @@ +# Testing Patterns + +**Analysis Date:** 2026-03-06 + +## Test Framework + +**Runner:** +- Rust's built-in `cargo test` +- No external test runner + +**Assertion Library:** +- `pretty_assertions_sorted` (workspace dependency) — provides `assert_eq!`, `assert_eq_sorted!`, `assert_ne!` with coloured diffs +- Standard `assert!` for boolean checks + +**Run Commands:** +```bash +cargo test --all # Run all tests (unit + integration) +cargo test --all integration # Run only integration tests +cargo test -p redical_ical # Run tests for a specific crate +cargo test -p redical_core # Run core crate tests only +``` + +## Test File Organization + +**Location:** +- Unit tests are co-located: `#[cfg(test)] mod tests { ... }` block at the bottom of each source file +- Integration tests: `tests/integration.rs` at workspace root — runs against a live Redis instance +- Integration test helpers split into `tests/macros.rs` and `tests/utils.rs` +- Core test utilities: `redical_core/src/testing/` — `macros.rs` and `utils.rs` +- Fuzz regression tests: `redical_ical/tests/fuzzing_hang_tests.rs` (marked `#[ignore]`) + +**Naming:** +- Test functions named after the method under test: `fn parse_ical()`, `fn render_ical()`, `fn date_time_parse_ical_error()` +- Multiple tests per method grouped by scenario suffix: `parse_ical`, `parse_ical_error`, `parse_ical_with_terminated_property_lookahead`, `render_ical_with_context_tz_override` + +**Structure:** +``` +redical_ical/src/values/date_time.rs # Unit tests at bottom of file +redical_ical/src/properties/event/dtstart.rs +redical_core/src/testing/macros.rs # Shared test macros for core +redical_core/src/testing/utils.rs # Shared test helpers for core +tests/integration.rs # Workspace-level integration tests +tests/macros.rs # Shared macros for integration tests +tests/utils.rs # Shared utils for integration tests +``` + +## Test Structure + +**Suite Organization:** +```rust +#[cfg(test)] +mod tests { + use super::*; + + use chrono::{NaiveDate, NaiveTime, NaiveDateTime}; + use crate::tests::{assert_parser_output, assert_parser_error}; + + #[test] + fn parse_ical() { + assert_parser_output!( + DTStartProperty::parse_ical("DTSTART:19960401T150000Z DESCRIPTION:Description text".into()), + ( + " DESCRIPTION:Description text", + DTStartProperty { ... }, + ), + ); + } + + #[test] + fn parse_ical_error() { + assert_parser_error!( + DTStartProperty::parse_ical("...".into()), + nom::Err::Failure( + span: "...", + message: "expected ...", + context: ["DTSTART"], + ), + ); + } + + #[test] + fn render_ical() { + assert_eq!( + MyType { ... }.render_ical(), + String::from("EXPECTED:VALUE"), + ); + } +} +``` + +**Patterns:** +- Each `impl ICalendarEntity` type has at minimum: `parse_ical`, `render_ical` test functions +- Error cases test both `nom::Err::Error` (soft fail / backtrackable) and `nom::Err::Failure` (hard fail after `cut()`) +- Happy-path tests use `assert_parser_output!`; error tests use `assert_parser_error!` +- Render tests use plain `assert_eq!` comparing `.render_ical()` to a `String::from("...")` literal + +## Mocking + +**Framework:** None — no mock library used. + +**Patterns:** +- No mocking of external dependencies; tests either use real instances or build minimal structs directly +- Integration tests spin up a real Redis server instance on port 6480 using the module binary +- The `run_all_integration_tests_sequentially!` macro runs all integration test functions sequentially through a single Redis connection (avoids port conflicts and state bleed) + +**What to Mock:** +- Not applicable — the codebase does not use mocking. + +**What NOT to Mock:** +- Redis connection — integration tests use real Redis. Do not introduce mock Redis clients. + +## Fixtures and Factories + +**Test Data:** +```rust +// Building typed values inline in test assertions +DateTime::UtcDateTime( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(1998_i32, 1_u32, 18_u32).unwrap(), + NaiveTime::from_hms_opt(23_u32, 0_u32, 0_u32).unwrap(), + ) +) +``` + +**Core test utilities** at `redical_core/src/testing/utils.rs`: +```rust +// Build an Event from iCal property string slices +build_event_from_ical(event_uid, vec![ + "DTSTART:20201231T160000Z", + "RRULE:FREQ=WEEKLY;BYDAY=MO", +]); + +// Build event with overrides +build_event_and_overrides_from_ical(uid, ical_parts, overrides); + +// Build a single event override +build_event_override_from_ical(dtstart_date_string, override_ical_parts); +``` + +**`build_property_from_ical!` macro** at `redical_core/src/testing/macros.rs`: +```rust +let property = build_property_from_ical!(DTStartProperty, "DTSTART:19960401T150000Z"); +``` + +**Location:** No separate fixtures directory — all test data is inline within test functions. + +## Coverage + +**Requirements:** None enforced — no coverage configuration found. + +**View Coverage:** +```bash +# Install cargo-tarpaulin then: +cargo tarpaulin --all +``` + +## Test Types + +**Unit Tests:** +- Scope: individual parser functions, entity types, value types, property types +- Location: `#[cfg(test)] mod tests` within each `.rs` file +- Cover: `parse_ical` (success cases, error cases, edge cases), `render_ical` (round-trip), `validate`, type conversions (`From`, `with_timezone`, etc.) + +**Integration Tests:** +- Location: `tests/integration.rs` +- Scope: full Redis command round-trips — set, get, list, delete, query, prune operations +- Require a running Redis server with the `redical` module loaded +- Run sequentially via `run_all_integration_tests_sequentially!` macro — each sub-test flushes the DB after completion + +**Fuzz/Regression Tests:** +- Location: `redical_ical/tests/fuzzing_hang_tests.rs` +- Scope: hang regression for previously discovered AFL fuzzing inputs +- Marked `#[ignore]` by default — run manually when testing parser performance + +## Common Patterns + +**Parser success assertion:** +```rust +assert_parser_output!( + SomeType::parse_ical("INPUT remaining".into()), + ( + " remaining", // expected remaining input + SomeType { ... }, // expected parsed value + ), +); +``` + +**Parser error assertion:** +```rust +assert_parser_error!( + SomeType::parse_ical(":".into()), + nom::Err::Error( // or nom::Err::Failure for hard failures + span: ":", + message: "expected iCalendar RFC-5545 ...", + context: ["OUTER_CONTEXT", "INNER_CONTEXT"], + ), +); +``` + +**Timeout-bounded test:** +```rust +assert_finishes_within_duration!( + 1000, // milliseconds + SomeType::parse_ical(potentially_slow_input.into()), +); +``` + +**Integration test helper macros** (defined in `tests/macros.rs`): +- `set_and_assert_calendar!(connection, uid)` — set + verify calendar exists +- `set_and_assert_event!(connection, cal_uid, event_uid, [ical_properties...])` — set + verify event +- `set_and_assert_event_override!(connection, cal_uid, event_uid, dtstart, [properties...])` — set + verify override +- `list_and_assert_matching_events!(connection, cal_uid, [[event_props...], ...])` — list + compare +- `query_calendar_and_assert_matching_event_instances!(connection, cal_uid, [query_props...], [results...])` — query + compare +- `assert_keyspace_events_published!(message_queue, event, keyname)` — Redis pub/sub assertions +- `assert_error_returned!(connection, "expected error", "COMMAND", arg1, arg2)` — error case validation + +--- + +*Testing analysis: 2026-03-06* diff --git a/Cargo.toml b/Cargo.toml index 194b1b92..bf81b4ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ serde = { version = "1.0.162", features = ["derive"] } libc = "0.2" nom = "6.0" rrule = { version = "0.10", features = ["serde", "exrule"] } -chrono = "0.4.19" +chrono = { version = "0.4.19", features = ["serde"] } chrono-tz = "0.6.1" regex = { version = "1.5.5", default-features = false, features = ["perf", "std"] } rstar = { version = "0.11.0", features = ["serde"] } @@ -20,8 +20,8 @@ num = "0.4.1" unicode-segmentation = "1.10.1" pretty_assertions_sorted = "1.2.3" anyhow = "1" -redis-module-macros = "2.0.2" -redis-module = "2.0.2" +redis-module-macros = "2.0.4" +redis-module = "2.0.4" redis = "0.23" itertools = "0.12.1" lazy_static = "1.4.0" diff --git a/redical_core/src/calendar.rs b/redical_core/src/calendar.rs index 5f8fc649..f873db54 100644 --- a/redical_core/src/calendar.rs +++ b/redical_core/src/calendar.rs @@ -1,5 +1,7 @@ use std::collections::{BTreeSet, BTreeMap, HashMap}; +use serde::{Serialize, Deserialize}; + use crate::inverted_index::{IndexedConclusion, InvertedCalendarIndex}; use crate::utils::{KeyValuePair, UpdatedHashMapMembers}; @@ -19,15 +21,35 @@ use redical_ical::{ }, }; -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct Calendar { pub uid: UIDProperty, pub events: BTreeMap>, pub indexes_active: bool, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_categories: InvertedCalendarIndex, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_location_type: InvertedCalendarIndex, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_related_to: InvertedCalendarIndex, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_geo: GeoSpatialCalendarIndex, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_class: InvertedCalendarIndex, } @@ -110,7 +132,7 @@ impl Calendar { // Rebuild the Calendar indexes from scratch, very helpful to perform at the tail // end of a bulk data import. - pub fn rebuild_indexes(&mut self) -> Result { + pub fn validate_and_rebuild_indexes(&mut self) -> Result { // Clear the indexes first to ensure full clean rebuild. self.clear_indexes(); @@ -126,7 +148,7 @@ impl Calendar { for event in self.events.values_mut() { let event_uid = event.uid.uid.to_string(); - event.rebuild_indexes()?; + event.validate_and_rebuild_indexes()?; if let Some(indexed_event_categories) = &event.indexed_categories { for (indexed_term, indexed_conclusion) in &indexed_event_categories.terms { diff --git a/redical_core/src/event.rs b/redical_core/src/event.rs index 61f1b7d8..83c2e931 100644 --- a/redical_core/src/event.rs +++ b/redical_core/src/event.rs @@ -1,6 +1,8 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::str::FromStr; +use serde::{Serialize, Deserialize}; + use rrule::{RRuleError, RRuleSet}; use redical_ical::{ @@ -49,7 +51,7 @@ use crate::geo_index::GeoPoint; use crate::utils::KeyValuePair; -#[derive(Default, Debug, Eq, PartialEq, Clone)] +#[derive(Default, Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub struct ScheduleProperties { pub rrule: Option, pub exrule: Option, @@ -58,6 +60,10 @@ pub struct ScheduleProperties { pub duration: Option, pub dtstart: Option, pub dtend: Option, + + // Computed field -- cached parse of RRULE/EXRULE/RDATE/EXDATE properties. + // Rebuilt by validate_and_rebuild_indexes() after deserialization. + #[serde(skip)] pub parsed_rrule_set: Option, } @@ -244,7 +250,7 @@ impl ScheduleProperties { } } -#[derive(Default, Debug, Eq, PartialEq, Clone)] +#[derive(Default, Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub struct IndexedProperties { pub geo: Option, pub related_to: Option>, @@ -373,7 +379,7 @@ impl IndexedProperties { } } -#[derive(Default, Debug, Eq, PartialEq, Clone)] +#[derive(Default, Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub struct PassiveProperties { pub properties: BTreeSet, } @@ -442,7 +448,7 @@ impl PassiveProperties { } } -#[derive(Debug, Eq, PartialEq, Clone)] +#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub struct Event { pub uid: UIDProperty, pub last_modified: LastModifiedProperty, @@ -453,10 +459,30 @@ pub struct Event { pub passive_properties: PassiveProperties, pub overrides: BTreeMap, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_categories: Option>, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_location_type: Option>, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_related_to: Option>, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_geo: Option>, + + // Computed index field -- rebuilt by validate_and_rebuild_indexes() after deserialization. + // Not serialized because it's derived from indexed_properties, not source data. + #[serde(skip)] pub indexed_class: Option>, } @@ -489,7 +515,9 @@ impl Event { Ok(true) } - pub fn rebuild_indexes(&mut self) -> Result { + pub fn validate_and_rebuild_indexes(&mut self) -> Result { + self.validate()?; + self.rebuild_indexed_categories()?; self.rebuild_indexed_location_type()?; self.rebuild_indexed_related_to()?; diff --git a/redical_core/src/event_occurrence_override.rs b/redical_core/src/event_occurrence_override.rs index ae414fd8..84f2f0b4 100644 --- a/redical_core/src/event_occurrence_override.rs +++ b/redical_core/src/event_occurrence_override.rs @@ -2,6 +2,8 @@ use std::collections::BTreeSet; use std::str::FromStr; +use serde::{Serialize, Deserialize}; + use crate::event::{IndexedProperties, PassiveProperties}; use redical_ical::{ @@ -22,7 +24,7 @@ use redical_ical::{ use redical_ical::values::date_time::DateTime as ICalDateTime; -#[derive(Debug, Eq, PartialEq, Clone)] +#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub struct EventOccurrenceOverride { pub last_modified: LastModifiedProperty, diff --git a/redical_core/src/geo_index.rs b/redical_core/src/geo_index.rs index 413a1d80..4e641c3e 100644 --- a/redical_core/src/geo_index.rs +++ b/redical_core/src/geo_index.rs @@ -4,6 +4,8 @@ use std::cmp::Ordering; use rstar::primitives::GeomWithData; +use serde::{Serialize, Deserialize}; + use std::hash::{Hash, Hasher}; use crate::{IndexedConclusion, InvertedCalendarIndexTerm}; @@ -144,7 +146,7 @@ impl Ord for GeoDistance { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeoPoint { pub lat: f64, pub long: f64, diff --git a/redical_core/src/queries/event_instance_query.rs b/redical_core/src/queries/event_instance_query.rs index cfd1d051..64a21f0f 100644 --- a/redical_core/src/queries/event_instance_query.rs +++ b/redical_core/src/queries/event_instance_query.rs @@ -498,14 +498,14 @@ mod test { fn test_uid_index_retrieval() { let mut calendar = Calendar::new(String::from("CALENDAR_UID")); - let event_one = Event::parse_ical("EVENT_ONE", "").unwrap(); - let event_two = Event::parse_ical("EVENT_TWO", "").unwrap(); - let event_three = Event::parse_ical("EVENT_THREE", "").unwrap(); + let event_one = Event::parse_ical("EVENT_ONE", "DTSTART:19700101T000500Z").unwrap(); + let event_two = Event::parse_ical("EVENT_TWO", "DTSTART:19700101T000500Z").unwrap(); + let event_three = Event::parse_ical("EVENT_THREE", "DTSTART:19700101T000500Z").unwrap(); calendar.insert_event(event_one); calendar.insert_event(event_two); calendar.insert_event(event_three); - calendar.rebuild_indexes().unwrap(); + calendar.validate_and_rebuild_indexes().unwrap(); let accessor = EventInstanceQueryIndexAccessor::new(&calendar); diff --git a/redical_core/src/queries/event_query.rs b/redical_core/src/queries/event_query.rs index 8ef4f934..3219aeb2 100644 --- a/redical_core/src/queries/event_query.rs +++ b/redical_core/src/queries/event_query.rs @@ -548,14 +548,14 @@ mod test { fn test_uid_index_retrieval() { let mut calendar = Calendar::new(String::from("CALENDAR_UID")); - let event_one = Event::parse_ical("EVENT_ONE", "").unwrap(); - let event_two = Event::parse_ical("EVENT_TWO", "").unwrap(); - let event_three = Event::parse_ical("EVENT_THREE", "").unwrap(); + let event_one = Event::parse_ical("EVENT_ONE", "DTSTART:19700101T000500Z").unwrap(); + let event_two = Event::parse_ical("EVENT_TWO", "DTSTART:19700101T000500Z").unwrap(); + let event_three = Event::parse_ical("EVENT_THREE", "DTSTART:19700101T000500Z").unwrap(); calendar.insert_event(event_one); calendar.insert_event(event_two); calendar.insert_event(event_three); - calendar.rebuild_indexes().unwrap(); + calendar.validate_and_rebuild_indexes().unwrap(); let accessor = EventQueryIndexAccessor::new(&calendar); diff --git a/redical_core/src/queries/indexed_property_filters.rs b/redical_core/src/queries/indexed_property_filters.rs index 7585e960..9cbda252 100644 --- a/redical_core/src/queries/indexed_property_filters.rs +++ b/redical_core/src/queries/indexed_property_filters.rs @@ -215,14 +215,14 @@ mod test { fn calendar_with_events() -> Calendar { let mut calendar = Calendar::new(String::from("CALENDAR_UID")); - let event_one = Event::parse_ical("EVENT_ONE", "").unwrap(); - let event_two = Event::parse_ical("EVENT_TWO", "").unwrap(); - let event_three = Event::parse_ical("EVENT_THREE", "").unwrap(); + let event_one = Event::parse_ical("EVENT_ONE", "DTSTART:19700101T000500Z").unwrap(); + let event_two = Event::parse_ical("EVENT_TWO", "DTSTART:19700101T000500Z").unwrap(); + let event_three = Event::parse_ical("EVENT_THREE", "DTSTART:19700101T000500Z").unwrap(); calendar.insert_event(event_one); calendar.insert_event(event_two); calendar.insert_event(event_three); - calendar.rebuild_indexes().unwrap(); + calendar.validate_and_rebuild_indexes().unwrap(); calendar } diff --git a/redical_core/src/testing/utils.rs b/redical_core/src/testing/utils.rs index 18cd228b..4d3eb573 100644 --- a/redical_core/src/testing/utils.rs +++ b/redical_core/src/testing/utils.rs @@ -23,7 +23,7 @@ pub fn build_event_and_overrides_from_ical( event.override_occurrence(&parsed_event_occurrence_override, true).unwrap(); } - event.rebuild_indexes().unwrap(); + event.validate_and_rebuild_indexes().unwrap(); event } diff --git a/redical_core/src/utils.rs b/redical_core/src/utils.rs index 1b74b0a0..7b464cde 100644 --- a/redical_core/src/utils.rs +++ b/redical_core/src/utils.rs @@ -4,12 +4,14 @@ use std::hash::Hash; use std::cmp::Ordering; +use serde::{Serialize, Deserialize}; + use redical_ical::{ ICalendarEntity, content_line::ContentLine, }; -#[derive(Debug, PartialEq, Eq, Hash, Clone)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] pub struct KeyValuePair { pub key: String, pub value: String, diff --git a/redical_ical/Cargo.toml b/redical_ical/Cargo.toml index 7a5904f2..9ac793af 100644 --- a/redical_ical/Cargo.toml +++ b/redical_ical/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +serde = { workspace = true } chrono = { workspace = true } chrono-tz = { workspace = true } nom = "7.1.3" diff --git a/redical_ical/src/content_line.rs b/redical_ical/src/content_line.rs index 8e53f7e6..1f03f65d 100644 --- a/redical_ical/src/content_line.rs +++ b/redical_ical/src/content_line.rs @@ -5,9 +5,11 @@ use nom::combinator::{cut, map}; use crate::grammar::{tag, colon, semicolon, x_name, name, param, value}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits, terminated_lookahead}; -#[derive(Debug, Clone, Eq, PartialEq, Default, Ord, PartialOrd)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Ord, PartialOrd, Serialize, Deserialize)] pub struct ContentLineParam(pub String, pub String); impl<'a> From<(ParserInput<'a>, ParserInput<'a>)> for ContentLineParam { @@ -49,7 +51,7 @@ impl ICalendarEntity for ContentLineParam { impl_icalendar_entity_traits!(ContentLineParam); -#[derive(Debug, Clone, Eq, PartialEq, Default, Ord, PartialOrd)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Ord, PartialOrd, Serialize, Deserialize)] pub struct ContentLineParams(pub Vec); impl From> for ContentLineParams { @@ -116,7 +118,7 @@ impl ContentLineParams { impl_icalendar_entity_traits!(ContentLineParams); -#[derive(Debug, Clone, Eq, PartialEq, Default, Ord, PartialOrd)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Ord, PartialOrd, Serialize, Deserialize)] pub struct ContentLine(pub String, pub ContentLineParams, pub String); impl<'a> From<(ParserInput<'a>, ContentLineParams, ParserInput<'a>)> for ContentLine { diff --git a/redical_ical/src/grammar.rs b/redical_ical/src/grammar.rs index ce9e63eb..a05afeb0 100644 --- a/redical_ical/src/grammar.rs +++ b/redical_ical/src/grammar.rs @@ -1433,7 +1433,9 @@ pub fn control(input: ParserInput) -> ParserResult { )(input) } -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub enum PositiveNegative { Positive, Negative, diff --git a/redical_ical/src/properties/calendar.rs b/redical_ical/src/properties/calendar.rs index 25ad49ef..275e4e77 100644 --- a/redical_ical/src/properties/calendar.rs +++ b/redical_ical/src/properties/calendar.rs @@ -9,9 +9,11 @@ use crate::grammar::wsp; use crate::properties::uid::UIDProperty; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits, convert_error}; -#[derive(Debug, Eq, PartialEq, Clone)] +#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub enum CalendarProperty { UID(UIDProperty), } @@ -38,7 +40,7 @@ impl std::hash::Hash for CalendarProperty { impl_icalendar_entity_traits!(CalendarProperty); -#[derive(Debug, Eq, PartialEq, Clone)] +#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub struct CalendarProperties(pub Vec); impl FromStr for CalendarProperties { diff --git a/redical_ical/src/properties/event/categories.rs b/redical_ical/src/properties/event/categories.rs index 219d54d2..e46c9ea7 100644 --- a/redical_ical/src/properties/event/categories.rs +++ b/redical_ical/src/properties/event/categories.rs @@ -15,11 +15,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, define_prope use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct CategoriesPropertyParams { pub language: Option, pub other: HashMap, @@ -107,7 +109,7 @@ impl From for ContentLineParams { // CATEGORIES:APPOINTMENT,EDUCATION // // CATEGORIES:MEETING -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct CategoriesProperty { pub params: CategoriesPropertyParams, pub categories: List, diff --git a/redical_ical/src/properties/event/class.rs b/redical_ical/src/properties/event/class.rs index 84a6f2c0..f9b11245 100644 --- a/redical_ical/src/properties/event/class.rs +++ b/redical_ical/src/properties/event/class.rs @@ -14,11 +14,13 @@ use crate::content_line::{ContentLineParams, ContentLine}; use crate::values::class::ClassValue; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct ClassPropertyParams { pub other: HashMap, } @@ -86,7 +88,7 @@ impl From for ContentLineParams { // Example: The following is an example of this property: // // CLASS:PUBLIC -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct ClassProperty { pub params: ClassPropertyParams, pub class: ClassValue, diff --git a/redical_ical/src/properties/event/dtend.rs b/redical_ical/src/properties/event/dtend.rs index d1d5bbe0..d2538d51 100644 --- a/redical_ical/src/properties/event/dtend.rs +++ b/redical_ical/src/properties/event/dtend.rs @@ -15,11 +15,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, ICalendarDat use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, ParserError, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct DTEndPropertyParams { pub tzid: Option, pub value_type: Option, @@ -139,7 +141,7 @@ impl DTEndPropertyParams { // DTEND:19960401T150000Z // // DTEND;VALUE=DATE:19980704 -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct DTEndProperty { pub params: DTEndPropertyParams, pub date_time: DateTime, diff --git a/redical_ical/src/properties/event/dtstart.rs b/redical_ical/src/properties/event/dtstart.rs index aefcc344..9b36a93b 100644 --- a/redical_ical/src/properties/event/dtstart.rs +++ b/redical_ical/src/properties/event/dtstart.rs @@ -15,11 +15,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, ICalendarDat use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, ParserError, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct DTStartPropertyParams { pub tzid: Option, pub value_type: Option, @@ -142,7 +144,7 @@ impl DTStartPropertyParams { // Example: The following is an example of this property: // // DTSTART:19980118T073000Z -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct DTStartProperty { pub params: DTStartPropertyParams, pub date_time: DateTime, diff --git a/redical_ical/src/properties/event/duration.rs b/redical_ical/src/properties/event/duration.rs index 3c82c4de..01da66b4 100644 --- a/redical_ical/src/properties/event/duration.rs +++ b/redical_ical/src/properties/event/duration.rs @@ -14,11 +14,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, define_prope use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct DurationPropertyParams { pub other: HashMap, } @@ -89,7 +91,7 @@ impl From for ContentLineParams { // // DURATION:PT15M // -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct DurationProperty { pub params: DurationPropertyParams, pub duration: Duration, diff --git a/redical_ical/src/properties/event/exdate.rs b/redical_ical/src/properties/event/exdate.rs index 5823c84a..14947136 100644 --- a/redical_ical/src/properties/event/exdate.rs +++ b/redical_ical/src/properties/event/exdate.rs @@ -16,11 +16,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, ICalendarDat use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserError, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct ExDatePropertyParams { pub tzid: Option, pub value_type: Option, @@ -142,7 +144,7 @@ impl ExDatePropertyParams { // Example: The following is an example of this property: // // EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct ExDateProperty { pub params: ExDatePropertyParams, pub date_times: List, diff --git a/redical_ical/src/properties/event/exrule.rs b/redical_ical/src/properties/event/exrule.rs index 3367d283..ef92e835 100644 --- a/redical_ical/src/properties/event/exrule.rs +++ b/redical_ical/src/properties/event/exrule.rs @@ -14,11 +14,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, define_prope use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct ExRulePropertyParams { pub other: HashMap, } @@ -85,7 +87,7 @@ impl From for ContentLineParams { // exrule = "EXRULE" rrulparam ":" recur CRLF // // rrulparam = *(";" other-param) -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct ExRuleProperty { pub params: ExRulePropertyParams, pub value: Recur, diff --git a/redical_ical/src/properties/event/geo.rs b/redical_ical/src/properties/event/geo.rs index d7e42d18..727d115a 100644 --- a/redical_ical/src/properties/event/geo.rs +++ b/redical_ical/src/properties/event/geo.rs @@ -14,11 +14,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, ICalendarGeo use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct GeoPropertyParams { pub other: HashMap, } @@ -86,7 +88,7 @@ impl From for ContentLineParams { // Example: The following is an example of this property: // // GEO:37.386013;-122.082932 -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct GeoProperty { pub params: GeoPropertyParams, pub latitude: Option, diff --git a/redical_ical/src/properties/event/location_type.rs b/redical_ical/src/properties/event/location_type.rs index 514c6a5d..cc9aee43 100644 --- a/redical_ical/src/properties/event/location_type.rs +++ b/redical_ical/src/properties/event/location_type.rs @@ -15,11 +15,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, define_prope use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct LocationTypePropertyParams { pub other: HashMap, } @@ -85,7 +87,7 @@ impl From for ContentLineParams { // LOCATION-TYPE:ONLINE,ZOOM // // LOCATION-TYPE:HOTEL -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct LocationTypeProperty { pub params: LocationTypePropertyParams, pub types: List, diff --git a/redical_ical/src/properties/event/mod.rs b/redical_ical/src/properties/event/mod.rs index 7ebc3652..b024ece1 100644 --- a/redical_ical/src/properties/event/mod.rs +++ b/redical_ical/src/properties/event/mod.rs @@ -45,9 +45,11 @@ pub use passive::PassiveProperty; use crate::properties::uid::UIDProperty; use crate::properties::last_modified::LastModifiedProperty; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserContext, ParserResult, convert_error}; -#[derive(Debug, Eq, PartialEq, Clone)] +#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub enum EventProperty { UID(UIDProperty), LastModified(LastModifiedProperty), @@ -169,7 +171,7 @@ impl std::fmt::Display for EventProperty { } } -#[derive(Debug, Eq, PartialEq, Clone)] +#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub struct EventProperties(pub Vec); impl FromStr for EventProperties { diff --git a/redical_ical/src/properties/event/passive.rs b/redical_ical/src/properties/event/passive.rs index 34723c0d..8cdcf0b5 100644 --- a/redical_ical/src/properties/event/passive.rs +++ b/redical_ical/src/properties/event/passive.rs @@ -3,10 +3,12 @@ use nom::combinator::{map, all_consuming}; use crate::content_line::{ContentLine, ContentLineParams}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, ParserContext}; use crate::properties::ICalendarProperty; -#[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd)] +#[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd, Serialize, Deserialize)] pub enum PassiveProperty { Calscale(ContentLineParams, String), Method(ContentLineParams, String), diff --git a/redical_ical/src/properties/event/rdate.rs b/redical_ical/src/properties/event/rdate.rs index ad911161..b1602999 100644 --- a/redical_ical/src/properties/event/rdate.rs +++ b/redical_ical/src/properties/event/rdate.rs @@ -16,11 +16,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, ICalendarDat use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, ParserError, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct RDatePropertyParams { pub tzid: Option, pub value_type: Option, @@ -150,7 +152,7 @@ impl RDatePropertyParams { // 19970526,19970704,19970901,19971014,19971128,19971129,19971225 // // TODO: Implement PERIOD VALUE type. -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct RDateProperty { pub params: RDatePropertyParams, pub date_times: List, diff --git a/redical_ical/src/properties/event/related_to.rs b/redical_ical/src/properties/event/related_to.rs index 3fec96ca..d964815f 100644 --- a/redical_ical/src/properties/event/related_to.rs +++ b/redical_ical/src/properties/event/related_to.rs @@ -15,11 +15,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, define_prope use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct RelatedToPropertyParams { pub reltype: Option, pub other: HashMap, @@ -106,7 +108,7 @@ impl From for ContentLineParams { // RELATED-TO:jsmith.part7.19960817T083000.xyzMail@example.com // // RELATED-TO:19960401-080045-4000F192713-0052@example.com -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct RelatedToProperty { pub params: RelatedToPropertyParams, pub uid: Text, diff --git a/redical_ical/src/properties/event/rrule.rs b/redical_ical/src/properties/event/rrule.rs index 2b80c7bf..ce5527db 100644 --- a/redical_ical/src/properties/event/rrule.rs +++ b/redical_ical/src/properties/event/rrule.rs @@ -14,11 +14,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, define_prope use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct RRulePropertyParams { pub other: HashMap, } @@ -90,7 +92,7 @@ impl From for ContentLineParams { // // DTSTART;TZID=America/New_York:19970902T090000 // RRULE:FREQ=DAILY;COUNT=10 -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct RRuleProperty { pub params: RRulePropertyParams, pub value: Recur, diff --git a/redical_ical/src/properties/last_modified.rs b/redical_ical/src/properties/last_modified.rs index 5266b42a..cec7a655 100644 --- a/redical_ical/src/properties/last_modified.rs +++ b/redical_ical/src/properties/last_modified.rs @@ -16,12 +16,14 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, ICalendarDat use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; // TODO: Potentially accomodate RANGE param if required. -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct LastModifiedPropertyParams { pub millis: Option, pub other: HashMap, @@ -93,7 +95,7 @@ impl ICalendarPropertyParams for LastModifiedPropertyParams { // // LAST-MODIFIED:19960817T133000Z // -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct LastModifiedProperty { pub params: LastModifiedPropertyParams, pub date_time: DateTime, diff --git a/redical_ical/src/properties/uid.rs b/redical_ical/src/properties/uid.rs index 45bd2d8e..5be60df3 100644 --- a/redical_ical/src/properties/uid.rs +++ b/redical_ical/src/properties/uid.rs @@ -14,11 +14,13 @@ use crate::properties::{ICalendarProperty, ICalendarPropertyParams, define_prope use crate::content_line::{ContentLineParams, ContentLine}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use std::collections::HashMap; -#[derive(Debug, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default, Serialize, Deserialize)] pub struct UIDPropertyParams { pub other: HashMap, } @@ -82,7 +84,7 @@ impl From for ContentLineParams { // Example: The following is an example of this property: // // UID:19960401T080045Z-4000F192713-0052@example.com -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct UIDProperty { pub params: UIDPropertyParams, pub uid: Text, diff --git a/redical_ical/src/values/class.rs b/redical_ical/src/values/class.rs index 3a7920ba..0b9d8cb6 100644 --- a/redical_ical/src/values/class.rs +++ b/redical_ical/src/values/class.rs @@ -4,12 +4,14 @@ use nom::combinator::map; use crate::grammar::{tag, x_name, iana_token}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits, map_err_message}; // classvalue = "PUBLIC" / "PRIVATE" / "CONFIDENTIAL" / iana-token // / x-name // ;Default is PUBLIC -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub enum ClassValue { Public, Private, diff --git a/redical_ical/src/values/date.rs b/redical_ical/src/values/date.rs index d8fc9c35..4490e2ff 100644 --- a/redical_ical/src/values/date.rs +++ b/redical_ical/src/values/date.rs @@ -4,6 +4,8 @@ use nom::combinator::{recognize, map_res}; use nom::bytes::complete::take_while_m_n; use nom::character::is_digit; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, ParserError, impl_icalendar_entity_traits, map_err_message}; /// Parse date chars. @@ -183,7 +185,7 @@ pub fn date_mday(input: ParserInput) -> ParserResult { // date-month = 2DIGIT ;01-12 // date-mday = 2DIGIT ;01-28, 01-29, 01-30, 01-31 // ;based on month/year -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct Date { pub year: i32, pub month: u32, diff --git a/redical_ical/src/values/date_time.rs b/redical_ical/src/values/date_time.rs index be9f32c1..f4af9f68 100644 --- a/redical_ical/src/values/date_time.rs +++ b/redical_ical/src/values/date_time.rs @@ -8,6 +8,8 @@ use nom::sequence::{pair, preceded}; use nom::error::context; use nom::combinator::{recognize, map, map_res, opt, cut}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits, map_err_message}; use crate::grammar::latin_capital_letter_t; @@ -18,7 +20,7 @@ use crate::values::{ }; // VALUE = ("DATE-TIME" / "DATE") -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub enum ValueType { DateTime, Date, @@ -113,7 +115,7 @@ pub fn date_time(input: ParserInput) -> ParserResult { // // date-time = date "T" time ;As specified in the DATE and TIME // ;value definitions -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub enum DateTime { LocalDate(NaiveDate), LocalDateTime(NaiveDateTime), diff --git a/redical_ical/src/values/duration.rs b/redical_ical/src/values/duration.rs index 170324fd..7770f4c2 100644 --- a/redical_ical/src/values/duration.rs +++ b/redical_ical/src/values/duration.rs @@ -9,6 +9,8 @@ use nom::{ use crate::grammar::PositiveNegative; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits, map_err_message}; const SECONDS_IN_MINUTE: i64 = 60; @@ -153,7 +155,7 @@ pub fn dur_second(input: ParserInput) -> ParserResult { } -#[derive(Default, Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Default, Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct Duration { pub positive_negative: Option, pub weeks: Option, diff --git a/redical_ical/src/values/float.rs b/redical_ical/src/values/float.rs index 7ca417da..60c38d23 100644 --- a/redical_ical/src/values/float.rs +++ b/redical_ical/src/values/float.rs @@ -2,6 +2,8 @@ use nom::combinator::{recognize, map, cut, map_res, opt}; use nom::character::complete::{one_of, digit1}; use nom::sequence::{preceded, tuple}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits}; use crate::grammar::period; @@ -29,7 +31,7 @@ pub fn float(input: ParserInput) -> ParserResult { // notation: // // float = (["+"] / "-") 1*DIGIT ["." 1*DIGIT] -#[derive(Debug, Clone, PartialOrd, PartialEq)] +#[derive(Debug, Clone, PartialOrd, PartialEq, Serialize, Deserialize)] pub struct Float(pub f64); impl Eq for Float {} diff --git a/redical_ical/src/values/integer.rs b/redical_ical/src/values/integer.rs index d2fddc2a..beace8f7 100644 --- a/redical_ical/src/values/integer.rs +++ b/redical_ical/src/values/integer.rs @@ -6,6 +6,8 @@ use nom::bytes::complete::take_while_m_n; use nom::character::is_digit; use nom::sequence::pair; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, ParserError, impl_icalendar_entity_traits}; use crate::grammar::PositiveNegative; @@ -28,7 +30,7 @@ pub fn integer(input: ParserInput) -> ParserResult { // notation: // // integer = (["+"] / "-") 1*DIGIT ["." 1*DIGIT] -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct Integer(pub i64); impl ICalendarEntity for Integer { diff --git a/redical_ical/src/values/list.rs b/redical_ical/src/values/list.rs index 590259e7..af3b4c85 100644 --- a/redical_ical/src/values/list.rs +++ b/redical_ical/src/values/list.rs @@ -5,6 +5,8 @@ use nom::combinator::map; use crate::grammar::comma; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult}; /// Parses and serializes a list of values @@ -50,7 +52,7 @@ use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult}; /// assert_eq!(parsed_list, List(vec![Integer(10), Integer(20), Integer(30)])); /// ``` /// [plus / minus] 1*digit -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct List(pub Vec) where T: std::fmt::Debug + Clone + ICalendarEntity + Eq + PartialEq + std::hash::Hash, diff --git a/redical_ical/src/values/recur.rs b/redical_ical/src/values/recur.rs index d487b6e6..22c4a5aa 100644 --- a/redical_ical/src/values/recur.rs +++ b/redical_ical/src/values/recur.rs @@ -8,6 +8,8 @@ use nom::bytes::complete::take_while1; use crate::grammar::{comma, semicolon, tag}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, ParserError, impl_icalendar_entity_traits, map_err_message}; use crate::values::date_time::DateTime; @@ -17,7 +19,7 @@ use crate::values::list::List; #[macro_export] macro_rules! build_ical_param { ($struct_name:ident, $key_str:expr, $value_parser:expr, $value_type:ty $(,)*) => { - #[derive(Debug, Clone, Eq, PartialEq)] + #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct $struct_name(pub $value_type); impl ICalendarEntity for $struct_name { @@ -291,7 +293,7 @@ pub fn bysplist(input: ParserInput) -> ParserResult> { /// /// freq = "SECONDLY" / "MINUTELY" / "HOURLY" / "DAILY" /// / "WEEKLY" / "MONTHLY" / "YEARLY" -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub enum Frequency { Secondly, Minutely, @@ -368,7 +370,7 @@ impl_icalendar_entity_traits!(Frequency); /// /// weekdaynum = [[plus / minus] ordwk] weekday /// ordwk = 1*2DIGIT ;1 to 53 -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct WeekDayNum(pub Option, pub WeekDay); impl ICalendarEntity for WeekDayNum { @@ -437,7 +439,7 @@ impl_icalendar_entity_traits!(WeekDayNum); /// weekday = "SU" / "MO" / "TU" / "WE" / "TH" / "FR" / "SA" /// ;Corresponding to SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, /// ;FRIDAY, and SATURDAY days of the week. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub enum WeekDay { Sunday, Monday, @@ -501,7 +503,7 @@ impl_icalendar_entity_traits!(WeekDay); /// ; /// ; The other rule parts are OPTIONAL, /// ; but MUST NOT occur more than once. -#[derive(Default, Debug, Clone, Eq, PartialEq)] +#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct Recur { pub freq: Option, pub until: Option, diff --git a/redical_ical/src/values/reltype.rs b/redical_ical/src/values/reltype.rs index 54936088..2006d92f 100644 --- a/redical_ical/src/values/reltype.rs +++ b/redical_ical/src/values/reltype.rs @@ -4,6 +4,8 @@ use nom::combinator::map; use crate::grammar::{tag, x_name, iana_token}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits, map_err_message}; // RELTYPE = ("PARENT" ; Parent relationship - Default @@ -13,7 +15,7 @@ use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_i // ; iCalendar relationship type // / x-name) ; A non-standard, experimental // ; relationship type -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub enum Reltype { Parent, // Parent relationship - Default Child, // Child relationship diff --git a/redical_ical/src/values/text.rs b/redical_ical/src/values/text.rs index e8aefacd..24f9550a 100644 --- a/redical_ical/src/values/text.rs +++ b/redical_ical/src/values/text.rs @@ -5,6 +5,8 @@ use nom::multi::many0; use nom::branch::alt; use nom::bytes::complete::{tag, take_while_m_n}; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, impl_icalendar_entity_traits, terminated_lookahead}; use crate::grammar::{colon, dquote, is_safe_char}; @@ -79,7 +81,7 @@ pub fn is_tsafe_char(input: char) -> bool { // %x5D-7E / NON-US-ASCII // ; Any character except CONTROLs not needed by the current // ; character set, DQUOTE, ";", ":", "\", "," -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct Text(pub String); impl ICalendarEntity for Text { diff --git a/redical_ical/src/values/time.rs b/redical_ical/src/values/time.rs index c32c08de..283c7024 100644 --- a/redical_ical/src/values/time.rs +++ b/redical_ical/src/values/time.rs @@ -4,6 +4,8 @@ use nom::combinator::{recognize, map_res, opt}; use nom::bytes::complete::{tag, take_while_m_n}; use nom::character::is_digit; +use serde::{Serialize, Deserialize}; + use crate::{RenderingContext, ICalendarEntity, ParserInput, ParserResult, ParserError, impl_icalendar_entity_traits, map_err_message}; /// Parse time chars. @@ -179,7 +181,7 @@ pub fn time_utc(input: ParserInput) -> ParserResult { // ;The "60" value is used to account for positive "leap" seconds. // // time-utc = "Z" -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct Time { pub hour: u32, pub minute: u32, diff --git a/redical_ical/src/values/tzid.rs b/redical_ical/src/values/tzid.rs index 3f4bd205..702869f5 100644 --- a/redical_ical/src/values/tzid.rs +++ b/redical_ical/src/values/tzid.rs @@ -2,6 +2,8 @@ use chrono::prelude::TimeZone; use chrono::LocalResult; use chrono_tz::Tz; +use serde::{Serialize, Deserialize, Serializer, Deserializer}; + use nom::error::context; use nom::sequence::pair; use nom::combinator::{opt, map_res, recognize}; @@ -78,6 +80,21 @@ impl Tzid { } } +impl Serialize for Tzid { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> Deserialize<'de> for Tzid { + fn deserialize>(deserializer: D) -> Result { + let tzid_string = String::deserialize(deserializer)?; + let tz: Tz = tzid_string.parse().map_err(serde::de::Error::custom)?; + + Ok(Tzid(tz)) + } +} + impl From for Tz { fn from(tzid: Tzid) -> Self { tzid.0.to_owned() diff --git a/redical_redis/Cargo.toml b/redical_redis/Cargo.toml index 24d89048..8559e799 100644 --- a/redical_redis/Cargo.toml +++ b/redical_redis/Cargo.toml @@ -10,8 +10,8 @@ crate-type = ["cdylib", "rlib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -redis-module = "2.0.2" -redis-module-macros = "2.0.2" +redis-module = "2.0.4" +redis-module-macros = "2.0.4" bincode = "1.3.3" serde = { workspace = true } libc = "0.2" diff --git a/redical_redis/src/commands/rdcl_cal_idx_rebuild.rs b/redical_redis/src/commands/rdcl_cal_idx_rebuild.rs index 1a3e4890..04962454 100644 --- a/redical_redis/src/commands/rdcl_cal_idx_rebuild.rs +++ b/redical_redis/src/commands/rdcl_cal_idx_rebuild.rs @@ -22,7 +22,7 @@ pub fn redical_calendar_idx_rebuild(ctx: &Context, args: Vec) -> Re ))); }; - calendar.rebuild_indexes().map_err(RedisError::String)?; + calendar.validate_and_rebuild_indexes().map_err(RedisError::String)?; notify_keyspace_event(ctx, &calendar_uid)?; diff --git a/redical_redis/src/commands/rdcl_evt_set.rs b/redical_redis/src/commands/rdcl_evt_set.rs index 41fd6093..067b5db7 100644 --- a/redical_redis/src/commands/rdcl_evt_set.rs +++ b/redical_redis/src/commands/rdcl_evt_set.rs @@ -100,7 +100,7 @@ pub fn redical_event_set(ctx: &Context, args: Vec) -> RedisResult { } if calendar.indexes_active { - event.rebuild_indexes().map_err(RedisError::String)?; + event.validate_and_rebuild_indexes().map_err(RedisError::String)?; let updated_event_categories_diff = InvertedEventIndex::diff_indexed_terms( existing_event diff --git a/redical_redis/src/datatype/mod.rs b/redical_redis/src/datatype/mod.rs index 894d79a2..88c744b8 100644 --- a/redical_redis/src/datatype/mod.rs +++ b/redical_redis/src/datatype/mod.rs @@ -1,17 +1,53 @@ use redical_core::Calendar; use redis_module::{ + logging, native_types::RedisType, raw, RedisModuleIO, RedisModuleString, RedisModuleTypeMethods, }; use std::{ ffi::{c_int, c_void}, + panic::{catch_unwind, AssertUnwindSafe}, ptr::null_mut, }; mod rdb_data; -use rdb_data::RDBCalendar; +#[cfg(test)] +pub(crate) mod test_helpers; + +use rdb_data::{RDBCalendar, RDBCalendarEnvelope}; + +const BUILD_VERSION: Option<&str> = option_env!("GIT_SHA"); + +/// Thin wrappers around redis_module::logging that are no-ops in test mode. +/// The upstream `cfg!(test)` check only applies when redis-module itself is +/// under test, not when our crate is tested as a dependent. +mod log { + /// Log at DEBUG level; silently ignored during tests to avoid FFI calls. + #[allow(unused_variables)] + pub fn debug(message: &str) { + if !cfg!(test) { + super::logging::log_debug(message); + } + } + + /// Log at NOTICE level; silently ignored during tests to avoid FFI calls. + #[allow(unused_variables)] + pub fn notice(message: &str) { + if !cfg!(test) { + super::logging::log_notice(message); + } + } + + /// Log at WARNING level; silently ignored during tests to avoid FFI calls. + #[allow(unused_variables)] + pub fn warning(message: &str) { + if !cfg!(test) { + super::logging::log_warning(message); + } + } +} pub const CALENDAR_DATA_TYPE_NAME: &str = "RICAL_CAL"; pub const CALENDAR_DATA_TYPE_VERSION: i32 = 1; @@ -42,58 +78,164 @@ pub static CALENDAR_DATA_TYPE: RedisType = RedisType::new( }, ); +/// Redis RDB load callback. Deserializes a Calendar from the RDB snapshot, +/// first attempting the envelope format, then falling back to a bare logical +/// dump for backward compatibility. pub extern "C" fn rdb_load(rdb: *mut raw::RedisModuleIO, _encver: c_int) -> *mut c_void { - let Ok(buffer) = raw::load_string_buffer(rdb) else { - return null_mut(); - }; + let Ok(buffer) = + raw::load_string_buffer(rdb) else { + log::warning("RDB load: failed to read string buffer from RDB"); + + return null_mut(); + }; let bytes: &[u8] = buffer.as_ref(); - let rdb_calendar: RDBCalendar = bincode::deserialize(bytes).unwrap(); + let calendar = + match bincode::deserialize::(bytes) { + Ok(envelope) => { + load_from_envelope(envelope) + }, - let calendar = match Calendar::try_from(&rdb_calendar) { - Ok(calendar) => calendar, + Err(_) => { + log::notice("RDB calendar load: not envelope format, trying logical dump"); - // TODO: Handle properly - log error and return null etc. - Err(error) => { - panic!("rdb_load failed for Calendar with error: {:#?}", error.to_string()); - }, - }; + load_from_legacy_logical_dump(bytes) + }, + }; - Box::into_raw(Box::new(calendar)).cast::() + Box::into_raw( + Box::new(calendar) + ).cast::() } +/// Restore a Calendar from a versioned envelope. When the build version +/// matches the saved version, takes a fast path by deserializing the raw +/// bincode dump directly. On version mismatch, corrupted data, or panic, +/// falls back to rebuilding the Calendar from the logical dump. +pub(crate) fn load_from_envelope(envelope: RDBCalendarEnvelope) -> Calendar { + let version_match = matches!( + (&envelope.version, BUILD_VERSION), (Some(saved), Some(current)) if saved == current + ); + + if !version_match { + let saved = envelope.version.as_deref().unwrap_or("None"); + let current = BUILD_VERSION.unwrap_or("None"); + + log::warning( + &format!("RDB load: fast path skipped (version build digest mismatch: {saved} vs {current})") + ); + } else { + let result = + catch_unwind( + AssertUnwindSafe(|| -> Result { + let mut calendar = bincode::deserialize::(&envelope.raw_dump) + .map_err(|error| format!("{error}"))?; + + calendar.validate_and_rebuild_indexes() + .map_err(|error| error.to_string())?; + + Ok(calendar) + }) + ); + + match result { + Ok( + Ok(calendar) + ) => { + log::debug("RDB load: fast path OK"); + + return calendar; + }, + + Ok( + Err(error) + ) => { + log::warning( + &format!("RDB load: fast path failed ({error}), using logical dump fallback") + ); + }, + + Err(panic_payload) => { + let message = + if let Some(panic_error_message) = panic_payload.downcast_ref::<&str>() { + (*panic_error_message).to_string() + } else if let Some(panic_error_message) = panic_payload.downcast_ref::() { + panic_error_message.clone() + } else { + String::from("unknown panic") + }; + + log::warning( + &format!("RDB load: fast path panicked (payload: '{message}'), using logical dump fallback") + ); + }, + } + } + + Calendar::try_from(&envelope.logical_dump).unwrap_or_else(|error| { + panic!("RDB load: logical dump fallback failed: {error}") + }) +} + +/// Restore a Calendar from a bare logical dump (portable iCal representation) +/// without envelope wrapping, versioning, or a raw bincode dump. +/// +/// This caters to legacy versions of RediCal which simply dumped a logical +/// representation of a Calendar to RDB. +pub(crate) fn load_from_legacy_logical_dump(bytes: &[u8]) -> Calendar { + let rdb_calendar: RDBCalendar = bincode::deserialize(bytes).unwrap(); + + Calendar::try_from(&rdb_calendar).unwrap_or_else(|error| { + panic!("RDB load: logical dump failed for Calendar with error: {error:#?}") + }) +} + +/// Redis RDB save callback. Serializes a Calendar into a versioned envelope +/// containing both the raw bincode dump (for fast reload on matching builds) +/// and the logical dump (portable iCal representation for cross-version compatibility). pub unsafe extern "C" fn rdb_save(rdb: *mut raw::RedisModuleIO, value: *mut c_void) { let calendar = unsafe { &*(value as *mut Calendar) }; - let rdb_calendar = match RDBCalendar::try_from(calendar) { - Ok(rdb_calendar) => rdb_calendar, + let raw_dump = bincode::serialize(calendar).unwrap(); - // TODO: Handle properly - log error and return null etc. - Err(error) => { + let rdb_calendar = + RDBCalendar::try_from(calendar).unwrap_or_else(|error| { panic!("rdb_save failed for Calendar with error: {error:#?}"); - }, - }; + }); - let bytes: Vec = bincode::serialize(&rdb_calendar).unwrap(); + let envelope = + RDBCalendarEnvelope { + version: BUILD_VERSION.map(String::from), + raw_dump, + logical_dump: rdb_calendar, + }; - let str = std::str::from_utf8_unchecked(&bytes[..]); // no save_string_buffer available in redis-module :( + let bytes = bincode::serialize(&envelope).unwrap(); - raw::save_string(rdb, str); + raw::save_slice(rdb, &bytes); } +/// Redis AOF rewrite callback. Currently a no-op because a Calendar is built +/// from multiple commands and there is no single command that can reconstruct it. unsafe extern "C" fn aof_rewrite( _aof: *mut RedisModuleIO, _key: *mut RedisModuleString, _value: *mut c_void, ) { - todo!(); + // A Calendar is built from multiple commands (RICAL_SET, property decorators, etc.) + // so there is no single Redis command that can reconstruct it. AOF rewrite is a no-op + // until a multi-command emit strategy is designed in a future version. } +/// Redis memory usage callback. Returns 0 as accurate Calendar memory +/// accounting is not yet implemented. unsafe extern "C" fn mem_usage(_value: *const c_void) -> usize { 0 } +/// Redis free callback. Reclaims the heap-allocated Calendar when Redis +/// evicts or deletes a key. Handles the NULL pointer case for Redis 6.0. unsafe extern "C" fn free(value: *mut c_void) { if value.is_null() { // on Redis 6.0 we might get a NULL value here, so we need to handle it. @@ -107,6 +249,8 @@ unsafe extern "C" fn free(value: *mut c_void) { drop(Box::from_raw(calendar)); } +/// Redis COPY command callback. Produces a deep clone of the Calendar so +/// the source and destination keys are fully independent. unsafe extern "C" fn copy( _fromkey: *mut RedisModuleString, _tokey: *mut RedisModuleString, @@ -118,3 +262,99 @@ unsafe extern "C" fn copy( Box::into_raw(Box::new(calendar_cloned)).cast::() } + +#[cfg(test)] +mod load_tests { + use super::*; + + use super::test_helpers::{build_test_calendar, fixture_path}; + + use pretty_assertions_sorted::assert_eq; + + #[test] + fn load_from_envelope_with_none_version_uses_logical_dump_fallback() { + let calendar = build_test_calendar(); + let rdb_calendar = RDBCalendar::try_from(&calendar).unwrap(); + let raw_dump = bincode::serialize(&calendar).unwrap(); + + let envelope = RDBCalendarEnvelope { + version: None, + raw_dump, + logical_dump: rdb_calendar, + }; + + let result = load_from_envelope(envelope); + + assert_eq!(result, calendar); + } + + #[test] + fn load_from_legacy_logical_dump_produces_correct_calendar() { + let calendar = build_test_calendar(); + let rdb_calendar = RDBCalendar::try_from(&calendar).unwrap(); + let bytes = bincode::serialize(&rdb_calendar).unwrap(); + + let result = load_from_legacy_logical_dump(&bytes); + + assert_eq!(result, calendar); + } + + #[test] + fn load_from_envelope_with_corrupted_raw_dump_falls_back_to_logical_dump() { + let calendar = build_test_calendar(); + let rdb_calendar = RDBCalendar::try_from(&calendar).unwrap(); + + // BUILD_VERSION is None in tests, so we can't trigger the fast path directly. + // Instead, test the logical dump fallback: even with garbage raw_dump, the + // envelope's logical_dump produces the correct Calendar. + let envelope = RDBCalendarEnvelope { + version: None, + raw_dump: vec![0xFF, 0xFF, 0xFF], + logical_dump: rdb_calendar, + }; + + let result = load_from_envelope(envelope); + + assert_eq!(result, calendar); + } + + #[test] + fn load_from_legacy_logical_dump_fixture_produces_correct_calendar() { + let bytes = std::fs::read(fixture_path("rdb_calendar_logical_dump.bin")).unwrap(); + + let result = load_from_legacy_logical_dump(&bytes); + + assert_eq!(result, build_test_calendar()); + } + + #[test] + fn load_mismatch_fixture_falls_back_to_logical_dump() { + let bytes = std::fs::read(fixture_path("rdb_calendar_envelope_mismatch.bin")).unwrap(); + + let envelope: RDBCalendarEnvelope = bincode::deserialize(&bytes).unwrap(); + + let result = load_from_envelope(envelope); + + assert_eq!(result, build_test_calendar()); + } + + #[test] + fn envelope_round_trip_produces_correct_calendar() { + let calendar = build_test_calendar(); + let rdb_calendar = RDBCalendar::try_from(&calendar).unwrap(); + let raw_dump = bincode::serialize(&calendar).unwrap(); + + let envelope = RDBCalendarEnvelope { + version: BUILD_VERSION.map(String::from), + raw_dump, + logical_dump: rdb_calendar, + }; + + let bytes = bincode::serialize(&envelope).unwrap(); + let deserialized = bincode::deserialize::(&bytes).unwrap(); + + let result = load_from_envelope(deserialized); + + assert_eq!(result, calendar); + } +} diff --git a/redical_redis/src/datatype/rdb_data.rs b/redical_redis/src/datatype/rdb_data.rs index 5b0b3a7f..0215f7b7 100644 --- a/redical_redis/src/datatype/rdb_data.rs +++ b/redical_redis/src/datatype/rdb_data.rs @@ -51,6 +51,13 @@ impl std::fmt::Display for ParseRDBEntityError { } } +#[derive(Serialize, Deserialize, Debug)] +pub struct RDBCalendarEnvelope { + pub version: Option, + pub raw_dump: Vec, + pub logical_dump: RDBCalendar, +} + #[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] pub struct RDBCalendar(String, Vec, Vec); @@ -111,7 +118,11 @@ impl TryFrom<&RDBCalendar> for Calendar { calendar.insert_event(parse_event_result?); } - calendar.rebuild_indexes().map_err(|error| ParseRDBEntityError::OnSelf(rdb_calendar_uid.to_string(), error))?; + calendar + .validate_and_rebuild_indexes() + .map_err(|error| { + ParseRDBEntityError::OnSelf(rdb_calendar_uid.to_string(), error) + })?; Ok( calendar @@ -187,7 +198,7 @@ impl TryFrom<&RDBEvent> for Event { event.override_occurrence(&parse_event_occurrence_override_result?, false).map_err(|error| ParseRDBEntityError::OnSelf(rdb_event_uid.to_string(), error))?; } - event.rebuild_indexes().map_err(|error| ParseRDBEntityError::OnSelf(rdb_event_uid.to_string(), error))?; + event.validate_and_rebuild_indexes().map_err(|error| ParseRDBEntityError::OnSelf(rdb_event_uid.to_string(), error))?; Ok( event @@ -274,13 +285,13 @@ mod test { event.override_occurrence(&event_occurrence_override, true).unwrap(); event.validate().unwrap(); - event.rebuild_indexes().unwrap(); + event.validate_and_rebuild_indexes().unwrap(); let mut calendar = Calendar::new(String::from("CALENDAR_UID")); calendar.insert_event(event.clone()); - calendar.rebuild_indexes().unwrap(); + calendar.validate_and_rebuild_indexes().unwrap(); let rdb_calendar = RDBCalendar::try_from(&calendar).unwrap(); @@ -341,13 +352,13 @@ mod test { event.override_occurrence(&event_occurrence_override, true).unwrap(); event.validate().unwrap(); - event.rebuild_indexes().unwrap(); + event.validate_and_rebuild_indexes().unwrap(); let mut calendar = Calendar::new(String::from("CALENDAR_UID")); calendar.insert_event(event.clone()); - calendar.rebuild_indexes().unwrap(); + calendar.validate_and_rebuild_indexes().unwrap(); let invalid_rdb_calendar = RDBCalendar( @@ -395,6 +406,61 @@ mod test { ); } + #[test] + fn test_rdb_calendar_envelope_round_trip_with_version() { + let mut calendar = Calendar::new(String::from("DUMP_UID")); + + let event = Event::parse_ical( + "EVENT_UID", + "RRULE:FREQ=WEEKLY;UNTIL=19700101T000500Z;INTERVAL=1 \ + CLASS:PUBLIC CATEGORIES:CATEGORY_ONE \ + DTSTART:19700101T000500Z \ + LAST-MODIFIED:19700101T010500Z", + ).unwrap(); + + calendar.insert_event(event); + calendar.validate_and_rebuild_indexes().unwrap(); + + let raw_dump = bincode::serialize(&calendar).unwrap(); + + let rdb_calendar = RDBCalendar::try_from(&calendar).unwrap(); + + let envelope = RDBCalendarEnvelope { + version: Some(String::from("abc123")), + raw_dump: raw_dump.clone(), + logical_dump: rdb_calendar.clone(), + }; + + let envelope_bytes = bincode::serialize(&envelope).unwrap(); + let deserialized: RDBCalendarEnvelope = bincode::deserialize(&envelope_bytes).unwrap(); + + assert_eq!(deserialized.version, Some(String::from("abc123"))); + assert_eq!(deserialized.raw_dump, raw_dump); + assert_eq!(deserialized.logical_dump, rdb_calendar); + } + + #[test] + fn test_rdb_calendar_envelope_round_trip_with_no_version() { + let calendar = Calendar::new(String::from("EMPTY_DUMP_UID")); + + let raw_dump = bincode::serialize(&calendar).unwrap(); + + let rdb_calendar = RDBCalendar::try_from(&calendar).unwrap(); + + let envelope = RDBCalendarEnvelope { + version: None, + raw_dump: raw_dump.clone(), + logical_dump: rdb_calendar.clone(), + }; + + let envelope_bytes = bincode::serialize(&envelope).unwrap(); + let deserialized: RDBCalendarEnvelope = bincode::deserialize(&envelope_bytes).unwrap(); + + assert_eq!(deserialized.version, None); + assert_eq!(deserialized.raw_dump, raw_dump); + assert_eq!(deserialized.logical_dump, rdb_calendar); + } + #[test] fn test_calendar_level_parse_rdb_entity_error_to_string() { assert_eq!( @@ -422,6 +488,68 @@ mod test { ); } + #[test] + fn test_calendar_bincode_round_trip() { + let mut calendar = Calendar::new(String::from("TEST_UID")); + + let event = Event::parse_ical( + "EVENT_UID", + "RRULE:FREQ=WEEKLY;UNTIL=19700101T000500Z;INTERVAL=1 \ + CLASS:PUBLIC CATEGORIES:CATEGORY_ONE \ + DTSTART:19700101T000500Z \ + LAST-MODIFIED:19700101T010500Z", + ).unwrap(); + + calendar.insert_event(event); + calendar.validate_and_rebuild_indexes().unwrap(); + + let bytes = bincode::serialize(&calendar).unwrap(); + let mut deserialized: Calendar = bincode::deserialize(&bytes).unwrap(); + deserialized.validate_and_rebuild_indexes().unwrap(); + + assert_eq!(calendar, deserialized); + } + + #[test] + fn test_empty_calendar_bincode_round_trip() { + let calendar = Calendar::new(String::from("EMPTY_UID")); + + let bytes = bincode::serialize(&calendar).unwrap(); + let mut deserialized: Calendar = bincode::deserialize(&bytes).unwrap(); + deserialized.validate_and_rebuild_indexes().unwrap(); + + assert_eq!(calendar, deserialized); + } + + #[test] + #[ignore] + fn generate_fixtures() { + use crate::datatype::test_helpers::{build_test_calendar, fixture_path}; + + let calendar = build_test_calendar(); + + let rdb_calendar = RDBCalendar::try_from(&calendar).unwrap(); + + // Logical dump fixture: bare RDBCalendar bincode bytes + let logical_dump_bytes = bincode::serialize(&rdb_calendar).unwrap(); + + // Mismatch fixture: RDBCalendarEnvelope with non-matching version + let envelope = RDBCalendarEnvelope { + version: Some(String::from("fixture_mismatch")), + raw_dump: bincode::serialize(&calendar).unwrap(), + logical_dump: rdb_calendar, + }; + + let mismatch_bytes = bincode::serialize(&envelope).unwrap(); + + let fixtures_dir = fixture_path(""); + + std::fs::create_dir_all(&fixtures_dir).unwrap(); + + std::fs::write(fixture_path("rdb_calendar_logical_dump.bin"), &logical_dump_bytes).unwrap(); + std::fs::write(fixture_path("rdb_calendar_envelope_mismatch.bin"), &mismatch_bytes).unwrap(); + } + #[test] fn test_event_occurrence_override_level_parse_rdb_entity_error_to_string() { assert_eq!( diff --git a/redical_redis/src/datatype/test_helpers.rs b/redical_redis/src/datatype/test_helpers.rs new file mode 100644 index 00000000..79a948bb --- /dev/null +++ b/redical_redis/src/datatype/test_helpers.rs @@ -0,0 +1,37 @@ +use crate::core::{Calendar, Event, EventOccurrenceOverride}; + +pub fn build_test_calendar() -> Calendar { + let mut calendar = Calendar::new(String::from("LOAD_TEST_UID")); + + let mut event = Event::parse_ical( + "EVENT_UID", + "RRULE:FREQ=WEEKLY;UNTIL=19700101T000500Z;INTERVAL=1 \ + CLASS:PUBLIC CATEGORIES:CATEGORY_ONE \ + DTSTART:19700101T000500Z \ + LAST-MODIFIED:19700101T010500Z", + ).unwrap(); + + let event_override = EventOccurrenceOverride::parse_ical( + "19700101T000500Z", + "CLASS:PRIVATE \ + CATEGORIES:\"CATEGORY THREE\",CATEGORY_ONE,CATEGORY_TWO \ + LAST-MODIFIED:19700101T020500Z", + ).unwrap(); + + event.override_occurrence(&event_override, true).unwrap(); + event.validate().unwrap(); + + calendar.insert_event(event); + calendar.validate_and_rebuild_indexes().unwrap(); + + calendar +} + +pub fn fixture_path(filename: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("tests") + .join("fixtures") + .join(filename) +} diff --git a/tests/fixtures/rdb_calendar_envelope_mismatch.bin b/tests/fixtures/rdb_calendar_envelope_mismatch.bin new file mode 100644 index 00000000..b261e92f Binary files /dev/null and b/tests/fixtures/rdb_calendar_envelope_mismatch.bin differ diff --git a/tests/fixtures/rdb_calendar_logical_dump.bin b/tests/fixtures/rdb_calendar_logical_dump.bin new file mode 100644 index 00000000..d2ae6fe7 Binary files /dev/null and b/tests/fixtures/rdb_calendar_logical_dump.bin differ