Skip to main content

temporalio_common_wasm/
search_attributes.rs

1//! Type-safe search attribute APIs for the Temporal Rust SDK.
2//!
3//! Search attributes are key-value pairs attached to workflows that enable
4//! server-side filtering via visibility queries. This module provides a typed
5//! layer over the raw proto payloads so that attribute values are checked at
6//! compile time.
7//!
8//! # Example
9//!
10//! ```
11//! use temporalio_common_wasm::search_attributes::SearchAttributeKey;
12//!
13//! const MY_BOOL: SearchAttributeKey<bool> = SearchAttributeKey::bool("my_bool");
14//! const MY_KW: SearchAttributeKey<String> = SearchAttributeKey::keyword("my_keyword");
15//!
16//! let update = MY_BOOL.value_set(true);
17//! let unset = MY_KW.value_unset();
18//! ```
19
20use std::{
21    collections::{BTreeMap, HashMap},
22    marker::PhantomData,
23};
24
25use tracing::warn;
26
27use crate::{
28    data_converters::{
29        GenericPayloadConverter, PayloadConversionError, PayloadConverter, SerializationContext,
30        SerializationContextData,
31    },
32    protos::temporal::api::{
33        common::v1::{Payload, SearchAttributes as ProtoSearchAttributes},
34        enums::v1::IndexedValueType,
35    },
36};
37
38/// Metadata key for the search attribute value type, kept consistent across all SDKs.
39const TYPE_METADATA_KEY: &str = "type";
40
41/// Errors arising from search attribute serialization or deserialization.
42#[derive(Debug, thiserror::Error)]
43#[non_exhaustive]
44pub enum SearchAttributeError {
45    /// Payload conversion failed.
46    #[error("failed to convert search attribute payload: {0}")]
47    PayloadConversion(#[from] PayloadConversionError),
48
49    /// The payload is missing required metadata or has an unexpected encoding.
50    #[error("invalid search attribute payload: {reason}")]
51    InvalidPayload {
52        /// Description of what was wrong with the payload.
53        reason: String,
54    },
55
56    /// A timestamp value could not be formatted or parsed as RFC3339.
57    #[error("invalid timestamp: {0}")]
58    InvalidTimestamp(String),
59}
60
61// ---------------------------------------------------------------------------
62// SDK-owned Timestamp type
63// ---------------------------------------------------------------------------
64
65/// An SDK-owned timestamp for Datetime search attributes.
66///
67/// This type decouples the public API from `prost_types::Timestamp`. Conversion
68/// traits are provided for [`prost_types::Timestamp`] and
69/// [`std::time::SystemTime`].
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
71pub struct Timestamp {
72    seconds: i64,
73    nanos: i32,
74}
75
76impl Timestamp {
77    /// The maximum valid value for nanoseconds.
78    const MAX_NANOS: i32 = 999_999_999;
79
80    /// Creates a new `Timestamp`.
81    ///
82    /// # Arguments
83    /// * `seconds` — seconds since the Unix epoch (negative for pre-epoch).
84    /// * `nanos` — non-negative nanosecond offset within the second,
85    ///   in the range `[0, 999_999_999]`. Values outside this range are
86    ///   clamped.
87    pub fn new(seconds: i64, nanos: i32) -> Self {
88        Self {
89            seconds,
90            nanos: nanos.clamp(0, Self::MAX_NANOS),
91        }
92    }
93
94    /// Returns seconds since the Unix epoch.
95    pub fn seconds(&self) -> i64 {
96        self.seconds
97    }
98
99    /// Returns the nanosecond component (always in `[0, 999_999_999]`).
100    pub fn nanos(&self) -> i32 {
101        self.nanos
102    }
103
104    /// Returns this timestamp as a `prost_types::Timestamp`.
105    pub fn to_prost(&self) -> prost_types::Timestamp {
106        prost_types::Timestamp {
107            seconds: self.seconds,
108            nanos: self.nanos,
109        }
110    }
111}
112
113impl std::fmt::Display for Timestamp {
114    /// Formats the timestamp as an RFC3339 string (e.g., `2023-11-14T22:13:20.000000000Z`).
115    /// Falls back to `Debug` formatting if the timestamp is out of chrono's range.
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match timestamp_to_rfc3339(self) {
118            Ok(s) => f.write_str(&s),
119            Err(_) => write!(f, "Timestamp({}, {})", self.seconds, self.nanos),
120        }
121    }
122}
123
124impl From<prost_types::Timestamp> for Timestamp {
125    fn from(ts: prost_types::Timestamp) -> Self {
126        Timestamp::new(ts.seconds, ts.nanos)
127    }
128}
129
130impl From<Timestamp> for prost_types::Timestamp {
131    fn from(ts: Timestamp) -> Self {
132        prost_types::Timestamp {
133            seconds: ts.seconds(),
134            nanos: ts.nanos(),
135        }
136    }
137}
138
139impl From<std::time::SystemTime> for Timestamp {
140    fn from(st: std::time::SystemTime) -> Self {
141        match st.duration_since(std::time::UNIX_EPOCH) {
142            Ok(dur) => Timestamp::new(dur.as_secs() as i64, dur.subsec_nanos() as i32),
143            Err(e) => {
144                // Normalize to protobuf convention: nanos always non-negative.
145                // Example: 1.25s before epoch → { seconds: -2, nanos: 750_000_000 }
146                let dur = e.duration();
147                let secs = dur.as_secs() as i64;
148                let nanos = dur.subsec_nanos();
149                if nanos == 0 {
150                    Timestamp::new(-secs, 0)
151                } else {
152                    Timestamp::new(-(secs + 1), (1_000_000_000 - nanos) as i32)
153                }
154            }
155        }
156    }
157}
158
159impl TryFrom<Timestamp> for std::time::SystemTime {
160    type Error = SearchAttributeError;
161
162    fn try_from(ts: Timestamp) -> Result<Self, Self::Error> {
163        let epoch = std::time::UNIX_EPOCH;
164        if ts.seconds >= 0 {
165            epoch
166                .checked_add(std::time::Duration::new(
167                    ts.seconds as u64,
168                    ts.nanos.max(0) as u32,
169                ))
170                .ok_or_else(|| {
171                    SearchAttributeError::InvalidTimestamp(
172                        "timestamp out of SystemTime range".into(),
173                    )
174                })
175        } else {
176            // Reverse the normalization: { seconds: -2, nanos: 750_000_000 }
177            // means 1.25s before epoch → Duration::new(1, 250_000_000)
178            let abs_secs = ts.seconds.unsigned_abs();
179            let nanos = ts.nanos.max(0) as u32;
180            let dur = if nanos == 0 {
181                std::time::Duration::new(abs_secs, 0)
182            } else {
183                std::time::Duration::new(abs_secs - 1, 1_000_000_000 - nanos)
184            };
185            epoch.checked_sub(dur).ok_or_else(|| {
186                SearchAttributeError::InvalidTimestamp("timestamp out of SystemTime range".into())
187            })
188        }
189    }
190}
191
192// ---------------------------------------------------------------------------
193// SearchAttributeValue trait
194// ---------------------------------------------------------------------------
195
196mod private {
197    pub trait Sealed {}
198    impl Sealed for bool {}
199    impl Sealed for i64 {}
200    impl Sealed for f64 {}
201    impl Sealed for String {}
202    impl Sealed for super::Timestamp {}
203    impl Sealed for Vec<String> {}
204}
205
206/// A value type that can be stored as a Temporal search attribute.
207///
208/// This trait is sealed and implemented for: `bool`, `i64`, `f64`, `String`,
209/// [`Timestamp`], and `Vec<String>`.
210pub trait SearchAttributeValue: private::Sealed + Clone + Sized {
211    /// Encode this value into a search attribute [`Payload`].
212    fn to_search_attribute_payload(
213        &self,
214        indexed_value_type: IndexedValueType,
215    ) -> Result<Payload, SearchAttributeError>;
216
217    /// Decode a value from a search attribute [`Payload`].
218    fn from_search_attribute_payload(payload: &Payload) -> Result<Self, SearchAttributeError>;
219
220    /// The default [`IndexedValueType`] for this Rust type.
221    ///
222    /// This is used internally when a key does not explicitly specify the
223    /// indexed value type. Most callers should use [`SearchAttributeKey`]
224    /// constructors rather than calling this directly.
225    fn default_indexed_value_type() -> IndexedValueType;
226}
227
228// ---------------------------------------------------------------------------
229// Shared JSON payload helpers (reuses the SDK's JSON payload encoding conventions)
230// ---------------------------------------------------------------------------
231
232fn type_metadata_str(ivt: IndexedValueType) -> &'static str {
233    match ivt {
234        IndexedValueType::Bool => "Bool",
235        IndexedValueType::Int => "Int",
236        IndexedValueType::Double => "Double",
237        IndexedValueType::Keyword => "Keyword",
238        IndexedValueType::Text => "Text",
239        IndexedValueType::Datetime => "Datetime",
240        IndexedValueType::KeywordList => "KeywordList",
241        IndexedValueType::Unspecified => "Unspecified",
242    }
243}
244
245/// Encode a serde-serializable value into a search attribute [`Payload`].
246///
247/// This uses the SDK's JSON payload converter, then adds the search-attribute
248/// `type` metadata key.
249fn encode_json_search_attr<T: serde::Serialize + 'static>(
250    value: &T,
251    indexed_value_type: IndexedValueType,
252) -> Result<Payload, SearchAttributeError> {
253    let converter = PayloadConverter::serde_json();
254    let context = SerializationContext::new(&SerializationContextData::None, &converter);
255    let mut payload = converter.to_payload(&context, value)?;
256    payload.metadata.insert(
257        TYPE_METADATA_KEY.to_string(),
258        type_metadata_str(indexed_value_type).as_bytes().to_vec(),
259    );
260    Ok(payload)
261}
262
263/// Decode a search attribute [`Payload`] back into a concrete type.
264///
265/// This delegates payload interpretation to the SDK's JSON payload converter.
266fn decode_json_search_attr<T: serde::de::DeserializeOwned + 'static>(
267    payload: &Payload,
268) -> Result<T, SearchAttributeError> {
269    let converter = PayloadConverter::serde_json();
270    let context = SerializationContext::new(&SerializationContextData::None, &converter);
271    Ok(converter.from_payload(&context, payload.clone())?)
272}
273
274// ---------------------------------------------------------------------------
275// Macro for simple (serde-native) SearchAttributeValue impls
276// ---------------------------------------------------------------------------
277
278/// Implements [`SearchAttributeValue`] for types that are directly
279/// serde-serializable as their JSON wire representation (no special conversion).
280macro_rules! impl_simple_search_attribute_value {
281    ($ty:ty, $ivt:expr) => {
282        impl SearchAttributeValue for $ty {
283            fn to_search_attribute_payload(
284                &self,
285                indexed_value_type: IndexedValueType,
286            ) -> Result<Payload, SearchAttributeError> {
287                encode_json_search_attr(self, indexed_value_type)
288            }
289
290            fn from_search_attribute_payload(
291                payload: &Payload,
292            ) -> Result<Self, SearchAttributeError> {
293                decode_json_search_attr(payload)
294            }
295
296            fn default_indexed_value_type() -> IndexedValueType {
297                $ivt
298            }
299        }
300    };
301}
302
303impl_simple_search_attribute_value!(bool, IndexedValueType::Bool);
304impl_simple_search_attribute_value!(i64, IndexedValueType::Int);
305impl_simple_search_attribute_value!(String, IndexedValueType::Keyword);
306impl_simple_search_attribute_value!(Vec<String>, IndexedValueType::KeywordList);
307
308// f64 requires a manual impl to reject NaN and Infinity, which serde_json
309// silently serializes as `null` rather than returning an error.
310impl SearchAttributeValue for f64 {
311    fn to_search_attribute_payload(
312        &self,
313        indexed_value_type: IndexedValueType,
314    ) -> Result<Payload, SearchAttributeError> {
315        if !self.is_finite() {
316            return Err(SearchAttributeError::InvalidPayload {
317                reason: format!("f64 search attribute value must be finite, got {}", self),
318            });
319        }
320        encode_json_search_attr(self, indexed_value_type)
321    }
322
323    fn from_search_attribute_payload(payload: &Payload) -> Result<Self, SearchAttributeError> {
324        decode_json_search_attr(payload)
325    }
326
327    fn default_indexed_value_type() -> IndexedValueType {
328        IndexedValueType::Double
329    }
330}
331
332// ---------------------------------------------------------------------------
333// Timestamp SearchAttributeValue impl (RFC3339 string on the wire)
334// ---------------------------------------------------------------------------
335
336/// Format a [`Timestamp`] as an RFC3339 string using `chrono`.
337fn timestamp_to_rfc3339(ts: &Timestamp) -> Result<String, SearchAttributeError> {
338    use chrono::{DateTime, Utc};
339
340    let nanos = u32::try_from(ts.nanos()).unwrap_or(0);
341    let dt = DateTime::<Utc>::from_timestamp(ts.seconds(), nanos).ok_or_else(|| {
342        SearchAttributeError::InvalidTimestamp(format!(
343            "cannot represent seconds={} nanos={} as DateTime",
344            ts.seconds(),
345            ts.nanos()
346        ))
347    })?;
348    Ok(dt.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true))
349}
350
351/// Parse an RFC3339 string into a [`Timestamp`] using `chrono`.
352fn rfc3339_to_timestamp(s: &str) -> Result<Timestamp, SearchAttributeError> {
353    use chrono::DateTime;
354
355    // Strip surrounding quotes if present — some SDKs or raw payloads may
356    // pass the RFC3339 string with JSON-style quotes still attached.
357    let s = s.trim_matches('"');
358    let dt = DateTime::parse_from_rfc3339(s).map_err(|e| {
359        SearchAttributeError::InvalidTimestamp(format!("failed to parse RFC3339 '{}': {}", s, e))
360    })?;
361    Ok(Timestamp::new(
362        dt.timestamp(),
363        dt.timestamp_subsec_nanos() as i32,
364    ))
365}
366
367impl SearchAttributeValue for Timestamp {
368    fn to_search_attribute_payload(
369        &self,
370        indexed_value_type: IndexedValueType,
371    ) -> Result<Payload, SearchAttributeError> {
372        let rfc3339 = timestamp_to_rfc3339(self)?;
373        encode_json_search_attr(&rfc3339, indexed_value_type)
374    }
375
376    fn from_search_attribute_payload(payload: &Payload) -> Result<Self, SearchAttributeError> {
377        let s: String = decode_json_search_attr(payload)?;
378        rfc3339_to_timestamp(&s)
379    }
380
381    fn default_indexed_value_type() -> IndexedValueType {
382        IndexedValueType::Datetime
383    }
384}
385
386// ---------------------------------------------------------------------------
387// SearchAttributeKey
388// ---------------------------------------------------------------------------
389
390/// A typed handle for a named search attribute, carrying its value type at the
391/// type level. Construct via the const factory methods such as
392/// [`SearchAttributeKey::bool`], [`SearchAttributeKey::keyword`], etc.
393///
394/// # Key names
395///
396/// Key names must be `&'static str`, which enables compile-time construction
397/// via `const` but means runtime-determined key names are not supported.
398/// For dynamic key names (e.g., from config), use
399/// [`SearchAttributes::raw_payload`] as an escape hatch for untyped access.
400///
401/// ```
402/// use temporalio_common_wasm::search_attributes::SearchAttributeKey;
403///
404/// const MY_KEY: SearchAttributeKey<String> = SearchAttributeKey::keyword("my_attr");
405/// ```
406#[derive(Debug, Clone, Copy)]
407pub struct SearchAttributeKey<T: SearchAttributeValue> {
408    name: &'static str,
409    indexed_value_type: IndexedValueType,
410    _marker: PhantomData<T>,
411}
412
413impl<T: SearchAttributeValue> SearchAttributeKey<T> {
414    /// Returns the attribute name used as the key in the proto map.
415    pub fn name(&self) -> &'static str {
416        self.name
417    }
418
419    /// Returns the [`IndexedValueType`] configured for this key.
420    pub fn indexed_value_type(&self) -> IndexedValueType {
421        self.indexed_value_type
422    }
423
424    /// Create a [`SearchAttributeUpdate`] that sets the attribute to the given value.
425    ///
426    /// # Panics
427    ///
428    /// Panics if the value cannot be serialized to JSON. This can happen for
429    /// `f64` values that are `NaN` or `Infinity` (which are not valid JSON),
430    /// or for `Timestamp` values with out-of-range seconds. Use
431    /// [`try_value_set`](Self::try_value_set) for a fallible alternative.
432    pub fn value_set(&self, val: T) -> SearchAttributeUpdate {
433        self.try_value_set(val)
434            .expect("search attribute serialization failed (use try_value_set for non-finite f64 or out-of-range timestamps)")
435    }
436
437    /// Fallible version of [`value_set`](Self::value_set). Returns an error
438    /// instead of panicking if the value cannot be serialized.
439    pub fn try_value_set(&self, val: T) -> Result<SearchAttributeUpdate, SearchAttributeError> {
440        let payload = val.to_search_attribute_payload(self.indexed_value_type)?;
441        Ok(SearchAttributeUpdate {
442            name: self.name.to_string(),
443            payload: Some(payload),
444        })
445    }
446
447    /// Create a [`SearchAttributeUpdate`] that removes this attribute.
448    pub fn value_unset(&self) -> SearchAttributeUpdate {
449        SearchAttributeUpdate {
450            name: self.name.to_string(),
451            payload: None,
452        }
453    }
454}
455
456impl SearchAttributeKey<bool> {
457    /// Create a key for a `Bool`-typed search attribute.
458    pub const fn bool(name: &'static str) -> Self {
459        Self {
460            name,
461            indexed_value_type: IndexedValueType::Bool,
462            _marker: PhantomData,
463        }
464    }
465}
466
467impl SearchAttributeKey<i64> {
468    /// Create a key for an `Int`-typed search attribute.
469    pub const fn int(name: &'static str) -> Self {
470        Self {
471            name,
472            indexed_value_type: IndexedValueType::Int,
473            _marker: PhantomData,
474        }
475    }
476}
477
478impl SearchAttributeKey<f64> {
479    /// Create a key for a `Double`-typed search attribute.
480    pub const fn float(name: &'static str) -> Self {
481        Self {
482            name,
483            indexed_value_type: IndexedValueType::Double,
484            _marker: PhantomData,
485        }
486    }
487}
488
489impl SearchAttributeKey<String> {
490    /// Create a key for a `Keyword`-typed search attribute.
491    pub const fn keyword(name: &'static str) -> Self {
492        Self {
493            name,
494            indexed_value_type: IndexedValueType::Keyword,
495            _marker: PhantomData,
496        }
497    }
498
499    /// Create a key for a `Text`-typed search attribute.
500    pub const fn text(name: &'static str) -> Self {
501        Self {
502            name,
503            indexed_value_type: IndexedValueType::Text,
504            _marker: PhantomData,
505        }
506    }
507}
508
509impl SearchAttributeKey<Timestamp> {
510    /// Create a key for a `Datetime`-typed search attribute.
511    pub const fn datetime(name: &'static str) -> Self {
512        Self {
513            name,
514            indexed_value_type: IndexedValueType::Datetime,
515            _marker: PhantomData,
516        }
517    }
518}
519
520impl SearchAttributeKey<Vec<String>> {
521    /// Create a key for a `KeywordList`-typed search attribute.
522    pub const fn keyword_list(name: &'static str) -> Self {
523        Self {
524            name,
525            indexed_value_type: IndexedValueType::KeywordList,
526            _marker: PhantomData,
527        }
528    }
529}
530
531// ---------------------------------------------------------------------------
532// SearchAttributeUpdate
533// ---------------------------------------------------------------------------
534
535/// A pending mutation to a single search attribute.
536///
537/// When `payload` is `None`, the attribute should be removed. The semantics
538/// differ slightly depending on how the update is consumed:
539///
540/// - [`SearchAttributes::new`] / [`SearchAttributes::apply`]: a `None` payload
541///   removes the key from the in-memory collection (the key is simply absent).
542/// - [`SearchAttributes::updates_to_proto`]: a `None` payload produces an
543///   empty [`Payload`] in the proto map, signaling the server to clear that
544///   attribute.
545#[derive(Debug, Clone)]
546pub struct SearchAttributeUpdate {
547    pub(crate) name: String,
548    pub(crate) payload: Option<Payload>,
549}
550
551impl SearchAttributeUpdate {
552    /// Returns the attribute name being updated.
553    pub fn name(&self) -> &str {
554        &self.name
555    }
556
557    /// Returns `true` if this update removes the attribute.
558    pub fn is_unset(&self) -> bool {
559        self.payload.is_none()
560    }
561}
562
563// ---------------------------------------------------------------------------
564// SearchAttributes
565// ---------------------------------------------------------------------------
566
567/// A collection of search attribute payloads, providing type-safe access via
568/// [`SearchAttributeKey`].
569#[derive(Debug, Clone, Default, PartialEq)]
570pub struct SearchAttributes {
571    fields: BTreeMap<String, Payload>,
572}
573
574impl SearchAttributes {
575    /// Construct from an iterator of [`SearchAttributeUpdate`]s.
576    ///
577    /// Updates with `None` payloads remove any existing entry for that key.
578    pub fn new(updates: impl IntoIterator<Item = SearchAttributeUpdate>) -> Self {
579        let mut fields = BTreeMap::new();
580        for update in updates {
581            match update.payload {
582                Some(payload) => {
583                    fields.insert(update.name, payload);
584                }
585                None => {
586                    fields.remove(&update.name);
587                }
588            }
589        }
590        Self { fields }
591    }
592
593    /// Apply a single update to this collection. If the update sets a value,
594    /// it is inserted (replacing any existing entry); if the update unsets a
595    /// value, the entry is removed.
596    pub fn apply(&mut self, update: SearchAttributeUpdate) {
597        match update.payload {
598            Some(payload) => {
599                self.fields.insert(update.name, payload);
600            }
601            None => {
602                self.fields.remove(&update.name);
603            }
604        }
605    }
606
607    /// Retrieve a typed value. Returns `None` if the key is absent or
608    /// deserialization fails (graceful degradation — no panic on type mismatch).
609    pub fn get<T: SearchAttributeValue>(&self, key: &SearchAttributeKey<T>) -> Option<T> {
610        let payload = self.fields.get(key.name())?;
611        match T::from_search_attribute_payload(payload) {
612            Ok(val) => Some(val),
613            Err(e) => {
614                warn!(
615                    key = key.name(),
616                    error = %e,
617                    "Failed to deserialize search attribute; returning None. \
618                     Use try_get() for explicit error handling."
619                );
620                None
621            }
622        }
623    }
624
625    /// Retrieve a typed value, distinguishing "key absent" from "deserialization
626    /// failed". Returns `Ok(None)` if the key is absent, `Ok(Some(val))` on
627    /// success, or `Err` if the payload is present but cannot be deserialized.
628    pub fn try_get<T: SearchAttributeValue>(
629        &self,
630        key: &SearchAttributeKey<T>,
631    ) -> Result<Option<T>, SearchAttributeError> {
632        match self.fields.get(key.name()) {
633            None => Ok(None),
634            Some(payload) => T::from_search_attribute_payload(payload).map(Some),
635        }
636    }
637
638    /// Returns `true` if a payload exists for the given key.
639    pub fn contains_key<T: SearchAttributeValue>(&self, key: &SearchAttributeKey<T>) -> bool {
640        self.fields.contains_key(key.name())
641    }
642
643    /// Returns true if there are no search attributes.
644    pub fn is_empty(&self) -> bool {
645        self.fields.is_empty()
646    }
647
648    /// Returns the number of search attributes.
649    pub fn len(&self) -> usize {
650        self.fields.len()
651    }
652
653    /// Returns an iterator over the attribute names in lexicographic order.
654    pub fn keys(&self) -> impl Iterator<Item = &str> {
655        self.fields.keys().map(|s| s.as_str())
656    }
657
658    /// Returns a reference to the raw payload for the given attribute name,
659    /// if present. This is useful for advanced use cases such as forwarding
660    /// payloads without deserializing them.
661    pub fn raw_payload(&self, name: &str) -> Option<&Payload> {
662        self.fields.get(name)
663    }
664
665    /// Convert to the proto wire representation.
666    pub fn to_proto(&self) -> ProtoSearchAttributes {
667        ProtoSearchAttributes {
668            indexed_fields: self.fields.clone().into_iter().collect(),
669        }
670    }
671
672    /// Convert to the proto wire representation, consuming `self` to avoid
673    /// cloning.
674    pub fn into_proto(self) -> ProtoSearchAttributes {
675        ProtoSearchAttributes {
676            indexed_fields: self.fields.into_iter().collect(),
677        }
678    }
679
680    /// Construct from the proto wire representation by cloning the inner map.
681    pub fn from_proto(attrs: &ProtoSearchAttributes) -> Self {
682        Self {
683            fields: attrs.indexed_fields.clone().into_iter().collect(),
684        }
685    }
686}
687
688impl From<ProtoSearchAttributes> for SearchAttributes {
689    /// Construct from an owned proto, moving the inner map without cloning.
690    fn from(attrs: ProtoSearchAttributes) -> Self {
691        Self {
692            fields: attrs.indexed_fields.into_iter().collect(),
693        }
694    }
695}
696
697impl SearchAttributes {
698    /// Convert to the proto representation, producing empty-data payloads for
699    /// entries that were unset. This is used when building an upsert command
700    /// that needs to explicitly clear attributes on the server.
701    pub fn updates_to_proto(
702        updates: impl IntoIterator<Item = SearchAttributeUpdate>,
703    ) -> ProtoSearchAttributes {
704        let mut indexed_fields = HashMap::new();
705        for update in updates {
706            let payload = update.payload.unwrap_or_default();
707            indexed_fields.insert(update.name, payload);
708        }
709        ProtoSearchAttributes { indexed_fields }
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    const BOOL_KEY: SearchAttributeKey<bool> = SearchAttributeKey::bool("my_bool");
718    const INT_KEY: SearchAttributeKey<i64> = SearchAttributeKey::int("my_int");
719    const FLOAT_KEY: SearchAttributeKey<f64> = SearchAttributeKey::float("my_float");
720    const KW_KEY: SearchAttributeKey<String> = SearchAttributeKey::keyword("my_keyword");
721    const TEXT_KEY: SearchAttributeKey<String> = SearchAttributeKey::text("my_text");
722    const DT_KEY: SearchAttributeKey<Timestamp> = SearchAttributeKey::datetime("my_datetime");
723    const KWL_KEY: SearchAttributeKey<Vec<String>> =
724        SearchAttributeKey::keyword_list("my_keyword_list");
725
726    fn assert_payload_metadata(payload: &Payload, expected_type: &str) {
727        assert_eq!(
728            payload.metadata.get("encoding").unwrap(),
729            b"json/plain".as_slice()
730        );
731        assert_eq!(
732            payload.metadata.get(TYPE_METADATA_KEY).unwrap(),
733            expected_type.as_bytes()
734        );
735    }
736
737    #[test]
738    fn round_trip_bool() {
739        let val = true;
740        let payload = val
741            .to_search_attribute_payload(IndexedValueType::Bool)
742            .unwrap();
743        assert_payload_metadata(&payload, "Bool");
744        assert!(bool::from_search_attribute_payload(&payload).unwrap());
745    }
746
747    #[test]
748    fn round_trip_int() {
749        let val: i64 = -42;
750        let payload = val
751            .to_search_attribute_payload(IndexedValueType::Int)
752            .unwrap();
753        assert_payload_metadata(&payload, "Int");
754        assert_eq!(i64::from_search_attribute_payload(&payload).unwrap(), -42);
755    }
756
757    #[test]
758    fn round_trip_double() {
759        let val: f64 = 1.23;
760        let payload = val
761            .to_search_attribute_payload(IndexedValueType::Double)
762            .unwrap();
763        assert_payload_metadata(&payload, "Double");
764        let decoded = f64::from_search_attribute_payload(&payload).unwrap();
765        assert!((decoded - 1.23).abs() < f64::EPSILON);
766    }
767
768    #[test]
769    fn round_trip_keyword() {
770        let val = "hello".to_string();
771        let payload = val
772            .to_search_attribute_payload(IndexedValueType::Keyword)
773            .unwrap();
774        assert_payload_metadata(&payload, "Keyword");
775        assert_eq!(
776            String::from_search_attribute_payload(&payload).unwrap(),
777            "hello"
778        );
779    }
780
781    #[test]
782    fn round_trip_text() {
783        let val = "some long text".to_string();
784        let payload = val
785            .to_search_attribute_payload(IndexedValueType::Text)
786            .unwrap();
787        assert_payload_metadata(&payload, "Text");
788        assert_eq!(
789            String::from_search_attribute_payload(&payload).unwrap(),
790            "some long text"
791        );
792    }
793
794    #[test]
795    fn round_trip_datetime() {
796        let ts = Timestamp::new(1_700_000_000, 123_456_789);
797        let payload = ts
798            .to_search_attribute_payload(IndexedValueType::Datetime)
799            .unwrap();
800        assert_payload_metadata(&payload, "Datetime");
801
802        let json_str: String = serde_json::from_slice(&payload.data).unwrap();
803        assert!(json_str.ends_with('Z'));
804        assert!(json_str.contains('T'));
805
806        let decoded = Timestamp::from_search_attribute_payload(&payload).unwrap();
807        assert_eq!(decoded.seconds(), ts.seconds());
808        assert_eq!(decoded.nanos(), ts.nanos());
809
810        let attrs = SearchAttributes::new([DT_KEY.value_set(ts.clone())]);
811        let got = attrs.get(&DT_KEY).unwrap();
812        assert_eq!(got.seconds(), ts.seconds());
813        assert_eq!(got.nanos(), ts.nanos());
814    }
815
816    #[test]
817    fn round_trip_datetime_no_nanos() {
818        let ts = Timestamp::new(0, 0);
819        let payload = ts
820            .to_search_attribute_payload(IndexedValueType::Datetime)
821            .unwrap();
822        let decoded = Timestamp::from_search_attribute_payload(&payload).unwrap();
823        assert_eq!(decoded.seconds(), 0);
824        assert_eq!(decoded.nanos(), 0);
825    }
826
827    #[test]
828    fn round_trip_keyword_list() {
829        let val = vec!["a".to_string(), "b".to_string(), "c".to_string()];
830        let payload = val
831            .to_search_attribute_payload(IndexedValueType::KeywordList)
832            .unwrap();
833        assert_payload_metadata(&payload, "KeywordList");
834        assert_eq!(
835            Vec::<String>::from_search_attribute_payload(&payload).unwrap(),
836            vec!["a", "b", "c"]
837        );
838    }
839
840    #[test]
841    fn typed_search_attributes_new_and_get() {
842        let attrs = SearchAttributes::new([
843            BOOL_KEY.value_set(true),
844            INT_KEY.value_set(99),
845            FLOAT_KEY.value_set(2.72),
846            KW_KEY.value_set("kw_val".into()),
847            TEXT_KEY.value_set("text_val".into()),
848            KWL_KEY.value_set(vec!["x".into(), "y".into()]),
849        ]);
850
851        assert_eq!(attrs.len(), 6);
852        assert!(!attrs.is_empty());
853        assert_eq!(attrs.get(&BOOL_KEY), Some(true));
854        assert_eq!(attrs.get(&INT_KEY), Some(99));
855        assert!((attrs.get(&FLOAT_KEY).unwrap() - 2.72).abs() < f64::EPSILON);
856        assert_eq!(attrs.get(&KW_KEY), Some("kw_val".into()));
857        assert_eq!(attrs.get(&TEXT_KEY), Some("text_val".into()));
858        assert_eq!(
859            attrs.get(&KWL_KEY),
860            Some(vec!["x".to_string(), "y".to_string()])
861        );
862    }
863
864    #[test]
865    fn to_proto_from_proto_round_trip() {
866        let attrs = SearchAttributes::new([BOOL_KEY.value_set(false), INT_KEY.value_set(7)]);
867
868        let proto = attrs.to_proto();
869        assert_eq!(proto.indexed_fields.len(), 2);
870
871        let restored = SearchAttributes::from_proto(&proto);
872        assert_eq!(restored.get(&BOOL_KEY), Some(false));
873        assert_eq!(restored.get(&INT_KEY), Some(7));
874    }
875
876    #[test]
877    fn value_unset_removes_entry() {
878        let attrs = SearchAttributes::new([BOOL_KEY.value_set(true), BOOL_KEY.value_unset()]);
879        assert!(attrs.is_empty());
880        assert_eq!(attrs.get(&BOOL_KEY), None);
881    }
882
883    #[test]
884    fn keyword_vs_text_disambiguation() {
885        let kw_update = KW_KEY.value_set("same_value".into());
886        let text_update = TEXT_KEY.value_set("same_value".into());
887
888        let kw_payload = kw_update.payload.as_ref().unwrap();
889        let text_payload = text_update.payload.as_ref().unwrap();
890
891        assert_eq!(
892            kw_payload.metadata.get(TYPE_METADATA_KEY).unwrap(),
893            b"Keyword"
894        );
895        assert_eq!(
896            text_payload.metadata.get(TYPE_METADATA_KEY).unwrap(),
897            b"Text"
898        );
899
900        assert_eq!(KW_KEY.indexed_value_type(), IndexedValueType::Keyword);
901        assert_eq!(TEXT_KEY.indexed_value_type(), IndexedValueType::Text);
902    }
903
904    #[test]
905    fn get_returns_none_for_missing_key() {
906        let attrs = SearchAttributes::default();
907        assert_eq!(attrs.get(&BOOL_KEY), None);
908        assert!(!attrs.contains_key(&INT_KEY));
909    }
910
911    #[test]
912    fn get_returns_none_for_type_mismatch() {
913        let attrs = SearchAttributes::new([BOOL_KEY.value_set(true)]);
914        // Try to read the bool payload as an i64 — should gracefully return None
915        let mismatched_key = SearchAttributeKey::<i64>::int("my_bool");
916        assert_eq!(attrs.get(&mismatched_key), None);
917    }
918
919    #[test]
920    fn updates_to_proto_includes_empty_payload_for_unset() {
921        let proto =
922            SearchAttributes::updates_to_proto([BOOL_KEY.value_set(true), INT_KEY.value_unset()]);
923
924        let bool_payload = proto.indexed_fields.get("my_bool").unwrap();
925        assert!(!bool_payload.data.is_empty());
926
927        let int_payload = proto.indexed_fields.get("my_int").unwrap();
928        assert!(int_payload.data.is_empty());
929        assert!(int_payload.metadata.is_empty());
930    }
931
932    #[test]
933    fn contains_key_returns_true_when_present() {
934        let attrs = SearchAttributes::new([INT_KEY.value_set(42)]);
935        assert!(attrs.contains_key(&INT_KEY));
936    }
937
938    #[test]
939    fn timestamp_rfc3339_format() {
940        let ts = Timestamp::new(1_700_000_000, 0);
941        let rfc = timestamp_to_rfc3339(&ts).unwrap();
942        // SecondsFormat::Nanos emits full precision even for zero nanos
943        assert_eq!(rfc, "2023-11-14T22:13:20.000000000Z");
944    }
945
946    #[test]
947    fn timestamp_rfc3339_with_nanos() {
948        let ts = Timestamp::new(1_700_000_000, 500_000_000);
949        let rfc = timestamp_to_rfc3339(&ts).unwrap();
950        assert_eq!(rfc, "2023-11-14T22:13:20.500000000Z");
951
952        let parsed = rfc3339_to_timestamp(&rfc).unwrap();
953        assert_eq!(parsed.seconds(), ts.seconds());
954        assert_eq!(parsed.nanos(), ts.nanos());
955    }
956
957    #[test]
958    fn search_attribute_update_accessors() {
959        let set = BOOL_KEY.value_set(true);
960        assert_eq!(set.name(), "my_bool");
961        assert!(!set.is_unset());
962
963        let unset = BOOL_KEY.value_unset();
964        assert_eq!(unset.name(), "my_bool");
965        assert!(unset.is_unset());
966    }
967
968    #[test]
969    fn timestamp_from_prost_types() {
970        let prost_ts = prost_types::Timestamp {
971            seconds: 1_000_000,
972            nanos: 42,
973        };
974        let ts: Timestamp = prost_ts.into();
975        assert_eq!(ts.seconds(), 1_000_000);
976        assert_eq!(ts.nanos(), 42);
977
978        let back: prost_types::Timestamp = ts.into();
979        assert_eq!(back.seconds, 1_000_000);
980        assert_eq!(back.nanos, 42);
981    }
982
983    #[test]
984    fn timestamp_from_system_time() {
985        // Use nanos aligned to 100ns boundary for Windows compatibility
986        // (Windows SystemTime uses FILETIME with 100ns tick resolution)
987        let st = std::time::UNIX_EPOCH + std::time::Duration::new(1_700_000_000, 123_456_700);
988        let ts: Timestamp = st.into();
989        assert_eq!(ts.seconds(), 1_700_000_000);
990        assert_eq!(ts.nanos(), 123_456_700);
991
992        let back: std::time::SystemTime = ts.try_into().unwrap();
993        assert_eq!(back, st);
994    }
995
996    // --- Edge-case tests (from review feedback) ---
997
998    #[test]
999    fn timestamp_pre_epoch_normalized() {
1000        // 1.25 seconds before epoch → { seconds: -2, nanos: 750_000_000 }
1001        let st = std::time::UNIX_EPOCH - std::time::Duration::new(1, 250_000_000);
1002        let ts: Timestamp = st.into();
1003        assert_eq!(ts.seconds(), -2);
1004        assert_eq!(ts.nanos(), 750_000_000);
1005
1006        let back: std::time::SystemTime = ts.try_into().unwrap();
1007        assert_eq!(back, st);
1008    }
1009
1010    #[test]
1011    fn timestamp_pre_epoch_exact_second() {
1012        // Exactly 5 seconds before epoch
1013        let st = std::time::UNIX_EPOCH - std::time::Duration::new(5, 0);
1014        let ts: Timestamp = st.into();
1015        assert_eq!(ts.seconds(), -5);
1016        assert_eq!(ts.nanos(), 0);
1017
1018        let back: std::time::SystemTime = ts.try_into().unwrap();
1019        assert_eq!(back, st);
1020    }
1021
1022    #[test]
1023    fn timestamp_pre_epoch_rfc3339_round_trip() {
1024        let ts = Timestamp::new(-2, 750_000_000);
1025        let payload = ts
1026            .to_search_attribute_payload(IndexedValueType::Datetime)
1027            .unwrap();
1028        let decoded = Timestamp::from_search_attribute_payload(&payload).unwrap();
1029        assert_eq!(decoded.seconds(), ts.seconds());
1030        assert_eq!(decoded.nanos(), ts.nanos());
1031    }
1032
1033    #[test]
1034    #[should_panic(expected = "search attribute serialization failed")]
1035    fn value_set_panics_on_nan() {
1036        FLOAT_KEY.value_set(f64::NAN);
1037    }
1038
1039    #[test]
1040    #[should_panic(expected = "search attribute serialization failed")]
1041    fn value_set_panics_on_infinity() {
1042        FLOAT_KEY.value_set(f64::INFINITY);
1043    }
1044
1045    #[test]
1046    fn try_value_set_returns_error_on_nan() {
1047        let result = FLOAT_KEY.try_value_set(f64::NAN);
1048        assert!(result.is_err());
1049    }
1050
1051    #[test]
1052    fn try_value_set_returns_error_on_infinity() {
1053        let result = FLOAT_KEY.try_value_set(f64::INFINITY);
1054        assert!(result.is_err());
1055    }
1056
1057    #[test]
1058    fn round_trip_empty_string() {
1059        let val = String::new();
1060        let payload = val
1061            .to_search_attribute_payload(IndexedValueType::Keyword)
1062            .unwrap();
1063        assert_eq!(String::from_search_attribute_payload(&payload).unwrap(), "");
1064    }
1065
1066    #[test]
1067    fn round_trip_empty_keyword_list() {
1068        let val: Vec<String> = vec![];
1069        let payload = val
1070            .to_search_attribute_payload(IndexedValueType::KeywordList)
1071            .unwrap();
1072        assert_eq!(
1073            Vec::<String>::from_search_attribute_payload(&payload).unwrap(),
1074            Vec::<String>::new()
1075        );
1076    }
1077
1078    #[test]
1079    fn round_trip_large_int_boundaries() {
1080        for val in [i64::MAX, i64::MIN, 0i64] {
1081            let payload = val
1082                .to_search_attribute_payload(IndexedValueType::Int)
1083                .unwrap();
1084            assert_eq!(i64::from_search_attribute_payload(&payload).unwrap(), val);
1085        }
1086    }
1087
1088    #[test]
1089    fn decode_missing_encoding_metadata() {
1090        let payload = Payload {
1091            metadata: HashMap::new(),
1092            data: b"true".to_vec(),
1093            ..Default::default()
1094        };
1095        let result = bool::from_search_attribute_payload(&payload);
1096        assert!(result.is_err());
1097    }
1098
1099    #[test]
1100    fn decode_wrong_encoding_metadata() {
1101        let mut metadata = HashMap::new();
1102        metadata.insert("encoding".to_string(), b"binary/plain".to_vec());
1103        let payload = Payload {
1104            metadata,
1105            data: b"true".to_vec(),
1106            ..Default::default()
1107        };
1108        let result = bool::from_search_attribute_payload(&payload);
1109        assert!(result.is_err());
1110    }
1111
1112    #[test]
1113    fn decode_garbage_json_data() {
1114        let mut metadata = HashMap::new();
1115        metadata.insert("encoding".to_string(), b"json/plain".to_vec());
1116        let payload = Payload {
1117            metadata,
1118            data: b"not-valid-json!!!".to_vec(),
1119            ..Default::default()
1120        };
1121        let result = bool::from_search_attribute_payload(&payload);
1122        assert!(result.is_err());
1123    }
1124
1125    #[test]
1126    fn search_attribute_keys_have_replay_stable_order() {
1127        let attrs = SearchAttributes::from(ProtoSearchAttributes {
1128            indexed_fields: HashMap::from([
1129                ("zebra".to_owned(), Payload::default()),
1130                ("alpha".to_owned(), Payload::default()),
1131                ("middle".to_owned(), Payload::default()),
1132            ]),
1133        });
1134
1135        assert_eq!(
1136            attrs.keys().collect::<Vec<_>>(),
1137            vec!["alpha", "middle", "zebra"]
1138        );
1139    }
1140
1141    #[test]
1142    fn raw_payload_returns_payload() {
1143        let attrs = SearchAttributes::new([BOOL_KEY.value_set(true)]);
1144        let payload = attrs.raw_payload("my_bool").unwrap();
1145        assert!(!payload.data.is_empty());
1146        assert!(attrs.raw_payload("nonexistent").is_none());
1147    }
1148
1149    #[test]
1150    fn into_proto_moves_without_clone() {
1151        let attrs = SearchAttributes::new([INT_KEY.value_set(7)]);
1152        let proto = attrs.into_proto();
1153        assert_eq!(proto.indexed_fields.len(), 1);
1154    }
1155
1156    #[test]
1157    fn search_attribute_key_is_copy() {
1158        let key = BOOL_KEY;
1159        let key2 = key; // Copy, not move
1160        assert_eq!(key.name(), key2.name());
1161    }
1162
1163    #[test]
1164    fn timestamp_new_clamps_negative_nanos() {
1165        let ts = Timestamp::new(100, -42);
1166        assert_eq!(ts.seconds(), 100);
1167        assert_eq!(ts.nanos(), 0); // clamped to 0
1168    }
1169
1170    #[test]
1171    fn timestamp_new_clamps_excessive_nanos() {
1172        let ts = Timestamp::new(100, 2_000_000_000);
1173        assert_eq!(ts.seconds(), 100);
1174        assert_eq!(ts.nanos(), 999_999_999); // clamped to MAX_NANOS
1175    }
1176
1177    #[test]
1178    fn timestamp_to_prost_round_trips() {
1179        let ts = Timestamp::new(1_700_000_000, 123_456_789);
1180        let prost_ts = ts.to_prost();
1181        assert_eq!(prost_ts.seconds, 1_700_000_000);
1182        assert_eq!(prost_ts.nanos, 123_456_789);
1183        let back: Timestamp = prost_ts.into();
1184        assert_eq!(back, ts);
1185    }
1186
1187    #[test]
1188    fn apply_inserts_and_removes() {
1189        let mut attrs = SearchAttributes::new([INT_KEY.value_set(42)]);
1190        assert_eq!(attrs.get(&INT_KEY), Some(42));
1191
1192        // Apply an update that changes the value
1193        attrs.apply(INT_KEY.value_set(99));
1194        assert_eq!(attrs.get(&INT_KEY), Some(99));
1195
1196        // Apply an unset
1197        attrs.apply(INT_KEY.value_unset());
1198        assert_eq!(attrs.get(&INT_KEY), None);
1199        assert!(attrs.is_empty());
1200    }
1201
1202    #[test]
1203    fn from_owned_proto_moves_without_clone() {
1204        let proto = ProtoSearchAttributes {
1205            indexed_fields: {
1206                let mut m = HashMap::new();
1207                m.insert("k".to_string(), INT_KEY.value_set(7).payload.unwrap());
1208                m
1209            },
1210        };
1211        let attrs: SearchAttributes = proto.into();
1212        assert_eq!(attrs.get(&SearchAttributeKey::int("k")), Some(7));
1213    }
1214
1215    #[test]
1216    fn search_attributes_equality() {
1217        let a = SearchAttributes::new([BOOL_KEY.value_set(true), INT_KEY.value_set(42)]);
1218        let b = SearchAttributes::new([BOOL_KEY.value_set(true), INT_KEY.value_set(42)]);
1219        let c = SearchAttributes::new([BOOL_KEY.value_set(false), INT_KEY.value_set(42)]);
1220        assert_eq!(a, b);
1221        assert_ne!(a, c);
1222    }
1223
1224    #[test]
1225    fn from_proto_trait_matches_from_proto_method() {
1226        let updates = [INT_KEY.value_set(99), BOOL_KEY.value_set(true)];
1227        let proto = SearchAttributes::new(updates).to_proto();
1228        let via_method = SearchAttributes::from_proto(&proto);
1229        let via_trait: SearchAttributes = proto.into();
1230        assert_eq!(via_method, via_trait);
1231    }
1232}