Skip to main content

sim_lib_search_core/
lib.rs

1//! Provider-neutral, effect-free search and research records.
2//!
3//! Providers may make claims. Only verified web selectors become citations;
4//! no conversion exists from [`ProviderClaim`] to [`Citation`]. Ranking is
5//! represented as contributions and never implemented here.
6
7#![forbid(unsafe_code)]
8
9use sim_kernel::{ContentId, Datum, NumberLiteral};
10use sim_lib_net_core::normalize_retrieval_uri;
11use sim_lib_web_core::{DecodeLimits, EvidenceSelector, WebRecordError, WebRepresentation};
12use std::{error::Error, fmt};
13
14/// Network-free cookbook descriptors embedded at build time.
15pub static RECIPES: sim_cookbook::EmbeddedDir =
16    include!(concat!(env!("OUT_DIR"), "/cookbook_recipes.rs"));
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum SearchError {
20    EmptyQuery,
21    BoundExceeded(&'static str),
22    InvalidRecord(&'static str),
23    Citation(WebRecordError),
24    Wire(String),
25}
26impl fmt::Display for SearchError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        write!(f, "{self:?}")
29    }
30}
31impl Error for SearchError {}
32impl From<WebRecordError> for SearchError {
33    fn from(value: WebRecordError) -> Self {
34        Self::Citation(value)
35    }
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct SearchQuery {
40    pub text: String,
41    pub sites: Vec<SearchSite>,
42    pub language: Option<String>,
43    pub limit: u32,
44}
45impl SearchQuery {
46    pub fn checked(
47        text: String,
48        sites: Vec<SearchSite>,
49        language: Option<String>,
50        limit: u32,
51    ) -> Result<Self, SearchError> {
52        if text.trim().is_empty() {
53            return Err(SearchError::EmptyQuery);
54        }
55        if text.len() > 16_384 || sites.len() > 256 || limit == 0 || limit > 10_000 {
56            return Err(SearchError::BoundExceeded("query"));
57        }
58        Ok(Self {
59            text,
60            sites,
61            language,
62            limit,
63        })
64    }
65    pub fn to_datum(&self) -> Datum {
66        node(
67            "query",
68            vec![
69                field("text", Datum::String(self.text.clone())),
70                field(
71                    "sites",
72                    Datum::Vector(self.sites.iter().map(SearchSite::to_datum).collect()),
73                ),
74                field(
75                    "language",
76                    self.language.clone().map_or(Datum::Nil, Datum::String),
77                ),
78                field("limit", u32d(self.limit)),
79            ],
80        )
81    }
82}
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct SearchSite {
85    pub domain: String,
86    pub include_subdomains: bool,
87}
88impl SearchSite {
89    fn to_datum(&self) -> Datum {
90        node(
91            "site",
92            vec![
93                field("domain", Datum::String(self.domain.clone())),
94                field("include-subdomains", Datum::Bool(self.include_subdomains)),
95            ],
96        )
97    }
98}
99
100/// A provider's unverified title/snippet statement.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct ProviderClaim {
103    pub provider: String,
104    pub uri: String,
105    pub title: Option<String>,
106    pub snippet: Option<String>,
107    pub position: Option<u32>,
108}
109/// Retrieval identity observed independently of a provider claim.
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct SearchObservation {
112    pub retrieval_uri: String,
113    pub claim: Option<ProviderClaim>,
114    pub capture_id: Option<ContentId>,
115}
116impl SearchObservation {
117    pub fn checked(
118        uri: &str,
119        claim: Option<ProviderClaim>,
120        capture_id: Option<ContentId>,
121    ) -> Result<Self, SearchError> {
122        Ok(Self {
123            retrieval_uri: normalize_retrieval_uri(uri)
124                .map_err(|e| SearchError::Wire(e.to_string()))?
125                .as_str()
126                .to_owned(),
127            claim,
128            capture_id,
129        })
130    }
131}
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct SearchPage {
134    pub query: SearchQuery,
135    pub observations: Vec<SearchObservation>,
136    pub continuation: Option<String>,
137}
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct SearchNotice {
140    pub code: String,
141    pub message: String,
142}
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct AliasEvidence {
145    pub left_uri: String,
146    pub right_uri: String,
147    pub basis: String,
148    pub evidence_id: ContentId,
149}
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub struct RankContribution {
152    pub observation: u32,
153    pub contributor: String,
154    pub score: NumberLiteral,
155    pub reason: String,
156}
157#[derive(Clone, Debug, PartialEq, Eq)]
158pub struct SearchRun {
159    pub query: SearchQuery,
160    pub pages: Vec<SearchPage>,
161    pub notices: Vec<SearchNotice>,
162    pub aliases: Vec<AliasEvidence>,
163    pub rank: Vec<RankContribution>,
164}
165
166/// A checked citation can only be built from a matching representation selector.
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct Citation {
169    pub selector: EvidenceSelector,
170}
171impl Citation {
172    pub fn checked(
173        rep: &WebRepresentation,
174        selector: EvidenceSelector,
175    ) -> Result<Self, SearchError> {
176        selector.verify(rep)?;
177        Ok(Self { selector })
178    }
179    pub fn to_datum(&self) -> Datum {
180        node(
181            "citation",
182            vec![field("selector", self.selector.to_datum())],
183        )
184    }
185    pub fn from_datum(
186        value: &Datum,
187        rep: &WebRepresentation,
188        limits: DecodeLimits,
189    ) -> Result<Self, SearchError> {
190        let Datum::Node { tag, fields } = value else {
191            return Err(SearchError::InvalidRecord("citation"));
192        };
193        if tag != &sym("citation") || fields.len() != 1 || fields[0].0 != sym("selector") {
194            return Err(SearchError::InvalidRecord("citation"));
195        }
196        Self::checked(
197            rep,
198            EvidenceSelector::from_datum(&fields[0].1, rep, limits)?,
199        )
200    }
201}
202#[derive(Clone, Debug, PartialEq, Eq)]
203pub struct ResearchBundle {
204    pub run: SearchRun,
205    pub representations: Vec<ContentId>,
206    pub citations: Vec<Citation>,
207    pub notices: Vec<SearchNotice>,
208}
209
210/// Pure object-safe provider wire boundary. Implementations own syntax only.
211pub trait SearchWireCodec {
212    fn codec_id(&self) -> &str;
213    fn codec_version(&self) -> &str;
214    fn encode_request(
215        &self,
216        request: &SearchQuery,
217        limits: DecodeLimits,
218    ) -> Result<Vec<u8>, SearchError>;
219    fn decode_config(&self, input: &[u8], limits: DecodeLimits) -> Result<Datum, SearchError>;
220    fn decode_response(
221        &self,
222        input: &[u8],
223        request: &SearchQuery,
224        limits: DecodeLimits,
225    ) -> Result<SearchPage, SearchError>;
226}
227
228mod wire;
229
230pub use wire::{RECORD_DESCRIPTORS, RecordDescriptor};
231use wire::{field, node, sym, u32d};
232
233#[cfg(test)]
234mod tests;