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