#![allow(clippy::missing_errors_doc)]
use std::borrow::Borrow;
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize};
use crate::error::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(transparent)]
pub struct Id(String);
impl Id {
pub fn new(id: impl Into<String>) -> Result<Self, Error> {
let id = id.into();
if id.is_empty() {
return Err(Error::InvalidValue(
"an RDML id must be at least one character".into(),
));
}
if let Some(c) = id.chars().find(|c| c.is_control()) {
return Err(Error::InvalidValue(format!(
"an RDML id must not contain control characters \
(found {c:?} in `{}`)",
id.escape_debug()
)));
}
Ok(Id(id))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Id {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for Id {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for Id {
fn borrow(&self) -> &str {
&self.0
}
}
impl FromStr for Id {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
Id::new(s)
}
}
impl TryFrom<&str> for Id {
type Error = Error;
fn try_from(s: &str) -> Result<Self, Error> {
Id::new(s)
}
}
impl TryFrom<String> for Id {
type Error = Error;
fn try_from(s: String) -> Result<Self, Error> {
Id::new(s)
}
}
impl<'de> Deserialize<'de> for Id {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Id::new(s).map_err(serde::de::Error::custom)
}
}
macro_rules! id_reference {
($(#[$doc:meta])* $name:ident => $target:literal) => {
$(#[$doc])*
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(Id);
impl $name {
#[doc = concat!("`", $target, "`")]
pub fn new(id: impl Into<String>) -> Result<Self, Error> {
Ok(Self(Id::new(id)?))
}
pub fn id(&self) -> &Id {
&self.0
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0.as_str())
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
self.0.as_str()
}
}
impl From<Id> for $name {
fn from(id: Id) -> Self {
Self(id)
}
}
impl From<$name> for Id {
fn from(r: $name) -> Id {
r.0
}
}
impl FromStr for $name {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
Self::new(s)
}
}
impl PartialEq<str> for $name {
fn eq(&self, other: &str) -> bool {
self.as_str() == other
}
}
impl PartialEq<&str> for $name {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
};
}
id_reference! {
SampleRef => "sample"
}
id_reference! {
TargetRef => "target"
}
id_reference! {
DyeRef => "dye"
}
id_reference! {
DocumentationRef => "documentation"
}
id_reference! {
ExperimenterRef => "experimenter"
}
id_reference! {
TccRef => "thermalCyclingConditions"
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Reasons(Vec<String>);
impl Reasons {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn one(reason: impl Into<String>) -> Self {
Self(vec![reason.into()])
}
pub fn push(&mut self, reason: impl Into<String>) {
self.0.push(reason.into());
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.0.iter().map(String::as_str)
}
pub fn from_joined(s: &str) -> Self {
Self(
s.split(';')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(String::from)
.collect(),
)
}
#[must_use]
pub fn to_joined(&self) -> String {
self.0.join(";")
}
}
impl fmt::Display for Reasons {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_joined())
}
}
impl FromStr for Reasons {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
Ok(Self::from_joined(s))
}
}
impl<S: Into<String>> FromIterator<S> for Reasons {
fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
Self(iter.into_iter().map(Into::into).collect())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct DateTime(String);
impl DateTime {
pub fn new(s: impl Into<String>) -> Result<Self, Error> {
let s = s.into();
if !is_valid_xs_datetime(&s) {
return Err(Error::InvalidValue(format!(
"`{s}` is not a valid xs:dateTime \
(expected YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm])"
)));
}
Ok(DateTime(s))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for DateTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for DateTime {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
DateTime::new(s)
}
}
impl<'de> Deserialize<'de> for DateTime {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
DateTime::new(s).map_err(serde::de::Error::custom)
}
}
fn is_valid_xs_datetime(s: &str) -> bool {
fn digits(s: &str, n: usize) -> Option<(&str, u32)> {
let (d, rest) = s.split_at_checked(n)?;
if d.len() == n && d.bytes().all(|b| b.is_ascii_digit()) {
Some((rest, d.parse().ok()?))
} else {
None
}
}
fn expect(s: &str, c: char) -> Option<&str> {
s.strip_prefix(c)
}
let s = s.strip_prefix('-').unwrap_or(s);
let year_len = s.bytes().take_while(u8::is_ascii_digit).count();
if year_len < 4 {
return false;
}
let s = &s[year_len..];
let Some(s) = expect(s, '-') else {
return false;
};
let Some((s, month)) = digits(s, 2) else {
return false;
};
let Some(s) = expect(s, '-') else {
return false;
};
let Some((s, day)) = digits(s, 2) else {
return false;
};
let Some(s) = expect(s, 'T') else {
return false;
};
let Some((s, hour)) = digits(s, 2) else {
return false;
};
let Some(s) = expect(s, ':') else {
return false;
};
let Some((s, min)) = digits(s, 2) else {
return false;
};
let Some(s) = expect(s, ':') else {
return false;
};
let Some((s, sec)) = digits(s, 2) else {
return false;
};
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return false;
}
if hour > 24 || min > 59 || sec > 60 {
return false;
}
let s = if let Some(rest) = s.strip_prefix('.') {
let frac = rest.bytes().take_while(u8::is_ascii_digit).count();
if frac == 0 {
return false;
}
&rest[frac..]
} else {
s
};
match s {
"" | "Z" => true,
_ => {
let Some(rest) = s.strip_prefix(['+', '-']) else {
return false;
};
let Some((rest, tzh)) = digits(rest, 2) else {
return false;
};
let Some(rest) = expect(rest, ':') else {
return false;
};
let Some(("", tzm)) = digits(rest, 2) else {
return false;
};
tzh <= 14 && tzm <= 59
}
}
}
#[cfg(feature = "time")]
mod time_conversions {
use super::DateTime;
use crate::error::Error;
use time::format_description::well_known::Rfc3339;
use time::macros::format_description;
impl From<time::OffsetDateTime> for DateTime {
fn from(t: time::OffsetDateTime) -> Self {
DateTime(
t.format(&Rfc3339)
.expect("RFC 3339 formatting of an OffsetDateTime cannot fail"),
)
}
}
impl From<time::PrimitiveDateTime> for DateTime {
fn from(t: time::PrimitiveDateTime) -> Self {
let fmt = format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]");
DateTime(
t.format(&fmt)
.expect("fixed-format formatting of a PrimitiveDateTime cannot fail"),
)
}
}
impl TryFrom<&DateTime> for time::OffsetDateTime {
type Error = Error;
fn try_from(dt: &DateTime) -> Result<Self, Error> {
time::OffsetDateTime::parse(dt.as_str(), &Rfc3339)
.map_err(|e| Error::InvalidValue(format!("`{dt}`: {e}")))
}
}
impl TryFrom<&DateTime> for time::PrimitiveDateTime {
type Error = Error;
fn try_from(dt: &DateTime) -> Result<Self, Error> {
let fmt = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second][optional [.[subsecond]]]"
);
let s = dt.as_str();
let core = s
.find(['Z', '+'])
.or_else(|| s.rfind('-').filter(|&i| i > 10))
.map_or(s, |i| &s[..i]);
time::PrimitiveDateTime::parse(core, &fmt)
.map_err(|e| Error::InvalidValue(format!("`{dt}`: {e}")))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct Sequence(String);
impl Sequence {
pub fn new(s: impl Into<String>) -> Result<Self, Error> {
let s = s.into();
if s.is_empty() {
return Err(Error::InvalidValue(
"a sequence must contain at least one base".into(),
));
}
if let Some(bad) = s
.chars()
.find(|c| !"acgtryswkmbdhvnACGTRYSWKMBDHVN".contains(*c))
{
return Err(Error::InvalidValue(format!(
"`{bad}` is not an IUPAC nucleotide code (in sequence `{s}`)"
)));
}
Ok(Sequence(s))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
false
}
}
impl fmt::Display for Sequence {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for Sequence {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
Sequence::new(s)
}
}
impl<'de> Deserialize<'de> for Sequence {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Sequence::new(s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_rejects_empty() {
assert!(Id::new("").is_err());
assert!(Id::new("x").is_ok());
assert!(Id::new("Sample 1 (dil 1:10)").is_ok());
}
#[test]
fn id_rejects_control_characters() {
assert!(Id::new("line\nbreak").is_err());
assert!(Id::new("tab\there").is_err());
assert!(Id::new("cr\rhere").is_err());
assert!(Id::new("nul\0").is_err());
assert!(Id::new("plain spaces are fine").is_ok());
assert!(SampleRef::new("also\nchecked").is_err());
}
#[test]
fn refs_are_distinct_types() {
let s = SampleRef::new("a").unwrap();
assert_eq!(s, "a");
assert_eq!(s.to_string(), "a");
}
#[test]
fn reasons_join_and_split() {
let r = Reasons::from_joined("no amplification; bubble ;");
assert_eq!(r.len(), 2);
assert_eq!(r.to_joined(), "no amplification;bubble");
assert!(Reasons::from_joined("").is_empty());
let single = Reasons::one("manual");
assert_eq!(single.to_joined(), "manual");
}
#[test]
fn datetime_lexical_validation() {
for ok in [
"2026-08-14T09:30:00",
"2026-08-14T09:30:00Z",
"2026-08-14T09:30:00.123Z",
"2026-08-14T09:30:00+02:00",
"2026-08-14T09:30:00-05:30",
"12026-01-01T00:00:00",
] {
assert!(DateTime::new(ok).is_ok(), "should accept {ok}");
}
for bad in [
"",
"2026-08-14",
"09:30:00",
"2026-8-14T09:30:00",
"2026-08-14 09:30:00",
"2026-13-14T09:30:00",
"2026-08-14T09:30:00+2:00",
"2026-08-14T09:30:00.",
"not a date",
] {
assert!(DateTime::new(bad).is_err(), "should reject {bad}");
}
}
#[test]
fn sequence_validation() {
assert!(Sequence::new("ACGTacgtNRY").is_ok());
assert!(Sequence::new("ACGU").is_err());
assert!(Sequence::new("AC GT").is_err());
assert!(Sequence::new("").is_err());
}
#[cfg(feature = "time")]
#[test]
fn time_conversions() {
use time::macros::datetime;
let dt: DateTime = datetime!(2026-08-14 09:30:00 UTC).into();
assert_eq!(dt.as_str(), "2026-08-14T09:30:00Z");
let back: time::OffsetDateTime = (&dt).try_into().unwrap();
assert_eq!(back, datetime!(2026-08-14 09:30:00 UTC));
let naive: DateTime = datetime!(2026-08-14 09:30:00).into();
assert_eq!(naive.as_str(), "2026-08-14T09:30:00");
let back: time::PrimitiveDateTime = (&naive).try_into().unwrap();
assert_eq!(back, datetime!(2026-08-14 09:30:00));
assert!(time::OffsetDateTime::try_from(&naive).is_err());
let offset = DateTime::new("2026-08-14T09:30:00+02:00").unwrap();
let back: time::PrimitiveDateTime = (&offset).try_into().unwrap();
assert_eq!(back, datetime!(2026-08-14 09:30:00));
}
#[test]
fn serde_transparency() {
let id = Id::new("s1").unwrap();
assert_eq!(serde_json::to_string(&id).unwrap(), "\"s1\"");
let back: Id = serde_json::from_str("\"s1\"").unwrap();
assert_eq!(back, id);
assert!(serde_json::from_str::<Id>("\"\"").is_err());
let r: Reasons = ["a", "b"].into_iter().collect();
assert_eq!(serde_json::to_string(&r).unwrap(), "[\"a\",\"b\"]");
}
}