use crate::bin_table::Value;
use std::error::Error;
use std::str::from_utf8;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AsciiColumnFormat {
Character(usize),
Integer(usize),
Fixed(usize, usize),
Exponential(usize, usize),
Double(usize, usize),
}
impl AsciiColumnFormat {
pub fn bytes_len(&self) -> usize {
match self {
AsciiColumnFormat::Character(width)
| AsciiColumnFormat::Integer(width)
| AsciiColumnFormat::Fixed(width, _)
| AsciiColumnFormat::Exponential(width, _)
| AsciiColumnFormat::Double(width, _) => *width,
}
}
pub fn parse_into_value(&self, field: &[u8]) -> crate::Result<Value> {
let text = from_utf8(field).map_err(|error| {
crate::Error::DeserializationError(format!("Column is not valid UTF-8: {}", error))
})?;
if let AsciiColumnFormat::Character(_) = self {
return Ok(Value::String(text.trim_end().to_string()));
}
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(Value::Null);
}
match self {
AsciiColumnFormat::Character(_) => unreachable!("handled above"),
AsciiColumnFormat::Integer(_) => trimmed
.parse::<i64>()
.map(|value| Value::I64(vec![value]))
.map_err(|_| self.invalid(trimmed)),
AsciiColumnFormat::Fixed(..)
| AsciiColumnFormat::Exponential(..)
| AsciiColumnFormat::Double(..) => trimmed
.replace(['D', 'd'], "E")
.parse::<f64>()
.map(|value| Value::F64(vec![value]))
.map_err(|_| self.invalid(trimmed)),
}
}
fn invalid(&self, text: &str) -> crate::Error {
crate::Error::DeserializationError(format!(
"Column of format {} cannot read {:?}",
String::from(*self),
text
))
}
}
impl From<AsciiColumnFormat> for String {
fn from(value: AsciiColumnFormat) -> String {
match value {
AsciiColumnFormat::Character(width) => format!("A{}", width),
AsciiColumnFormat::Integer(width) => format!("I{}", width),
AsciiColumnFormat::Fixed(width, decimals) => format!("F{}.{}", width, decimals),
AsciiColumnFormat::Exponential(width, decimals) => format!("E{}.{}", width, decimals),
AsciiColumnFormat::Double(width, decimals) => format!("D{}.{}", width, decimals),
}
}
}
impl TryFrom<String> for AsciiColumnFormat {
type Error = Box<dyn Error + Send + Sync>;
fn try_from(value: String) -> Result<Self, Self::Error> {
let value = value.trim();
let mut chars = value.chars();
let code = chars
.next()
.ok_or_else(|| format!("Empty ASCII table format: {:?}", value))?;
let rest = chars.as_str();
let (width, decimals) = match rest.split_once('.') {
Some((width, decimals)) => (width, Some(decimals)),
None => (rest, None),
};
let width = width
.trim()
.parse::<usize>()
.map_err(|_| format!("ASCII table format {:?} has no field width", value))?;
let decimals = match decimals {
Some(decimals) => decimals
.trim()
.parse::<usize>()
.map_err(|_| format!("ASCII table format {:?} has an invalid scale", value))?,
None => 0,
};
match code {
'A' => Ok(AsciiColumnFormat::Character(width)),
'I' => Ok(AsciiColumnFormat::Integer(width)),
'F' => Ok(AsciiColumnFormat::Fixed(width, decimals)),
'E' => Ok(AsciiColumnFormat::Exponential(width, decimals)),
'D' => Ok(AsciiColumnFormat::Double(width, decimals)),
_ => Err(From::from(format!(
"Invalid ASCII table format: {:?}",
value
))),
}
}
}
#[cfg(test)]
mod tests {
use super::AsciiColumnFormat;
use crate::bin_table::Value;
fn format(text: &str) -> AsciiColumnFormat {
AsciiColumnFormat::try_from(text.to_string()).expect("a valid ASCII table format")
}
#[test]
fn formats_parse_with_and_without_a_scale() {
assert_eq!(format("A20"), AsciiColumnFormat::Character(20));
assert_eq!(format("I10"), AsciiColumnFormat::Integer(10));
assert_eq!(format("F12.5"), AsciiColumnFormat::Fixed(12, 5));
assert_eq!(format("E15.7"), AsciiColumnFormat::Exponential(15, 7));
assert_eq!(format("D25.17"), AsciiColumnFormat::Double(25, 17));
}
#[test]
fn binary_table_codes_are_not_ascii_table_formats() {
assert!(AsciiColumnFormat::try_from("1J".to_string()).is_err());
assert!(AsciiColumnFormat::try_from("16X".to_string()).is_err());
}
#[test]
fn numbers_are_read_out_of_their_fixed_width_fields() {
assert!(matches!(
format("I10").parse_into_value(b" 1234"),
Ok(Value::I64(ref v)) if v == &[1234]
));
assert!(matches!(
format("F8.2").parse_into_value(b" -12.50"),
Ok(Value::F64(ref v)) if v == &[-12.5]
));
}
#[test]
fn a_d_exponent_marker_reads_as_a_double() {
assert!(matches!(
format("D12.4").parse_into_value(b" 1.5D+02"),
Ok(Value::F64(ref v)) if v == &[150.0]
));
}
#[test]
fn a_blank_numeric_field_is_undefined() {
assert!(matches!(
format("I10").parse_into_value(b" "),
Ok(Value::Null)
));
}
#[test]
fn a_character_field_keeps_its_text_and_drops_its_padding() {
assert!(matches!(
format("A8").parse_into_value(b"NGC 4565"),
Ok(Value::String(ref text)) if text == "NGC 4565"
));
assert!(matches!(
format("A8").parse_into_value(b"M31 "),
Ok(Value::String(ref text)) if text == "M31"
));
}
#[test]
fn a_field_that_is_not_a_number_is_an_error() {
let error = format("I10")
.parse_into_value(b" abcd")
.expect_err("`abcd` is not an integer");
assert!(error.to_string().contains("abcd"), "got: {error}");
}
}