neodes_codec 0.1.3

Library to read and write data from DSN files as described in the NeoDes norm.
Documentation
mod id;
mod value;

pub use id::*;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
pub use value::{NeodesValue, NeodesValueError};

/// NeoDes Line
/// Encodes a NeoDes line represented as `SWW.GXX.YY.ZZZ,'value'`
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
#[cfg_attr(
	feature = "serde",
	derive(serde_with::DeserializeFromStr, serde_with::SerializeDisplay)
)]
pub struct NeodesLine {
	/// The id of the NeoDes line  (`SWW.GXX.YY.ZZZ`)
	id: FieldId,

	/// The value filled in the NeoDes line (`value`)
	value: NeodesValue,
}

impl NeodesLine {
	/// Create a NeodesLine from an id and a value
	///
	/// # Examples
	///
	/// ```
	/// # use std::error::Error;
	/// # fn main() -> Result<(), Box<dyn Error>> {
	/// use std::str::FromStr;
	/// use neodes_codec::line::{FieldId, NeodesLine, NeodesValue};
	///
	/// let id = FieldId::from_str("S21.G00.13.123")?;
	/// let value = NeodesValue::from_str("X")?;
	/// assert_eq!(NeodesLine::new(id, value).to_string(), "S21.G00.13.123,'X'");
	///
	/// # Ok(())
	/// # }
	/// ```
	#[inline]
	pub fn new(id: FieldId, value: NeodesValue) -> Self {
		Self { id, value }
	}

	/// Field ID
	/// This is the first part of the line (`SWW.GXX.YY.ZZZ`)
	#[inline]
	pub fn field_id(&self) -> FieldId {
		self.id
	}

	/// The value filled in the NeoDes line (`value`)
	#[inline]
	pub fn value(&self) -> &NeodesValue {
		&self.value
	}
}

const MAX_NEODES_LINE_LENGTH: usize = 255;

impl FromStr for NeodesLine {
	type Err = NeodesLineParseError;

	/// # Examples
	///
	/// ```
	/// # use std::error::Error;
	/// # fn main() -> Result<(), Box<dyn Error>> {
	/// use neodes_codec::line::{FieldId, NeodesLine, NeodesLineParseError, NeodesValue, ShortBlockId, ShortFieldId, ShortGroupId, ShortStrucId};
	/// use std::str::FromStr;
	///
	/// assert_eq!(
	///     NeodesLine::from_str("S21.G00.01.123,'X'"),
	///     Ok(NeodesLine::new(
	///         FieldId::new(
	///             ShortStrucId::from_u8_lossy(21),
	///             ShortGroupId::from_u8_lossy(0),
	///             ShortBlockId::from_u8_lossy(1),
	///             ShortFieldId::from_u16_lossy(123)
	///         ),
	///         NeodesValue::from_str("X")?
	///     ))
	/// );
	/// assert_eq!(NeodesLine::from_str("S21.G00.01.123'X'"), Err(NeodesLineParseError::Format));
	///
	/// # Ok(())
	/// # }
	/// ```
	#[inline]
	fn from_str(mut source: &str) -> Result<Self, Self::Err> {
		// Check length
		if source.is_empty() {
			return Err(NeodesLineParseError::EmptyLine);
		}

		if source.chars().count() > MAX_NEODES_LINE_LENGTH {
			return Err(NeodesLineParseError::TooLong);
		}

		// Remove \r at the end
		if source.ends_with('\r') {
			source = &source[..source.len() - 1];
		}

		let (field_id_src, value_src): (&str, &str) =
			source.split_once(',').ok_or(NeodesLineParseError::Format)?;

		// Parse ID
		let field_id = FieldId::from_str(field_id_src)?;

		// Parse value
		if value_src.len() < 2
			|| value_src.chars().next().is_none_or(|t| t != '\'')
			|| value_src.chars().last().is_none_or(|t| t != '\'')
		{
			return Err(NeodesLineParseError::Format);
		}

		let value =
			NeodesValue::from_str(&value_src[1..value_src.len() - 1]).map_err(|value_error| {
				NeodesLineParseError::Value {
					field_id,
					value_error,
				}
			})?;

		Ok(NeodesLine {
			id: field_id,
			value,
		})
	}
}

impl Display for NeodesLine {
	#[inline]
	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
		write!(f, "{},'{}'", self.id, self.value)
	}
}

/// Error returned when trying to parse a line that doesn't respect the standard
/// format : `SWW.GXX.YY.ZZZ,'value'`
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub enum NeodesLineParseError {
	/// Line is empty
	EmptyLine,

	/// Line length is greater than the maximum authorized length, including
	/// terminating characters \r or \n
	TooLong,

	/// Either comma or quotes are misplaced, or the line is too short to be
	/// valid
	Format,

	/// An error occurred while parsing the id
	Id(FieldIdParseError),

	/// An error occurred while parsing the value. The field ID was parsed though.
	Value {
		/// Field ID parsed before the value was.
		field_id: FieldId,

		/// Error that occurred while parsing value
		value_error: NeodesValueError,
	},
}

impl From<FieldIdParseError> for NeodesLineParseError {
	#[inline]
	fn from(value: FieldIdParseError) -> Self {
		NeodesLineParseError::Id(value)
	}
}

impl Error for NeodesLineParseError {}

impl Display for NeodesLineParseError {
	#[inline]
	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
		match self {
			NeodesLineParseError::EmptyLine => {
				write!(f, "Line must not be empty")
			}
			NeodesLineParseError::Format => {
				write!(f, "Line must be formatted as \"SWW.GXX.YY.ZZZ,'<value>'\"")
			}
			NeodesLineParseError::TooLong => {
				write!(f, "Line must be 256 characters long or shorter")
			}
			NeodesLineParseError::Id(id_error) => {
				write!(
					f,
					"Line id must respect format \"S99.G99.99.999\" --> {}",
					id_error
				)
			}
			NeodesLineParseError::Value {
				field_id: _,
				value_error,
			} => {
				write!(f, "Error parsing value --> {}", value_error)
			}
		}
	}
}

#[cfg(test)]
mod tests {
	use crate::line::NeodesLineParseError::EmptyLine;
	use crate::line::{
		FieldId, NeodesLine, NeodesLineParseError, NeodesValue, NeodesValueError, ShortBlockId,
		ShortFieldId, ShortGroupId, ShortStrucId,
	};
	use parameterized::parameterized;
	use std::str::FromStr;

	const FIELD_ID: FieldId = FieldId::new(
		ShortStrucId::from_u8_lossy(12),
		ShortGroupId::from_u8_lossy(34),
		ShortBlockId::from_u8_lossy(56),
		ShortFieldId::from_u16_lossy(789),
	);

	#[parameterized(input = {
			"S21.G0",
			"S21.G00.01.123",
			"S21.G00.01.123,",
			"S21.G00.01.123,'",
			"S21.G00.01.123,x'",
			"S21.G00.01.123,'x",
		}
	)]
	fn returns_parsing_errors(input: &str) {
		assert_eq!(
			NeodesLine::from_str(input),
			Err(NeodesLineParseError::Format)
		);
	}

	#[test]
	fn error_on_empty_line() {
		assert_eq!(NeodesLine::from_str(""), Err(EmptyLine))
	}

	#[test]
	fn error_if_too_long() {
		assert_eq!(
			NeodesLine::from_str(
				"S12.G34.56.789,'00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'"
			),
			Err(NeodesLineParseError::TooLong)
		);
	}

	#[test]
	fn accents_count_as_one() {
		assert!(
			NeodesLine::from_str("S12.G34.56.789,'ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé'")
				.is_ok()
		);
	}

	#[test]
	fn return_is_removed() {
		assert_eq!(
			NeodesLine::from_str("S21.G00.01.023,'X'\r"),
			NeodesLine::from_str("S21.G00.01.023,'X'")
		)
	}

	#[test]
	fn can_parse_str() {
		let input = "S12.G34.56.789,'dslhéfbsld'hjhjsgbsbgfgb'";

		assert_eq!(
			NeodesLine::from_str(input),
			Ok(NeodesLine {
				id: FIELD_ID,
				value: NeodesValue::from_str("dslhéfbsld'hjhjsgbsbgfgb").unwrap(),
			})
		)
	}

	#[test]
	fn cannot_parse_str_with_emoji() {
		let input = "S12.G34.56.789,'dslhfbsld👎hjhjsgbsbgfgb'";

		assert_eq!(
			NeodesLine::from_str(input),
			Err(NeodesLineParseError::Value {
				field_id: FIELD_ID,
				value_error: NeodesValueError::ForbiddenCharacter
			})
		)
	}

	#[test]
	#[cfg(feature = "serde")]
	fn can_serialize() {
		let value = NeodesLine {
			id: FIELD_ID,
			value: NeodesValue::from_str("dslhfbsld'hjhjsgbsbgfgb").unwrap(),
		};
		let result: NeodesLine =
			serde_json::from_str("\"S12.G34.56.789,'dslhfbsld'hjhjsgbsbgfgb'\"").unwrap();
		assert_eq!(result, value)
	}

	#[test]
	#[cfg(feature = "serde")]
	fn can_deserialize() {
		let value = NeodesLine {
			id: FIELD_ID,
			value: NeodesValue::from_str("dslhfbsld'hjhjsgbsbgfgb").unwrap(),
		};
		let result = serde_json::to_string(&value).unwrap();
		assert_eq!(result, "\"S12.G34.56.789,'dslhfbsld'hjhjsgbsbgfgb'\"")
	}
}