Skip to content

Commit 8078870

Browse files
domdomeggclaudeuntitaker
authored
feat: Add string validation support (pattern, minLength, maxLength) (#51)
* feat: Add string validation support (pattern, minLength, maxLength) Implements detection of JSON Schema string validation constraints. Changes: - Add 9 new change types for pattern, minLength, and maxLength - Pattern changes conservatively treated as breaking (regex subset detection is complex and out of scope) - MinLength/MaxLength with smart directional breaking logic: - Increasing minLength: breaking (rejects shorter strings) - Decreasing minLength: non-breaking (accepts more strings) - Decreasing maxLength: breaking (rejects longer strings) - Increasing maxLength: non-breaking (accepts more strings) - Comprehensive test coverage: 14 test fixtures covering all scenarios Fixes #23 Fixes #50 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * cargo fmt --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
1 parent 6499f1c commit 8078870

30 files changed

Lines changed: 499 additions & 0 deletions

src/diff_walker.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,104 @@ impl<F: FnMut(Change)> DiffWalker<F> {
387387
}
388388
}
389389

390+
fn diff_pattern(&mut self, json_path: &str, lhs: &mut SchemaObject, rhs: &mut SchemaObject) {
391+
let lhs_pattern = &lhs.string().pattern;
392+
let rhs_pattern = &rhs.string().pattern;
393+
394+
match (lhs_pattern, rhs_pattern) {
395+
(Some(lhs_pat), Some(rhs_pat)) if lhs_pat != rhs_pat => {
396+
(self.cb)(Change {
397+
path: json_path.to_owned(),
398+
change: ChangeKind::PatternChange {
399+
old_pattern: lhs_pat.clone(),
400+
new_pattern: rhs_pat.clone(),
401+
},
402+
});
403+
}
404+
(Some(removed_pat), None) => {
405+
(self.cb)(Change {
406+
path: json_path.to_owned(),
407+
change: ChangeKind::PatternRemove {
408+
removed: removed_pat.clone(),
409+
},
410+
});
411+
}
412+
(None, Some(added_pat)) => {
413+
(self.cb)(Change {
414+
path: json_path.to_owned(),
415+
change: ChangeKind::PatternAdd {
416+
added: added_pat.clone(),
417+
},
418+
});
419+
}
420+
_ => {} // No change or both None
421+
}
422+
}
423+
424+
fn diff_min_length(&mut self, json_path: &str, lhs: &mut SchemaObject, rhs: &mut SchemaObject) {
425+
let lhs_min = lhs.string().min_length;
426+
let rhs_min = rhs.string().min_length;
427+
428+
match (lhs_min, rhs_min) {
429+
(Some(lhs_val), Some(rhs_val)) if lhs_val != rhs_val => {
430+
(self.cb)(Change {
431+
path: json_path.to_owned(),
432+
change: ChangeKind::MinLengthChange {
433+
old_value: lhs_val,
434+
new_value: rhs_val,
435+
},
436+
});
437+
}
438+
(Some(removed_val), None) => {
439+
(self.cb)(Change {
440+
path: json_path.to_owned(),
441+
change: ChangeKind::MinLengthRemove {
442+
removed: removed_val,
443+
},
444+
});
445+
}
446+
(None, Some(added_val)) => {
447+
(self.cb)(Change {
448+
path: json_path.to_owned(),
449+
change: ChangeKind::MinLengthAdd { added: added_val },
450+
});
451+
}
452+
_ => {} // No change or both None
453+
}
454+
}
455+
456+
fn diff_max_length(&mut self, json_path: &str, lhs: &mut SchemaObject, rhs: &mut SchemaObject) {
457+
let lhs_max = lhs.string().max_length;
458+
let rhs_max = rhs.string().max_length;
459+
460+
match (lhs_max, rhs_max) {
461+
(Some(lhs_val), Some(rhs_val)) if lhs_val != rhs_val => {
462+
(self.cb)(Change {
463+
path: json_path.to_owned(),
464+
change: ChangeKind::MaxLengthChange {
465+
old_value: lhs_val,
466+
new_value: rhs_val,
467+
},
468+
});
469+
}
470+
(Some(removed_val), None) => {
471+
(self.cb)(Change {
472+
path: json_path.to_owned(),
473+
change: ChangeKind::MaxLengthRemove {
474+
removed: removed_val,
475+
},
476+
});
477+
}
478+
(None, Some(added_val)) => {
479+
(self.cb)(Change {
480+
path: json_path.to_owned(),
481+
change: ChangeKind::MaxLengthAdd { added: added_val },
482+
});
483+
}
484+
_ => {} // No change or both None
485+
}
486+
}
487+
390488
fn resolve_references(
391489
&self,
392490
lhs: &mut SchemaObject,
@@ -496,6 +594,9 @@ impl<F: FnMut(Change)> DiffWalker<F> {
496594
}
497595
self.diff_const(json_path, lhs, rhs);
498596
self.diff_format(json_path, lhs, rhs);
597+
self.diff_pattern(json_path, lhs, rhs);
598+
self.diff_min_length(json_path, lhs, rhs);
599+
self.diff_max_length(json_path, lhs, rhs);
499600
// If we split the types, we don't want to compare type-specific properties
500601
// because they are already compared in the `Self::diff_any_of`
501602
if !is_lhs_split && !is_rhs_split {

src/types.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,57 @@ pub enum ChangeKind {
124124
/// The new format value.
125125
new_format: String,
126126
},
127+
/// A pattern constraint has been added.
128+
PatternAdd {
129+
/// The pattern that was added.
130+
added: String,
131+
},
132+
/// A pattern constraint has been removed.
133+
PatternRemove {
134+
/// The pattern that was removed.
135+
removed: String,
136+
},
137+
/// A pattern constraint has been changed.
138+
PatternChange {
139+
/// The old pattern value.
140+
old_pattern: String,
141+
/// The new pattern value.
142+
new_pattern: String,
143+
},
144+
/// A minLength constraint has been added.
145+
MinLengthAdd {
146+
/// The minLength value that was added.
147+
added: u32,
148+
},
149+
/// A minLength constraint has been removed.
150+
MinLengthRemove {
151+
/// The minLength value that was removed.
152+
removed: u32,
153+
},
154+
/// A minLength constraint has been changed.
155+
MinLengthChange {
156+
/// The old minLength value.
157+
old_value: u32,
158+
/// The new minLength value.
159+
new_value: u32,
160+
},
161+
/// A maxLength constraint has been added.
162+
MaxLengthAdd {
163+
/// The maxLength value that was added.
164+
added: u32,
165+
},
166+
/// A maxLength constraint has been removed.
167+
MaxLengthRemove {
168+
/// The maxLength value that was removed.
169+
removed: u32,
170+
},
171+
/// A maxLength constraint has been changed.
172+
MaxLengthChange {
173+
/// The old maxLength value.
174+
old_value: u32,
175+
/// The new maxLength value.
176+
new_value: u32,
177+
},
127178
}
128179

129180
impl ChangeKind {
@@ -170,6 +221,25 @@ impl ChangeKind {
170221
Self::FormatAdd { .. } => true,
171222
Self::FormatRemove { .. } => false,
172223
Self::FormatChange { .. } => true,
224+
// Pattern changes are conservatively treated as breaking.
225+
// Determining if one regex is a subset of another requires complex analysis.
226+
Self::PatternAdd { .. } => true,
227+
Self::PatternRemove { .. } => false,
228+
Self::PatternChange { .. } => true,
229+
// MinLength: increasing restricts (breaking), decreasing relaxes (non-breaking)
230+
Self::MinLengthAdd { .. } => true,
231+
Self::MinLengthRemove { .. } => false,
232+
Self::MinLengthChange {
233+
old_value,
234+
new_value,
235+
} => new_value > old_value,
236+
// MaxLength: decreasing restricts (breaking), increasing relaxes (non-breaking)
237+
Self::MaxLengthAdd { .. } => true,
238+
Self::MaxLengthRemove { .. } => false,
239+
Self::MaxLengthChange {
240+
old_value,
241+
new_value,
242+
} => new_value < old_value,
173243
}
174244
}
175245
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"lhs": { "type": "string" },
3+
"rhs": { "type": "string", "maxLength": 10 }
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"lhs": { "type": "string", "maxLength": 10 },
3+
"rhs": { "type": "string", "maxLength": 5 }
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"lhs": { "type": "string", "maxLength": 5 },
3+
"rhs": { "type": "string", "maxLength": 10 }
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"lhs": { "type": "string", "maxLength": 10 },
3+
"rhs": { "type": "string" }
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"lhs": { "type": "string", "maxLength": 10 },
3+
"rhs": { "type": "string", "maxLength": 10 }
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"lhs": { "type": "string" },
3+
"rhs": { "type": "string", "minLength": 5 }
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"lhs": { "type": "string", "minLength": 5 },
3+
"rhs": { "type": "string", "minLength": 3 }
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"lhs": { "type": "string", "minLength": 3 },
3+
"rhs": { "type": "string", "minLength": 5 }
4+
}

0 commit comments

Comments
 (0)