Skip to main content

seedfaker_core/
validate.rs

1//! Declarative validation rules for field combinations and parameters.
2//!
3//! Each rule is an isolated pure function: `&CheckCtx -> Option<&str>`.
4//! Rules cannot panic, mutate state, or depend on each other.
5//! Add a rule = add one entry to RULES. Remove = remove one entry.
6
7use 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    /// Get resolved age range (min, max) if age field has explicit range.
53    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            // ctx strict + age + birthdate: age is derived from birthdate.
70            // If age has range, check if it's compatible with possible birth years.
71            let Some(age_range) = c.age_range() else {
72                return None; // no range on age → OK, age derived from birthdate
73            };
74            // Implied birth years from age range: until - age_max .. until - age_min
75            let implied_birth_from = c.until - age_range.1;
76            let implied_birth_to = c.until - age_range.0;
77            // Actual possible birth years (from since or ~100 years back)
78            let actual_birth_from = c.since;
79            let actual_birth_to = c.until;
80            // Check overlap
81            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                // Overlap exists but age range will override derivation
89                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}