use crate::errors::FirestoreError;
use crate::FirestoreResult;
use serde::{Deserialize, Serialize, Serializer};
use std::borrow::{Borrow, Cow};
use std::fmt::{Display, Formatter};
use std::str::FromStr;
#[derive(Clone, Copy)]
enum PathSegmentViolation {
Empty,
TooLong { len: usize },
ContainsSlash,
DotOrDotDot,
}
impl PathSegmentViolation {
const fn rule_message(self) -> &'static str {
match self {
Self::Empty => "must not be empty",
Self::TooLong { .. } => "must be at most 1500 bytes",
Self::ContainsSlash => "must not contain '/'",
Self::DotOrDotDot => "must not be \".\" or \"..\"",
}
}
}
const fn check_path_segment(segment: &str) -> Result<(), PathSegmentViolation> {
let bytes = segment.as_bytes();
let len = bytes.len();
if len == 0 {
return Err(PathSegmentViolation::Empty);
}
if len > 1500 {
return Err(PathSegmentViolation::TooLong { len });
}
let mut i = 0;
while i < len {
if bytes[i] == b'/' {
return Err(PathSegmentViolation::ContainsSlash);
}
i += 1;
}
if (len == 1 && bytes[0] == b'.') || (len == 2 && bytes[0] == b'.' && bytes[1] == b'.') {
return Err(PathSegmentViolation::DotOrDotDot);
}
Ok(())
}
pub(crate) fn validate_path_segment(segment: &str, field: &'static str) -> FirestoreResult<()> {
let violation = match check_path_segment(segment) {
Ok(()) => return Ok(()),
Err(violation) => violation,
};
let rule = violation.rule_message();
match violation {
PathSegmentViolation::Empty => Err(FirestoreError::invalid_parameters(
field,
rule.to_string(),
)),
PathSegmentViolation::TooLong { len } => Err(FirestoreError::invalid_parameters(
field,
format!("{rule}, was {len} bytes"),
)),
PathSegmentViolation::ContainsSlash => Err(FirestoreError::invalid_parameters(
field,
format!(
"{rule}: \"{}\" - a slash-delimited path such as \"users/123/posts\" is not a single ID; \
build it with `.parent(db.parent_path(\"users\", \"123\")?)` and pass \"posts\" as the {field}",
segment.escape_debug(),
),
)),
PathSegmentViolation::DotOrDotDot => Err(FirestoreError::invalid_parameters(
field,
format!("{rule}, got \"{segment}\""),
)),
}
}
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize)]
#[serde(try_from = "String")]
pub struct FirestoreDocumentId(Cow<'static, str>);
impl FirestoreDocumentId {
pub fn new<S: Into<String>>(id: S) -> FirestoreResult<Self> {
let id = id.into();
validate_path_segment(&id, "document_id")?;
Ok(Self(Cow::Owned(id)))
}
pub fn validate_str(id: &str) -> FirestoreResult<()> {
validate_path_segment(id, "document_id")
}
pub fn validate(&self) -> FirestoreResult<()> {
validate_path_segment(&self.0, "document_id")
}
pub const fn from_static(id: &'static str) -> Self {
match check_path_segment(id) {
Ok(()) => Self(Cow::Borrowed(id)),
Err(violation) => panic!("{}", violation.rule_message()),
}
}
pub fn value(&self) -> &str {
&self.0
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for FirestoreDocumentId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for FirestoreDocumentId {
fn borrow(&self) -> &str {
&self.0
}
}
impl Display for FirestoreDocumentId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(self.0.as_ref(), f)
}
}
impl TryFrom<&str> for FirestoreDocumentId {
type Error = FirestoreError;
fn try_from(id: &str) -> Result<Self, Self::Error> {
Self::new(id)
}
}
impl TryFrom<String> for FirestoreDocumentId {
type Error = FirestoreError;
fn try_from(id: String) -> Result<Self, Self::Error> {
Self::new(id)
}
}
impl FromStr for FirestoreDocumentId {
type Err = FirestoreError;
fn from_str(id: &str) -> Result<Self, Self::Err> {
Self::new(id)
}
}
impl PartialEq<str> for FirestoreDocumentId {
fn eq(&self, other: &str) -> bool {
self.0.as_ref() == other
}
}
impl PartialEq<FirestoreDocumentId> for str {
fn eq(&self, other: &FirestoreDocumentId) -> bool {
self == other.0.as_ref()
}
}
impl PartialEq<&str> for FirestoreDocumentId {
fn eq(&self, other: &&str) -> bool {
self.0.as_ref() == *other
}
}
impl PartialEq<FirestoreDocumentId> for &str {
fn eq(&self, other: &FirestoreDocumentId) -> bool {
*self == other.0.as_ref()
}
}
impl PartialEq<String> for FirestoreDocumentId {
fn eq(&self, other: &String) -> bool {
self.0.as_ref() == other.as_str()
}
}
impl PartialEq<FirestoreDocumentId> for String {
fn eq(&self, other: &FirestoreDocumentId) -> bool {
self.as_str() == other.0.as_ref()
}
}
impl Serialize for FirestoreDocumentId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.0.as_ref())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize)]
#[serde(try_from = "String")]
pub struct FirestoreCollectionId(Cow<'static, str>);
impl FirestoreCollectionId {
pub fn new<S: Into<String>>(id: S) -> FirestoreResult<Self> {
let id = id.into();
validate_path_segment(&id, "collection_id")?;
Ok(Self(Cow::Owned(id)))
}
pub fn validate_str(id: &str) -> FirestoreResult<()> {
validate_path_segment(id, "collection_id")
}
pub fn validate(&self) -> FirestoreResult<()> {
validate_path_segment(&self.0, "collection_id")
}
pub const fn from_static(id: &'static str) -> Self {
match check_path_segment(id) {
Ok(()) => Self(Cow::Borrowed(id)),
Err(violation) => panic!("{}", violation.rule_message()),
}
}
pub fn value(&self) -> &str {
&self.0
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for FirestoreCollectionId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for FirestoreCollectionId {
fn borrow(&self) -> &str {
&self.0
}
}
impl Display for FirestoreCollectionId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(self.0.as_ref(), f)
}
}
impl TryFrom<&str> for FirestoreCollectionId {
type Error = FirestoreError;
fn try_from(id: &str) -> Result<Self, Self::Error> {
Self::new(id)
}
}
impl TryFrom<String> for FirestoreCollectionId {
type Error = FirestoreError;
fn try_from(id: String) -> Result<Self, Self::Error> {
Self::new(id)
}
}
impl FromStr for FirestoreCollectionId {
type Err = FirestoreError;
fn from_str(id: &str) -> Result<Self, Self::Err> {
Self::new(id)
}
}
impl PartialEq<str> for FirestoreCollectionId {
fn eq(&self, other: &str) -> bool {
self.0.as_ref() == other
}
}
impl PartialEq<FirestoreCollectionId> for str {
fn eq(&self, other: &FirestoreCollectionId) -> bool {
self == other.0.as_ref()
}
}
impl PartialEq<&str> for FirestoreCollectionId {
fn eq(&self, other: &&str) -> bool {
self.0.as_ref() == *other
}
}
impl PartialEq<FirestoreCollectionId> for &str {
fn eq(&self, other: &FirestoreCollectionId) -> bool {
*self == other.0.as_ref()
}
}
impl PartialEq<String> for FirestoreCollectionId {
fn eq(&self, other: &String) -> bool {
self.0.as_ref() == other.as_str()
}
}
impl PartialEq<FirestoreCollectionId> for String {
fn eq(&self, other: &FirestoreCollectionId) -> bool {
self.as_str() == other.0.as_ref()
}
}
impl Serialize for FirestoreCollectionId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.0.as_ref())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_id_accepts_valid_ids() {
assert!(FirestoreDocumentId::new("user-42").is_ok());
assert!(FirestoreDocumentId::new("...").is_ok());
assert!(FirestoreDocumentId::new(".x").is_ok());
assert!(FirestoreDocumentId::new("__id7__").is_ok());
assert!(FirestoreDocumentId::new("__name__").is_ok());
}
#[test]
fn collection_id_accepts_valid_ids() {
assert!(FirestoreCollectionId::new("users").is_ok());
assert!(FirestoreCollectionId::new("...").is_ok());
assert!(FirestoreCollectionId::new(".x").is_ok());
assert!(FirestoreCollectionId::new("__id7__").is_ok());
assert!(FirestoreCollectionId::new("__name__").is_ok());
}
#[test]
fn rejects_empty_slash_and_dot_segments() {
for bad in ["", ".", "..", "a/b"] {
assert!(
FirestoreDocumentId::new(bad).is_err(),
"expected {bad:?} to be rejected as a document id"
);
assert!(
FirestoreCollectionId::new(bad).is_err(),
"expected {bad:?} to be rejected as a collection id"
);
}
}
#[test]
fn length_limit_is_measured_in_bytes_not_chars() {
let at_limit = "é".repeat(750);
assert_eq!(at_limit.len(), 1500);
assert!(FirestoreDocumentId::new(at_limit.clone()).is_ok());
assert!(FirestoreCollectionId::new(at_limit).is_ok());
let over_limit = "é".repeat(751);
assert_eq!(over_limit.len(), 1502);
assert!(FirestoreDocumentId::new(over_limit.clone()).is_err());
assert!(FirestoreCollectionId::new(over_limit).is_err());
}
#[test]
fn oversized_id_error_reports_byte_count_not_the_value() {
let huge = "x".repeat(10_000);
let err = FirestoreDocumentId::new(huge).unwrap_err();
let message = err.to_string();
assert!(
message.len() < 200,
"message should be short, was {} bytes: {message}",
message.len()
);
assert!(
!message.contains(&"x".repeat(10)),
"message must not echo the value"
);
assert!(message.contains("10000"));
}
#[test]
fn slash_error_escapes_embedded_newlines() {
let err = FirestoreCollectionId::new("a\nb/c").unwrap_err();
let message = err.to_string();
assert!(
!message.contains('\n'),
"message must not contain a raw newline: {message:?}"
);
assert!(
message.contains("\\n"),
"message should show the escaped newline: {message:?}"
);
}
#[test]
fn slash_error_names_the_parent_builder() {
let err = FirestoreCollectionId::new("users/123").unwrap_err();
let message = err.to_string();
assert!(
message.contains(".parent("),
"message should point at `.parent()`: {message}"
);
assert!(message.contains("collection_id"));
}
#[test]
fn value_and_as_str_agree() {
let id = FirestoreDocumentId::new("user-42").unwrap();
let v: &str = id.value();
assert_eq!(v, "user-42");
assert_eq!(id.value(), id.as_str());
}
#[test]
fn validate_str_and_validate_agree_with_new() {
assert!(FirestoreDocumentId::validate_str("user-42").is_ok());
assert!(FirestoreDocumentId::validate_str("a/b").is_err());
let id = FirestoreCollectionId::new("users").unwrap();
assert!(id.validate().is_ok());
}
#[test]
fn partial_eq_with_str_and_string_both_directions() {
let id = FirestoreDocumentId::new("user-42").unwrap();
assert_eq!(id, *"user-42");
assert_eq!(*"user-42", id);
assert_eq!(id, "user-42");
assert_eq!("user-42", id);
assert_eq!(id, "user-42".to_string());
assert_eq!("user-42".to_string(), id);
}
#[test]
fn try_from_and_from_str_round_trip() {
let via_try_from: FirestoreDocumentId = "user-42".try_into().unwrap();
let via_from_str: FirestoreDocumentId = "user-42".parse().unwrap();
assert_eq!(via_try_from, via_from_str);
assert!(FirestoreDocumentId::try_from("a/b").is_err());
assert!("a/b".parse::<FirestoreDocumentId>().is_err());
}
#[test]
fn deserialize_validates_via_try_from_string() {
use serde::de::value::{Error as ValueError, StringDeserializer};
use serde::de::IntoDeserializer;
let deserializer: StringDeserializer<ValueError> =
"user-42".to_string().into_deserializer();
let id = FirestoreDocumentId::deserialize(deserializer).unwrap();
assert_eq!(id.as_str(), "user-42");
let bad_deserializer: StringDeserializer<ValueError> =
"a/b".to_string().into_deserializer();
assert!(FirestoreDocumentId::deserialize(bad_deserializer).is_err());
}
#[test]
fn drop_in_via_as_ref_str_send() {
fn takes<S: AsRef<str> + Send>(_: S) {}
let doc_id = FirestoreDocumentId::new("doc-1").unwrap();
takes(doc_id.clone());
takes(&doc_id);
let coll_id = FirestoreCollectionId::new("col-1").unwrap();
takes(coll_id.clone());
takes(&coll_id);
}
const CONST_DOC_ID: FirestoreDocumentId = FirestoreDocumentId::from_static("const-doc");
static STATIC_COLLECTION_ID: FirestoreCollectionId =
FirestoreCollectionId::from_static("static-collection");
#[test]
fn from_static_builds_const_and_static_items() {
assert_eq!(CONST_DOC_ID.as_str(), "const-doc");
assert_eq!(STATIC_COLLECTION_ID.as_str(), "static-collection");
}
#[test]
fn from_static_agrees_with_new_on_valid_input() {
let via_new = FirestoreDocumentId::new("const-doc").unwrap();
assert_eq!(CONST_DOC_ID, via_new);
let via_new = FirestoreCollectionId::new("static-collection").unwrap();
assert_eq!(STATIC_COLLECTION_ID, via_new);
}
#[test]
fn borrowed_and_owned_are_equal_and_hash_the_same() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let borrowed = FirestoreDocumentId::from_static("user-42");
let owned = FirestoreDocumentId::new("user-42").unwrap();
assert_eq!(borrowed, owned);
let hash_of = |id: &FirestoreDocumentId| {
let mut hasher = DefaultHasher::new();
id.hash(&mut hasher);
hasher.finish()
};
assert_eq!(hash_of(&borrowed), hash_of(&owned));
}
#[test]
fn hashmap_lookup_by_str_finds_a_from_static_key() {
use std::collections::HashMap;
const KEY: FirestoreCollectionId = FirestoreCollectionId::from_static("users");
let mut map: HashMap<FirestoreCollectionId, u32> = HashMap::new();
map.insert(KEY, 1);
assert_eq!(map.get("users"), Some(&1));
}
}