From be39ed94e6d61631ae0bf600a45cd5e40e677b2c Mon Sep 17 00:00:00 2001 From: Shavonn Brown Date: Sat, 21 Feb 2026 23:38:33 -0500 Subject: [PATCH] feat: rrule and ruleset validate --- .changeset/heavy-times-turn.md | 9 +++ README.md | 69 ++++++++++++++++++++- src/index.ts | 1 + src/validate.ts | 31 ++++++++++ test/validate.test.ts | 108 +++++++++++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 .changeset/heavy-times-turn.md create mode 100644 src/validate.ts create mode 100644 test/validate.test.ts diff --git a/.changeset/heavy-times-turn.md b/.changeset/heavy-times-turn.md new file mode 100644 index 00000000..7b4a716b --- /dev/null +++ b/.changeset/heavy-times-turn.md @@ -0,0 +1,9 @@ +--- +"@offload-project/rrule": minor +--- + +Add `validate` function for checking RRULE and RRuleSet strings without throwing. + +- New `validate(s, options?)` function that returns `{ valid: true }` or `{ valid: false, error: { message, cause } }` +- Accepts the same string formats and options as `rrulestr` +- Exported `ValidationResult`, `ValidationSuccess`, and `ValidationError` types diff --git a/README.md b/README.md index ef3e6ccd..4357793e 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ $ npm install @offload-project/rrule **RRule:** ```js -import { datetime, RRule, RRuleSet, rrulestr } from 'rrule' +import { datetime, RRule, RRuleSet, rrulestr, validate } from 'rrule' // Create a rule: const rule = new RRule({ @@ -160,6 +160,19 @@ rrulestr( ) ``` +**validate:** + +```js +import { validate } from '@offload-project/rrule' + +// Check if a string is a valid rule or ruleset +validate('RRULE:FREQ=WEEKLY;COUNT=3') +// { valid: true } + +validate('RRULE:FREQ=BOGUS') +// { valid: false, error: { message: 'Invalid frequency: ...', cause: Error } } +``` + ### Important: Use UTC dates Dates in JavaScript are tricky. `RRule` tries to support as much flexibility as possible without adding any large @@ -743,6 +756,60 @@ Additionally, it accepts the following keyword arguments: --- +#### `validate` Function + +```js +validate(rruleStr[, options]) +``` + +Validates an RRULE or RRuleSet string without throwing. Accepts the same string formats and options as `rrulestr`. + +Returns a `ValidationResult`: + +```js +import { validate } from '@offload-project/rrule' + +// Valid single rule +validate('RRULE:FREQ=WEEKLY;COUNT=3') +// { valid: true } + +// Valid ruleset string +validate( + 'DTSTART:19970902T090000Z\n' + + 'RRULE:FREQ=YEARLY;COUNT=6;BYDAY=TU,TH\n' + + 'EXDATE:19970911T090000Z' +) +// { valid: true } + +// Invalid input +validate('RRULE:FREQ=BOGUS') +// { valid: false, error: { message: 'Invalid frequency: ...', cause: Error } } + +// With options (same as rrulestr options) +validate('RRULE:FREQ=DAILY', { dtstart: new Date('1997-09-02T09:00:00Z') }) +// { valid: true } +``` + +The result type is a discriminated union: + +```ts +interface ValidationSuccess { + valid: true +} + +interface ValidationError { + valid: false + error: { + message: string // Human-readable error description + cause?: Error // Original error object with stack trace + } +} + +type ValidationResult = ValidationSuccess | ValidationError +``` + +--- + ### Differences From iCalendar RFC - `RRule` has no `byday` keyword. The equivalent keyword has been replaced by the `byweekday` keyword, to remove the diff --git a/src/index.ts b/src/index.ts index 6a7667b4..81504a09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,4 +21,5 @@ export { RRule } from './rrule'; export { RRuleBase } from './rrulebase'; export { RRuleSet } from './rruleset'; export { type ByWeekday, Frequency, type Options } from './types'; +export { type ValidationError, type ValidationResult, type ValidationSuccess, validate } from './validate'; export { ALL_WEEKDAYS, Weekday, type WeekdayStr } from './weekday'; diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 00000000..11b5fcc7 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,31 @@ +import { type RRuleStrOptions, rrulestr } from './parse/rrulestr'; + +export interface ValidationSuccess { + valid: true; +} + +export interface ValidationError { + valid: false; + error: { + message: string; + cause?: Error; + }; +} + +export type ValidationResult = ValidationSuccess | ValidationError; + +export function validate(s: string, options?: Partial): ValidationResult { + try { + rrulestr(s, options); + return { valid: true }; + } catch (e) { + const cause = e instanceof Error ? e : new Error(String(e)); + return { + valid: false, + error: { + message: cause.message, + cause, + }, + }; + } +} diff --git a/test/validate.test.ts b/test/validate.test.ts new file mode 100644 index 00000000..72f4fcd2 --- /dev/null +++ b/test/validate.test.ts @@ -0,0 +1,108 @@ +import { validate } from '../src'; + +describe('validate', () => { + describe('valid inputs', () => { + it('validates a simple RRULE string', () => { + const result = validate('FREQ=YEARLY;COUNT=3'); + expect(result.valid).toBe(true); + }); + + it('validates an RRULE with DTSTART', () => { + const result = validate('DTSTART:19970902T090000Z\nRRULE:FREQ=YEARLY;COUNT=3'); + expect(result.valid).toBe(true); + }); + + it('validates a full ruleset string', () => { + const result = validate( + 'DTSTART:19970902T090000Z\n' + + 'RRULE:FREQ=YEARLY;COUNT=6;BYDAY=TU,TH\n' + + 'EXRULE:FREQ=YEARLY;COUNT=3;BYDAY=TH\n' + + 'RDATE:19970904T090000Z\n' + + 'EXDATE:19970911T090000Z', + ); + expect(result.valid).toBe(true); + }); + + it('validates with TZID', () => { + const result = validate('DTSTART;TZID=America/New_York:19970902T090000\nRRULE:FREQ=DAILY'); + expect(result.valid).toBe(true); + }); + + it('validates with options.dtstart provided', () => { + const result = validate('RRULE:FREQ=WEEKLY;COUNT=3', { + dtstart: new Date('1997-09-02T09:00:00Z'), + }); + expect(result.valid).toBe(true); + }); + }); + + describe('invalid inputs', () => { + it('rejects empty string', () => { + const result = validate(''); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error.message).toContain('Invalid empty string'); + } + }); + + it('rejects unsupported property', () => { + const result = validate('DTSTART:19970902T090000Z\nVTODO:something'); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error.message).toContain('unsupported property'); + } + }); + + it('rejects unknown RRULE property', () => { + const result = validate('RRULE:FREQ=YEARLY;BOGUSPROP=1'); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error.message).toContain("Unknown RRULE property 'BOGUSPROP'"); + } + }); + + it('rejects invalid weekday string', () => { + const result = validate('RRULE:FREQ=WEEKLY;BYDAY=XY'); + expect(result.valid).toBe(false); + }); + + it('rejects invalid UNTIL date format', () => { + const result = validate('RRULE:FREQ=YEARLY;UNTIL=not-a-date'); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error.message).toContain('Invalid UNTIL value'); + } + }); + + it('rejects invalid frequency', () => { + const result = validate('RRULE:FREQ=BOGUS'); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error.message).toContain('Invalid frequency'); + } + }); + + it('rejects unsupported RDATE parm', () => { + const result = validate('DTSTART:19970902T090000Z\nRDATE;BOGUS=1:19970904T090000Z'); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error.message).toContain('unsupported RDATE/EXDATE parm'); + } + }); + }); + + describe('contract', () => { + it('never throws', () => { + expect(() => validate(null as unknown as string)).not.toThrow(); + expect(() => validate(undefined as unknown as string)).not.toThrow(); + expect(() => validate(123 as unknown as string)).not.toThrow(); + }); + + it('returns error.cause as an Error instance', () => { + const result = validate(''); + if (!result.valid) { + expect(result.error.cause).toBeInstanceOf(Error); + } + }); + }); +});