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