use crate::genson_rs::{build_json_schema, get_builder, BuildConfig};
use serde::de::Error as DeError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::panic::{self, AssertUnwindSafe};
const MAX_JSON_ERROR_LENGTH: usize = 100;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaInferenceConfig {
pub ignore_outer_array: bool,
pub delimiter: Option<u8>,
pub schema_uri: Option<String>,
}
impl Default for SchemaInferenceConfig {
fn default() -> Self {
Self {
ignore_outer_array: true,
delimiter: None,
schema_uri: Some("AUTO".to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaInferenceResult {
pub schema: Value,
pub processed_count: usize,
}
fn validate_json(s: &str) -> Result<(), serde_json::Error> {
let mut de = serde_json::Deserializer::from_str(s);
serde::de::IgnoredAny::deserialize(&mut de)?; de.end()
}
fn validate_ndjson(s: &str) -> Result<(), serde_json::Error> {
for line in s.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
validate_json(trimmed)?; }
Ok(())
}
pub fn infer_json_schema_from_strings(
json_strings: &[String],
config: SchemaInferenceConfig,
) -> Result<SchemaInferenceResult, String> {
if json_strings.is_empty() {
return Err("No JSON strings provided".to_string());
}
let result = panic::catch_unwind(AssertUnwindSafe(
|| -> Result<SchemaInferenceResult, String> {
let mut builder = get_builder(config.schema_uri.as_deref());
let build_config = BuildConfig {
delimiter: config.delimiter,
ignore_outer_array: config.ignore_outer_array,
};
let mut processed_count = 0;
for (i, json_str) in json_strings.iter().enumerate() {
if json_str.trim().is_empty() {
continue;
}
let validation_result = if let Some(delim) = config.delimiter {
if delim == b'\n' {
validate_ndjson(json_str)
} else {
Err(serde_json::Error::custom(format!(
"Unsupported delimiter: {:?}",
delim
)))
}
} else {
validate_json(json_str)
};
if let Err(parse_error) = validation_result {
let truncated_json = if json_str.len() > MAX_JSON_ERROR_LENGTH {
format!(
"{}... [truncated {} chars]",
&json_str[..MAX_JSON_ERROR_LENGTH],
json_str.len() - MAX_JSON_ERROR_LENGTH
)
} else {
json_str.clone()
};
return Err(format!(
"Invalid JSON input at index {}: {} - JSON: {}",
i + 1,
parse_error,
truncated_json
));
}
let mut bytes = json_str.as_bytes().to_vec();
let _schema = build_json_schema(&mut builder, &mut bytes, &build_config);
processed_count += 1;
}
let final_schema = builder.to_schema();
Ok(SchemaInferenceResult {
schema: final_schema,
processed_count,
})
},
));
match result {
Ok(Ok(schema_result)) => Ok(schema_result),
Ok(Err(e)) => Err(e),
Err(_panic) => Err("JSON schema inference failed due to invalid JSON input".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use predicates::prelude::*;
use serde_json::json;
#[test]
fn test_basic_schema_inference() {
let json_strings = vec![
r#"{"name": "Alice", "age": 30}"#.to_string(),
r#"{"name": "Bob", "age": 25, "city": "NYC"}"#.to_string(),
];
let result =
infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default())
.expect("Schema inference should succeed");
assert_eq!(result.processed_count, 2);
let schema_str = result.schema.to_string();
predicate::str::contains("\"type\"")
.and(predicate::str::contains("object"))
.eval(&schema_str);
predicate::str::contains("\"properties\"").eval(&schema_str);
predicate::str::contains("\"name\"")
.and(predicate::str::contains("\"age\""))
.eval(&schema_str);
println!(
"✅ Generated schema: {}",
serde_json::to_string_pretty(&result.schema).unwrap()
);
}
#[test]
fn test_empty_input() {
let json_strings = vec![];
let result =
infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default());
assert!(result.is_err());
let error_msg = result.unwrap_err();
predicate::str::contains("No JSON strings provided").eval(&error_msg);
println!("✅ Empty input correctly rejected with: {}", error_msg);
}
#[test]
fn test_invalid_json_variants() {
let test_cases = vec![
(
r#"{"name": "Alice"}"#,
r#"{"invalid": json}"#,
"unquoted value",
),
(
r#"{"valid": "json"}"#,
r#"{"incomplete":"#,
"incomplete string",
),
(r#"{"good": "data"}"#, r#"{"trailing":,"#, "trailing comma"),
(
r#"{"working": true}"#,
r#"{invalid: "json"}"#,
"unquoted key",
),
(
r#"{"normal": "object"}"#,
r#"{"nested": {"broken": json}}"#,
"nested broken JSON",
),
];
for (valid_json, invalid_json, description) in test_cases {
println!("🧪 Testing: {}", description);
println!(" Valid JSON: {}", valid_json);
println!(" Invalid JSON: {}", invalid_json);
let json_strings = vec![valid_json.to_string(), invalid_json.to_string()];
let result =
infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default());
assert!(result.is_err(), "Expected error for case: {}", description);
let error_msg = result.unwrap_err();
predicate::str::contains("Invalid JSON input at position").eval(&error_msg);
if invalid_json.len() <= MAX_JSON_ERROR_LENGTH {
predicate::str::contains(invalid_json).eval(&error_msg);
} else {
predicate::str::contains("truncated").eval(&error_msg);
}
predicate::str::contains("panicked").not().eval(&error_msg);
predicate::str::contains("SIGABRT").not().eval(&error_msg);
println!(" ❌ Correctly failed with: {}", error_msg);
println!();
}
}
#[test]
fn test_mixed_valid_and_empty_strings() {
let json_strings = vec![
r#"{"name": "Alice", "age": 30}"#.to_string(),
"".to_string(), " ".to_string(), r#"{"name": "Bob", "age": 25}"#.to_string(),
];
let result =
infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default())
.expect("Should succeed with valid JSON, skipping empty strings");
assert_eq!(result.processed_count, 2);
let schema_str = result.schema.to_string();
predicate::str::contains("\"name\"")
.and(predicate::str::contains("\"age\""))
.eval(&schema_str);
println!(
"✅ Processed {} valid strings, skipped empty ones",
result.processed_count
);
}
#[test]
fn test_schema_config_variations() {
let json_strings = vec![r#"[{"item": "first"}, {"item": "second"}]"#.to_string()];
let config_array = SchemaInferenceConfig {
ignore_outer_array: false,
..Default::default()
};
let result = infer_json_schema_from_strings(&json_strings, config_array)
.expect("Should handle array schema");
let schema_str = result.schema.to_string();
predicate::str::contains("\"type\"")
.and(predicate::str::contains("array"))
.eval(&schema_str);
println!(
"✅ Array schema: {}",
serde_json::to_string_pretty(&result.schema).unwrap()
);
let config_object = SchemaInferenceConfig {
ignore_outer_array: true,
..Default::default()
};
let result = infer_json_schema_from_strings(&json_strings, config_object)
.expect("Should handle object schema from array items");
let schema_str = result.schema.to_string();
predicate::str::contains("\"type\"")
.and(predicate::str::contains("object"))
.eval(&schema_str);
predicate::str::contains("\"item\"").eval(&schema_str);
println!(
"✅ Object schema from array items: {}",
serde_json::to_string_pretty(&result.schema).unwrap()
);
}
#[test]
fn test_very_long_invalid_json() {
let long_value = "x".repeat(500); let long_invalid_json = format!(
r#"{{"field1": "{}", "field2": "{}", "field3": "{}", "field4": "{}", "invalid_syntax": }}"#,
long_value, long_value, long_value, long_value
);
let json_strings = vec![
r#"{"valid": "json"}"#.to_string(),
long_invalid_json.clone(),
];
println!(
"🧪 Testing very long invalid JSON ({} chars)",
long_invalid_json.len()
);
let result =
infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default());
assert!(result.is_err(), "Expected error for very long invalid JSON");
let error_msg = result.unwrap_err();
println!("The error message was: {}", error_msg);
predicate::str::contains("truncated").eval(&error_msg);
predicate::str::contains("Invalid JSON input at position 2").eval(&error_msg);
assert!(
error_msg.len() < long_invalid_json.len() / 2,
"Error message should be much shorter than original JSON"
);
println!(
" ❌ Correctly truncated long JSON in error: {}",
error_msg
);
assert!(
error_msg.len() < 500,
"Error message should be under 500 chars, got: {}",
error_msg.len()
);
}
#[test]
fn test_complex_nested_schema() {
let json_strings = vec![
json!({
"user": {
"id": 123,
"profile": {
"name": "Alice",
"preferences": ["dark_mode", "notifications"]
}
},
"metadata": {
"created_at": "2024-01-01",
"version": 1
}
})
.to_string(),
json!({
"user": {
"id": 456,
"profile": {
"name": "Bob",
"preferences": ["light_mode"]
}
},
"metadata": {
"created_at": "2024-01-02",
"version": 2
}
})
.to_string(),
];
let result =
infer_json_schema_from_strings(&json_strings, SchemaInferenceConfig::default())
.expect("Should handle complex nested schema");
assert_eq!(result.processed_count, 2);
let schema_str = result.schema.to_string();
predicate::str::contains("\"user\"")
.and(predicate::str::contains("\"metadata\""))
.and(predicate::str::contains("\"profile\""))
.and(predicate::str::contains("\"preferences\""))
.eval(&schema_str);
println!("✅ Complex nested schema generated successfully");
println!(
"Schema: {}",
serde_json::to_string_pretty(&result.schema).unwrap()
);
}
#[test]
fn test_ndjson_parsing() {
let ndjson_input = r#"
{"name": "Alice", "age": 30}
{"name": "Bob", "age": 25, "city": "NYC"}
{"name": "Charlie"}
"#;
let json_strings = vec![ndjson_input.to_string()];
let config = SchemaInferenceConfig {
delimiter: Some(b'\n'),
..Default::default()
};
let result = infer_json_schema_from_strings(&json_strings, config)
.expect("NDJSON schema inference should succeed");
assert_eq!(result.processed_count, 1,
"NDJSON should be counted as a single input string but parsed into multiple rows internally"
);
let schema_str = result.schema.to_string();
assert!(!schema_str.contains("Alice")); assert!(schema_str.contains("\"name\""));
assert!(schema_str.contains("\"age\""));
assert!(schema_str.contains("\"city\""));
println!(
"✅ NDJSON schema generated: {}",
serde_json::to_string_pretty(&result.schema).unwrap()
);
}
#[test]
fn test_invalid_ndjson_line() {
let ndjson_input = r#"
{"valid": true}
{"invalid": json}
{"also_valid": 123}
"#;
let json_strings = vec![ndjson_input.to_string()];
let config = SchemaInferenceConfig {
delimiter: Some(b'\n'),
..Default::default()
};
let result = infer_json_schema_from_strings(&json_strings, config);
assert!(result.is_err(), "Expected error for malformed NDJSON line");
let err_msg = result.unwrap_err();
eprintln!("Got error: {}", err_msg);
assert!(
err_msg.contains("Invalid JSON input at index 1: expected value at line 1 column 13"),
"Error message should report the failing line"
);
println!("✅ Correctly rejected malformed NDJSON: {}", err_msg);
}
}