Skip to main content

chio_listing/
search.rs

1//! Generic listing search surface: freshness windows, search policy, results, and divergences.
2
3use serde::{Deserialize, Serialize};
4
5use crate::util::validate_non_empty;
6use crate::{
7    GenericListingActorKind, GenericListingFreshnessState, GenericListingQuery,
8    GenericRegistryPublisher, SignedGenericListing, GENERIC_LISTING_SEARCH_ALGORITHM_V1,
9};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "camelCase")]
13pub struct GenericListingFreshnessWindow {
14    pub max_age_secs: u64,
15    pub valid_until: u64,
16}
17
18impl GenericListingFreshnessWindow {
19    pub fn validate(&self, generated_at: u64) -> Result<(), String> {
20        if self.max_age_secs == 0 {
21            return Err("freshness.max_age_secs must be greater than zero".to_string());
22        }
23        if self.valid_until <= generated_at {
24            return Err("freshness.valid_until must be greater than generated_at".to_string());
25        }
26        Ok(())
27    }
28
29    #[must_use]
30    pub fn assess(&self, generated_at: u64, now: u64) -> GenericListingReplicaFreshness {
31        let age_secs = now.saturating_sub(generated_at);
32        let state = if generated_at > now || age_secs > self.max_age_secs || now > self.valid_until
33        {
34            GenericListingFreshnessState::Stale
35        } else {
36            GenericListingFreshnessState::Fresh
37        };
38        GenericListingReplicaFreshness {
39            state,
40            age_secs,
41            max_age_secs: self.max_age_secs,
42            valid_until: self.valid_until,
43            generated_at,
44        }
45    }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[serde(rename_all = "camelCase")]
50pub struct GenericListingSearchPolicy {
51    pub algorithm: String,
52    pub reproducible_ordering: bool,
53    pub freshness_affects_ranking: bool,
54    pub visibility_only: bool,
55    pub explicit_trust_activation_required: bool,
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub ranking_inputs: Vec<String>,
58}
59
60impl Default for GenericListingSearchPolicy {
61    fn default() -> Self {
62        Self {
63            algorithm: GENERIC_LISTING_SEARCH_ALGORITHM_V1.to_string(),
64            reproducible_ordering: true,
65            freshness_affects_ranking: true,
66            visibility_only: true,
67            explicit_trust_activation_required: true,
68            ranking_inputs: vec![
69                "freshness".to_string(),
70                "status".to_string(),
71                "actor_kind".to_string(),
72                "actor_id".to_string(),
73                "published_at_desc".to_string(),
74                "publisher_role".to_string(),
75                "listing_id".to_string(),
76            ],
77        }
78    }
79}
80
81impl GenericListingSearchPolicy {
82    pub fn validate(&self) -> Result<(), String> {
83        validate_non_empty(&self.algorithm, "search_policy.algorithm")?;
84        if !self.reproducible_ordering {
85            return Err("generic listing search must remain reproducible".to_string());
86        }
87        if !self.visibility_only {
88            return Err("generic listing search must remain visibility-only".to_string());
89        }
90        if !self.explicit_trust_activation_required {
91            return Err(
92                "generic listing search must require explicit trust activation outside search"
93                    .to_string(),
94            );
95        }
96        Ok(())
97    }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[serde(rename_all = "camelCase")]
102pub struct GenericListingReplicaFreshness {
103    pub state: GenericListingFreshnessState,
104    pub age_secs: u64,
105    pub max_age_secs: u64,
106    pub valid_until: u64,
107    pub generated_at: u64,
108}
109
110impl GenericListingReplicaFreshness {
111    pub fn validate(&self) -> Result<(), String> {
112        if self.max_age_secs == 0 {
113            return Err("freshness.max_age_secs must be greater than zero".to_string());
114        }
115        if self.valid_until <= self.generated_at {
116            return Err("freshness.valid_until must be greater than generated_at".to_string());
117        }
118        Ok(())
119    }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
123#[serde(rename_all = "camelCase")]
124pub struct GenericListingSearchResult {
125    pub rank: u64,
126    pub listing: SignedGenericListing,
127    pub publisher: GenericRegistryPublisher,
128    pub freshness: GenericListingReplicaFreshness,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub replica_operator_ids: Vec<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(rename_all = "camelCase")]
135pub struct GenericListingSearchError {
136    pub operator_id: String,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub operator_name: Option<String>,
139    pub registry_url: String,
140    pub error: String,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "camelCase")]
145pub struct GenericListingDivergence {
146    pub divergence_key: String,
147    pub actor_id: String,
148    pub actor_kind: GenericListingActorKind,
149    pub publisher_operator_ids: Vec<String>,
150    pub reason: String,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154#[serde(rename_all = "camelCase")]
155pub struct GenericListingSearchResponse {
156    pub schema: String,
157    pub generated_at: u64,
158    pub query: GenericListingQuery,
159    pub search_policy: GenericListingSearchPolicy,
160    pub peer_count: u64,
161    pub reachable_count: u64,
162    pub stale_peer_count: u64,
163    pub divergence_count: u64,
164    pub result_count: u64,
165    pub results: Vec<GenericListingSearchResult>,
166    pub divergences: Vec<GenericListingDivergence>,
167    pub errors: Vec<GenericListingSearchError>,
168}