1use crate::stats::FieldStats;
2use crate::types::JsonType;
3use serde::Serialize;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
8pub enum Severity {
9 Info,
10 Warning,
11 Critical, }
13
14impl std::fmt::Display for Severity {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self {
17 Severity::Info => write!(f, "Info"),
18 Severity::Warning => write!(f, "Warning"),
19 Severity::Critical => write!(f, "Critical"),
20 }
21 }
22}
23
24#[derive(Debug, Clone, PartialEq, Serialize)]
26pub enum DriftIssue {
27 TypeInconsistency {
29 path: String,
30 types: HashMap<JsonType, TypeDistribution>,
31 minority_percentage: f64,
32 },
33 GhostKey {
35 path: String,
36 density: f64,
37 occurunces: u64,
38 total_samples: u64,
39 },
40 SparseField {
42 path: String,
43 density: f64,
44 occurrences: u64,
45 total_samples: u64,
46 },
47 MissingKey {
49 path: String,
50 density: f64,
51 expected_occurrences: u64,
52 actual_occurrences: u64,
53 },
54 SchemaEvolution {
56 path: String,
57 pattern: EvolutionPattern,
58 },
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize)]
63pub struct TypeDistribution {
64 pub json_type: JsonType,
65 pub count: u64,
66 pub percentage: f64,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize)]
71pub enum EvolutionPattern {
72 VersionMarker { marker_path: String },
74 DeprecatedNaming { old_path: String, new_path: String },
76 MutuallyExclusive { paths: Vec<String> },
78}
79
80impl DriftIssue {
81 pub fn severity(&self) -> Severity {
83 match self {
84 DriftIssue::TypeInconsistency {
85 minority_percentage,
86 ..
87 } => {
88 if *minority_percentage >= 10.0 {
89 Severity::Critical
90 } else if *minority_percentage >= 5.0 {
91 Severity::Warning
92 } else {
93 Severity::Info
94 }
95 }
96 DriftIssue::MissingKey { density, .. } => {
97 if *density < 0.90 {
98 Severity::Critical
99 } else if *density < 0.95 {
100 Severity::Warning
101 } else {
102 Severity::Info
103 }
104 }
105 DriftIssue::GhostKey { .. } => Severity::Info,
106 DriftIssue::SparseField { .. } => Severity::Info,
107 DriftIssue::SchemaEvolution { .. } => Severity::Warning,
108 }
109 }
110
111 pub fn path(&self) -> &str {
113 match self {
114 DriftIssue::TypeInconsistency { path, .. } => path,
115 DriftIssue::GhostKey { path, .. } => path,
116 DriftIssue::SparseField { path, .. } => path,
117 DriftIssue::MissingKey { path, .. } => path,
118 DriftIssue::SchemaEvolution { path, .. } => path,
119 }
120 }
121
122 pub fn description(&self) -> String {
124 match self {
125 DriftIssue::TypeInconsistency {
126 types,
127 minority_percentage,
128 ..
129 } => {
130 let mut type_list: Vec<_> = types.values().collect();
131 type_list.sort_by(|a, b| b.percentage.partial_cmp(&a.percentage).unwrap());
132 let type_sts: Vec<String> = type_list
133 .iter()
134 .map(|td| format!("{}:{:.1}", td.json_type, td.percentage))
135 .collect();
136 format!(
137 "Type inconsistency (minority: {:.1}%: {}",
138 minority_percentage,
139 type_sts.join(", ")
140 )
141 }
142 DriftIssue::GhostKey {
143 density,
144 occurunces,
145 total_samples,
146 ..
147 } => {
148 format!(
149 "Ghost key: {:.2}% present ({}/{} samples)",
150 density * 100.0,
151 occurunces,
152 total_samples
153 )
154 }
155 DriftIssue::SparseField {
156 density,
157 occurrences,
158 total_samples,
159 ..
160 } => {
161 format!(
162 "Sparse field: {:.2}% present ({}/{} samples)",
163 density * 100.0,
164 occurrences,
165 total_samples
166 )
167 }
168 DriftIssue::MissingKey {
169 density,
170 actual_occurrences,
171 expected_occurrences,
172 ..
173 } => {
174 let missing_count = expected_occurrences - actual_occurrences;
175 let missing_percentage = (1.0 - density) * 100.0;
176 format!(
177 "Missing key: {:.2}% missing ({}/{} samples missing field)",
178 missing_percentage, missing_count, expected_occurrences
179 )
180 }
181
182 DriftIssue::SchemaEvolution { pattern, .. } => match pattern {
183 EvolutionPattern::VersionMarker { marker_path } => {
184 format!("Schema evolution: version marker '{}'", marker_path)
185 }
186 EvolutionPattern::DeprecatedNaming { old_path, new_path } => {
187 format!(
188 "Schema evolution: deprecated field '{}' → '{}'",
189 old_path, new_path
190 )
191 }
192 EvolutionPattern::MutuallyExclusive { paths } => {
193 format!(
194 "Schema evolution: mutually exclusive fields: {}",
195 paths.join(", ")
196 )
197 }
198 },
199 }
200 }
201}
202
203#[derive(Debug, Clone)]
213pub struct DriftConfig {
214 pub type_inconsistency_threshold: f64,
216 pub ghost_key_threshold: f64,
218 pub sparse_field_threshold: f64,
220 pub missing_key_threshold: f64,
222 pub detect_schema_evolution: bool,
224}
225
226impl Default for DriftConfig {
227 fn default() -> Self {
228 Self {
229 type_inconsistency_threshold: 5.0,
230 ghost_key_threshold: 0.10,
231 sparse_field_threshold: 0.80,
232 missing_key_threshold: 0.95,
233 detect_schema_evolution: true,
234 }
235 }
236}
237
238pub fn detect_drift(stats: &HashMap<String, FieldStats>, config: &DriftConfig) -> Vec<DriftIssue> {
240 let mut issues = Vec::new();
241 for field_stats in stats.values() {
242 if let Some(issue) = detect_type_inconsistency(field_stats, config) {
243 issues.push(issue);
244 }
245 if let Some(issue) = detect_ghost_key(field_stats, config) {
246 issues.push(issue);
247 }
248 if let Some(issue) = detect_sparse_field(field_stats, config) {
249 issues.push(issue);
250 }
251 if let Some(issue) = detect_missing_key(field_stats, config) {
252 issues.push(issue);
253 }
254 }
255
256 if config.detect_schema_evolution {
257 issues.extend(detect_schema_evolution(stats));
258 }
259
260 issues.sort_by(|a, b| {
261 b.severity()
262 .cmp(&a.severity())
263 .then_with(|| a.path().cmp(b.path()))
264 });
265
266 issues
267}
268
269fn detect_type_inconsistency(stats: &FieldStats, config: &DriftConfig) -> Option<DriftIssue> {
271 if stats.types.len() < 2 {
273 return None;
274 }
275
276 let total_typed: u64 = stats.types.values().sum();
277 if total_typed == 0 {
278 return None;
279 }
280
281 let mut type_distributions: HashMap<JsonType, TypeDistribution> = HashMap::new();
283 for (json_type, count) in &stats.types {
284 let percentage = (*count as f64 / total_typed as f64) * 100.0;
285 type_distributions.insert(
286 *json_type,
287 TypeDistribution {
288 json_type: *json_type,
289 count: *count,
290 percentage,
291 },
292 );
293 }
294
295 let max_count = stats.types.values().max().copied().unwrap_or(0);
297 let minority_count: u64 = stats.types.values().filter(|&&c| c != max_count).sum();
298
299 let minority_percentage = (minority_count as f64 / total_typed as f64) * 100.0;
300
301 if minority_percentage >= config.type_inconsistency_threshold {
303 Some(DriftIssue::TypeInconsistency {
304 path: stats.path.clone(),
305 types: type_distributions,
306 minority_percentage,
307 })
308 } else {
309 None
310 }
311}
312
313fn detect_ghost_key(stats: &FieldStats, config: &DriftConfig) -> Option<DriftIssue> {
315 if stats.density <= config.ghost_key_threshold && stats.density > 0.0 {
316 Some(DriftIssue::GhostKey {
317 path: stats.path.clone(),
318 density: stats.density,
319 occurunces: stats.occurrences,
320 total_samples: stats.total_samples,
321 })
322 } else {
323 None
324 }
325}
326
327fn detect_sparse_field(stats: &FieldStats, config: &DriftConfig) -> Option<DriftIssue> {
329 if stats.density > config.ghost_key_threshold && stats.density <= config.sparse_field_threshold
331 {
332 Some(DriftIssue::SparseField {
333 path: stats.path.clone(),
334 density: stats.density,
335 occurrences: stats.occurrences,
336 total_samples: stats.total_samples,
337 })
338 } else {
339 None
340 }
341}
342
343fn detect_missing_key(stats: &FieldStats, config: &DriftConfig) -> Option<DriftIssue> {
345 if stats.density > config.sparse_field_threshold && stats.density < config.missing_key_threshold
347 {
348 let expected_occurrences = stats.total_samples;
349 Some(DriftIssue::MissingKey {
350 path: stats.path.clone(),
351 density: stats.density,
352 expected_occurrences,
353 actual_occurrences: stats.occurrences,
354 })
355 } else {
356 None
357 }
358}
359
360fn detect_schema_evolution(stats: &HashMap<String, FieldStats>) -> Vec<DriftIssue> {
362 let mut issues = Vec::new();
364
365 let version_markers = ["version", "schema_version", "v", "api_version"];
367 for path in stats.keys() {
368 let path_segments: Vec<&str> = path.split('.').collect();
369 for marker in &version_markers {
370 if path_segments
372 .iter()
373 .any(|seg| seg.to_lowercase() == *marker)
374 {
375 issues.push(DriftIssue::SchemaEvolution {
376 path: path.clone(),
377 pattern: EvolutionPattern::VersionMarker {
378 marker_path: path.clone(),
379 },
380 });
381 break;
382 }
383 }
384 }
385
386 let deprecated_prefixes = ["old_", "legacy_", "deprecated_"];
388 for path in stats.keys() {
389 for prefix in &deprecated_prefixes {
390 if path.to_lowercase().starts_with(prefix) {
391 let potential_new = path.replacen(prefix, "", 1);
393 if stats.contains_key(&potential_new) {
394 issues.push(DriftIssue::SchemaEvolution {
395 path: path.clone(),
396 pattern: EvolutionPattern::DeprecatedNaming {
397 old_path: path.clone(),
398 new_path: potential_new,
399 },
400 });
401 }
402 break;
403 }
404 }
405 }
406
407 let mut path_families: HashMap<String, Vec<String>> = HashMap::new();
411 for path in stats.keys() {
412 if let Some(base) = path.rsplit_once('_').map(|(base, _)| base) {
414 path_families
415 .entry(base.to_string())
416 .or_default()
417 .push(path.clone());
418 }
419 }
420
421 for (base, paths) in path_families {
422 if paths.len() >= 2 {
423 let densities: Vec<f64> = paths
425 .iter()
426 .filter_map(|p| stats.get(p).map(|s| s.density))
427 .collect();
428 if densities.len() >= 2 {
429 let sum: f64 = densities.iter().sum();
430 let max = densities.iter().copied().fold(0.0f64, f64::max);
431 if (sum - max).abs() < 0.1 {
433 issues.push(DriftIssue::SchemaEvolution {
434 path: base,
435 pattern: EvolutionPattern::MutuallyExclusive { paths },
436 });
437 }
438 }
439 }
440 }
441
442 issues
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 fn create_field_stats(
450 path: &str,
451 occurrences: u64,
452 total_samples: u64,
453 types: Vec<(JsonType, u64)>,
454 ) -> FieldStats {
455 let mut stats = FieldStats::new(path.to_string(), 1);
456 stats.occurrences = occurrences;
457 stats.total_samples = total_samples;
458 stats.density = occurrences as f64 / total_samples as f64;
459 for (json_type, count) in types {
460 stats.types.insert(json_type, count);
461 }
462 stats
463 }
464
465 #[test]
466 fn test_severity_ordering() {
467 assert!(Severity::Critical > Severity::Warning);
468 assert!(Severity::Warning > Severity::Info);
469 }
470
471 #[test]
472 fn test_type_inconsistency_detection() {
473 let config = DriftConfig::default();
474
475 let stats = create_field_stats(
477 "user.age",
478 100,
479 100,
480 vec![(JsonType::String, 92), (JsonType::Number, 8)],
481 );
482
483 let issue = detect_type_inconsistency(&stats, &config);
484 assert!(issue.is_some());
485
486 let issue = issue.unwrap();
487 assert_eq!(issue.severity(), Severity::Warning);
488 assert!(matches!(issue, DriftIssue::TypeInconsistency { .. }));
489
490 if let DriftIssue::TypeInconsistency {
491 minority_percentage,
492 types,
493 ..
494 } = issue
495 {
496 assert_eq!(minority_percentage, 8.0);
497 assert_eq!(types.len(), 2);
498 }
499 }
500
501 #[test]
502 fn test_type_inconsistency_critical_threshold() {
503 let config = DriftConfig::default();
504
505 let stats = create_field_stats(
507 "user.age",
508 100,
509 100,
510 vec![(JsonType::String, 85), (JsonType::Number, 15)],
511 );
512
513 let issue = detect_type_inconsistency(&stats, &config).unwrap();
514 assert_eq!(issue.severity(), Severity::Critical);
515 }
516
517 #[test]
518 fn test_type_inconsistency_below_threshold() {
519 let config = DriftConfig::default();
520
521 let stats = create_field_stats(
523 "user.name",
524 100,
525 100,
526 vec![(JsonType::String, 98), (JsonType::Number, 2)],
527 );
528
529 let issue = detect_type_inconsistency(&stats, &config);
530 assert!(issue.is_none());
531 }
532
533 #[test]
534 fn test_ghost_key_detection() {
535 let config = DriftConfig::default();
536
537 let stats = create_field_stats("billing.legacy_plan", 5, 1000, vec![(JsonType::String, 5)]);
539
540 let issue = detect_ghost_key(&stats, &config);
541 assert!(issue.is_some());
542
543 let issue = issue.unwrap();
544 assert!(matches!(issue, DriftIssue::GhostKey { .. }));
545 assert_eq!(issue.severity(), Severity::Info);
546
547 if let DriftIssue::GhostKey {
548 density,
549 occurunces,
550 total_samples,
551 ..
552 } = issue
553 {
554 assert_eq!(density, 0.005);
555 assert_eq!(occurunces, 5);
556 assert_eq!(total_samples, 1000);
557 }
558 }
559
560 #[test]
561 fn test_ghost_key_below_threshold() {
562 let config = DriftConfig::default();
563
564 let stats =
566 create_field_stats("user.middle_name", 150, 1000, vec![(JsonType::String, 150)]);
567
568 let issue = detect_ghost_key(&stats, &config);
569 assert!(issue.is_none());
570 }
571
572 #[test]
573 fn test_missing_key_detection() {
574 let config = DriftConfig::default();
575
576 let stats = create_field_stats("user.email", 850, 1000, vec![(JsonType::String, 850)]);
578
579 let issue = detect_missing_key(&stats, &config);
580 assert!(issue.is_some());
581
582 let issue = issue.unwrap();
583 assert!(matches!(issue, DriftIssue::MissingKey { .. }));
584 assert_eq!(issue.severity(), Severity::Critical);
585 }
586
587 #[test]
588 fn test_missing_key_warning_threshold() {
589 let config = DriftConfig::default();
590
591 let stats = create_field_stats("user.phone", 920, 1000, vec![(JsonType::String, 920)]);
593
594 let issue = detect_missing_key(&stats, &config);
595 assert!(issue.is_some());
596 assert_eq!(issue.unwrap().severity(), Severity::Warning);
597 }
598
599 #[test]
600 fn test_missing_key_above_threshold() {
601 let config = DriftConfig::default();
602
603 let stats = create_field_stats("user.id", 980, 1000, vec![(JsonType::Number, 980)]);
605
606 let issue = detect_missing_key(&stats, &config);
607 assert!(issue.is_none());
608 }
609
610 #[test]
611 fn test_schema_evolution_version_marker() {
612 let mut stats = HashMap::new();
613 stats.insert(
614 "schema_version".to_string(),
615 create_field_stats("schema_version", 1000, 1000, vec![(JsonType::Number, 1000)]),
616 );
617
618 let issues = detect_schema_evolution(&stats);
619 assert_eq!(issues.len(), 1);
620 assert!(matches!(
621 issues[0],
622 DriftIssue::SchemaEvolution {
623 pattern: EvolutionPattern::VersionMarker { .. },
624 ..
625 }
626 ));
627 }
628
629 #[test]
630 fn test_schema_evolution_deprecated_naming() {
631 let mut stats = HashMap::new();
632 stats.insert(
633 "legacy_address".to_string(),
634 create_field_stats("legacy_address", 100, 1000, vec![(JsonType::String, 100)]),
635 );
636 stats.insert(
637 "address".to_string(),
638 create_field_stats("address", 900, 1000, vec![(JsonType::String, 900)]),
639 );
640
641 let issues = detect_schema_evolution(&stats);
642 assert!(!issues.is_empty());
643
644 let deprecated = issues.iter().find(|i| {
645 matches!(
646 i,
647 DriftIssue::SchemaEvolution {
648 pattern: EvolutionPattern::DeprecatedNaming { .. },
649 ..
650 }
651 )
652 });
653 assert!(deprecated.is_some());
654 }
655
656 #[test]
657 fn test_detect_drift_comprehensive() {
658 let mut stats = HashMap::new();
659
660 stats.insert(
662 "user.age".to_string(),
663 create_field_stats(
664 "user.age",
665 100,
666 100,
667 vec![(JsonType::String, 92), (JsonType::Number, 8)],
668 ),
669 );
670
671 stats.insert(
673 "billing.legacy_plan".to_string(),
674 create_field_stats("billing.legacy_plan", 5, 1000, vec![(JsonType::String, 5)]),
675 );
676
677 stats.insert(
679 "user.email".to_string(),
680 create_field_stats("user.email", 850, 1000, vec![(JsonType::String, 850)]),
681 );
682
683 stats.insert(
685 "version".to_string(),
686 create_field_stats("version", 1000, 1000, vec![(JsonType::Number, 1000)]),
687 );
688
689 let config = DriftConfig::default();
690 let issues = detect_drift(&stats, &config);
691
692 assert!(issues.len() >= 4);
694
695 let severities: Vec<Severity> = issues.iter().map(|i| i.severity()).collect();
697 let mut sorted_severities = severities.clone();
698 sorted_severities.sort_by(|a, b| b.cmp(a));
699 assert_eq!(severities, sorted_severities);
700 }
701
702 #[test]
703 fn test_drift_issue_description() {
704 let issue = DriftIssue::TypeInconsistency {
705 path: "user.age".to_string(),
706 types: {
707 let mut map = HashMap::new();
708 map.insert(
709 JsonType::String,
710 TypeDistribution {
711 json_type: JsonType::String,
712 count: 92,
713 percentage: 92.0,
714 },
715 );
716 map.insert(
717 JsonType::Number,
718 TypeDistribution {
719 json_type: JsonType::Number,
720 count: 8,
721 percentage: 8.0,
722 },
723 );
724 map
725 },
726 minority_percentage: 8.0,
727 };
728
729 let desc = issue.description();
730 assert!(desc.contains("Type inconsistency"));
731 assert!(desc.contains("8.0%"));
732 }
733
734 #[test]
735 fn test_custom_config_thresholds() {
736 let config = DriftConfig {
737 type_inconsistency_threshold: 10.0,
738 ghost_key_threshold: 0.005,
739 sparse_field_threshold: 0.70,
740 missing_key_threshold: 0.99,
741 detect_schema_evolution: false,
742 };
743
744 let stats = create_field_stats(
746 "user.age",
747 100,
748 100,
749 vec![(JsonType::String, 92), (JsonType::Number, 8)],
750 );
751
752 let issue = detect_type_inconsistency(&stats, &config);
753 assert!(issue.is_none());
754 }
755}