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(Error, Debug)]
pub enum MarcError {
InvalidLeader {
record_index: Option<usize>,
byte_offset: Option<usize>,
record_byte_offset: Option<usize>,
source_name: Option<String>,
message: String,
bytes_near: Option<BytesNear>,
},
RecordLengthInvalid {
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
found: Option<Vec<u8>>,
expected: Option<String>,
bytes_near: Option<BytesNear>,
},
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>,
},
BaseAddressNotFound {
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
record_control_number: Option<String>,
bytes_near: Option<BytesNear>,
},
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>,
},
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>,
},
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>,
},
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>,
},
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>,
},
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>,
},
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>,
},
FieldNotFound {
record_index: Option<usize>,
record_control_number: Option<String>,
field_tag: String,
},
IoError {
#[source]
cause: std::io::Error,
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
},
XmlError {
#[source]
cause: Box<dyn std::error::Error + Send + Sync + 'static>,
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
},
JsonError {
#[source]
cause: serde_json::Error,
record_index: Option<usize>,
byte_offset: Option<usize>,
source_name: Option<String>,
},
WriterError {
record_index: Option<usize>,
record_control_number: Option<String>,
message: String,
},
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]
pub fn code(&self) -> &'static str {
match self {
MarcError::RecordLengthInvalid { .. } => "E001",
MarcError::InvalidLeader { .. } => "E002",
MarcError::BaseAddressInvalid { .. } => "E003",
MarcError::BaseAddressNotFound { .. } => "E004",
MarcError::TruncatedRecord { .. } => "E005",
MarcError::EndOfRecordNotFound { .. } => "E006",
MarcError::IoError { .. } => "E007",
MarcError::DirectoryInvalid { .. } => "E101",
MarcError::FieldNotFound { .. } => "E105",
MarcError::InvalidField { .. } => "E106",
MarcError::InvalidIndicator { .. } => "E201",
MarcError::BadSubfieldCode { .. } => "E202",
MarcError::EncodingError { .. } => "E301",
MarcError::XmlError { .. } => "E401",
MarcError::JsonError { .. } => "E402",
MarcError::WriterError { .. } => "E404",
MarcError::FatalReaderError { .. } => "E099",
}
}
#[must_use]
pub fn slug(&self) -> &'static str {
match self {
MarcError::RecordLengthInvalid { .. } => "record_length_invalid",
MarcError::InvalidLeader { .. } => "leader_invalid",
MarcError::BaseAddressInvalid { .. } => "base_address_invalid",
MarcError::BaseAddressNotFound { .. } => "base_address_not_found",
MarcError::TruncatedRecord { .. } => "truncated_record",
MarcError::EndOfRecordNotFound { .. } => "end_of_record_not_found",
MarcError::IoError { .. } => "io_error",
MarcError::DirectoryInvalid { .. } => "directory_invalid",
MarcError::FieldNotFound { .. } => "field_not_found",
MarcError::InvalidField { .. } => "invalid_field",
MarcError::InvalidIndicator { .. } => "invalid_indicator",
MarcError::BadSubfieldCode { .. } => "bad_subfield_code",
MarcError::EncodingError { .. } => "utf8_invalid",
MarcError::XmlError { .. } => "marcxml_invalid",
MarcError::JsonError { .. } => "marcjson_invalid",
MarcError::WriterError { .. } => "record_too_large_for_iso2709",
MarcError::FatalReaderError { .. } => "fatal_reader_error",
}
}
#[must_use]
pub fn to_json_value(&self) -> serde_json::Value {
use serde_json::{json, Map, Value};
let mut m: Map<String, Value> = Map::new();
m.insert("schema_version".into(), json!(SCHEMA_VERSION));
m.insert("class".into(), json!(self.kind_name()));
m.insert("code".into(), json!(self.code()));
m.insert("slug".into(), json!(self.slug()));
m.insert("severity".into(), json!("error"));
m.insert("help_url".into(), json!(self.help_url()));
m.insert("record_index".into(), opt_json(self.record_index()));
m.insert(
"record_control_number".into(),
self.record_control_number()
.map_or(Value::Null, Value::from),
);
m.insert(
"field_tag".into(),
self.field_tag().map_or(Value::Null, Value::from),
);
m.insert(
"indicator_position".into(),
opt_json(self.indicator_position_field()),
);
m.insert("subfield_code".into(), opt_json(self.subfield_code_field()));
m.insert("found".into(), Value::Null);
if let Some(bytes) = self.found_field() {
m.insert("found_hex".into(), json!(hex_encode(bytes)));
}
m.insert(
"expected".into(),
self.expected_field().map_or(Value::Null, Value::from),
);
m.insert("byte_offset".into(), opt_json(self.byte_offset()));
m.insert(
"record_byte_offset".into(),
opt_json(self.record_byte_offset()),
);
m.insert(
"source".into(),
self.source_name().map_or(Value::Null, Value::from),
);
m.insert("bytes_near".into(), Value::Null);
if let Some(window) = self.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) = self.message_text() {
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 MarcError::FatalReaderError {
cap, errors_seen, ..
} = self
{
m.insert("cap".into(), json!(*cap));
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())
}
fn indicator_position_field(&self) -> Option<u8> {
match self {
MarcError::InvalidIndicator {
indicator_position, ..
} => *indicator_position,
_ => None,
}
}
fn subfield_code_field(&self) -> Option<u8> {
match self {
MarcError::BadSubfieldCode { subfield_code, .. } => Some(*subfield_code),
_ => None,
}
}
fn found_field(&self) -> Option<&[u8]> {
match self {
MarcError::RecordLengthInvalid { found, .. }
| MarcError::BaseAddressInvalid { found, .. }
| MarcError::DirectoryInvalid { found, .. }
| MarcError::InvalidIndicator { found, .. } => found.as_deref(),
_ => None,
}
}
fn expected_field(&self) -> Option<&str> {
match self {
MarcError::RecordLengthInvalid { expected, .. }
| MarcError::BaseAddressInvalid { expected, .. }
| MarcError::DirectoryInvalid { expected, .. }
| MarcError::InvalidIndicator { expected, .. } => expected.as_deref(),
_ => None,
}
}
#[must_use]
pub fn bytes_near(&self) -> Option<&BytesNear> {
match self {
MarcError::InvalidLeader { bytes_near, .. }
| MarcError::RecordLengthInvalid { bytes_near, .. }
| MarcError::BaseAddressInvalid { bytes_near, .. }
| MarcError::BaseAddressNotFound { bytes_near, .. }
| MarcError::DirectoryInvalid { bytes_near, .. }
| MarcError::TruncatedRecord { bytes_near, .. }
| MarcError::EndOfRecordNotFound { bytes_near, .. }
| MarcError::InvalidIndicator { bytes_near, .. }
| MarcError::BadSubfieldCode { bytes_near, .. }
| MarcError::InvalidField { bytes_near, .. }
| MarcError::EncodingError { bytes_near, .. } => bytes_near.as_ref(),
_ => None,
}
}
#[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;
};
match &mut self {
MarcError::InvalidLeader {
bytes_near,
byte_offset,
..
}
| MarcError::RecordLengthInvalid {
bytes_near,
byte_offset,
..
}
| MarcError::BaseAddressInvalid {
bytes_near,
byte_offset,
..
}
| MarcError::BaseAddressNotFound {
bytes_near,
byte_offset,
..
}
| MarcError::DirectoryInvalid {
bytes_near,
byte_offset,
..
}
| MarcError::TruncatedRecord {
bytes_near,
byte_offset,
..
}
| MarcError::EndOfRecordNotFound {
bytes_near,
byte_offset,
..
}
| MarcError::InvalidIndicator {
bytes_near,
byte_offset,
..
}
| MarcError::BadSubfieldCode {
bytes_near,
byte_offset,
..
}
| MarcError::InvalidField {
bytes_near,
byte_offset,
..
}
| MarcError::EncodingError {
bytes_near,
byte_offset,
..
} => {
if 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)
};
*self.record_index_mut() = ridx;
if let Some(slot) = self.byte_offset_mut() {
*slot = Some(ctx.stream_byte_offset);
}
if let Some(slot) = self.record_byte_offset_mut() {
*slot = Some(ctx.record_byte_offset());
}
if let Some(slot) = self.source_name_mut() {
slot.clone_from(&ctx.source_name);
}
self
}
fn record_index_mut(&mut self) -> &mut Option<usize> {
match self {
MarcError::InvalidLeader { record_index, .. }
| MarcError::RecordLengthInvalid { record_index, .. }
| MarcError::BaseAddressInvalid { record_index, .. }
| MarcError::BaseAddressNotFound { record_index, .. }
| MarcError::DirectoryInvalid { record_index, .. }
| MarcError::TruncatedRecord { record_index, .. }
| MarcError::EndOfRecordNotFound { record_index, .. }
| MarcError::InvalidIndicator { record_index, .. }
| MarcError::BadSubfieldCode { record_index, .. }
| MarcError::InvalidField { record_index, .. }
| MarcError::EncodingError { record_index, .. }
| MarcError::FieldNotFound { record_index, .. }
| MarcError::IoError { record_index, .. }
| MarcError::XmlError { record_index, .. }
| MarcError::JsonError { record_index, .. }
| MarcError::WriterError { record_index, .. }
| MarcError::FatalReaderError { record_index, .. } => record_index,
}
}
fn byte_offset_mut(&mut self) -> Option<&mut Option<usize>> {
match self {
MarcError::InvalidLeader { byte_offset, .. }
| MarcError::RecordLengthInvalid { byte_offset, .. }
| MarcError::BaseAddressInvalid { byte_offset, .. }
| MarcError::BaseAddressNotFound { byte_offset, .. }
| MarcError::DirectoryInvalid { byte_offset, .. }
| MarcError::TruncatedRecord { byte_offset, .. }
| MarcError::EndOfRecordNotFound { byte_offset, .. }
| MarcError::InvalidIndicator { byte_offset, .. }
| MarcError::BadSubfieldCode { byte_offset, .. }
| MarcError::InvalidField { byte_offset, .. }
| MarcError::EncodingError { byte_offset, .. }
| MarcError::IoError { byte_offset, .. }
| MarcError::XmlError { byte_offset, .. }
| MarcError::JsonError { byte_offset, .. } => Some(byte_offset),
MarcError::FieldNotFound { .. }
| MarcError::WriterError { .. }
| MarcError::FatalReaderError { .. } => None,
}
}
fn record_byte_offset_mut(&mut self) -> Option<&mut Option<usize>> {
match self {
MarcError::InvalidLeader {
record_byte_offset, ..
}
| MarcError::DirectoryInvalid {
record_byte_offset, ..
}
| MarcError::TruncatedRecord {
record_byte_offset, ..
}
| MarcError::EndOfRecordNotFound {
record_byte_offset, ..
}
| MarcError::InvalidIndicator {
record_byte_offset, ..
}
| MarcError::BadSubfieldCode {
record_byte_offset, ..
}
| MarcError::InvalidField {
record_byte_offset, ..
} => Some(record_byte_offset),
_ => None,
}
}
fn source_name_mut(&mut self) -> Option<&mut Option<String>> {
match self {
MarcError::InvalidLeader { source_name, .. }
| MarcError::RecordLengthInvalid { source_name, .. }
| MarcError::BaseAddressInvalid { source_name, .. }
| MarcError::BaseAddressNotFound { source_name, .. }
| MarcError::DirectoryInvalid { source_name, .. }
| MarcError::TruncatedRecord { source_name, .. }
| MarcError::EndOfRecordNotFound { source_name, .. }
| MarcError::InvalidIndicator { source_name, .. }
| MarcError::BadSubfieldCode { source_name, .. }
| MarcError::InvalidField { source_name, .. }
| MarcError::EncodingError { source_name, .. }
| MarcError::IoError { source_name, .. }
| MarcError::XmlError { source_name, .. }
| MarcError::JsonError { source_name, .. } => Some(source_name),
MarcError::FieldNotFound { .. }
| MarcError::WriterError { .. }
| MarcError::FatalReaderError { .. } => None,
}
}
#[must_use]
pub fn help_url(&self) -> String {
format!("{DOCS_BASE_URL}/reference/error-codes/#{}", self.code())
}
fn kind_name(&self) -> &'static str {
match self {
MarcError::InvalidLeader { .. } => "InvalidLeader",
MarcError::RecordLengthInvalid { .. } => "RecordLengthInvalid",
MarcError::BaseAddressInvalid { .. } => "BaseAddressInvalid",
MarcError::BaseAddressNotFound { .. } => "BaseAddressNotFound",
MarcError::DirectoryInvalid { .. } => "DirectoryInvalid",
MarcError::TruncatedRecord { .. } => "TruncatedRecord",
MarcError::EndOfRecordNotFound { .. } => "EndOfRecordNotFound",
MarcError::InvalidIndicator { .. } => "InvalidIndicator",
MarcError::BadSubfieldCode { .. } => "BadSubfieldCode",
MarcError::InvalidField { .. } => "InvalidField",
MarcError::EncodingError { .. } => "EncodingError",
MarcError::FieldNotFound { .. } => "FieldNotFound",
MarcError::IoError { .. } => "IoError",
MarcError::XmlError { .. } => "XmlError",
MarcError::JsonError { .. } => "JsonError",
MarcError::WriterError { .. } => "WriterError",
MarcError::FatalReaderError { .. } => "FatalReaderError",
}
}
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 mut lines: Vec<(&'static str, String)> = Vec::new();
if let Some(s) = self.source_name() {
lines.push(("source:", s.to_string()));
}
if let Some(cn) = self.record_control_number() {
lines.push(("001:", cn.to_string()));
}
match self {
MarcError::InvalidIndicator {
indicator_position,
found,
expected,
..
} => {
if let (Some(pos), Some(exp)) = (indicator_position, expected) {
let found_repr = found
.as_deref()
.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}")));
}
},
MarcError::BadSubfieldCode { subfield_code, .. } => {
lines.push((
"subfield:",
format!(
"invalid code byte 0x{subfield_code:02X} ({:?})",
*subfield_code as char
),
));
},
MarcError::TruncatedRecord {
expected_length,
actual_length,
..
} => {
if let (Some(exp), Some(act)) = (expected_length, actual_length) {
lines.push(("length:", format!("expected {exp} bytes, found {act}")));
}
},
MarcError::FatalReaderError {
cap, errors_seen, ..
} => {
lines.push(("cap:", format!("{errors_seen} errors seen, cap {cap}")));
},
_ => {},
}
if let Some(off) = self.byte_offset() {
lines.push(("byte offset:", format!("0x{off:X} ({off}) in stream")));
}
if let Some(off) = self.record_byte_offset() {
lines.push(("record-relative:", format!("byte {off}")));
}
if let Some(msg) = self.message_text() {
lines.push(("message:", msg.to_string()));
}
lines
}
fn render_oneline(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut header_parts: Vec<String> = Vec::new();
if let Some(idx) = self.record_index() {
header_parts.push(format!("record {idx}"));
}
if let Some(cn) = self.record_control_number() {
header_parts.push(format!("001 '{cn}'"));
}
if let Some(tag) = self.field_tag() {
header_parts.push(format!("field {tag}"));
}
if let MarcError::InvalidIndicator {
indicator_position: Some(pos),
..
} = self
{
header_parts.push(format!("ind{pos}"));
}
if header_parts.is_empty() {
write!(f, "{}: ", self.kind_name())?;
} else {
write!(f, "[{}] ", header_parts.join(" · "))?;
}
write!(f, "{}", self.body_text())?;
if let Some(off) = self.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> {
match self {
MarcError::InvalidLeader { record_index, .. }
| MarcError::RecordLengthInvalid { record_index, .. }
| MarcError::BaseAddressInvalid { record_index, .. }
| MarcError::BaseAddressNotFound { record_index, .. }
| MarcError::DirectoryInvalid { record_index, .. }
| MarcError::TruncatedRecord { record_index, .. }
| MarcError::EndOfRecordNotFound { record_index, .. }
| MarcError::InvalidIndicator { record_index, .. }
| MarcError::BadSubfieldCode { record_index, .. }
| MarcError::InvalidField { record_index, .. }
| MarcError::EncodingError { record_index, .. }
| MarcError::FieldNotFound { record_index, .. }
| MarcError::IoError { record_index, .. }
| MarcError::XmlError { record_index, .. }
| MarcError::JsonError { record_index, .. }
| MarcError::WriterError { record_index, .. }
| MarcError::FatalReaderError { record_index, .. } => *record_index,
}
}
fn record_control_number(&self) -> Option<&str> {
match self {
MarcError::BaseAddressInvalid {
record_control_number,
..
}
| MarcError::BaseAddressNotFound {
record_control_number,
..
}
| MarcError::DirectoryInvalid {
record_control_number,
..
}
| MarcError::TruncatedRecord {
record_control_number,
..
}
| MarcError::EndOfRecordNotFound {
record_control_number,
..
}
| MarcError::InvalidIndicator {
record_control_number,
..
}
| MarcError::BadSubfieldCode {
record_control_number,
..
}
| MarcError::InvalidField {
record_control_number,
..
}
| MarcError::EncodingError {
record_control_number,
..
}
| MarcError::FieldNotFound {
record_control_number,
..
}
| MarcError::WriterError {
record_control_number,
..
} => record_control_number.as_deref(),
_ => None,
}
}
fn field_tag(&self) -> Option<&str> {
match self {
MarcError::DirectoryInvalid { field_tag, .. }
| MarcError::InvalidIndicator { field_tag, .. }
| MarcError::BadSubfieldCode { field_tag, .. }
| MarcError::InvalidField { field_tag, .. }
| MarcError::EncodingError { field_tag, .. } => field_tag.as_deref(),
MarcError::FieldNotFound { field_tag, .. } => Some(field_tag.as_str()),
_ => None,
}
}
fn byte_offset(&self) -> Option<usize> {
match self {
MarcError::InvalidLeader { byte_offset, .. }
| MarcError::RecordLengthInvalid { byte_offset, .. }
| MarcError::BaseAddressInvalid { byte_offset, .. }
| MarcError::BaseAddressNotFound { byte_offset, .. }
| MarcError::DirectoryInvalid { byte_offset, .. }
| MarcError::TruncatedRecord { byte_offset, .. }
| MarcError::EndOfRecordNotFound { byte_offset, .. }
| MarcError::InvalidIndicator { byte_offset, .. }
| MarcError::BadSubfieldCode { byte_offset, .. }
| MarcError::InvalidField { byte_offset, .. }
| MarcError::EncodingError { byte_offset, .. }
| MarcError::IoError { byte_offset, .. }
| MarcError::XmlError { byte_offset, .. }
| MarcError::JsonError { byte_offset, .. } => *byte_offset,
_ => None,
}
}
fn record_byte_offset(&self) -> Option<usize> {
match self {
MarcError::InvalidLeader {
record_byte_offset, ..
}
| MarcError::DirectoryInvalid {
record_byte_offset, ..
}
| MarcError::TruncatedRecord {
record_byte_offset, ..
}
| MarcError::EndOfRecordNotFound {
record_byte_offset, ..
}
| MarcError::InvalidIndicator {
record_byte_offset, ..
}
| MarcError::BadSubfieldCode {
record_byte_offset, ..
}
| MarcError::InvalidField {
record_byte_offset, ..
} => *record_byte_offset,
_ => None,
}
}
fn source_name(&self) -> Option<&str> {
match self {
MarcError::InvalidLeader { source_name, .. }
| MarcError::RecordLengthInvalid { source_name, .. }
| MarcError::BaseAddressInvalid { source_name, .. }
| MarcError::BaseAddressNotFound { source_name, .. }
| MarcError::DirectoryInvalid { source_name, .. }
| MarcError::TruncatedRecord { source_name, .. }
| MarcError::EndOfRecordNotFound { source_name, .. }
| MarcError::InvalidIndicator { source_name, .. }
| MarcError::BadSubfieldCode { source_name, .. }
| MarcError::InvalidField { source_name, .. }
| MarcError::EncodingError { source_name, .. }
| MarcError::IoError { source_name, .. }
| MarcError::XmlError { source_name, .. }
| MarcError::JsonError { source_name, .. }
| MarcError::FatalReaderError { source_name, .. } => source_name.as_deref(),
_ => None,
}
}
fn message_text(&self) -> Option<&str> {
match self {
MarcError::InvalidField { message, .. }
| MarcError::EncodingError { message, .. }
| MarcError::WriterError { message, .. } => Some(message.as_str()),
_ => None,
}
}
}
impl MarcError {
#[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 {
if 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::new(std::io::ErrorKind::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 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"
);
}
}
}