use std::borrow::Cow;
use std::sync::Arc;
use crate::date_time::Format;
use crate::document::document_variant_name;
use crate::{DateTime, Document, DocumentError, DocumentSettings, Number};
#[non_exhaustive]
#[derive(Clone, Debug, Default)]
pub struct DiscriminatedDocument {
document: Document,
discriminator: Option<String>,
settings: Option<Arc<dyn DocumentSettings>>,
}
impl DiscriminatedDocument {
pub fn new(document: Document) -> Self {
Self {
document,
discriminator: None,
settings: None,
}
}
pub fn with_discriminator(mut self, fqn: impl Into<String>) -> Self {
let fqn = fqn.into();
debug_assert!(
fqn.contains('#'),
"discriminator `{fqn}` must be an absolute shape id (namespace#name)"
);
self.discriminator = Some(fqn);
self
}
pub fn with_settings(mut self, settings: Arc<dyn DocumentSettings>) -> Self {
self.settings = Some(settings);
self
}
#[cfg(feature = "test-util")]
#[doc(hidden)]
pub fn set_discriminator_unchecked(&mut self, discriminator: impl Into<String>) {
self.discriminator = Some(discriminator.into());
}
pub fn discriminator(&self) -> Option<&str> {
self.discriminator.as_deref()
}
pub fn settings(&self) -> Option<&Arc<dyn DocumentSettings>> {
self.settings.as_ref()
}
pub fn document(&self) -> &Document {
&self.document
}
pub fn into_document(self) -> Document {
self.document
}
pub fn as_blob(&self) -> Result<Cow<'_, [u8]>, DocumentError> {
match &self.document {
Document::String(s) => match &self.settings {
Some(settings) => settings.coerce_string_to_blob(s).map(Cow::Owned),
None => crate::base64::decode(s).map(Cow::Owned).map_err(|e| {
DocumentError::invalid_input(format!(
"cannot base64-decode string as blob: {e}"
))
}),
},
other => Err(DocumentError::type_mismatch(format!(
"expected blob, found {}",
document_variant_name(other)
))),
}
}
pub fn as_timestamp(&self) -> Result<DateTime, DocumentError> {
match (&self.document, &self.settings) {
(Document::Number(n), Some(settings)) => settings.coerce_number_to_timestamp(n),
(Document::Number(n), None) => number_as_epoch_seconds(n),
(Document::String(s), Some(settings)) => settings.coerce_string_to_timestamp(s),
(Document::String(s), None) => DateTime::from_str(s, Format::DateTime).map_err(|e| {
DocumentError::invalid_input(format!(
"cannot parse string as a date-time timestamp: {e}"
))
}),
(other, _) => Err(DocumentError::type_mismatch(format!(
"expected timestamp, found {}",
document_variant_name(other)
))),
}
}
}
impl PartialEq for DiscriminatedDocument {
fn eq(&self, other: &Self) -> bool {
self.document == other.document && self.discriminator == other.discriminator
}
}
impl From<Document> for DiscriminatedDocument {
fn from(document: Document) -> Self {
Self::new(document)
}
}
fn number_as_epoch_seconds(n: &Number) -> Result<DateTime, DocumentError> {
match n {
Number::PosInt(v) => i64::try_from(*v)
.map(DateTime::from_secs)
.map_err(|_| DocumentError::invalid_input(format!("epoch seconds {v} out of range"))),
Number::NegInt(v) => Ok(DateTime::from_secs(*v)),
Number::Float(v) => {
if !v.is_finite() {
return Err(DocumentError::invalid_input(format!(
"epoch seconds {v} is not finite"
)));
}
Ok(DateTime::from_secs_f64(*v))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Number;
#[derive(Debug)]
struct TestSettings {
protocol: String,
}
impl DocumentSettings for TestSettings {
fn protocol_id(&self) -> &str {
&self.protocol
}
fn coerce_string_to_blob(&self, s: &str) -> Result<Vec<u8>, DocumentError> {
Ok(s.as_bytes().to_vec())
}
fn coerce_string_to_timestamp(&self, _s: &str) -> Result<DateTime, DocumentError> {
Ok(DateTime::from_secs(0))
}
fn coerce_number_to_timestamp(&self, n: &Number) -> Result<DateTime, DocumentError> {
let secs = match n {
Number::PosInt(v) => *v as i64,
Number::NegInt(v) => *v,
Number::Float(v) => *v as i64,
};
Ok(DateTime::from_secs(secs))
}
}
fn test_settings() -> Arc<dyn DocumentSettings> {
Arc::new(TestSettings {
protocol: "com.example#Test".to_owned(),
})
}
#[test]
fn new_attaches_no_discriminator_or_settings() {
let d = DiscriminatedDocument::new(Document::String("hi".to_owned()));
assert_eq!(d.discriminator(), None);
assert!(d.settings().is_none());
assert_eq!(d.document().as_string(), Some("hi"));
}
#[test]
fn with_discriminator_attaches_fqn() {
let d =
DiscriminatedDocument::new(Document::Null).with_discriminator("com.example#MyShape");
assert_eq!(d.discriminator(), Some("com.example#MyShape"));
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "absolute shape id")]
fn with_discriminator_rejects_relative_id() {
let _ = DiscriminatedDocument::new(Document::Null).with_discriminator("RelativeOnly");
}
#[test]
fn with_settings_attaches_settings() {
let d = DiscriminatedDocument::new(Document::String("x".to_owned()))
.with_settings(test_settings());
assert!(d.settings().is_some());
assert_eq!(d.settings().unwrap().protocol_id(), "com.example#Test");
}
#[test]
fn into_document_unwraps_to_inner() {
let inner = Document::String("hi".to_owned());
let d = DiscriminatedDocument::new(inner.clone()).with_discriminator("com.example#X");
assert_eq!(d.into_document(), inner);
}
#[test]
fn from_document_blanket_impl_works() {
let d: DiscriminatedDocument = Document::Bool(true).into();
assert_eq!(d.document().as_bool(), Some(true));
assert_eq!(d.discriminator(), None);
}
#[test]
fn partial_eq_compares_document_and_discriminator_only() {
let a = DiscriminatedDocument::new(Document::String("x".to_owned()))
.with_discriminator("com.example#A");
let b = DiscriminatedDocument::new(Document::String("x".to_owned()))
.with_discriminator("com.example#A")
.with_settings(test_settings());
assert_eq!(a, b);
let c = DiscriminatedDocument::new(Document::String("x".to_owned()))
.with_discriminator("com.example#B");
assert_ne!(a, c);
let d = DiscriminatedDocument::new(Document::String("y".to_owned()))
.with_discriminator("com.example#A");
assert_ne!(a, d);
}
#[test]
fn as_blob_coerces_string_when_settings_present() {
let d = DiscriminatedDocument::new(Document::String("hello".to_owned()))
.with_settings(test_settings());
match d.as_blob().unwrap() {
Cow::Owned(bytes) => assert_eq!(bytes, b"hello"),
Cow::Borrowed(_) => panic!("expected Cow::Owned for coerced String"),
}
}
#[test]
fn as_blob_falls_back_to_base64_without_settings() {
let d = DiscriminatedDocument::new(Document::String("YWJjZA==".to_owned()));
assert!(d.settings().is_none());
assert_eq!(d.as_blob().unwrap().as_ref(), b"abcd");
}
#[test]
fn as_blob_invalid_base64_without_settings_is_invalid_input() {
let d = DiscriminatedDocument::new(Document::String("not base64!!!".to_owned()));
let err = d.as_blob().unwrap_err();
assert!(matches!(err, DocumentError::InvalidInput { .. }));
}
#[test]
fn as_blob_type_mismatch_for_non_string_variants() {
let d = DiscriminatedDocument::new(Document::Number(Number::PosInt(42)))
.with_settings(test_settings());
let err = d.as_blob().unwrap_err();
assert!(matches!(err, DocumentError::TypeMismatch { .. }));
}
#[test]
fn as_timestamp_coerces_string_with_settings() {
let d = DiscriminatedDocument::new(Document::String("any string".to_owned()))
.with_settings(test_settings());
assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(0));
}
#[test]
fn as_timestamp_coerces_number_with_settings() {
let d = DiscriminatedDocument::new(Document::Number(Number::PosInt(1234)))
.with_settings(test_settings());
assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(1234));
}
#[test]
fn as_timestamp_number_defaults_to_epoch_seconds_without_settings() {
let d = DiscriminatedDocument::new(Document::Number(Number::PosInt(1234)));
assert!(d.settings().is_none());
assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(1234));
let d = DiscriminatedDocument::new(Document::Number(Number::NegInt(-5)));
assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(-5));
}
#[test]
fn as_timestamp_retains_fractional_epoch_seconds() {
let d = DiscriminatedDocument::new(Document::Number(Number::Float(1234.5)));
assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs_f64(1234.5));
}
#[test]
fn as_timestamp_string_defaults_to_date_time_without_settings() {
let d = DiscriminatedDocument::new(Document::String("1970-01-01T00:00:00Z".to_owned()));
assert_eq!(d.as_timestamp().unwrap(), DateTime::from_secs(0));
}
#[test]
fn as_timestamp_malformed_string_without_settings_is_invalid_input() {
let d = DiscriminatedDocument::new(Document::String("not a timestamp".to_owned()));
let err = d.as_timestamp().unwrap_err();
assert!(matches!(err, DocumentError::InvalidInput { .. }));
}
#[test]
fn as_timestamp_type_mismatch_for_non_coercible_variant() {
let d = DiscriminatedDocument::new(Document::Bool(true)).with_settings(test_settings());
let err = d.as_timestamp().unwrap_err();
assert!(matches!(err, DocumentError::TypeMismatch { .. }));
}
#[test]
fn default_settings_methods_return_unsupported_operation() {
#[derive(Debug)]
struct MinimalSettings;
impl DocumentSettings for MinimalSettings {
fn protocol_id(&self) -> &str {
"com.example#Minimal"
}
}
let s = MinimalSettings;
assert!(matches!(
s.coerce_string_to_blob("anything"),
Err(DocumentError::UnsupportedOperation { .. })
));
assert!(matches!(
s.coerce_string_to_timestamp("anything"),
Err(DocumentError::UnsupportedOperation { .. })
));
assert!(matches!(
s.coerce_number_to_timestamp(&Number::PosInt(0)),
Err(DocumentError::UnsupportedOperation { .. })
));
}
}