seedfaker_core/
validate.rs1use crate::field::Ordering;
8
9pub enum Severity {
10 Error,
11 Warn,
12}
13
14pub struct Rule {
15 pub id: &'static str,
16 pub severity: Severity,
17 pub check: fn(&CheckCtx<'_>) -> Option<&'static str>,
18}
19
20pub struct CheckCtx<'a> {
21 pub fields: &'a [FieldInfo<'a>],
22 pub ctx_strict: bool,
23 pub since: i64,
24 pub until: i64,
25 pub has_seed: bool,
26 pub has_until: bool,
27 pub format: Option<&'a str>,
28 pub corrupt: Option<&'a str>,
29 pub has_template: bool,
30}
31
32pub struct FieldInfo<'a> {
33 pub name: &'a str,
34 pub has_range: bool,
35 pub resolved_range: Option<(i64, i64)>,
36 pub ordering: Ordering,
37}
38
39impl CheckCtx<'_> {
40 pub fn has(&self, name: &str) -> bool {
41 self.fields.iter().any(|f| f.name == name)
42 }
43
44 pub fn has_range(&self, name: &str) -> bool {
45 self.fields.iter().any(|f| f.name == name && f.has_range)
46 }
47
48 pub fn has_ordering(&self, name: &str) -> bool {
49 self.fields.iter().any(|f| f.name == name && f.ordering != Ordering::None)
50 }
51
52 pub fn age_range(&self) -> Option<(i64, i64)> {
54 self.fields
55 .iter()
56 .find(|f| f.name == "age" && f.has_range)
57 .map(|f| f.resolved_range.unwrap_or((0, 120)))
58 }
59}
60
61pub const RULES: &[Rule] = &[
62 Rule {
63 id: "age-range-ctx-birthdate",
64 severity: Severity::Error,
65 check: |c| {
66 if !c.ctx_strict || !c.has("age") || !c.has("birthdate") {
67 return None;
68 }
69 let Some(age_range) = c.age_range() else {
72 return None; };
74 let implied_birth_from = c.until - age_range.1;
76 let implied_birth_to = c.until - age_range.0;
77 let actual_birth_from = c.since;
79 let actual_birth_to = c.until;
80 if implied_birth_to < actual_birth_from || implied_birth_from > actual_birth_to {
82 Some(
83 "age range impossible with current year parameters; \
84 age is derived from birthdate in --ctx strict and \
85 the implied birth years have no overlap with year range",
86 )
87 } else {
88 Some(
90 "age range with birthdate in --ctx strict: \
91 age is derived from birthdate — range on age is ignored. \
92 Remove birthdate to use age range, or remove range from age",
93 )
94 }
95 },
96 },
97 Rule {
98 id: "ordering-numeric-only",
99 severity: Severity::Error,
100 check: |c| {
101 const ORDERABLE: &[&str] =
102 &["integer", "float", "amount", "timestamp", "date", "age", "latency", "digits"];
103 for f in c.fields {
104 if f.ordering != Ordering::None && !ORDERABLE.contains(&f.name) {
105 return Some("asc/desc only supported on numeric and temporal fields");
106 }
107 }
108 None
109 },
110 },
111 Rule {
112 id: "year-range-sanity",
113 severity: Severity::Error,
114 check: |c| {
115 if c.since > c.until {
116 Some("--since must be <= --until")
117 } else {
118 None
119 }
120 },
121 },
122 Rule {
123 id: "format-template-conflict",
124 severity: Severity::Error,
125 check: |c| {
126 if c.format.is_some() && c.has_template {
127 Some("use --format or --template, not both")
128 } else {
129 None
130 }
131 },
132 },
133 Rule {
134 id: "format-unknown",
135 severity: Severity::Error,
136 check: |c| match c.format {
137 Some(s) if s != "csv" && s != "tsv" && s != "jsonl" && !s.starts_with("sql=") => {
138 Some("unknown format; expected: csv, tsv, jsonl, sql=TABLE")
139 }
140 Some("sql=") => Some("sql= requires a table name"),
141 _ => None,
142 },
143 },
144 Rule {
145 id: "corrupt-unknown",
146 severity: Severity::Error,
147 check: |c| match c.corrupt {
148 Some(s) if crate::script::Corrupt::parse_level(s).is_none() => {
149 Some("unknown corrupt level; expected: low, mid, high, extreme")
150 }
151 _ => None,
152 },
153 },
154 Rule {
155 id: "seed-without-until",
156 severity: Severity::Warn,
157 check: |c| {
158 if c.has_seed && !c.has_until {
159 Some("--seed without --until uses system year; pin --until for reproducibility")
160 } else {
161 None
162 }
163 },
164 },
165];
166
167#[derive(Default)]
168pub struct ValidationResult {
169 pub errors: Vec<String>,
170 pub warnings: Vec<String>,
171}
172
173pub fn validate(ctx: &CheckCtx<'_>) -> ValidationResult {
174 let mut result = ValidationResult::default();
175 for rule in RULES {
176 if let Some(msg) = (rule.check)(ctx) {
177 let formatted = format!("[{}] {}", rule.id, msg);
178 match rule.severity {
179 Severity::Error => result.errors.push(formatted),
180 Severity::Warn => result.warnings.push(formatted),
181 }
182 }
183 }
184 result
185}