Skip to main content

jsonette/
generator.rs

1/*
2 * Copyright (c) 2026 DevEtte.
3 *
4 * This project is dual-licensed under both the MIT License and the
5 * Apache License, Version 2.0 (the "License"). You may not use this
6 * file except in compliance with one of these licenses.
7 *
8 * You may obtain a copy of the Licenses at:
9 * - MIT: https://opensource.org
10 * - Apache 2.0: http://apache.org
11 *
12 * Unless required by applicable law or agreed to in writing, software
13 * distributed under the License is distributed on an "AS IS" BASIS,
14 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 * See the License for the specific language governing permissions and
16 * limitations under the License.
17 */
18
19//! Schema-based Dummy Data JSON Generator.
20//!
21//! Evaluates a template `JsonNode` schema and generates a new `JsonNode`
22//! containing dummy data according to the schema rules.
23
24use crate::json_node::{JsonNode, KeyValuePair};
25use crate::types::{Diagnostic, Span};
26use std::collections::HashMap;
27
28/// State for stateful generators like auto-incrementing integers.
29#[derive(Default)]
30struct GeneratorState {
31    variables: HashMap<String, f64>,
32}
33
34/// Options for the generation process.
35pub struct GeneratorOptions {
36    /// If provided, the generator will wrap the schema in an array and repeat
37    /// it until the approximate byte size is reached.
38    pub target_size_bytes: Option<usize>,
39    /// If provided, the generator will wrap the schema in an array and repeat
40    /// it exactly `target_count` times.
41    pub target_count: Option<usize>,
42}
43
44/// Evaluates a schema and returns the generated dummy data.
45/// Returns a list of diagnostics if the schema contains structural errors.
46///
47/// # Arguments
48///
49/// * `schema` - A reference to the `JsonNode` representing the generation template.
50/// * `options` - A reference to `GeneratorOptions` controlling target size or count.
51///
52/// # Returns
53///
54/// A `Result` containing the generated `JsonNode` AST on success, or a `Vec<Diagnostic>`
55/// representing all schema evaluation errors.
56pub fn generate_from_schema(
57    schema: &JsonNode,
58    options: &GeneratorOptions,
59) -> Result<JsonNode, Vec<Diagnostic>> {
60    let mut diagnostics = Vec::new();
61    let mut state = GeneratorState::default();
62
63    if let Some(target_size) = options.target_size_bytes {
64        let mut items = Vec::new();
65        let mut current_size = 0;
66
67        while current_size < target_size {
68            let item = evaluate_node(schema, &mut state, &mut diagnostics);
69            current_size += estimate_size(&item);
70            items.push(item);
71
72            if items.len() > 1000000 {
73                break;
74            }
75        }
76
77        if !diagnostics.is_empty() {
78            Err(diagnostics)
79        } else {
80            Ok(JsonNode::Array(items, Span::default()))
81        }
82    } else if let Some(target_count) = options.target_count {
83        let mut items = Vec::with_capacity(target_count);
84        for _ in 0..target_count {
85            items.push(evaluate_node(schema, &mut state, &mut diagnostics));
86        }
87
88        if !diagnostics.is_empty() {
89            Err(diagnostics)
90        } else {
91            Ok(JsonNode::Array(items, Span::default()))
92        }
93    } else {
94        let result = evaluate_node(schema, &mut state, &mut diagnostics);
95        if !diagnostics.is_empty() {
96            Err(diagnostics)
97        } else {
98            Ok(result)
99        }
100    }
101}
102
103/// Recursively evaluates a schema node.
104///
105/// # Arguments
106///
107/// * `node` - The current `JsonNode` in the schema being evaluated.
108/// * `state` - The mutable `GeneratorState` maintaining variables across evaluations.
109/// * `diagnostics` - A mutable vector accumulating any structural or evaluation errors.
110///
111/// # Returns
112///
113/// The evaluated `JsonNode` instance with resolved generator instructions.
114fn evaluate_node(
115    node: &JsonNode,
116    state: &mut GeneratorState,
117    diagnostics: &mut Vec<Diagnostic>,
118) -> JsonNode {
119    match node {
120        JsonNode::Object(pairs, span) => {
121            // Check if this is a generator instruction
122            if let Some(type_pair) = pairs.iter().find(|p| p.key == "@type") {
123                #[allow(clippy::collapsible_if)]
124                if let JsonNode::String(t, _) = &type_pair.value {
125                    return evaluate_instruction(t, pairs, state, diagnostics, span.clone());
126                }
127            }
128
129            // Otherwise, it's a regular nested object. Evaluate its children.
130            let mut new_pairs = Vec::with_capacity(pairs.len());
131            for pair in pairs {
132                // Handle @@ escape hatch
133                let key = if pair.key.starts_with("@@") {
134                    pair.key[1..].to_string()
135                } else {
136                    pair.key.clone()
137                };
138
139                new_pairs.push(KeyValuePair {
140                    key,
141                    value: evaluate_node(&pair.value, state, diagnostics),
142                });
143            }
144            JsonNode::Object(new_pairs, span.clone())
145        }
146        JsonNode::Array(items, span) => {
147            let new_items = items
148                .iter()
149                .map(|item| evaluate_node(item, state, diagnostics))
150                .collect();
151            JsonNode::Array(new_items, span.clone())
152        }
153        // Primitives pass through unchanged
154        _ => node.clone(),
155    }
156}
157
158/// Evaluates a specific generator instruction based on its `@type`.
159///
160/// # Arguments
161///
162/// * `instruction_type` - The string identifying the instruction (e.g., `uuid`, `integer`).
163/// * `pairs` - The key-value pairs of the object containing the instruction.
164/// * `state` - The mutable `GeneratorState` maintaining variables across evaluations.
165/// * `diagnostics` - A mutable vector accumulating any structural or evaluation errors.
166/// * `span` - The byte span of the instruction object.
167///
168/// # Returns
169///
170/// The generated `JsonNode` value for this instruction.
171fn evaluate_instruction(
172    instruction_type: &str,
173    pairs: &[KeyValuePair],
174    state: &mut GeneratorState,
175    diagnostics: &mut Vec<Diagnostic>,
176    span: Span,
177) -> JsonNode {
178    match instruction_type {
179        "uuid" => {
180            // Simple deterministic UUID for M0 to avoid rand dependency, or use a basic LCG.
181            // In a real app we'd use `uuid` or `rand`.
182            let lcg = state.variables.entry("lcg".to_string()).or_insert(1.0);
183            *lcg = (*lcg * 1664525.0 + 1013904223.0) % 4294967296.0;
184            let val = *lcg as u32;
185            JsonNode::String(
186                format!("uuid-{:08x}-1234-5678-abcd-123456789012", val),
187                span,
188            )
189        }
190        "integer" => {
191            let mut start = 0.0;
192            let mut step = 1.0;
193            for pair in pairs {
194                #[allow(clippy::collapsible_if)]
195                if pair.key == "@start" {
196                    if let JsonNode::Number(n, _, _) = pair.value {
197                        start = n;
198                    }
199                } else if pair.key == "@step" {
200                    if let JsonNode::Number(n, _, _) = pair.value {
201                        step = n;
202                    }
203                }
204            }
205            let state_key = format!("int_{}", span.start);
206            let current = state.variables.entry(state_key).or_insert(start);
207            let val = *current;
208            *current += step;
209            JsonNode::Number(val, val.to_string(), span)
210        }
211        "float" => {
212            let mut min = 0.0;
213            let mut max = 1.0;
214            for pair in pairs {
215                #[allow(clippy::collapsible_if)]
216                if pair.key == "@min" {
217                    if let JsonNode::Number(n, _, _) = pair.value {
218                        min = n;
219                    }
220                } else if pair.key == "@max" {
221                    if let JsonNode::Number(n, _, _) = pair.value {
222                        max = n;
223                    }
224                }
225            }
226            let lcg = state.variables.entry("lcg".to_string()).or_insert(1.0);
227            *lcg = (*lcg * 1664525.0 + 1013904223.0) % 4294967296.0;
228            let val = min + (*lcg / 4294967296.0) * (max - min);
229            JsonNode::Number(val, format!("{:.4}", val), span)
230        }
231        "bool" => {
232            let lcg = state.variables.entry("lcg".to_string()).or_insert(1.0);
233            *lcg = (*lcg * 1664525.0 + 1013904223.0) % 4294967296.0;
234            JsonNode::Bool(*lcg > 2147483648.0, span)
235        }
236        "string" => {
237            let mut template = "".to_string();
238            let mut pool = Vec::new();
239            let mut vars_schema = None;
240
241            for pair in pairs {
242                #[allow(clippy::collapsible_if)]
243                if pair.key == "@template" {
244                    if let JsonNode::String(s, _) = &pair.value {
245                        template = s.clone();
246                    }
247                } else if pair.key == "@pool" {
248                    if let JsonNode::Array(items, _) = &pair.value {
249                        for item in items {
250                            if let JsonNode::String(s, _) = item {
251                                pool.push(s.clone());
252                            }
253                        }
254                    }
255                } else if pair.key == "@vars" {
256                    vars_schema = Some(&pair.value);
257                }
258            }
259
260            if !pool.is_empty() {
261                let lcg = state.variables.entry("lcg".to_string()).or_insert(1.0);
262                *lcg = (*lcg * 1664525.0 + 1013904223.0) % 4294967296.0;
263                let idx = (*lcg as usize) % pool.len();
264                JsonNode::String(pool[idx].clone(), span)
265            } else if !template.is_empty() {
266                let mut output = template;
267                if let Some(JsonNode::Object(var_pairs, _)) = vars_schema {
268                    for var_pair in var_pairs {
269                        let var_val = evaluate_node(&var_pair.value, state, diagnostics);
270                        let var_str = match var_val {
271                            JsonNode::String(s, _) => s,
272                            JsonNode::Number(_, raw, _) => raw,
273                            JsonNode::Bool(b, _) => b.to_string(),
274                            _ => "".to_string(),
275                        };
276                        let placeholder = format!("{{{}}}", var_pair.key);
277                        output = output.replace(&placeholder, &var_str);
278                    }
279                }
280                JsonNode::String(output, span)
281            } else {
282                JsonNode::String("dummy".to_string(), span)
283            }
284        }
285        "array" => {
286            let mut count = 1;
287            let mut item_schema = &JsonNode::Null(span.clone());
288            for pair in pairs {
289                if pair.key == "@count" {
290                    if let JsonNode::Number(n, _, _) = pair.value {
291                        count = n as usize;
292                    }
293                } else if pair.key == "@item" {
294                    item_schema = &pair.value;
295                }
296            }
297
298            let mut items = Vec::with_capacity(count);
299            for _ in 0..count {
300                items.push(evaluate_node(item_schema, state, diagnostics));
301            }
302            JsonNode::Array(items, span)
303        }
304        _ => {
305            diagnostics.push(Diagnostic {
306                span: span.clone(),
307                message: format!("Unknown generator instruction: {}", instruction_type),
308            });
309            JsonNode::Null(span)
310        }
311    }
312}
313
314/// Very rough byte size estimator for loop termination.
315///
316/// # Arguments
317///
318/// * `node` - The `JsonNode` to estimate the serialized size for.
319///
320/// # Returns
321///
322/// An `usize` representing the approximate byte size of the node.
323fn estimate_size(node: &JsonNode) -> usize {
324    match node {
325        JsonNode::Null(_) => 4,
326        JsonNode::Bool(_, _) => 4,
327        JsonNode::Number(_, raw, _) => raw.len(),
328        JsonNode::String(s, _) => s.len() + 2,
329        JsonNode::Array(items, _) => {
330            items.iter().map(estimate_size).sum::<usize>() + items.len() + 2
331        }
332        JsonNode::Object(pairs, _) => {
333            pairs
334                .iter()
335                .map(|p| p.key.len() + 4 + estimate_size(&p.value))
336                .sum::<usize>()
337                + 2
338        }
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::parser::parse;
346
347    /// **Test Case**: Generator Evaluates Integer and UUID Instructions
348    ///
349    /// ### Description
350    /// Verifies that the generator correctly processes an `@type: integer` and `@type: uuid` schema.
351    ///
352    /// ### Test Procedure
353    /// 1. Provide a schema containing `integer` and `uuid` instructions.
354    /// 2. Generate exactly 2 items.
355    ///
356    /// ### Expected Result
357    /// The returned AST is an array of two objects with incrementing integers and string UUIDs.
358    #[test]
359    fn test_generator_evaluates_integer_and_uuid() {
360        let schema_str = r#"{
361            "id": { "@type": "uuid" },
362            "index": { "@type": "integer", "@start": 5, "@step": 2 }
363        }"#;
364        let schema = parse(schema_str).unwrap();
365        let opts = GeneratorOptions {
366            target_size_bytes: None,
367            target_count: Some(2),
368        };
369
370        let result = generate_from_schema(&schema, &opts).unwrap();
371        if let JsonNode::Array(items, _) = result {
372            assert_eq!(items.len(), 2);
373            if let JsonNode::Object(pairs, _) = &items[0] {
374                let index_node = pairs.iter().find(|p| p.key == "index").unwrap();
375                if let JsonNode::Number(val, _, _) = index_node.value {
376                    assert_eq!(val, 5.0);
377                } else {
378                    panic!("Expected Number");
379                }
380            }
381            if let JsonNode::Object(pairs, _) = &items[1] {
382                let index_node = pairs.iter().find(|p| p.key == "index").unwrap();
383                if let JsonNode::Number(val, _, _) = index_node.value {
384                    assert_eq!(val, 7.0);
385                } else {
386                    panic!("Expected Number");
387                }
388            }
389        } else {
390            panic!("Expected array");
391        }
392    }
393
394    /// **Test Case**: Generator Returns Diagnostics for Unknown Instructions
395    ///
396    /// ### Description
397    /// Verifies that providing an invalid `@type` instruction captures a diagnostic.
398    ///
399    /// ### Test Procedure
400    /// 1. Provide a schema with `@type: fake_type`.
401    ///
402    /// ### Expected Result
403    /// The generator returns an `Err(Vec<Diagnostic>)`.
404    #[test]
405    fn test_generator_unknown_instruction_returns_diagnostic() {
406        let schema_str = r#"{ "field": { "@type": "fake_type" } }"#;
407        let schema = parse(schema_str).unwrap();
408        let opts = GeneratorOptions {
409            target_size_bytes: None,
410            target_count: Some(1),
411        };
412
413        let result = generate_from_schema(&schema, &opts);
414        assert!(result.is_err());
415        let diags = result.unwrap_err();
416        assert_eq!(diags.len(), 1);
417        assert!(diags[0].message.contains("Unknown generator instruction"));
418    }
419
420    /// **Test Case**: Generator Handles Escaped Keys
421    ///
422    /// ### Description
423    /// Verifies that `@@type` is unescaped to `@type` correctly without triggering an instruction.
424    ///
425    /// ### Test Procedure
426    /// 1. Provide a schema containing `@@type`.
427    ///
428    /// ### Expected Result
429    /// The AST outputs a literal `@type` key.
430    #[test]
431    fn test_generator_escaped_keys() {
432        let schema_str = r#"{ "@@type": "User" }"#;
433        let schema = parse(schema_str).unwrap();
434        let opts = GeneratorOptions {
435            target_size_bytes: None,
436            target_count: None,
437        };
438
439        let result = generate_from_schema(&schema, &opts).unwrap();
440        if let JsonNode::Object(pairs, _) = result {
441            assert_eq!(pairs[0].key, "@type");
442            assert!(matches!(pairs[0].value, JsonNode::String(_, _)));
443        } else {
444            panic!("Expected object");
445        }
446    }
447}