use std::fmt;
use thiserror::Error;
pub const FOUND_BYTES_CAP: usize = 32;
pub const BYTES_NEAR_BEFORE: usize = 16;
pub const BYTES_NEAR_AFTER: usize = 16;
#[must_use]
pub fn truncate_bytes(input: &[u8]) -> Vec<u8> {
if input.len() > FOUND_BYTES_CAP {
input[..FOUND_BYTES_CAP].to_vec()
} else {
input.to_vec()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BytesNear {
pub bytes: Vec<u8>,
pub start_offset: usize,
}
impl BytesNear {
#[must_use]
pub fn capture(buffer: &[u8], buffer_start: usize, absolute_offset: usize) -> Option<Self> {
if absolute_offset < buffer_start {
return None;
}
let rel = absolute_offset - buffer_start;
if rel > buffer.len() {
return None;
}
let window_start = rel.saturating_sub(BYTES_NEAR_BEFORE);
let window_end = rel.saturating_add(BYTES_NEAR_AFTER).min(buffer.len());
Some(BytesNear {
bytes: buffer[window_start..window_end].to_vec(),
start_offset: buffer_start + window_start,
})
}
}
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct ErrorMetadata<'a> {
pub code: &'static str,
pub slug: &'static str,
pub kind: &'static str,
pub record_index: Option<usize>,
pub byte_offset: Option<usize>,
pub record_byte_offset: Option<usize>,
pub source_name: Option<&'a str>,
pub record_control_number: Option<&'a str>,
pub field_tag: Option<&'a str>,
pub message: Option<&'a str>,
pub indicator_position: Option<u8>,
pub subfield_code: Option<u8>,
pub found: Option<&'a [u8]>,
pub expected: Option<&'a str>,
pub bytes_near: Option<&'a BytesNear>,
pub expected_length: Option<usize>,
pub actual_length: Option<usize>,
pub cap: Option<usize>,
pub errors_seen: Option<usize>,
}
struct ErrorFieldsMut<'a> {
record_index: &'a mut Option<usize>,
byte_offset: Option<&'a mut Option<usize>>,
record_byte_offset: Option<&'a mut Option<usize>>,
source_name: Option<&'a mut Option<String>>,
bytes_near: Option<&'a mut Option<BytesNear>>,
}
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum MarcError {
#[non_exhaustive]
InvalidLeader {
record_index: Option<usize>,
byte_offset: Option<usize>,
record_byte_offset: Option<usize>,
source_name: Option<String>,
message: String,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
RecordLengthInvalid {
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
found: Option<Vec<u8>>,
expected: Option<String>,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
BaseAddressInvalid {
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
found: Option<Vec<u8>>,
expected: Option<String>,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
BaseAddressNotFound {
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
DirectoryInvalid {
record_index: Option<usize>,
byte_offset: Option<usize>,
record_byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
field_tag: Option<String>,
found: Option<Vec<u8>>,
expected: Option<String>,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
TruncatedRecord {
record_index: Option<usize>,
byte_offset: Option<usize>,
record_byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
expected_length: Option<usize>,
actual_length: Option<usize>,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
EndOfRecordNotFound {
record_index: Option<usize>,
byte_offset: Option<usize>,
record_byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
InvalidIndicator {
record_index: Option<usize>,
byte_offset: Option<usize>,
record_byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
field_tag: Option<String>,
indicator_position: Option<u8>,
found: Option<Vec<u8>>,
expected: Option<String>,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
BadSubfieldCode {
record_index: Option<usize>,
byte_offset: Option<usize>,
record_byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
field_tag: Option<String>,
subfield_code: u8,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
InvalidField {
record_index: Option<usize>,
byte_offset: Option<usize>,
record_byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
field_tag: Option<String>,
message: String,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
EncodingError {
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
field_tag: Option<String>,
message: String,
bytes_near: Option<BytesNear>,
},
#[non_exhaustive]
FieldNotFound {
record_index: Option<usize>,
record_control_number: Option<String>,
field_tag: String,
},
#[non_exhaustive]
IoError {
#[source]
cause: std::io::Error,
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
},
#[non_exhaustive]
XmlError {
#[source]
cause: Box<dyn std::error::Error + Send + Sync + 'static>,
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
},
#[non_exhaustive]
JsonError {
#[source]
cause: serde_json::Error,
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
},
#[non_exhaustive]
WriterError {
record_index: Option<usize>,
record_control_number: Option<String>,
message: String,
},
#[non_exhaustive]
FatalReaderError {
cap: usize,
errors_seen: usize,
record_index: Option<usize>,
source_name: Option<String>,
},
}
impl Clone for MarcError {
#[allow(clippy::too_many_lines)] fn clone(&self) -> Self {
match self {
MarcError::InvalidLeader {
record_index,
byte_offset,
record_byte_offset,
source_name,
message,
bytes_near,
} => MarcError::InvalidLeader {
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.clone(),
message: message.clone(),
bytes_near: bytes_near.clone(),
},
MarcError::RecordLengthInvalid {
record_index,
byte_offset,
source_name,
found,
expected,
bytes_near,
} => MarcError::RecordLengthInvalid {
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.clone(),
found: found.clone(),
expected: expected.clone(),
bytes_near: bytes_near.clone(),
},
MarcError::BaseAddressInvalid {
record_index,
byte_offset,
source_name,
record_control_number,
found,
expected,
bytes_near,
} => MarcError::BaseAddressInvalid {
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.clone(),
record_control_number: record_control_number.clone(),
found: found.clone(),
expected: expected.clone(),
bytes_near: bytes_near.clone(),
},
MarcError::BaseAddressNotFound {
record_index,
byte_offset,
source_name,
record_control_number,
bytes_near,
} => MarcError::BaseAddressNotFound {
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.clone(),
record_control_number: record_control_number.clone(),
bytes_near: bytes_near.clone(),
},
MarcError::DirectoryInvalid {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
field_tag,
found,
expected,
bytes_near,
} => MarcError::DirectoryInvalid {
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.clone(),
record_control_number: record_control_number.clone(),
field_tag: field_tag.clone(),
found: found.clone(),
expected: expected.clone(),
bytes_near: bytes_near.clone(),
},
MarcError::TruncatedRecord {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
expected_length,
actual_length,
bytes_near,
} => MarcError::TruncatedRecord {
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.clone(),
record_control_number: record_control_number.clone(),
expected_length: *expected_length,
actual_length: *actual_length,
bytes_near: bytes_near.clone(),
},
MarcError::EndOfRecordNotFound {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
bytes_near,
} => MarcError::EndOfRecordNotFound {
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.clone(),
record_control_number: record_control_number.clone(),
bytes_near: bytes_near.clone(),
},
MarcError::InvalidIndicator {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
field_tag,
indicator_position,
found,
expected,
bytes_near,
} => MarcError::InvalidIndicator {
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.clone(),
record_control_number: record_control_number.clone(),
field_tag: field_tag.clone(),
indicator_position: *indicator_position,
found: found.clone(),
expected: expected.clone(),
bytes_near: bytes_near.clone(),
},
MarcError::BadSubfieldCode {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
field_tag,
subfield_code,
bytes_near,
} => MarcError::BadSubfieldCode {
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.clone(),
record_control_number: record_control_number.clone(),
field_tag: field_tag.clone(),
subfield_code: *subfield_code,
bytes_near: bytes_near.clone(),
},
MarcError::InvalidField {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
field_tag,
message,
bytes_near,
} => MarcError::InvalidField {
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.clone(),
record_control_number: record_control_number.clone(),
field_tag: field_tag.clone(),
message: message.clone(),
bytes_near: bytes_near.clone(),
},
MarcError::EncodingError {
record_index,
byte_offset,
source_name,
record_control_number,
field_tag,
message,
bytes_near,
} => MarcError::EncodingError {
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.clone(),
record_control_number: record_control_number.clone(),
field_tag: field_tag.clone(),
message: message.clone(),
bytes_near: bytes_near.clone(),
},
MarcError::FieldNotFound {
record_index,
record_control_number,
field_tag,
} => MarcError::FieldNotFound {
record_index: *record_index,
record_control_number: record_control_number.clone(),
field_tag: field_tag.clone(),
},
MarcError::IoError {
cause,
record_index,
byte_offset,
source_name,
} => MarcError::IoError {
cause: std::io::Error::new(cause.kind(), cause.to_string()),
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.clone(),
},
MarcError::XmlError {
cause,
record_index,
byte_offset,
source_name,
} => MarcError::XmlError {
cause: cause.to_string().into(),
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.clone(),
},
MarcError::JsonError {
cause,
record_index,
byte_offset,
source_name,
} => MarcError::JsonError {
cause: <serde_json::Error as serde::de::Error>::custom(cause.to_string()),
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.clone(),
},
MarcError::WriterError {
record_index,
record_control_number,
message,
} => MarcError::WriterError {
record_index: *record_index,
record_control_number: record_control_number.clone(),
message: message.clone(),
},
MarcError::FatalReaderError {
cap,
errors_seen,
record_index,
source_name,
} => MarcError::FatalReaderError {
cap: *cap,
errors_seen: *errors_seen,
record_index: *record_index,
source_name: source_name.clone(),
},
}
}
}
impl MarcError {
#[must_use]
pub fn detailed(&self) -> String {
let mut out = String::new();
let kind = self.kind_name();
let context = self.context_summary();
if context.is_empty() {
out.push_str(kind);
} else {
out.push_str(kind);
out.push_str(" at ");
out.push_str(&context);
}
let lines = self.detail_lines();
let label_width = lines.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
for (label, value) in &lines {
out.push_str("\n ");
out.push_str(label);
for _ in label.len()..=label_width {
out.push(' ');
}
out.push_str(value);
}
if let Some(window) = self.bytes_near() {
out.push('\n');
out.push('\n');
out.push_str(&render_hex_dump(window, self.byte_offset()));
}
out
}
#[must_use]
#[allow(clippy::too_many_lines)] pub fn metadata(&self) -> ErrorMetadata<'_> {
match self {
MarcError::InvalidLeader {
record_index,
byte_offset,
record_byte_offset,
source_name,
message: _,
bytes_near,
} => ErrorMetadata {
code: "E002",
slug: "leader_invalid",
kind: "InvalidLeader",
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.as_deref(),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::RecordLengthInvalid {
record_index,
byte_offset,
source_name,
found,
expected,
bytes_near,
} => ErrorMetadata {
code: "E001",
slug: "record_length_invalid",
kind: "RecordLengthInvalid",
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.as_deref(),
found: found.as_deref(),
expected: expected.as_deref(),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::BaseAddressInvalid {
record_index,
byte_offset,
source_name,
record_control_number,
found,
expected,
bytes_near,
} => ErrorMetadata {
code: "E003",
slug: "base_address_invalid",
kind: "BaseAddressInvalid",
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.as_deref(),
record_control_number: record_control_number.as_deref(),
found: found.as_deref(),
expected: expected.as_deref(),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::BaseAddressNotFound {
record_index,
byte_offset,
source_name,
record_control_number,
bytes_near,
} => ErrorMetadata {
code: "E004",
slug: "base_address_not_found",
kind: "BaseAddressNotFound",
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.as_deref(),
record_control_number: record_control_number.as_deref(),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::DirectoryInvalid {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
field_tag,
found,
expected,
bytes_near,
} => ErrorMetadata {
code: "E101",
slug: "directory_invalid",
kind: "DirectoryInvalid",
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.as_deref(),
record_control_number: record_control_number.as_deref(),
field_tag: field_tag.as_deref(),
found: found.as_deref(),
expected: expected.as_deref(),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::TruncatedRecord {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
expected_length,
actual_length,
bytes_near,
} => ErrorMetadata {
code: "E005",
slug: "truncated_record",
kind: "TruncatedRecord",
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.as_deref(),
record_control_number: record_control_number.as_deref(),
expected_length: *expected_length,
actual_length: *actual_length,
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::EndOfRecordNotFound {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
bytes_near,
} => ErrorMetadata {
code: "E006",
slug: "end_of_record_not_found",
kind: "EndOfRecordNotFound",
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.as_deref(),
record_control_number: record_control_number.as_deref(),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::InvalidIndicator {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
field_tag,
indicator_position,
found,
expected,
bytes_near,
} => ErrorMetadata {
code: "E201",
slug: "invalid_indicator",
kind: "InvalidIndicator",
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.as_deref(),
record_control_number: record_control_number.as_deref(),
field_tag: field_tag.as_deref(),
indicator_position: *indicator_position,
found: found.as_deref(),
expected: expected.as_deref(),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::BadSubfieldCode {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
field_tag,
subfield_code,
bytes_near,
} => ErrorMetadata {
code: "E202",
slug: "bad_subfield_code",
kind: "BadSubfieldCode",
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.as_deref(),
record_control_number: record_control_number.as_deref(),
field_tag: field_tag.as_deref(),
subfield_code: Some(*subfield_code),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::InvalidField {
record_index,
byte_offset,
record_byte_offset,
source_name,
record_control_number,
field_tag,
message,
bytes_near,
} => ErrorMetadata {
code: "E106",
slug: "invalid_field",
kind: "InvalidField",
record_index: *record_index,
byte_offset: *byte_offset,
record_byte_offset: *record_byte_offset,
source_name: source_name.as_deref(),
record_control_number: record_control_number.as_deref(),
field_tag: field_tag.as_deref(),
message: Some(message),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::EncodingError {
record_index,
byte_offset,
source_name,
record_control_number,
field_tag,
message,
bytes_near,
} => ErrorMetadata {
code: "E301",
slug: "utf8_invalid",
kind: "EncodingError",
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.as_deref(),
record_control_number: record_control_number.as_deref(),
field_tag: field_tag.as_deref(),
message: Some(message),
bytes_near: bytes_near.as_ref(),
..ErrorMetadata::default()
},
MarcError::FieldNotFound {
record_index,
record_control_number,
field_tag,
} => ErrorMetadata {
code: "E105",
slug: "field_not_found",
kind: "FieldNotFound",
record_index: *record_index,
record_control_number: record_control_number.as_deref(),
field_tag: Some(field_tag.as_str()),
..ErrorMetadata::default()
},
MarcError::IoError {
cause: _,
record_index,
byte_offset,
source_name,
} => ErrorMetadata {
code: "E007",
slug: "io_error",
kind: "IoError",
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.as_deref(),
..ErrorMetadata::default()
},
MarcError::XmlError {
cause: _,
record_index,
byte_offset,
source_name,
} => ErrorMetadata {
code: "E401",
slug: "marcxml_invalid",
kind: "XmlError",
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.as_deref(),
..ErrorMetadata::default()
},
MarcError::JsonError {
cause: _,
record_index,
byte_offset,
source_name,
} => ErrorMetadata {
code: "E402",
slug: "marcjson_invalid",
kind: "JsonError",
record_index: *record_index,
byte_offset: *byte_offset,
source_name: source_name.as_deref(),
..ErrorMetadata::default()
},
MarcError::WriterError {
record_index,
record_control_number,
message,
} => ErrorMetadata {
code: "E404",
slug: "record_too_large_for_iso2709",
kind: "WriterError",
record_index: *record_index,
record_control_number: record_control_number.as_deref(),
message: Some(message),
..ErrorMetadata::default()
},
MarcError::FatalReaderError {
cap,
errors_seen,
record_index,
source_name,
} => ErrorMetadata {
code: "E099",
slug: "fatal_reader_error",
kind: "FatalReaderError",
record_index: *record_index,
source_name: source_name.as_deref(),
cap: Some(*cap),
errors_seen: Some(*errors_seen),
..ErrorMetadata::default()
},
}
}
#[allow(clippy::too_many_lines)] fn fields_mut(&mut self) -> ErrorFieldsMut<'_> {
match self {
MarcError::InvalidLeader {
record_index,
byte_offset,
record_byte_offset,
source_name,
bytes_near,
..
}
| MarcError::DirectoryInvalid {
record_index,
byte_offset,
record_byte_offset,
source_name,
bytes_near,
..
}
| MarcError::TruncatedRecord {
record_index,
byte_offset,
record_byte_offset,
source_name,
bytes_near,
..
}
| MarcError::EndOfRecordNotFound {
record_index,
byte_offset,
record_byte_offset,
source_name,
bytes_near,
..
}
| MarcError::InvalidIndicator {
record_index,
byte_offset,
record_byte_offset,
source_name,
bytes_near,
..
}
| MarcError::BadSubfieldCode {
record_index,
byte_offset,
record_byte_offset,
source_name,
bytes_near,
..
}
| MarcError::InvalidField {
record_index,
byte_offset,
record_byte_offset,
source_name,
bytes_near,
..
} => ErrorFieldsMut {
record_index,
byte_offset: Some(byte_offset),
record_byte_offset: Some(record_byte_offset),
source_name: Some(source_name),
bytes_near: Some(bytes_near),
},
MarcError::RecordLengthInvalid {
record_index,
byte_offset,
source_name,
bytes_near,
..
}
| MarcError::BaseAddressInvalid {
record_index,
byte_offset,
source_name,
bytes_near,
..
}
| MarcError::BaseAddressNotFound {
record_index,
byte_offset,
source_name,
bytes_near,
..
}
| MarcError::EncodingError {
record_index,
byte_offset,
source_name,
bytes_near,
..
} => ErrorFieldsMut {
record_index,
byte_offset: Some(byte_offset),
record_byte_offset: None,
source_name: Some(source_name),
bytes_near: Some(bytes_near),
},
MarcError::IoError {
record_index,
byte_offset,
source_name,
..
}
| MarcError::XmlError {
record_index,
byte_offset,
source_name,
..
}
| MarcError::JsonError {
record_index,
byte_offset,
source_name,
..
} => ErrorFieldsMut {
record_index,
byte_offset: Some(byte_offset),
record_byte_offset: None,
source_name: Some(source_name),
bytes_near: None,
},
MarcError::FieldNotFound { record_index, .. }
| MarcError::WriterError { record_index, .. } => ErrorFieldsMut {
record_index,
byte_offset: None,
record_byte_offset: None,
source_name: None,
bytes_near: None,
},
MarcError::FatalReaderError {
record_index,
source_name,
..
} => ErrorFieldsMut {
record_index,
byte_offset: None,
record_byte_offset: None,
source_name: Some(source_name),
bytes_near: None,
},
}
}
#[must_use]
pub fn code(&self) -> &'static str {
self.metadata().code
}
#[must_use]
pub fn slug(&self) -> &'static str {
self.metadata().slug
}
#[must_use]
pub fn to_json_value(&self) -> serde_json::Value {
use serde_json::{Map, Value, json};
let md = self.metadata();
let mut m: Map<String, Value> = Map::new();
m.insert("schema_version".into(), json!(SCHEMA_VERSION));
m.insert("class".into(), json!(md.kind));
m.insert("code".into(), json!(md.code));
m.insert("slug".into(), json!(md.slug));
m.insert("severity".into(), json!("error"));
m.insert("help_url".into(), json!(self.help_url()));
m.insert("record_index".into(), opt_json(md.record_index));
m.insert(
"record_control_number".into(),
md.record_control_number.map_or(Value::Null, Value::from),
);
m.insert(
"field_tag".into(),
md.field_tag.map_or(Value::Null, Value::from),
);
m.insert("indicator_position".into(), opt_json(md.indicator_position));
m.insert("subfield_code".into(), opt_json(md.subfield_code));
m.insert("found".into(), Value::Null);
if let Some(bytes) = md.found {
m.insert("found_hex".into(), json!(hex_encode(bytes)));
}
m.insert(
"expected".into(),
md.expected.map_or(Value::Null, Value::from),
);
m.insert("byte_offset".into(), opt_json(md.byte_offset));
m.insert("record_byte_offset".into(), opt_json(md.record_byte_offset));
m.insert(
"source".into(),
md.source_name.map_or(Value::Null, Value::from),
);
m.insert("bytes_near".into(), Value::Null);
if let Some(window) = md.bytes_near {
m.insert("bytes_near_hex".into(), json!(hex_encode(&window.bytes)));
m.insert("bytes_near_offset".into(), json!(window.start_offset));
} else {
m.insert("bytes_near_offset".into(), Value::Null);
}
if let Some(msg) = md.message {
m.insert("message".into(), json!(msg));
}
if let MarcError::TruncatedRecord {
expected_length,
actual_length,
..
} = self
{
m.insert("expected_length".into(), opt_json(*expected_length));
m.insert("actual_length".into(), opt_json(*actual_length));
}
if let Some(cap) = md.cap {
m.insert("cap".into(), json!(cap));
}
if let Some(errors_seen) = md.errors_seen {
m.insert("errors_seen".into(), json!(errors_seen));
}
let cause_str = std::error::Error::source(self).map(ToString::to_string);
m.insert("_cause".into(), cause_str.map_or(Value::Null, Value::from));
Value::Object(m)
}
pub fn to_json(&self) -> std::result::Result<String, serde_json::Error> {
serde_json::to_string(&self.to_json_value())
}
#[must_use]
pub fn bytes_near(&self) -> Option<&BytesNear> {
self.metadata().bytes_near
}
#[must_use]
pub fn with_bytes_near(mut self, buffer: &[u8], buffer_start_offset: usize) -> Self {
let anchor = self.byte_offset().unwrap_or(buffer_start_offset);
let Some(window) = BytesNear::capture(buffer, buffer_start_offset, anchor) else {
return self;
};
let fields = self.fields_mut();
if let Some(bytes_near) = fields.bytes_near {
if let Some(byte_offset) = fields.byte_offset
&& byte_offset.is_none()
{
*byte_offset = Some(buffer_start_offset);
}
*bytes_near = Some(window);
}
self
}
#[must_use]
pub fn with_position(mut self, ctx: &crate::iso2709::ParseContext) -> Self {
let ridx = if ctx.record_index == 0 {
None
} else {
Some(ctx.record_index)
};
let fields = self.fields_mut();
*fields.record_index = ridx;
if let Some(slot) = fields.byte_offset {
*slot = Some(ctx.stream_byte_offset);
}
if let Some(slot) = fields.record_byte_offset {
*slot = Some(ctx.record_byte_offset());
}
if let Some(slot) = fields.source_name {
slot.clone_from(&ctx.source_name);
}
self
}
#[must_use]
pub fn help_url(&self) -> String {
format!("{DOCS_BASE_URL}/reference/error-codes/#{}", self.code())
}
fn kind_name(&self) -> &'static str {
self.metadata().kind
}
fn context_summary(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(idx) = self.record_index() {
parts.push(format!("record {idx}"));
}
if let Some(tag) = self.field_tag() {
parts.push(format!("field {tag}"));
}
parts.join(", ")
}
fn detail_lines(&self) -> Vec<(&'static str, String)> {
let md = self.metadata();
let mut lines: Vec<(&'static str, String)> = Vec::new();
if let Some(s) = md.source_name {
lines.push(("source:", s.to_string()));
}
if let Some(cn) = md.record_control_number {
lines.push(("001:", cn.to_string()));
}
if let (Some(pos), Some(exp)) = (md.indicator_position, md.expected) {
let found_repr = md
.found
.map_or_else(|| "?".to_string(), format_found_bytes_python_repr);
let label = if pos == 0 {
"indicator 0:"
} else {
"indicator 1:"
};
lines.push((label, format!("found {found_repr}, expected {exp}")));
}
if let Some(code) = md.subfield_code {
lines.push((
"subfield:",
format!("invalid code byte 0x{code:02X} ({:?})", code as char),
));
}
if let (Some(exp), Some(act)) = (md.expected_length, md.actual_length) {
lines.push(("length:", format!("expected {exp} bytes, found {act}")));
}
if let (Some(cap), Some(errors_seen)) = (md.cap, md.errors_seen) {
lines.push(("cap:", format!("{errors_seen} errors seen, cap {cap}")));
}
if let Some(off) = md.byte_offset {
lines.push(("byte offset:", format!("0x{off:X} ({off}) in stream")));
}
if let Some(off) = md.record_byte_offset {
lines.push(("record-relative:", format!("byte {off}")));
}
if let Some(msg) = md.message {
lines.push(("message:", msg.to_string()));
}
lines
}
fn render_oneline(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let md = self.metadata();
let mut header_parts: Vec<String> = Vec::new();
if let Some(idx) = md.record_index {
header_parts.push(format!("record {idx}"));
}
if let Some(cn) = md.record_control_number {
header_parts.push(format!("001 '{cn}'"));
}
if let Some(tag) = md.field_tag {
header_parts.push(format!("field {tag}"));
}
if let Some(pos) = md.indicator_position {
header_parts.push(format!("ind{pos}"));
}
if header_parts.is_empty() {
write!(f, "{}: ", md.kind)?;
} else {
write!(f, "[{}] ", header_parts.join(" · "))?;
}
write!(f, "{}", self.body_text())?;
if let Some(off) = md.byte_offset {
write!(f, " (byte 0x{off:X} / {off})")?;
}
Ok(())
}
fn body_text(&self) -> String {
match self {
MarcError::InvalidLeader { message, .. } => format!("invalid leader: {message}"),
MarcError::RecordLengthInvalid {
found, expected, ..
} => match (found, expected) {
(Some(f), Some(e)) => format!(
"invalid record length {} — expected {e}",
format_found_bytes_python_repr(f)
),
_ => "invalid record length".to_string(),
},
MarcError::BaseAddressInvalid {
found, expected, ..
} => match (found, expected) {
(Some(f), Some(e)) => format!(
"invalid base address {} — expected {e}",
format_found_bytes_python_repr(f)
),
_ => "invalid base address".to_string(),
},
MarcError::BaseAddressNotFound { .. } => "base address not found".to_string(),
MarcError::DirectoryInvalid {
found, expected, ..
} => match (found, expected) {
(Some(f), Some(e)) => format!(
"invalid directory entry {} — expected {e}",
format_found_bytes_python_repr(f)
),
_ => "invalid directory entry".to_string(),
},
MarcError::TruncatedRecord {
expected_length,
actual_length,
..
} => match (expected_length, actual_length) {
(Some(e), Some(a)) => format!("truncated record: expected {e} bytes, found {a}"),
_ => "truncated record".to_string(),
},
MarcError::EndOfRecordNotFound { .. } => "end-of-record marker not found".to_string(),
MarcError::InvalidIndicator {
found, expected, ..
} => match (found, expected) {
(Some(f), Some(e)) => format!(
"invalid {} — expected {e}",
format_found_bytes_python_repr(f)
),
_ => "invalid indicator".to_string(),
},
MarcError::BadSubfieldCode { subfield_code, .. } => {
format!("invalid subfield code 0x{subfield_code:02X}")
},
MarcError::InvalidField { message, .. } => format!("invalid field: {message}"),
MarcError::EncodingError { message, .. } => format!("encoding error: {message}"),
MarcError::FieldNotFound { field_tag, .. } => {
format!("field {field_tag} not found")
},
MarcError::IoError { cause, .. } => format!("I/O error: {cause}"),
MarcError::XmlError { cause, .. } => format!("XML parse error: {cause}"),
MarcError::JsonError { cause, .. } => format!("JSON parse error: {cause}"),
MarcError::WriterError { message, .. } => format!("writer error: {message}"),
MarcError::FatalReaderError {
cap, errors_seen, ..
} => format!(
"fatal reader error: recovered-error cap exceeded ({errors_seen} errors, cap {cap})"
),
}
}
fn record_index(&self) -> Option<usize> {
self.metadata().record_index
}
fn field_tag(&self) -> Option<&str> {
self.metadata().field_tag
}
fn byte_offset(&self) -> Option<usize> {
self.metadata().byte_offset
}
}
impl MarcError {
#[must_use]
pub fn invalid_field(message: impl Into<String>) -> Self {
Self::invalid_field_msg(message)
}
#[must_use]
pub fn truncated_record(expected_length: Option<usize>, actual_length: Option<usize>) -> Self {
MarcError::TruncatedRecord {
record_index: None,
byte_offset: None,
record_byte_offset: None,
source_name: None,
record_control_number: None,
expected_length,
actual_length,
bytes_near: None,
}
}
#[must_use]
pub fn record_length_invalid(found: Option<Vec<u8>>, expected: Option<String>) -> Self {
MarcError::RecordLengthInvalid {
record_index: None,
byte_offset: None,
source_name: None,
found,
expected,
bytes_near: None,
}
}
#[must_use]
pub fn fatal_reader_error(cap: usize, errors_seen: usize) -> Self {
MarcError::FatalReaderError {
cap,
errors_seen,
record_index: None,
source_name: None,
}
}
#[must_use]
pub fn with_record_index(mut self, record_index: Option<usize>) -> Self {
*self.fields_mut().record_index = record_index;
self
}
#[must_use]
pub fn with_byte_offset(mut self, byte_offset: Option<usize>) -> Self {
if let Some(slot) = self.fields_mut().byte_offset {
*slot = byte_offset;
}
self
}
#[must_use]
pub fn with_record_byte_offset(mut self, record_byte_offset: Option<usize>) -> Self {
if let Some(slot) = self.fields_mut().record_byte_offset {
*slot = record_byte_offset;
}
self
}
#[must_use]
pub fn with_source_name(mut self, source_name: Option<String>) -> Self {
if let Some(slot) = self.fields_mut().source_name {
*slot = source_name;
}
self
}
#[must_use]
pub fn with_bytes_near_window(mut self, window: Option<BytesNear>) -> Self {
if let Some(slot) = self.fields_mut().bytes_near {
*slot = window;
}
self
}
#[must_use]
pub(crate) fn invalid_field_msg(msg: impl Into<String>) -> Self {
MarcError::InvalidField {
record_index: None,
byte_offset: None,
record_byte_offset: None,
source_name: None,
record_control_number: None,
field_tag: None,
message: msg.into(),
bytes_near: None,
}
}
#[must_use]
pub(crate) fn encoding_msg(msg: impl Into<String>) -> Self {
MarcError::EncodingError {
record_index: None,
byte_offset: None,
source_name: None,
record_control_number: None,
field_tag: None,
message: msg.into(),
bytes_near: None,
}
}
#[must_use]
pub(crate) fn leader_msg(message: impl Into<String>) -> Self {
MarcError::InvalidLeader {
record_index: None,
byte_offset: None,
record_byte_offset: None,
source_name: None,
message: message.into(),
bytes_near: None,
}
}
}
impl fmt::Display for MarcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if f.alternate() {
write!(f, "{}", self.detailed())
} else {
self.render_oneline(f)
}
}
}
fn format_found_bytes_python_repr(bytes: &[u8]) -> String {
let mut out = String::from("b'");
for &b in bytes {
match b {
b'\\' => out.push_str(r"\\"),
b'\'' => out.push_str(r"\'"),
b'\n' => out.push_str(r"\n"),
b'\r' => out.push_str(r"\r"),
b'\t' => out.push_str(r"\t"),
0x20..=0x7E => out.push(b as char),
_ => {
use std::fmt::Write;
let _ = write!(out, "\\x{b:02x}");
},
}
}
out.push('\'');
out
}
pub const DOCS_BASE_URL: &str = "https://dchud.github.io/mrrc";
pub const SCHEMA_VERSION: u32 = 1;
fn opt_json<T: Into<serde_json::Value>>(v: Option<T>) -> serde_json::Value {
v.map_or(serde_json::Value::Null, Into::into)
}
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{b:02x}");
}
out
}
#[must_use]
pub fn render_hex_dump(window: &BytesNear, byte_offset: Option<usize>) -> String {
use std::fmt::Write;
const ROW_WIDTH: usize = 16;
let mut out = String::new();
let anchor = byte_offset.unwrap_or(window.start_offset);
let _ = write!(out, "bytes near offset 0x{anchor:X}:");
for (row_idx, chunk) in window.bytes.chunks(ROW_WIDTH).enumerate() {
let row_start = window.start_offset + row_idx * ROW_WIDTH;
out.push('\n');
let _ = write!(out, " 0x{row_start:04X}: ");
for (i, b) in chunk.iter().enumerate() {
if i == 8 {
out.push(' ');
}
let _ = write!(out, "{b:02x} ");
}
for i in chunk.len()..ROW_WIDTH {
if i == 8 {
out.push(' ');
}
out.push_str(" ");
}
out.push('|');
for &b in chunk {
if (0x20..=0x7E).contains(&b) {
out.push(b as char);
} else {
out.push('.');
}
}
for _ in chunk.len()..ROW_WIDTH {
out.push(' ');
}
out.push('|');
if let Some(abs) = byte_offset
&& abs >= row_start
&& abs < row_start + chunk.len()
{
let col = abs - row_start;
let caret_col = 13 + col * 3 + usize::from(col >= 8);
out.push('\n');
for _ in 0..caret_col {
out.push(' ');
}
out.push_str("^^ offending byte");
}
}
out
}
pub type Result<T> = std::result::Result<T, MarcError>;
impl From<std::io::Error> for MarcError {
fn from(cause: std::io::Error) -> Self {
MarcError::IoError {
cause,
record_index: None,
byte_offset: None,
source_name: None,
}
}
}
#[must_use]
pub fn empty_errors_arc() -> std::sync::Arc<Vec<MarcError>> {
static EMPTY: std::sync::OnceLock<std::sync::Arc<Vec<MarcError>>> = std::sync::OnceLock::new();
EMPTY
.get_or_init(|| std::sync::Arc::new(Vec::new()))
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_bytes_short_input_passes_through() {
assert_eq!(truncate_bytes(b"hello"), b"hello");
}
#[test]
fn truncate_bytes_long_input_capped() {
let input = vec![b'x'; 100];
assert_eq!(truncate_bytes(&input).len(), FOUND_BYTES_CAP);
}
#[test]
fn display_invalid_indicator_produces_actionable_oneliner() {
let err = MarcError::InvalidIndicator {
record_index: Some(847),
byte_offset: Some(7217),
record_byte_offset: Some(42),
source_name: Some("harvest.mrc".into()),
record_control_number: Some("ocm01234567".into()),
field_tag: Some("245".into()),
indicator_position: Some(1),
found: Some(b":".to_vec()),
expected: Some("digit or space".into()),
bytes_near: None,
};
let s = err.to_string();
assert!(s.starts_with("[record 847"), "got: {s}");
assert!(s.contains("001 'ocm01234567'"), "got: {s}");
assert!(s.contains("field 245"), "got: {s}");
assert!(s.contains("ind1"), "got: {s}");
assert!(s.contains("(byte 0x1C31 / 7217)"), "got: {s}");
}
#[test]
fn detailed_invalid_indicator_multiline() {
let err = MarcError::InvalidIndicator {
record_index: Some(847),
byte_offset: Some(7217),
record_byte_offset: Some(42),
source_name: Some("harvest.mrc".into()),
record_control_number: Some("ocm01234567".into()),
field_tag: Some("245".into()),
indicator_position: Some(1),
found: Some(b":".to_vec()),
expected: Some("digit or space".into()),
bytes_near: None,
};
let d = err.detailed();
assert!(
d.starts_with("InvalidIndicator at record 847, field 245"),
"got: {d}"
);
assert!(d.contains("source:"), "got: {d}");
assert!(d.contains("harvest.mrc"), "got: {d}");
assert!(d.contains("001:"), "got: {d}");
assert!(d.contains("indicator"), "got: {d}");
assert!(d.contains("byte offset:"), "got: {d}");
assert!(d.contains("0x1C31 (7217)"), "got: {d}");
assert!(d.contains("record-relative:"), "got: {d}");
}
#[test]
fn io_error_source_chain_walks() {
let io = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "boom");
let err = MarcError::IoError {
cause: io,
record_index: Some(1),
byte_offset: Some(0),
source_name: None,
};
let chain = std::error::Error::source(&err);
assert!(chain.is_some());
assert!(chain.unwrap().to_string().contains("boom"));
}
#[test]
fn from_io_error_blanket_conversion() {
fn returns_io() -> std::io::Result<()> {
Err(std::io::Error::other("nope"))
}
fn wraps() -> Result<()> {
returns_io()?;
Ok(())
}
let err = wraps().unwrap_err();
assert!(matches!(err, MarcError::IoError { .. }));
}
fn invalid_indicator_full() -> MarcError {
MarcError::InvalidIndicator {
record_index: Some(847),
byte_offset: Some(7217),
record_byte_offset: Some(42),
source_name: Some("harvest.mrc".into()),
record_control_number: Some("ocm01234567".into()),
field_tag: Some("245".into()),
indicator_position: Some(1),
found: Some(b":".to_vec()),
expected: Some("digit or space".into()),
bytes_near: None,
}
}
#[test]
fn snapshot_display_invalid_indicator_full_context() {
insta::assert_snapshot!(invalid_indicator_full().to_string());
}
#[test]
fn snapshot_detailed_invalid_indicator_full_context() {
insta::assert_snapshot!(invalid_indicator_full().detailed());
}
#[test]
fn snapshot_display_no_context_falls_back_to_kind_name() {
let err = MarcError::BaseAddressNotFound {
record_index: None,
byte_offset: None,
source_name: None,
record_control_number: None,
bytes_near: None,
};
insta::assert_snapshot!(err.to_string());
}
#[test]
fn snapshot_display_directory_invalid_with_truncated_found() {
let big_input: Vec<u8> = (b'a'..=b'z').cycle().take(60).collect();
let truncated = truncate_bytes(&big_input);
let err = MarcError::DirectoryInvalid {
record_index: Some(3),
byte_offset: Some(0x100),
record_byte_offset: Some(24),
source_name: Some("collection.mrc".into()),
record_control_number: Some("oc00000003".into()),
field_tag: Some("245".into()),
found: Some(truncated),
expected: Some("12-byte numeric directory entry".into()),
bytes_near: None,
};
insta::assert_snapshot!(err.to_string());
}
#[test]
fn snapshot_detailed_truncated_record() {
let err = MarcError::TruncatedRecord {
record_index: Some(12),
byte_offset: Some(0x4000),
record_byte_offset: Some(0x80),
source_name: Some("partial.mrc".into()),
record_control_number: Some("oc00000012".into()),
expected_length: Some(1024),
actual_length: Some(640),
bytes_near: None,
};
insta::assert_snapshot!(err.detailed());
}
const ERROR_CODES: &[(&str, &str)] = &[
("E001", "record_length_invalid"),
("E002", "leader_invalid"),
("E003", "base_address_invalid"),
("E004", "base_address_not_found"),
("E005", "truncated_record"),
("E006", "end_of_record_not_found"),
("E007", "io_error"),
("E099", "fatal_reader_error"),
("E101", "directory_invalid"),
("E105", "field_not_found"),
("E106", "invalid_field"),
("E201", "invalid_indicator"),
("E202", "bad_subfield_code"),
("E301", "utf8_invalid"),
("E401", "marcxml_invalid"),
("E402", "marcjson_invalid"),
("E404", "record_too_large_for_iso2709"),
];
#[test]
fn error_codes_and_slugs_are_unique() {
let codes: std::collections::HashSet<_> = ERROR_CODES.iter().map(|(c, _)| *c).collect();
let slugs: std::collections::HashSet<_> = ERROR_CODES.iter().map(|(_, s)| *s).collect();
assert_eq!(
codes.len(),
ERROR_CODES.len(),
"duplicate code in ERROR_CODES"
);
assert_eq!(
slugs.len(),
ERROR_CODES.len(),
"duplicate slug in ERROR_CODES"
);
}
#[test]
fn help_url_anchors_on_docs_page() {
let err = MarcError::FieldNotFound {
record_index: None,
record_control_number: None,
field_tag: "245".into(),
};
assert_eq!(
err.help_url(),
format!("{DOCS_BASE_URL}/reference/error-codes/#E105"),
);
}
#[test]
fn metadata_snapshot_exposes_all_carried_fields() {
let err = invalid_indicator_full();
let md = err.metadata();
assert_eq!(md.code, "E201");
assert_eq!(md.slug, "invalid_indicator");
assert_eq!(md.kind, "InvalidIndicator");
assert_eq!(md.record_index, Some(847));
assert_eq!(md.byte_offset, Some(7217));
assert_eq!(md.record_byte_offset, Some(42));
assert_eq!(md.source_name, Some("harvest.mrc"));
assert_eq!(md.record_control_number, Some("ocm01234567"));
assert_eq!(md.field_tag, Some("245"));
assert_eq!(md.indicator_position, Some(1));
assert_eq!(md.found, Some(b":".as_slice()));
assert_eq!(md.expected, Some("digit or space"));
assert!(md.bytes_near.is_none());
assert!(md.message.is_none());
assert!(md.subfield_code.is_none());
assert!(md.expected_length.is_none());
assert!(md.actual_length.is_none());
assert!(md.cap.is_none());
assert!(md.errors_seen.is_none());
}
#[test]
fn public_constructors_and_setters_build_enriched_errors() {
let err = MarcError::truncated_record(Some(100), Some(80))
.with_record_index(Some(3))
.with_byte_offset(Some(0x40))
.with_record_byte_offset(Some(8))
.with_source_name(Some("a.mrc".into()))
.with_bytes_near_window(Some(BytesNear {
bytes: vec![1, 2],
start_offset: 0x3E,
}));
let md = err.metadata();
assert_eq!(md.code, "E005");
assert_eq!(md.record_index, Some(3));
assert_eq!(md.byte_offset, Some(0x40));
assert_eq!(md.record_byte_offset, Some(8));
assert_eq!(md.source_name, Some("a.mrc"));
assert_eq!(md.expected_length, Some(100));
assert_eq!(md.actual_length, Some(80));
assert!(md.bytes_near.is_some());
assert_eq!(MarcError::invalid_field("boom").code(), "E106");
assert_eq!(
MarcError::record_length_invalid(Some(b"00X42".to_vec()), Some("5 digits".into()))
.code(),
"E001"
);
let err = MarcError::fatal_reader_error(10, 11)
.with_byte_offset(Some(5))
.with_record_byte_offset(Some(5))
.with_bytes_near_window(Some(BytesNear {
bytes: vec![0],
start_offset: 0,
}));
let md = err.metadata();
assert_eq!(md.code, "E099");
assert_eq!(md.cap, Some(10));
assert_eq!(md.errors_seen, Some(11));
assert_eq!(md.byte_offset, None);
assert_eq!(md.record_byte_offset, None);
assert!(md.bytes_near.is_none());
}
#[test]
fn to_json_value_invalid_indicator_full_schema() {
let err = invalid_indicator_full();
let v = err.to_json_value();
let obj = v.as_object().expect("to_json_value returns an object");
assert_eq!(obj["schema_version"], serde_json::json!(1));
assert_eq!(obj["class"], serde_json::json!("InvalidIndicator"));
assert_eq!(obj["code"], serde_json::json!("E201"));
assert_eq!(obj["slug"], serde_json::json!("invalid_indicator"));
assert_eq!(obj["severity"], serde_json::json!("error"));
assert!(obj["help_url"].as_str().unwrap().ends_with("#E201"));
assert_eq!(obj["record_index"], serde_json::json!(847));
assert_eq!(
obj["record_control_number"],
serde_json::json!("ocm01234567")
);
assert_eq!(obj["field_tag"], serde_json::json!("245"));
assert_eq!(obj["indicator_position"], serde_json::json!(1));
assert_eq!(obj["expected"], serde_json::json!("digit or space"));
assert_eq!(obj["byte_offset"], serde_json::json!(7217));
assert_eq!(obj["record_byte_offset"], serde_json::json!(42));
assert_eq!(obj["source"], serde_json::json!("harvest.mrc"));
assert_eq!(obj["found"], serde_json::Value::Null);
assert_eq!(obj["found_hex"], serde_json::json!("3a"));
assert_eq!(obj["_cause"], serde_json::Value::Null);
}
#[test]
fn to_json_includes_cause_chain_for_io_error() {
let io = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "test eof");
let err = MarcError::IoError {
cause: io,
record_index: Some(5),
byte_offset: Some(100),
source_name: None,
};
let v = err.to_json_value();
let obj = v.as_object().unwrap();
assert_eq!(obj["_cause"], serde_json::json!("test eof"));
assert_eq!(obj["code"], serde_json::json!("E007"));
}
#[test]
fn to_json_truncated_record_includes_length_extras() {
let err = MarcError::TruncatedRecord {
record_index: Some(12),
byte_offset: Some(0x4000),
record_byte_offset: Some(0x80),
source_name: Some("partial.mrc".into()),
record_control_number: Some("oc00000012".into()),
expected_length: Some(1024),
actual_length: Some(640),
bytes_near: None,
};
let v = err.to_json_value();
let obj = v.as_object().unwrap();
assert_eq!(obj["expected_length"], serde_json::json!(1024));
assert_eq!(obj["actual_length"], serde_json::json!(640));
}
#[test]
fn to_json_returns_valid_json_string() {
let err = invalid_indicator_full();
let s = err.to_json().expect("serialize");
let _parsed: serde_json::Value = serde_json::from_str(&s).expect("parse");
}
#[test]
fn snapshot_display_writer_error() {
let err = MarcError::WriterError {
record_index: Some(99),
record_control_number: Some("oc00000099".into()),
message: "Record length exceeds 4GB limit (5000000000 bytes)".into(),
};
insta::assert_snapshot!(err.to_string());
}
#[test]
fn bytes_near_capture_returns_none_outside_buffer() {
let buf = b"abcdef".to_vec();
assert!(BytesNear::capture(&buf, 100, 50).is_none());
assert!(BytesNear::capture(&buf, 100, 107).is_none());
}
#[test]
fn bytes_near_capture_clamps_at_buffer_boundaries() {
let buf: Vec<u8> = (0..20).collect();
let window = BytesNear::capture(&buf, 1000, 1000).unwrap();
assert_eq!(window.start_offset, 1000);
assert_eq!(window.bytes.len(), 16);
let window = BytesNear::capture(&buf, 1000, 1018).unwrap();
assert_eq!(window.bytes.len(), 16 + 2); assert_eq!(window.start_offset, 1002);
}
#[test]
fn to_json_bytes_near_surfaces_hex_and_offset() {
let err = MarcError::InvalidIndicator {
record_index: Some(1),
byte_offset: Some(100),
record_byte_offset: None,
source_name: None,
record_control_number: None,
field_tag: Some("245".into()),
indicator_position: Some(0),
found: Some(b":".to_vec()),
expected: Some("digit or space".into()),
bytes_near: Some(BytesNear {
bytes: vec![0x20, 0x3a, 0x30],
start_offset: 99,
}),
};
let obj = err.to_json_value();
let obj = obj.as_object().unwrap();
assert_eq!(obj["bytes_near"], serde_json::Value::Null);
assert_eq!(obj["bytes_near_hex"], serde_json::json!("203a30"));
assert_eq!(obj["bytes_near_offset"], serde_json::json!(99));
}
#[test]
fn to_json_bytes_near_is_null_when_absent() {
let err = MarcError::InvalidIndicator {
record_index: None,
byte_offset: None,
record_byte_offset: None,
source_name: None,
record_control_number: None,
field_tag: None,
indicator_position: None,
found: None,
expected: None,
bytes_near: None,
};
let obj = err.to_json_value();
let obj = obj.as_object().unwrap();
assert_eq!(obj["bytes_near"], serde_json::Value::Null);
assert!(!obj.contains_key("bytes_near_hex"));
assert_eq!(obj["bytes_near_offset"], serde_json::Value::Null);
}
#[test]
fn detailed_includes_hex_dump_with_caret_when_bytes_near_set() {
let mut window_bytes = Vec::with_capacity(32);
window_bytes.extend(b"2023nyu ");
window_bytes.extend(b":0\x000 0 eng d\x1e245");
let err = MarcError::InvalidIndicator {
record_index: Some(847),
byte_offset: Some(0x1C31),
record_byte_offset: Some(42),
source_name: Some("harvest.mrc".into()),
record_control_number: Some("ocm01234567".into()),
field_tag: Some("245".into()),
indicator_position: Some(0),
found: Some(b":".to_vec()),
expected: Some("digit or space".into()),
bytes_near: Some(BytesNear {
bytes: window_bytes,
start_offset: 0x1C21,
}),
};
let d = err.detailed();
assert!(d.contains("bytes near offset 0x1C31:"), "got:\n{d}");
assert!(d.contains("0x1C21:"), "got:\n{d}");
assert!(d.contains("0x1C31:"), "got:\n{d}");
assert!(d.contains("^^ offending byte"), "got:\n{d}");
assert!(d.contains("|2023nyu"), "got:\n{d}");
}
#[test]
fn clone_io_error_preserves_kind_and_message_and_positional_fields() {
let original = MarcError::IoError {
cause: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
record_index: Some(3),
byte_offset: Some(42),
source_name: Some("file.mrc".into()),
};
let cloned = original.clone();
match (&original, &cloned) {
(
MarcError::IoError {
cause: c1,
record_index: r1,
byte_offset: b1,
source_name: s1,
},
MarcError::IoError {
cause: c2,
record_index: r2,
byte_offset: b2,
source_name: s2,
},
) => {
assert_eq!(c1.kind(), c2.kind());
assert_eq!(c1.to_string(), c2.to_string());
assert_eq!(r1, r2);
assert_eq!(b1, b2);
assert_eq!(s1, s2);
},
_ => unreachable!("clone changed variant"),
}
}
#[test]
fn clone_xml_error_preserves_rendered_cause_message() {
let original = MarcError::XmlError {
cause: Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"unexpected EOF in element <record>",
)),
record_index: Some(7),
byte_offset: Some(1024),
source_name: Some("collection.xml".into()),
};
let cloned = original.clone();
match (&original, &cloned) {
(
MarcError::XmlError {
cause: c1,
record_index: r1,
byte_offset: b1,
source_name: s1,
},
MarcError::XmlError {
cause: c2,
record_index: r2,
byte_offset: b2,
source_name: s2,
},
) => {
assert_eq!(c1.to_string(), c2.to_string());
assert_eq!(r1, r2);
assert_eq!(b1, b2);
assert_eq!(s1, s2);
},
_ => unreachable!("clone changed variant"),
}
}
#[test]
fn clone_json_error_preserves_rendered_cause_message() {
let parse_err = serde_json::from_str::<serde_json::Value>("{bad json").unwrap_err();
let expected_msg = parse_err.to_string();
let original = MarcError::JsonError {
cause: parse_err,
record_index: Some(2),
byte_offset: Some(8),
source_name: Some("rec.json".into()),
};
let cloned = original.clone();
match (&original, &cloned) {
(
MarcError::JsonError {
cause: c1,
record_index: r1,
byte_offset: b1,
source_name: s1,
},
MarcError::JsonError {
cause: c2,
record_index: r2,
byte_offset: b2,
source_name: s2,
},
) => {
assert_eq!(c1.to_string(), expected_msg);
assert_eq!(c2.to_string(), expected_msg);
assert_eq!(r1, r2);
assert_eq!(b1, b2);
assert_eq!(s1, s2);
},
_ => unreachable!("clone changed variant"),
}
}
#[test]
#[allow(clippy::too_many_lines)] fn clone_round_trips_pure_data_variants_by_debug_equality() {
let bytes_near = Some(BytesNear {
bytes: vec![0xDE, 0xAD, 0xBE, 0xEF],
start_offset: 0x100,
});
let variants: Vec<MarcError> = vec![
MarcError::InvalidLeader {
record_index: Some(1),
byte_offset: Some(2),
record_byte_offset: Some(0),
source_name: Some("a".into()),
message: "bad leader".into(),
bytes_near: bytes_near.clone(),
},
MarcError::RecordLengthInvalid {
record_index: Some(1),
byte_offset: Some(2),
source_name: Some("a".into()),
found: Some(b"00X42".to_vec()),
expected: Some("5 ASCII digits".into()),
bytes_near: bytes_near.clone(),
},
MarcError::BaseAddressInvalid {
record_index: Some(1),
byte_offset: Some(12),
source_name: Some("a".into()),
record_control_number: Some("rec0001".into()),
found: Some(b"0X000".to_vec()),
expected: Some("5 ASCII digits".into()),
bytes_near: bytes_near.clone(),
},
MarcError::BaseAddressNotFound {
record_index: Some(1),
byte_offset: Some(12),
source_name: Some("a".into()),
record_control_number: Some("rec0001".into()),
bytes_near: bytes_near.clone(),
},
MarcError::DirectoryInvalid {
record_index: Some(1),
byte_offset: Some(48),
record_byte_offset: Some(24),
source_name: Some("a".into()),
record_control_number: Some("rec0001".into()),
field_tag: Some("245".into()),
found: Some(b"XYZ".to_vec()),
expected: Some("3 ASCII digits".into()),
bytes_near: bytes_near.clone(),
},
MarcError::TruncatedRecord {
record_index: Some(1),
byte_offset: Some(80),
record_byte_offset: Some(56),
source_name: Some("a".into()),
record_control_number: Some("rec0001".into()),
expected_length: Some(100),
actual_length: Some(80),
bytes_near: bytes_near.clone(),
},
MarcError::EndOfRecordNotFound {
record_index: Some(1),
byte_offset: Some(99),
record_byte_offset: Some(75),
source_name: Some("a".into()),
record_control_number: Some("rec0001".into()),
bytes_near: bytes_near.clone(),
},
MarcError::InvalidIndicator {
record_index: Some(1),
byte_offset: Some(60),
record_byte_offset: Some(36),
source_name: Some("a".into()),
record_control_number: Some("rec0001".into()),
field_tag: Some("245".into()),
indicator_position: Some(0),
found: Some(b":".to_vec()),
expected: Some("digit or space".into()),
bytes_near: bytes_near.clone(),
},
MarcError::BadSubfieldCode {
record_index: Some(1),
byte_offset: Some(65),
record_byte_offset: Some(41),
source_name: Some("a".into()),
record_control_number: Some("rec0001".into()),
field_tag: Some("245".into()),
subfield_code: b'@',
bytes_near: bytes_near.clone(),
},
MarcError::InvalidField {
record_index: Some(1),
byte_offset: Some(70),
record_byte_offset: Some(46),
source_name: Some("a".into()),
record_control_number: Some("rec0001".into()),
field_tag: Some("245".into()),
message: "field too short".into(),
bytes_near: bytes_near.clone(),
},
MarcError::EncodingError {
record_index: Some(1),
byte_offset: Some(50),
source_name: Some("a".into()),
record_control_number: Some("rec0001".into()),
field_tag: Some("245".into()),
message: "invalid utf-8".into(),
bytes_near: bytes_near.clone(),
},
MarcError::FieldNotFound {
record_index: Some(1),
record_control_number: Some("rec0001".into()),
field_tag: "999".into(),
},
MarcError::WriterError {
record_index: Some(5),
record_control_number: Some("rec0005".into()),
message: "exceeds 99999 bytes".into(),
},
MarcError::FatalReaderError {
cap: 10,
errors_seen: 11,
record_index: Some(12),
source_name: Some("a".into()),
},
];
for original in &variants {
let cloned = original.clone();
assert_eq!(
format!("{original:?}"),
format!("{cloned:?}"),
"Debug drift after clone"
);
}
}
}