use std::fmt;
use serde::{Deserialize, Serialize};
use crate::domain::error::{DomainError, WireResult};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct Source(#[serde(serialize_with = "serialize_uri")] Inner);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Inner {
uri: String,
scheme_len: usize,
}
fn serialize_uri<S>(inner: &Inner, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
ser.serialize_str(&inner.uri)
}
impl Source {
pub fn new(uri: impl Into<String>) -> WireResult<Self> {
let uri = uri.into();
let scheme_len = parse_scheme_len(&uri)?;
Ok(Self(Inner { uri, scheme_len }))
}
pub fn as_str(&self) -> &str {
&self.0.uri
}
pub fn scheme(&self) -> &str {
&self.0.uri[..self.0.scheme_len]
}
}
fn parse_scheme_len(s: &str) -> WireResult<usize> {
if s.is_empty() {
return Err(DomainError::InvalidSource("source uri must not be empty".into()).into());
}
match s.split_once(':') {
Some((scheme, _)) if !scheme.is_empty() => Ok(scheme.len()),
_ => Err(DomainError::InvalidSource(format!(
"source uri must carry a scheme prefix (`<scheme>:<rest>`): {s}"
))
.into()),
}
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0.uri)
}
}
impl AsRef<str> for Source {
fn as_ref(&self) -> &str {
&self.0.uri
}
}
impl From<Source> for String {
fn from(value: Source) -> Self {
value.0.uri
}
}
impl TryFrom<String> for Source {
type Error = crate::domain::error::WireError;
fn try_from(value: String) -> WireResult<Self> {
Self::new(value)
}
}
impl TryFrom<&str> for Source {
type Error = crate::domain::error::WireError;
fn try_from(value: &str) -> WireResult<Self> {
Self::new(value.to_owned())
}
}
impl<'de> Deserialize<'de> for Source {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Self::new(raw).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::error::WireError;
#[test]
fn new_accepts_valid_uri() {
let src = Source::new("mini-app://mailbox?alias=for_alice").expect("valid uri");
assert_eq!(src.as_str(), "mini-app://mailbox?alias=for_alice");
assert_eq!(src.scheme(), "mini-app");
}
#[test]
fn new_rejects_empty() {
let err = Source::new("").expect_err("empty must reject");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidSource(_))
));
}
#[test]
fn new_rejects_missing_scheme() {
let err = Source::new("mailbox/for_alice").expect_err("no scheme must reject");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidSource(_))
));
}
#[test]
fn scheme_returns_prefix() {
let src = Source::new("persona-pack://carol/projections").unwrap();
assert_eq!(src.scheme(), "persona-pack");
}
#[test]
fn serde_roundtrip() {
let src = Source::new("outline://book/x?alias=for_bob").unwrap();
let json = serde_json::to_string(&src).unwrap();
assert_eq!(json, "\"outline://book/x?alias=for_bob\"");
let parsed: Source = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, src);
}
#[test]
fn serde_rejects_empty() {
let err = serde_json::from_str::<Source>("\"\"").expect_err("empty must reject");
assert!(err.to_string().contains("source uri must not be empty"));
}
#[test]
fn serde_rejects_missing_scheme() {
let err = serde_json::from_str::<Source>("\"plain_value\"")
.expect_err("missing scheme must reject");
assert!(err.to_string().contains("scheme prefix"));
}
#[test]
fn scheme_only_uri_is_accepted() {
let src = Source::new("file:").expect("scheme-only must be accepted");
assert_eq!(src.scheme(), "file");
assert_eq!(src.as_str(), "file:");
}
#[test]
fn new_rejects_empty_scheme_with_colon() {
let err = Source::new(":foo").expect_err("empty scheme must reject");
assert!(matches!(
err,
WireError::Domain(DomainError::InvalidSource(_))
));
}
#[test]
fn string_surface_roundtrip() {
let raw = "mini-app://mailbox?alias=for_alice";
let src = Source::new(raw).unwrap();
assert_eq!(src.to_string(), raw); assert_eq!(<Source as AsRef<str>>::as_ref(&src), raw); let back: String = src.into();
assert_eq!(back, raw); }
}