use crate::options::{DecodeOptions, EncodeOptions};
#[cfg(test)]
use copybook_core::Occurs;
use copybook_core::{Error, ErrorCode, ErrorContext, Field, Result, Schema};
use serde_json::Value;
use std::collections::HashMap;
use tracing::{debug, warn};
#[derive(Debug, Clone)]
pub struct OdoValidationResult {
pub actual_count: u32,
pub was_clamped: bool,
pub warning: Option<Error>,
}
#[derive(Debug, Clone)]
pub struct RedefinesContext {
pub cluster_views: HashMap<String, Vec<String>>,
pub field_to_cluster: HashMap<String, String>,
}
#[allow(clippy::too_many_arguments)]
#[inline]
#[must_use = "Handle the Result or propagate the error"]
pub fn validate_odo_counter(
counter_value: u32,
min_count: u32,
max_count: u32,
field_path: &str,
counter_path: &str,
record_index: u64,
byte_offset: u64,
strict_mode: bool,
) -> Result<OdoValidationResult> {
debug!(
"Validating ODO counter: value={}, min={}, max={}, field={}, counter={}, record={}, strict={}",
counter_value, min_count, max_count, field_path, counter_path, record_index, strict_mode
);
if counter_value >= min_count && counter_value <= max_count {
return Ok(OdoValidationResult {
actual_count: counter_value,
was_clamped: false,
warning: None,
});
}
if strict_mode {
let error_msg = if counter_value < min_count {
format!(
"ODO counter value {counter_value} is below minimum {min_count} for array '{field_path}'"
)
} else {
format!(
"ODO counter value {counter_value} exceeds maximum {max_count} for array '{field_path}'"
)
};
return Err(
Error::new(ErrorCode::CBKS301_ODO_CLIPPED, error_msg).with_context(ErrorContext {
record_index: Some(record_index),
field_path: Some(field_path.to_string()),
byte_offset: Some(byte_offset),
line_number: None,
details: Some(format!(
"counter_field={counter_path}, counter_value={counter_value}"
)),
}),
);
}
let (actual_count, error_code, action) = if counter_value < min_count {
(
min_count,
ErrorCode::CBKS302_ODO_RAISED,
"raised to minimum",
)
} else {
(
max_count,
ErrorCode::CBKS301_ODO_CLIPPED,
"clipped to maximum",
)
};
let warning = Error::new(
error_code,
format!(
"ODO counter value {counter_value} {action} {actual_count} for array '{field_path}' (was {counter_value})"
),
)
.with_context(ErrorContext {
record_index: Some(record_index),
field_path: Some(field_path.to_string()),
byte_offset: Some(byte_offset),
line_number: None,
details: Some(format!(
"counter_field={counter_path}, original_value={counter_value}, clamped_value={actual_count}"
)),
});
warn!("{}", warning);
Ok(OdoValidationResult {
actual_count,
was_clamped: true,
warning: Some(warning),
})
}
#[cfg(test)]
#[inline]
#[must_use = "Handle the Result or propagate the error"]
pub fn validate_odo_tail_position(
schema: &Schema,
odo_field_path: &str,
counter_field_path: &str,
) -> Result<()> {
debug!(
"Validating ODO tail position: array={}, counter={}",
odo_field_path, counter_field_path
);
let odo_field = schema.find_field(odo_field_path).ok_or_else(|| {
Error::new(
ErrorCode::CBKS121_COUNTER_NOT_FOUND,
format!("ODO array field '{odo_field_path}' not found in schema"),
)
})?;
match &odo_field.occurs {
Some(Occurs::ODO {
min: _,
max: _,
counter_path: _,
}) => (),
_ => {
return Err(Error::new(
ErrorCode::CBKP021_ODO_NOT_TAIL,
format!("Field '{odo_field_path}' is not an ODO array"),
));
}
}
let counter_field = schema.find_field(counter_field_path).ok_or_else(|| {
Error::new(
ErrorCode::CBKS121_COUNTER_NOT_FOUND,
format!("ODO counter field '{counter_field_path}' not found in schema"),
)
})?;
if counter_field.offset >= odo_field.offset {
return Err(Error::new(
ErrorCode::CBKP021_ODO_NOT_TAIL,
format!(
"ODO counter '{counter_field_path}' (offset {}) must precede array '{odo_field_path}' (offset {}) in byte order",
counter_field.offset, odo_field.offset
),
));
}
if counter_field.redefines_of.is_some() {
return Err(Error::new(
ErrorCode::CBKS121_COUNTER_NOT_FOUND,
format!("ODO counter field '{counter_field_path}' cannot be inside a REDEFINES region"),
));
}
debug!(
"ODO tail position validation passed for array '{}' with counter '{}'",
odo_field_path, counter_field_path
);
Ok(())
}
pub fn build_redefines_context(schema: &Schema, json_data: &Value) -> RedefinesContext {
let mut context = RedefinesContext {
cluster_views: HashMap::new(),
field_to_cluster: HashMap::new(),
};
collect_redefines_relationships(schema, &mut context);
if let Value::Object(obj) = json_data {
analyze_json_for_redefines(&mut context, obj);
}
debug!(
"Built REDEFINES context: {} clusters, {} field mappings",
context.cluster_views.len(),
context.field_to_cluster.len()
);
context
}
#[inline]
#[must_use = "Handle the Result or propagate the error"]
pub fn validate_redefines_encoding(
context: &RedefinesContext,
cluster_path: &str,
field_path: &str,
json_data: &Value,
use_raw: bool,
record_index: u64,
byte_offset: u64,
) -> Result<()> {
debug!(
"Validating REDEFINES encoding: cluster={}, field={}, use_raw={}",
cluster_path, field_path, use_raw
);
if use_raw
&& let Value::Object(obj) = json_data
&& let Some(Value::String(_)) = obj.get("__raw_b64")
{
debug!("Using raw data for REDEFINES cluster '{}'", cluster_path);
return Ok(());
}
let non_null_views = context
.cluster_views
.get(cluster_path)
.map_or(0, std::vec::Vec::len);
if non_null_views == 0 {
return Err(Error::new(
ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
format!("No non-null views found for REDEFINES cluster '{cluster_path}'"),
)
.with_context(ErrorContext {
record_index: Some(record_index),
field_path: Some(field_path.to_string()),
byte_offset: Some(byte_offset),
line_number: None,
details: Some(format!("cluster_path={cluster_path}")),
}));
}
if non_null_views == 1 {
debug!(
"Single non-null view found for REDEFINES cluster '{}'",
cluster_path
);
return Ok(());
}
let views_list = context
.cluster_views
.get(cluster_path)
.map_or_else(|| "unknown".to_string(), |views| views.join(", "));
Err(Error::new(
ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
format!(
"Ambiguous REDEFINES write: multiple non-null views ({views_list}) for cluster '{cluster_path}'"
),
)
.with_context(ErrorContext {
record_index: Some(record_index),
field_path: Some(field_path.to_string()),
byte_offset: Some(byte_offset),
line_number: None,
details: Some(format!(
"cluster_path={cluster_path}, non_null_views={non_null_views}"
)),
}))
}
pub fn handle_missing_counter_field(
counter_path: &str,
array_path: &str,
schema: &Schema,
record_index: u64,
byte_offset: u64,
) -> Error {
debug!(
"Handling missing counter field: counter={}, array={}",
counter_path, array_path
);
let all_fields = schema.all_fields();
let mut suggestions = Vec::new();
for field in all_fields {
if field.path.contains(counter_path) || counter_path.contains(&field.name) {
suggestions.push(field.path.clone());
}
}
let details = if suggestions.is_empty() {
format!("array_field={array_path}, searched_paths=all_schema_fields")
} else {
format!(
"array_field={array_path}, similar_fields=[{}]",
suggestions.join(", ")
)
};
Error::new(
ErrorCode::CBKS121_COUNTER_NOT_FOUND,
format!(
"ODO counter field '{counter_path}' not found for array '{array_path}'. {}",
if suggestions.is_empty() {
"No similar field names found in schema."
} else {
"Did you mean one of the similar fields listed in details?"
}
),
)
.with_context(ErrorContext {
record_index: Some(record_index),
field_path: Some(array_path.to_string()),
byte_offset: Some(byte_offset),
line_number: None,
details: Some(details),
})
}
fn collect_redefines_relationships(schema: &Schema, context: &mut RedefinesContext) {
collect_redefines_from_fields(&schema.fields, context);
}
fn collect_redefines_from_fields(fields: &[Field], context: &mut RedefinesContext) {
for field in fields {
if let Some(ref target) = field.redefines_of {
let target_name = target.split('.').next_back().unwrap_or(target);
context
.field_to_cluster
.insert(field.name.clone(), target_name.to_string());
context
.cluster_views
.entry(target_name.to_string())
.or_default();
}
collect_redefines_from_fields(&field.children, context);
}
}
fn analyze_json_for_redefines(
context: &mut RedefinesContext,
json_obj: &serde_json::Map<String, Value>,
) {
for (key, value) in json_obj {
if key.starts_with("__") {
continue;
}
if let Some(cluster_path) = context.field_to_cluster.get(key) {
if !value.is_null() {
let views = context
.cluster_views
.entry(cluster_path.clone())
.or_default();
if !views.contains(key) {
views.push(key.clone());
}
}
} else {
if context.cluster_views.contains_key(key) && !value.is_null() {
let views = context.cluster_views.entry(key.clone()).or_default();
if !views.contains(key) {
views.push(key.clone());
}
}
}
if let Value::Object(nested_obj) = value {
analyze_json_for_redefines(context, nested_obj);
}
}
}
#[derive(Clone)]
pub struct OdoValidationContext {
pub field_path: String,
pub counter_path: String,
pub record_index: u64,
pub byte_offset: u64,
}
pub fn create_comprehensive_error_context(
record_index: u64,
field_path: &str,
byte_offset: u64,
additional_details: Option<String>,
) -> ErrorContext {
ErrorContext {
record_index: Some(record_index),
field_path: Some(field_path.to_string()),
byte_offset: Some(byte_offset),
line_number: None,
details: additional_details,
}
}
#[inline]
#[must_use = "Handle the Result or propagate the error"]
pub fn validate_odo_decode(
counter_value: u32,
min_count: u32,
max_count: u32,
context: &OdoValidationContext,
options: &DecodeOptions,
) -> Result<OdoValidationResult> {
validate_odo_counter(
counter_value,
min_count,
max_count,
&context.field_path,
&context.counter_path,
context.record_index,
context.byte_offset,
options.strict_mode,
)
}
#[inline]
#[must_use = "Handle the Result or propagate the error"]
pub fn validate_odo_encode(
array_length: usize,
min_count: u32,
max_count: u32,
context: &OdoValidationContext,
options: &EncodeOptions,
) -> Result<u32> {
let array_length_u32 = u32::try_from(array_length).map_err(|_| {
Error::new(
ErrorCode::CBKE521_ARRAY_LEN_OOB,
format!("Array length {array_length} exceeds u32::MAX"),
)
.with_context(create_comprehensive_error_context(
context.record_index,
&context.field_path,
context.byte_offset,
Some(format!("Array length: {array_length}")),
))
})?;
let validation_result = validate_odo_counter(
array_length_u32,
min_count,
max_count,
&context.field_path,
&context.counter_path,
context.record_index,
context.byte_offset,
options.strict_mode,
)?;
if validation_result.was_clamped {
return Err(Error::new(
ErrorCode::CBKE521_ARRAY_LEN_OOB,
format!(
"JSON array length {array_length} is out of bounds for ODO field '{}' (min={min_count}, max={max_count})",
context.field_path
),
)
.with_context(create_comprehensive_error_context(
context.record_index,
&context.field_path,
context.byte_offset,
Some(format!(
"counter_field={}, array_length={array_length}",
context.counter_path
)),
)));
}
Ok(validation_result.actual_count)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use anyhow::{Context, Result};
use copybook_core::{Field, FieldKind, Schema};
type TestResult = Result<()>;
fn create_test_schema_with_odo() -> Schema {
let mut schema = Schema::new();
let counter = Field {
path: "ROOT.COUNTER".to_string(),
name: "COUNTER".to_string(),
level: 5,
kind: FieldKind::ZonedDecimal {
digits: 3,
scale: 0,
signed: false,
sign_separate: None,
},
offset: 0,
len: 3,
redefines_of: None,
occurs: None,
sync_padding: None,
synchronized: false,
blank_when_zero: false,
resolved_renames: None,
children: vec![],
};
let array_field = Field {
path: "ROOT.ARRAY".to_string(),
name: "ARRAY".to_string(),
level: 5,
kind: FieldKind::Alphanum { len: 10 },
offset: 3,
len: 10,
redefines_of: None,
occurs: Some(Occurs::ODO {
min: 0,
max: 5,
counter_path: "ROOT.COUNTER".to_string(),
}),
sync_padding: None,
synchronized: false,
blank_when_zero: false,
resolved_renames: None,
children: vec![],
};
schema.fields = vec![counter, array_field];
schema
}
#[test]
fn test_odo_validation_within_bounds() -> TestResult {
let result = validate_odo_counter(3, 0, 5, "ROOT.ARRAY", "ROOT.COUNTER", 1, 3, false)?;
assert_eq!(result.actual_count, 3);
assert!(!result.was_clamped);
assert!(result.warning.is_none());
Ok(())
}
#[test]
fn test_odo_validation_strict_mode_over_max() -> TestResult {
let error = validate_odo_counter(10, 0, 5, "ROOT.ARRAY", "ROOT.COUNTER", 1, 3, true)
.err()
.context("expected ODO validation to fail in strict mode")?;
assert_eq!(error.code, ErrorCode::CBKS301_ODO_CLIPPED);
let context = error
.context
.context("expected error context for strict ODO failure")?;
assert_eq!(context.record_index, Some(1));
assert_eq!(context.field_path, Some("ROOT.ARRAY".to_string()));
assert_eq!(context.byte_offset, Some(3));
Ok(())
}
#[test]
fn test_odo_validation_lenient_mode_clamp_max() -> TestResult {
let result = validate_odo_counter(10, 0, 5, "ROOT.ARRAY", "ROOT.COUNTER", 1, 3, false)?;
assert_eq!(result.actual_count, 5);
assert!(result.was_clamped);
let warning = result
.warning
.context("expected warning when lenient mode clamps to max")?;
assert_eq!(warning.code, ErrorCode::CBKS301_ODO_CLIPPED);
Ok(())
}
#[test]
fn test_odo_validation_lenient_mode_raise_min() -> TestResult {
let result = validate_odo_counter(0, 1, 5, "ROOT.ARRAY", "ROOT.COUNTER", 1, 3, false)?;
assert_eq!(result.actual_count, 1);
assert!(result.was_clamped);
let warning = result
.warning
.context("expected warning when lenient mode raises to min")?;
assert_eq!(warning.code, ErrorCode::CBKS302_ODO_RAISED);
Ok(())
}
#[test]
fn test_odo_tail_position_validation() {
let schema = create_test_schema_with_odo();
let result = validate_odo_tail_position(&schema, "ROOT.ARRAY", "ROOT.COUNTER");
assert!(result.is_ok());
}
#[test]
fn test_odo_tail_position_validation_counter_after_array() -> TestResult {
let mut schema = create_test_schema_with_odo();
schema.fields[0].offset = 10; schema.fields[1].offset = 0;
let result = validate_odo_tail_position(&schema, "ROOT.ARRAY", "ROOT.COUNTER");
let error = result
.err()
.context("expected failure when counter trails array")?;
assert_eq!(error.code, ErrorCode::CBKP021_ODO_NOT_TAIL);
Ok(())
}
#[test]
fn test_missing_counter_field_handling() -> TestResult {
let schema = Schema::new();
let error = handle_missing_counter_field("NONEXISTENT", "ROOT.ARRAY", &schema, 1, 10);
assert_eq!(error.code, ErrorCode::CBKS121_COUNTER_NOT_FOUND);
let context = error
.context
.context("expected context describing missing counter field")?;
assert_eq!(context.record_index, Some(1));
assert_eq!(context.field_path, Some("ROOT.ARRAY".to_string()));
assert_eq!(context.byte_offset, Some(10));
Ok(())
}
#[test]
fn test_redefines_context_building() {
let mut schema = Schema::new();
let field_a = Field {
path: "ROOT.FIELD_A".to_string(),
name: "FIELD_A".to_string(),
level: 5,
kind: FieldKind::Alphanum { len: 10 },
offset: 0,
len: 10,
redefines_of: None,
occurs: None,
sync_padding: None,
synchronized: false,
blank_when_zero: false,
resolved_renames: None,
children: vec![],
};
let field_b = Field {
path: "ROOT.FIELD_B".to_string(),
name: "FIELD_B".to_string(),
level: 5,
kind: FieldKind::ZonedDecimal {
digits: 5,
scale: 0,
signed: false,
sign_separate: None,
},
offset: 0,
len: 5,
redefines_of: Some("ROOT.FIELD_A".to_string()),
occurs: None,
sync_padding: None,
synchronized: false,
blank_when_zero: false,
resolved_renames: None,
children: vec![],
};
schema.fields = vec![field_a, field_b];
let json_data = serde_json::json!({
"FIELD_A": "Hello",
"FIELD_B": null
});
let context = build_redefines_context(&schema, &json_data);
assert_eq!(context.field_to_cluster.len(), 1);
assert!(context.field_to_cluster.contains_key("FIELD_B"));
assert_eq!(context.field_to_cluster["FIELD_B"], "FIELD_A");
}
#[test]
fn test_comprehensive_error_context() {
let context = create_comprehensive_error_context(
42,
"ROOT.TEST_FIELD",
100,
Some("additional info".to_string()),
);
assert_eq!(context.record_index, Some(42));
assert_eq!(context.field_path, Some("ROOT.TEST_FIELD".to_string()));
assert_eq!(context.byte_offset, Some(100));
assert_eq!(context.details, Some("additional info".to_string()));
}
}