genson_core/
schema.rs

1#[cfg_attr(feature = "trace", crustrace::omni)]
2mod innermod {
3    use crate::genson_rs::{build_json_schema, get_builder, BuildConfig};
4    use serde::de::Error as DeError;
5    use serde::{Deserialize, Serialize};
6    use serde_json::Value;
7    use std::borrow::Cow;
8    use std::panic::{self, AssertUnwindSafe};
9
10    /// Maximum length of JSON string to include in error messages before truncating
11    const MAX_JSON_ERROR_LENGTH: usize = 100;
12
13    #[derive(Debug, Clone, Serialize, Deserialize)]
14    pub struct SchemaInferenceConfig {
15        /// Whether to treat top-level arrays as streams of objects
16        pub ignore_outer_array: bool,
17        /// Delimiter for NDJSON format (None for regular JSON)
18        pub delimiter: Option<u8>,
19        /// Schema URI to use ("AUTO" for auto-detection)
20        pub schema_uri: Option<String>,
21        /// Threshold above which non-fixed keys are treated as a map
22        pub map_threshold: usize,
23        /// Maximum number of required keys a Map can have. If None, no gating based on required keys.
24        /// If Some(n), objects with more than n required keys will be forced to Record type.
25        pub map_max_required_keys: Option<usize>,
26        /// Enable unification of compatible but non-homogeneous record schemas into maps
27        pub unify_maps: bool,
28        /// Force override of field treatment, e.g. {"labels": "map"}
29        pub force_field_types: std::collections::HashMap<String, String>,
30        /// Wrap the inferred top-level schema under a single required field with this name.
31        /// Example: wrap_root = Some("labels") turns `{...}` into
32        /// `{"type":"object","properties":{"labels":{...}},"required":["labels"]}`.
33        pub wrap_root: Option<String>,
34        /// Whether to output Avro schema rather than regular JSON Schema.
35        #[cfg(feature = "avro")]
36        pub avro: bool,
37    }
38
39    impl Default for SchemaInferenceConfig {
40        fn default() -> Self {
41            Self {
42                ignore_outer_array: true,
43                delimiter: None,
44                schema_uri: Some("AUTO".to_string()),
45                map_threshold: 20,
46                map_max_required_keys: None,
47                unify_maps: false,
48                force_field_types: std::collections::HashMap::new(),
49                wrap_root: None,
50                #[cfg(feature = "avro")]
51                avro: false,
52            }
53        }
54    }
55
56    #[derive(Debug, Clone, Serialize, Deserialize)]
57    pub struct SchemaInferenceResult {
58        pub schema: Value,
59        pub processed_count: usize,
60    }
61
62    #[cfg(feature = "avro")]
63    impl SchemaInferenceResult {
64        pub fn to_avro_schema(
65            &self,
66            namespace: &str,
67            utility_namespace: Option<&str>,
68            base_uri: Option<&str>,
69            split_top_level: bool,
70        ) -> Value {
71            avrotize::converter::jsons_to_avro(
72                &self.schema,
73                namespace,
74                utility_namespace.unwrap_or(""),
75                base_uri.unwrap_or("genson-core"),
76                split_top_level,
77            )
78        }
79    }
80
81    fn validate_json(s: &str) -> Result<(), serde_json::Error> {
82        let mut de = serde_json::Deserializer::from_str(s);
83        serde::de::IgnoredAny::deserialize(&mut de)?; // lightweight: ignores the parsed value
84        de.end()
85    }
86
87    fn validate_ndjson(s: &str) -> Result<(), serde_json::Error> {
88        for line in s.lines() {
89            let trimmed = line.trim();
90            if trimmed.is_empty() {
91                continue;
92            }
93            validate_json(trimmed)?; // propagate serde_json::Error
94        }
95        Ok(())
96    }
97
98    /// Check if a collection of record schemas can be unified into a single schema with selective nullable fields.
99    ///
100    /// This function determines whether heterogeneous record schemas are "unifiable" - meaning they
101    /// can be merged into a single schema where only missing fields become nullable. This enables
102    /// map inference for cases where record values have compatible but non-identical structures.
103    ///
104    /// Schemas are considered unifiable if:
105    /// 1. All schemas represent record types (`"type": "object"` with `"properties"`)
106    /// 2. Field names are either disjoint OR have identical types when they overlap
107    /// 3. No field has conflicting type definitions across schemas
108    ///
109    /// Fields present in all schemas remain required, while fields missing from some schemas
110    /// become nullable unions (e.g., `["null", {"type": "string"}]`).
111    ///
112    /// # Returns
113    ///
114    /// - `Some(unified_schema)` if schemas can be unified - contains all unique fields with selective nullability
115    /// - `None` if schemas cannot be unified due to:
116    ///   - Non-record types in the collection
117    ///   - Conflicting field types (same field name, different types)
118    ///   - Empty schema collection
119    fn check_unifiable_schemas(schemas: &[Value]) -> Option<Value> {
120        if schemas.is_empty() {
121            return None;
122        }
123
124        // Only unify record schemas
125        if !schemas
126            .iter()
127            .all(|s| s.get("type") == Some(&Value::String("object".into())))
128        {
129            return None;
130        }
131
132        let mut all_fields = ordermap::OrderMap::new();
133        let mut field_counts = std::collections::HashMap::new();
134
135        // Collect all field types and count occurrences
136        for schema in schemas {
137            if let Some(Value::Object(props)) = schema.get("properties") {
138                for (field_name, field_schema) in props {
139                    *field_counts.entry(field_name.clone()).or_insert(0) += 1;
140
141                    match all_fields.entry(field_name.clone()) {
142                        ordermap::map::Entry::Vacant(e) => {
143                            e.insert(field_schema.clone());
144                        }
145                        ordermap::map::Entry::Occupied(mut e) => {
146                            let existing = e.get().clone();
147                            if existing != *field_schema {
148                                // Try recursive unify if both are objects
149                                if existing.get("type") == Some(&Value::String("object".into()))
150                                    && field_schema.get("type")
151                                        == Some(&Value::String("object".into()))
152                                {
153                                    if let Some(unified) = check_unifiable_schemas(&[
154                                        existing.clone(),
155                                        field_schema.clone(),
156                                    ]) {
157                                        e.insert(unified);
158                                    } else {
159                                        return None;
160                                    }
161                                } else {
162                                    return None; // fundamentally incompatible types
163                                }
164                            }
165                        }
166                    }
167                }
168            }
169        }
170
171        let total_schemas = schemas.len();
172        let mut unified_properties = serde_json::Map::new();
173
174        // Required in all -> non-nullable
175        for (field_name, field_type) in &all_fields {
176            let count = field_counts.get(field_name).unwrap_or(&0);
177            if *count == total_schemas {
178                unified_properties.insert(field_name.clone(), field_type.clone());
179            }
180        }
181
182        // Missing in some -> nullable
183        for (field_name, field_type) in &all_fields {
184            let count = field_counts.get(field_name).unwrap_or(&0);
185            if *count < total_schemas {
186                unified_properties
187                    .insert(field_name.clone(), serde_json::json!(["null", field_type]));
188            }
189        }
190
191        Some(serde_json::json!({
192            "type": "object",
193            "properties": unified_properties
194        }))
195    }
196
197    /// Post-process an inferred JSON Schema to rewrite certain object shapes as maps.
198    ///
199    /// This mutates the schema in place, applying user overrides and heuristics.
200    ///
201    /// # Rules
202    /// - If the current field name matches a `force_field_types` override, that wins
203    ///   (`"map"` rewrites to `additionalProperties`, `"record"` leaves as-is).
204    /// - Otherwise, applies map inference heuristics based on:
205    ///   - Total key cardinality (`map_threshold`)
206    ///   - Required key cardinality (`map_max_required_keys`)
207    ///   - Value homogeneity (all values must be homogeneous) OR
208    ///   - Value unifiability (compatible record schemas when `unify_maps` enabled)
209    /// - Recurses into nested objects/arrays, carrying field names down so overrides apply.
210    fn rewrite_objects(
211        schema: &mut Value,
212        field_name: Option<&str>,
213        config: &SchemaInferenceConfig,
214    ) {
215        if let Value::Object(obj) = schema {
216            // --- Forced overrides by field name ---
217            if let Some(name) = field_name {
218                if let Some(forced) = config.force_field_types.get(name) {
219                    match forced.as_str() {
220                        "map" => {
221                            obj.remove("properties");
222                            obj.remove("required");
223                            obj.insert(
224                                "additionalProperties".to_string(),
225                                serde_json::json!({ "type": "string" }),
226                            );
227                            return; // no need to apply heuristics or recurse
228                        }
229                        "record" => {
230                            if let Some(props) =
231                                obj.get_mut("properties").and_then(|p| p.as_object_mut())
232                            {
233                                for (k, v) in props {
234                                    rewrite_objects(v, Some(k), config);
235                                }
236                            }
237                            if let Some(items) = obj.get_mut("items") {
238                                rewrite_objects(items, None, config);
239                            }
240                            return;
241                        }
242                        _ => {}
243                    }
244                }
245            }
246
247            // --- Heuristic rewrite ---
248            if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
249                let key_count = props.len(); // |UK| - total keys observed
250                let above_threshold = key_count >= config.map_threshold;
251
252                // Copy out child schema shapes
253                let child_schemas: Vec<Value> = props.values().cloned().collect();
254
255                // Detect map-of-records only if:
256                // - all children are identical
257                // - and that child is itself an object with "properties" (i.e. a proper record)
258                if above_threshold {
259                    if let Some(first) = child_schemas.first() {
260                        if first.get("type") == Some(&Value::String("object".into()))
261                            && first.get("properties").is_some()
262                            && child_schemas.len() > 1
263                        {
264                            let all_same = child_schemas.iter().all(|other| other == first);
265                            if all_same {
266                                obj.remove("properties");
267                                obj.remove("required");
268                                obj.insert("additionalProperties".to_string(), first.clone());
269                                return;
270                            }
271                        }
272                    }
273                }
274
275                // Calculate required key count |RK|
276                let required_key_count = obj
277                    .get("required")
278                    .and_then(|r| r.as_array())
279                    .map(|r| r.len())
280                    .unwrap_or(0);
281
282                // Check for unifiable schemas
283                let mut unified_schema: Option<Value> = None;
284                if let Some(first_schema) = props.values().next() {
285                    if props.values().all(|schema| schema == first_schema) {
286                        unified_schema = Some(first_schema.clone());
287                    } else if config.unify_maps {
288                        // Detect if these are all arrays of records
289                        if child_schemas
290                            .iter()
291                            .all(|s| s.get("type") == Some(&Value::String("array".into())))
292                        {
293                            // Collect item schemas, short-circuit if any missing
294                            let mut item_schemas = Vec::with_capacity(child_schemas.len());
295                            let mut all_items_ok = true;
296                            for s in &child_schemas {
297                                if let Some(items) = s.get("items") {
298                                    item_schemas.push(items.clone());
299                                } else {
300                                    all_items_ok = false;
301                                    break;
302                                }
303                            }
304                            if all_items_ok {
305                                if let Some(unified_items) = check_unifiable_schemas(&item_schemas)
306                                {
307                                    unified_schema = Some(serde_json::json!({
308                                        "type": "array",
309                                        "items": unified_items
310                                    }));
311                                }
312                            }
313                        } else {
314                            unified_schema = check_unifiable_schemas(&child_schemas);
315                        }
316                    }
317                }
318
319                // Apply map inference logic
320                let should_be_map = if above_threshold && unified_schema.is_some() {
321                    if let Some(max_required) = config.map_max_required_keys {
322                        required_key_count <= max_required
323                    } else {
324                        true
325                    }
326                } else {
327                    false
328                };
329
330                if should_be_map {
331                    if let Some(schema) = unified_schema {
332                        obj.remove("properties");
333                        obj.remove("required");
334                        obj.insert("type".to_string(), Value::String("object".to_string()));
335                        obj.insert("additionalProperties".to_string(), schema);
336                        return;
337                    }
338                }
339            }
340
341            // --- Recurse into nested values ---
342            if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
343                for (k, v) in props {
344                    rewrite_objects(v, Some(k), config);
345                }
346            }
347            if let Some(items) = obj.get_mut("items") {
348                rewrite_objects(items, None, config);
349            }
350            for v in obj.values_mut() {
351                rewrite_objects(v, None, config);
352            }
353        } else if let Value::Array(arr) = schema {
354            for v in arr {
355                rewrite_objects(v, None, config);
356            }
357        }
358    }
359
360    /// Recursively reorder union type arrays in a JSON Schema by canonical precedence.
361    ///
362    /// Special case: preserves the common `["null", T]` pattern without reordering.
363    pub fn reorder_unions(schema: &mut Value) {
364        match schema {
365            Value::Object(obj) => {
366                if let Some(Value::Array(types)) = obj.get_mut("type") {
367                    // sort by canonical precedence, but keep ["null", T] pattern intact
368                    if !(types.len() == 2 && types.iter().any(|t| t == "null")) {
369                        types.sort_by_key(type_rank);
370                    }
371                }
372                // recurse into properties/items/etc.
373                for v in obj.values_mut() {
374                    reorder_unions(v);
375                }
376            }
377            Value::Array(arr) => {
378                for v in arr {
379                    reorder_unions(v);
380                }
381            }
382            _ => {}
383        }
384    }
385
386    /// Assign a numeric precedence rank to a JSON Schema type.
387    ///
388    /// Used by `reorder_unions` to sort union members deterministically.
389    /// - Null always first
390    /// - Containers before scalars (to enforce widening)
391    /// - Scalars ordered by narrowness
392    /// - Unknown types last
393    pub fn type_rank(val: &Value) -> usize {
394        match val {
395            Value::String(s) => type_string_rank(s),
396            Value::Object(obj) => {
397                if let Some(Value::String(t)) = obj.get("type") {
398                    type_string_rank(t)
399                } else {
400                    100 // object with no "type" field
401                }
402            }
403            _ => 100, // non-string/non-object
404        }
405    }
406
407    /// Internal helper: rank by type string
408    fn type_string_rank(s: &str) -> usize {
409        match s {
410            // Null always first
411            "null" => 0,
412
413            // Containers before scalars: widening takes precedence
414            "map" => 1,
415            "array" => 2,
416            "object" | "record" => 3,
417
418            // Scalars (ordered by 'narrowness')
419            "boolean" => 10,
420            "integer" | "int" | "long" => 11,
421            "number" | "float" | "double" => 12,
422            "enum" => 13,
423            "string" => 14,
424            "fixed" => 15,
425            "bytes" => 16,
426
427            // Fallback
428            _ => 99,
429        }
430    }
431
432    /// Infer JSON schema from a collection of JSON strings
433    pub fn infer_json_schema_from_strings(
434        json_strings: &[String],
435        config: SchemaInferenceConfig,
436    ) -> Result<SchemaInferenceResult, String> {
437        if json_strings.is_empty() {
438            return Err("No JSON strings provided".to_string());
439        }
440
441        // Wrap the entire genson-rs interaction in panic handling
442        let result = panic::catch_unwind(AssertUnwindSafe(
443            || -> Result<SchemaInferenceResult, String> {
444                // Create schema builder
445                let mut builder = get_builder(config.schema_uri.as_deref());
446
447                // Build config for genson-rs
448                let build_config = BuildConfig {
449                    delimiter: config.delimiter,
450                    ignore_outer_array: config.ignore_outer_array,
451                };
452
453                let mut processed_count = 0;
454
455                // Process each JSON string
456                for (i, json_str) in json_strings.iter().enumerate() {
457                    if json_str.trim().is_empty() {
458                        continue;
459                    }
460
461                    // Choose validation strategy based on delimiter
462                    let validation_result = if let Some(delim) = config.delimiter {
463                        if delim == b'\n' {
464                            validate_ndjson(json_str)
465                        } else {
466                            Err(serde_json::Error::custom(format!(
467                                "Unsupported delimiter: {:?}",
468                                delim
469                            )))
470                        }
471                    } else {
472                        validate_json(json_str)
473                    };
474
475                    if let Err(parse_error) = validation_result {
476                        let truncated_json = if json_str.len() > MAX_JSON_ERROR_LENGTH {
477                            format!(
478                                "{}... [truncated {} chars]",
479                                &json_str[..MAX_JSON_ERROR_LENGTH],
480                                json_str.len() - MAX_JSON_ERROR_LENGTH
481                            )
482                        } else {
483                            json_str.clone()
484                        };
485
486                        return Err(format!(
487                            "Invalid JSON input at index {}: {} - JSON: {}",
488                            i + 1,
489                            parse_error,
490                            truncated_json
491                        ));
492                    }
493
494                    // Safe: JSON is valid, now hand off to genson-rs
495                    let prepared_json: Cow<str> = if let Some(ref field) = config.wrap_root {
496                        if config.delimiter == Some(b'\n') {
497                            // NDJSON: wrap each line separately
498                            let mut wrapped_lines = Vec::new();
499                            for line in json_str.lines() {
500                                let trimmed = line.trim();
501                                if trimmed.is_empty() {
502                                    continue;
503                                }
504                                let inner_val: Value =
505                                    serde_json::from_str(trimmed).map_err(|e| {
506                                        format!(
507                                            "Failed to parse NDJSON line before wrap_root: {}",
508                                            e
509                                        )
510                                    })?;
511                                wrapped_lines
512                                    .push(serde_json::json!({ field: inner_val }).to_string());
513                            }
514                            Cow::Owned(wrapped_lines.join("\n"))
515                        } else {
516                            // Single JSON doc
517                            let inner_val: Value = serde_json::from_str(json_str).map_err(|e| {
518                                format!("Failed to parse JSON before wrap_root: {}", e)
519                            })?;
520                            Cow::Owned(serde_json::json!({ field: inner_val }).to_string())
521                        }
522                    } else {
523                        Cow::Borrowed(json_str)
524                    };
525
526                    let mut bytes = prepared_json.as_bytes().to_vec();
527
528                    // Build schema incrementally - this is where panics happen
529                    let _schema = build_json_schema(&mut builder, &mut bytes, &build_config);
530                    processed_count += 1;
531                }
532
533                // Get final schema
534                let mut final_schema = builder.to_schema();
535                rewrite_objects(&mut final_schema, None, &config);
536                reorder_unions(&mut final_schema);
537
538                #[cfg(feature = "avro")]
539                if config.avro {
540                    let avro_schema = SchemaInferenceResult {
541                        schema: final_schema.clone(),
542                        processed_count,
543                    }
544                    .to_avro_schema(
545                        "genson", // namespace
546                        Some(""),
547                        Some(""), // base_uri
548                        false,    // don't split top-level
549                    );
550                    return Ok(SchemaInferenceResult {
551                        schema: avro_schema,
552                        processed_count,
553                    });
554                }
555
556                Ok(SchemaInferenceResult {
557                    schema: final_schema,
558                    processed_count,
559                })
560            },
561        ));
562
563        // Handle the result of panic::catch_unwind
564        match result {
565            Ok(Ok(schema_result)) => Ok(schema_result),
566            Ok(Err(e)) => Err(e),
567            Err(_panic) => {
568                Err("JSON schema inference failed due to invalid JSON input".to_string())
569            }
570        }
571    }
572
573    #[cfg(test)]
574    mod tests {
575        use super::*;
576        use predicates::prelude::*;
577        use serde_json::json;
578
579        #[test]
580        fn test_reorder_unions_string_float_null() {
581            // Unordered union: string, float, null
582            let mut schema = json!({
583                "type": ["string", "float", "null"]
584            });
585
586            reorder_unions(&mut schema);
587
588            // After reordering, null should come first, then float/number, then string
589            assert_eq!(
590                schema,
591                json!({
592                    "type": ["null", "float", "string"]
593                })
594            );
595        }
596
597        #[test]
598        fn test_basic_schema_inference() {
599            let json_strings = vec![
600                r#"{"name": "Alice", "age": 30}"#.to_string(),
601                r#"{"name": "Bob", "age": 25, "city": "NYC"}"#.to_string(),
602            ];
603
604            let result =
605                infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default())
606                    .expect("Schema inference should succeed");
607
608            // Test processed count
609            assert_eq!(result.processed_count, 2);
610
611            // Use predicates to test schema structure
612            let schema_str = result.schema.to_string();
613
614            predicate::str::contains("\"type\"")
615                .and(predicate::str::contains("object"))
616                .eval(&schema_str);
617
618            predicate::str::contains("\"properties\"").eval(&schema_str);
619
620            // Check that both name and age properties are present
621            predicate::str::contains("\"name\"")
622                .and(predicate::str::contains("\"age\""))
623                .eval(&schema_str);
624
625            println!(
626                "โœ… Generated schema: {}",
627                serde_json::to_string_pretty(&result.schema).unwrap()
628            );
629        }
630
631        #[test]
632        fn test_empty_input() {
633            let json_strings = vec![];
634            let result =
635                infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default());
636
637            assert!(result.is_err());
638
639            let error_msg = result.unwrap_err();
640            predicate::str::contains("No JSON strings provided").eval(&error_msg);
641
642            println!("โœ… Empty input correctly rejected with: {}", error_msg);
643        }
644
645        #[test]
646        fn test_invalid_json_variants() {
647            let test_cases = vec![
648                (
649                    r#"{"name": "Alice"}"#,
650                    r#"{"invalid": json}"#,
651                    "unquoted value",
652                ),
653                (
654                    r#"{"valid": "json"}"#,
655                    r#"{"incomplete":"#,
656                    "incomplete string",
657                ),
658                (r#"{"good": "data"}"#, r#"{"trailing":,"#, "trailing comma"),
659                (
660                    r#"{"working": true}"#,
661                    r#"{invalid: "json"}"#,
662                    "unquoted key",
663                ),
664                (
665                    r#"{"normal": "object"}"#,
666                    r#"{"nested": {"broken": json}}"#,
667                    "nested broken JSON",
668                ),
669            ];
670
671            for (valid_json, invalid_json, description) in test_cases {
672                println!("๐Ÿงช Testing: {}", description);
673                println!("   Valid JSON: {}", valid_json);
674                println!("   Invalid JSON: {}", invalid_json);
675
676                let json_strings = vec![valid_json.to_string(), invalid_json.to_string()];
677
678                let result =
679                    infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default());
680
681                // Should return an error instead of panicking
682                assert!(result.is_err(), "Expected error for case: {}", description);
683
684                let error_msg = result.unwrap_err();
685
686                // Use predicates to verify error message content
687                predicate::str::contains("Invalid JSON input at position").eval(&error_msg);
688
689                // For short JSON strings, verify the content is included
690                if invalid_json.len() <= MAX_JSON_ERROR_LENGTH {
691                    predicate::str::contains(invalid_json).eval(&error_msg);
692                } else {
693                    // For long JSON, just check that truncation happened
694                    predicate::str::contains("truncated").eval(&error_msg);
695                }
696
697                // Ensure we don't have panic-related messages
698                predicate::str::contains("panicked").not().eval(&error_msg);
699
700                predicate::str::contains("SIGABRT").not().eval(&error_msg);
701
702                println!("   โŒ Correctly failed with: {}", error_msg);
703                println!();
704            }
705        }
706
707        #[test]
708        fn test_mixed_valid_and_empty_strings() {
709            let json_strings = vec![
710                r#"{"name": "Alice", "age": 30}"#.to_string(),
711                "".to_string(),    // Empty string should be skipped
712                "   ".to_string(), // Whitespace-only should be skipped
713                r#"{"name": "Bob", "age": 25}"#.to_string(),
714            ];
715
716            let result =
717                infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default())
718                    .expect("Should succeed with valid JSON, skipping empty strings");
719
720            // Should process only the 2 valid JSON strings
721            assert_eq!(result.processed_count, 2);
722
723            let schema_str = result.schema.to_string();
724            predicate::str::contains("\"name\"")
725                .and(predicate::str::contains("\"age\""))
726                .eval(&schema_str);
727
728            println!(
729                "โœ… Processed {} valid strings, skipped empty ones",
730                result.processed_count
731            );
732        }
733
734        #[test]
735        fn test_schema_config_variations() {
736            let json_strings = vec![r#"[{"item": "first"}, {"item": "second"}]"#.to_string()];
737
738            // Test with ignore_outer_array = false
739            let config_array = SchemaInferenceConfig {
740                ignore_outer_array: false,
741                ..Default::default()
742            };
743
744            let result = infer_json_schema_from_strings(&json_strings, config_array)
745                .expect("Should handle array schema");
746
747            let schema_str = result.schema.to_string();
748            predicate::str::contains("\"type\"")
749                .and(predicate::str::contains("array"))
750                .eval(&schema_str);
751
752            println!(
753                "โœ… Array schema: {}",
754                serde_json::to_string_pretty(&result.schema).unwrap()
755            );
756
757            // Test with ignore_outer_array = true (default)
758            let config_object = SchemaInferenceConfig {
759                ignore_outer_array: true,
760                ..Default::default()
761            };
762
763            let result = infer_json_schema_from_strings(&json_strings, config_object)
764                .expect("Should handle object schema from array items");
765
766            let schema_str = result.schema.to_string();
767            predicate::str::contains("\"type\"")
768                .and(predicate::str::contains("object"))
769                .eval(&schema_str);
770
771            predicate::str::contains("\"item\"").eval(&schema_str);
772
773            println!(
774                "โœ… Object schema from array items: {}",
775                serde_json::to_string_pretty(&result.schema).unwrap()
776            );
777        }
778
779        #[test]
780        fn test_very_long_invalid_json() {
781            // Create a very long invalid JSON string
782            let long_value = "x".repeat(500); // 500 char string
783            let long_invalid_json = format!(
784                r#"{{"field1": "{}", "field2": "{}", "field3": "{}", "field4": "{}", "invalid_syntax": }}"#,
785                long_value, long_value, long_value, long_value
786            );
787
788            let json_strings = vec![
789                r#"{"valid": "json"}"#.to_string(),
790                long_invalid_json.clone(),
791            ];
792
793            println!(
794                "๐Ÿงช Testing very long invalid JSON ({} chars)",
795                long_invalid_json.len()
796            );
797
798            let result =
799                infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default());
800
801            assert!(result.is_err(), "Expected error for very long invalid JSON");
802
803            let error_msg = result.unwrap_err();
804
805            println!("The error message was: {}", error_msg);
806
807            // Should contain truncation indicator
808            predicate::str::contains("truncated").eval(&error_msg);
809
810            // Should contain position information
811            predicate::str::contains("Invalid JSON input at position 2").eval(&error_msg);
812
813            // Error message should be reasonable length (much shorter than original JSON)
814            assert!(
815                error_msg.len() < long_invalid_json.len() / 2,
816                "Error message should be much shorter than original JSON"
817            );
818
819            println!(
820                "   โŒ Correctly truncated long JSON in error: {}",
821                error_msg
822            );
823
824            // Verify the error message doesn't exceed a reasonable length
825            assert!(
826                error_msg.len() < 500,
827                "Error message should be under 500 chars, got: {}",
828                error_msg.len()
829            );
830        }
831
832        #[test]
833        fn test_complex_nested_schema() {
834            let json_strings = vec![
835                json!({
836                    "user": {
837                        "id": 123,
838                        "profile": {
839                            "name": "Alice",
840                            "preferences": ["dark_mode", "notifications"]
841                        }
842                    },
843                    "metadata": {
844                        "created_at": "2024-01-01",
845                        "version": 1
846                    }
847                })
848                .to_string(),
849                json!({
850                    "user": {
851                        "id": 456,
852                        "profile": {
853                            "name": "Bob",
854                            "preferences": ["light_mode"]
855                        }
856                    },
857                    "metadata": {
858                        "created_at": "2024-01-02",
859                        "version": 2
860                    }
861                })
862                .to_string(),
863            ];
864
865            let result =
866                infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default())
867                    .expect("Should handle complex nested schema");
868
869            assert_eq!(result.processed_count, 2);
870
871            let schema_str = result.schema.to_string();
872
873            // Check for nested structure
874            predicate::str::contains("\"user\"")
875                .and(predicate::str::contains("\"metadata\""))
876                .and(predicate::str::contains("\"profile\""))
877                .and(predicate::str::contains("\"preferences\""))
878                .eval(&schema_str);
879
880            println!("โœ… Complex nested schema generated successfully");
881            println!(
882                "Schema: {}",
883                serde_json::to_string_pretty(&result.schema).unwrap()
884            );
885        }
886
887        #[test]
888        fn test_ndjson_parsing() {
889            // Two valid JSON objects separated by newlines (NDJSON format)
890            let ndjson_input = r#"
891{"name": "Alice", "age": 30}
892{"name": "Bob", "age": 25, "city": "NYC"}
893{"name": "Charlie"}
894"#;
895
896            let json_strings = vec![ndjson_input.to_string()];
897
898            let config = SchemaInferenceConfig {
899                delimiter: Some(b'\n'),
900                ..Default::default()
901            };
902
903            let result = infer_json_schema_from_strings(&json_strings, config)
904                .expect("NDJSON schema inference should succeed");
905
906            // All 3 objects should be processed
907            assert_eq!(result.processed_count, 1,
908            "NDJSON should be counted as a single input string but parsed into multiple rows internally"
909        );
910
911            let schema_str = result.schema.to_string();
912
913            // The schema should include properties from all lines
914            assert!(!schema_str.contains("Alice")); // values are not in schema
915            assert!(schema_str.contains("\"name\""));
916            assert!(schema_str.contains("\"age\""));
917            assert!(schema_str.contains("\"city\""));
918
919            println!(
920                "โœ… NDJSON schema generated: {}",
921                serde_json::to_string_pretty(&result.schema).unwrap()
922            );
923        }
924
925        #[test]
926        fn test_invalid_ndjson_line() {
927            // Second line is malformed
928            let ndjson_input = r#"
929{"valid": true}
930{"invalid": json}
931{"also_valid": 123}
932"#;
933
934            let json_strings = vec![ndjson_input.to_string()];
935
936            let config = SchemaInferenceConfig {
937                delimiter: Some(b'\n'),
938                ..Default::default()
939            };
940
941            let result = infer_json_schema_from_strings(&json_strings, config);
942
943            assert!(result.is_err(), "Expected error for malformed NDJSON line");
944
945            let err_msg = result.unwrap_err();
946            eprintln!("Got error: {}", err_msg);
947            assert!(
948                err_msg
949                    .contains("Invalid JSON input at index 1: expected value at line 1 column 13"),
950                "Error message should report the failing line"
951            );
952            println!("โœ… Correctly rejected malformed NDJSON: {}", err_msg);
953        }
954
955        /// Two objects with varying keys and homogeneous string values (low map threshold)
956        #[test]
957        fn test_map_threshold_rewrite() {
958            let json_strings = vec![
959                r#"{"labels": {"en": "Hello", "fr": "Bonjour"}}"#.to_string(),
960                r#"{"labels": {"de": "Hallo", "es": "Hola"}}"#.to_string(),
961            ];
962
963            let config = SchemaInferenceConfig {
964                map_threshold: 2,
965                ..Default::default()
966            };
967            let result = infer_json_schema_from_strings(&json_strings, config).unwrap();
968
969            let labels = &result.schema["properties"]["labels"];
970            assert_eq!(labels["type"], "object");
971            assert!(labels.get("additionalProperties").is_some());
972            assert!(labels.get("properties").is_none());
973        }
974
975        /// Two objects with varying keys and homogeneous string values (default map threshold)
976        #[test]
977        fn test_map_threshold_as_record() {
978            let json_strings = vec![
979                r#"{"labels": {"en": "Hello", "fr": "Bonjour"}}"#.to_string(),
980                r#"{"labels": {"de": "Hallo", "es": "Hola"}}"#.to_string(),
981            ];
982
983            let result =
984                infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default())
985                    .unwrap();
986
987            let labels = &result.schema["properties"]["labels"];
988            assert!(labels.get("properties").is_some());
989            assert!(labels.get("additionalProperties").is_none());
990        }
991
992        #[test]
993        fn test_wrap_root_inserts_single_required_field() {
994            let json_strings = vec![
995                r#"{"en":{"language":"en","value":"Hello"},"fr":{"language":"fr","value":"Bonjour"}}"#.to_string(),
996            ];
997
998            let cfg = SchemaInferenceConfig {
999                wrap_root: Some("labels".to_string()),
1000                ..Default::default()
1001            };
1002
1003            let out = infer_json_schema_from_strings(&json_strings, cfg).unwrap();
1004            let sch = out.schema;
1005
1006            assert_eq!(sch["type"], "object");
1007            assert_eq!(sch["required"], serde_json::json!(["labels"]));
1008            assert!(sch["properties"]["labels"].is_object());
1009        }
1010
1011        #[test]
1012        fn test_rewrite_objects_map_of_records() {
1013            use serde_json::json;
1014
1015            // Schema that genson-rs would roughly emit for {"en": {...}, "fr": {...}}
1016            let mut schema = json!({
1017                "type": "object",
1018                "properties": {
1019                    "en": {
1020                        "type": "object",
1021                        "properties": {
1022                            "language": { "type": "string" },
1023                            "value": { "type": "string" }
1024                        },
1025                        "required": ["language", "value"]
1026                    },
1027                    "fr": {
1028                        "type": "object",
1029                        "properties": {
1030                            "language": { "type": "string" },
1031                            "value": { "type": "string" }
1032                        },
1033                        "required": ["language", "value"]
1034                    }
1035                },
1036                "required": ["en","fr"]
1037            });
1038
1039            let cfg = SchemaInferenceConfig {
1040                map_threshold: 2, // force detection at 2 keys
1041                ..Default::default()
1042            };
1043
1044            rewrite_objects(&mut schema, None, &cfg);
1045
1046            // After rewrite, we should have additionalProperties instead of fixed properties
1047            assert_eq!(schema["type"], "object");
1048            assert!(schema.get("properties").is_none());
1049            assert!(schema.get("required").is_none());
1050
1051            // additionalProperties should carry the inner record shape
1052            let ap = schema
1053                .get("additionalProperties")
1054                .expect("should insert additionalProperties");
1055
1056            assert_eq!(ap["type"], "object");
1057            assert_eq!(ap["properties"]["language"], json!({ "type": "string" }));
1058            assert_eq!(ap["properties"]["value"], json!({ "type": "string" }));
1059        }
1060
1061        #[test]
1062        fn test_map_of_strings_not_promoted_to_records() {
1063            let schema = json!({
1064                "type": "object",
1065                "properties": {
1066                    "labels": {
1067                        "type": "object",
1068                        "properties": {
1069                            "en": { "type": "string" },
1070                            "fr": { "type": "string" }
1071                        },
1072                        "required": ["en", "fr"]
1073                    }
1074                },
1075                "required": ["labels"]
1076            });
1077
1078            let mut sch = schema.clone();
1079            let cfg = SchemaInferenceConfig {
1080                map_threshold: 2,
1081                ..Default::default()
1082            };
1083            rewrite_objects(&mut sch, None, &cfg);
1084
1085            assert_eq!(
1086                sch["properties"]["labels"]["additionalProperties"]["type"],
1087                "string"
1088            );
1089        }
1090
1091        #[test]
1092        fn test_rewrite_objects_respects_map_max_required_keys() {
1093            let mut schema = json!({
1094                "type": "object",
1095                "properties": {
1096                    "field1": {"type": "string"},
1097                    "field2": {"type": "string"}
1098                },
1099                "required": ["field1", "field2"]
1100            });
1101
1102            let config = SchemaInferenceConfig {
1103                map_threshold: 2,
1104                map_max_required_keys: Some(1), // Max 1 required key for maps
1105                ..Default::default()
1106            };
1107
1108            rewrite_objects(&mut schema, None, &config);
1109
1110            // Should remain as record because 2 required keys > 1
1111            assert_eq!(schema["type"], "object");
1112            assert!(schema.get("properties").is_some());
1113            assert!(schema.get("additionalProperties").is_none());
1114        }
1115
1116        #[test]
1117        fn test_rewrite_objects_allows_map_with_few_required_keys() {
1118            let mut schema = json!({
1119                "type": "object",
1120                "properties": {
1121                    "field1": {"type": "string"},
1122                    "field2": {"type": "string"}
1123                },
1124                "required": ["field1"] // Only 1 required key
1125            });
1126
1127            let config = SchemaInferenceConfig {
1128                map_threshold: 2,
1129                map_max_required_keys: Some(1), // Max 1 required key for maps
1130                ..Default::default()
1131            };
1132
1133            rewrite_objects(&mut schema, None, &config);
1134
1135            // Should become map because 1 required key โ‰ค 1
1136            assert_eq!(schema["type"], "object");
1137            assert!(schema.get("additionalProperties").is_some());
1138            assert!(schema.get("properties").is_none());
1139        }
1140
1141        #[test]
1142        fn test_rewrite_objects_none_max_required_keys_preserves_behavior() {
1143            let mut schema = json!({
1144                "type": "object",
1145                "properties": {
1146                    "field1": {"type": "string"},
1147                    "field2": {"type": "string"},
1148                    "field3": {"type": "string"}
1149                },
1150                "required": ["field1", "field2", "field3"]
1151            });
1152
1153            let config = SchemaInferenceConfig {
1154                map_threshold: 2,
1155                map_max_required_keys: None, // No gating
1156                ..Default::default()
1157            };
1158
1159            rewrite_objects(&mut schema, None, &config);
1160
1161            // Should become map because None means no gating (old behavior)
1162            assert_eq!(schema["type"], "object");
1163            assert!(schema.get("additionalProperties").is_some());
1164            assert!(schema.get("properties").is_none());
1165        }
1166
1167        #[test]
1168        fn test_rewrite_objects_zero_max_required_keys() {
1169            let mut schema = json!({
1170                "type": "object",
1171                "properties": {
1172                    "field1": {"type": "string"},
1173                    "field2": {"type": "string"}
1174                },
1175                "required": ["field1"]
1176            });
1177
1178            let config = SchemaInferenceConfig {
1179                map_threshold: 2,
1180                map_max_required_keys: Some(0), // Only allow maps with 0 required keys
1181                ..Default::default()
1182            };
1183
1184            rewrite_objects(&mut schema, None, &config);
1185
1186            // Should remain as record because 1 required key > 0
1187            assert_eq!(schema["type"], "object");
1188            assert!(schema.get("properties").is_some());
1189            assert!(schema.get("additionalProperties").is_none());
1190        }
1191
1192        #[test]
1193        fn test_rewrite_objects_zero_required_keys_allowed() {
1194            let mut schema = json!({
1195                "type": "object",
1196                "properties": {
1197                    "field1": {"type": "string"},
1198                    "field2": {"type": "string"}
1199                }
1200                // No required array = 0 required keys
1201            });
1202
1203            let config = SchemaInferenceConfig {
1204                map_threshold: 2,
1205                map_max_required_keys: Some(0), // Only allow maps with 0 required keys
1206                ..Default::default()
1207            };
1208
1209            rewrite_objects(&mut schema, None, &config);
1210
1211            // Should become map because 0 required keys โ‰ค 0
1212            assert_eq!(schema["type"], "object");
1213            assert!(schema.get("additionalProperties").is_some());
1214            assert!(schema.get("properties").is_none());
1215        }
1216
1217        #[test]
1218        fn test_rewrite_objects_force_override_wins() {
1219            let mut schema = json!({
1220                "type": "object",
1221                "properties": {
1222                    "field1": {"type": "string"},
1223                    "field2": {"type": "string"}
1224                },
1225                "required": ["field1", "field2"]
1226            });
1227
1228            let mut force_types = std::collections::HashMap::new();
1229            force_types.insert("test_field".to_string(), "map".to_string());
1230
1231            let config = SchemaInferenceConfig {
1232                map_threshold: 2,
1233                map_max_required_keys: Some(0), // Would normally block this
1234                force_field_types: force_types,
1235                ..Default::default()
1236            };
1237
1238            // Apply with field name that matches force override
1239            rewrite_objects(&mut schema, Some("test_field"), &config);
1240
1241            // Should become map despite having required keys due to force override
1242            assert_eq!(schema["type"], "object");
1243            assert!(schema.get("additionalProperties").is_some());
1244            assert!(schema.get("properties").is_none());
1245        }
1246
1247        #[test]
1248        fn test_rewrite_objects_non_homogeneous_values_not_rewritten() {
1249            let mut schema = json!({
1250                "type": "object",
1251                "properties": {
1252                    "field1": {"type": "string"},
1253                    "field2": {"type": "integer"} // Non-homogeneous
1254                },
1255                "required": []
1256            });
1257
1258            let config = SchemaInferenceConfig {
1259                map_threshold: 2,
1260                map_max_required_keys: Some(5), // Would allow this
1261                ..Default::default()
1262            };
1263
1264            rewrite_objects(&mut schema, None, &config);
1265
1266            // Should remain as record because values are not homogeneous
1267            assert_eq!(schema["type"], "object");
1268            assert!(schema.get("properties").is_some());
1269            assert!(schema.get("additionalProperties").is_none());
1270        }
1271
1272        #[test]
1273        fn test_rewrite_objects_below_threshold_not_rewritten() {
1274            let mut schema = json!({
1275                "type": "object",
1276                "properties": {
1277                    "field1": {"type": "string"}
1278                },
1279                "required": []
1280            });
1281
1282            let config = SchemaInferenceConfig {
1283                map_threshold: 5,               // Above the key count
1284                map_max_required_keys: Some(5), // Would allow this
1285                ..Default::default()
1286            };
1287
1288            rewrite_objects(&mut schema, None, &config);
1289
1290            // Should remain as record because below threshold
1291            assert_eq!(schema["type"], "object");
1292            assert!(schema.get("properties").is_some());
1293            assert!(schema.get("additionalProperties").is_none());
1294        }
1295
1296        // Existing tests...
1297        #[test]
1298        fn test_map_max_required_keys_with_wrap_root() {
1299            let json_strings = vec![
1300                r#"{"en":{"language":"en","value":"Hello"},"fr":{"language":"fr","value":"Bonjour"}}"#.to_string(),
1301            ];
1302
1303            let cfg = SchemaInferenceConfig {
1304                wrap_root: Some("labels".to_string()),
1305                map_threshold: 2,
1306                map_max_required_keys: Some(1), // Should allow the wrapped content
1307                ..Default::default()
1308            };
1309
1310            let out = infer_json_schema_from_strings(&json_strings, cfg).unwrap();
1311            let sch = out.schema;
1312
1313            assert_eq!(sch["type"], "object");
1314            assert_eq!(sch["required"], serde_json::json!(["labels"]));
1315
1316            // The wrapped labels should have the map logic applied
1317            let labels_content = &sch["properties"]["labels"];
1318            assert!(labels_content.is_object());
1319
1320            println!("โœ… map_max_required_keys works with wrap_root");
1321        }
1322    }
1323}
1324pub use innermod::*;