Skip to main content

nerve_ipc/
message.rs

1//! V1 message-level schema for NERVE.
2//!
3//! Each NERVE frame carries a binary header (see `frame.rs`) followed by a
4//! JSON payload defined here.  JSON is used in V1 for debuggability and for
5//! easy implementation from JavaScript.
6//!
7//! # Message directions
8//!
9//! | `MessageType`   | Wire byte | Direction                      |
10//! |-----------------|-----------|-------------------------------|
11//! | Ping            | 0x01      | bidirectional                  |
12//! | `SearchQuery`   | 0x02      | browser extension → AI daemon  |
13//! | `SearchResult`  | 0x03      | AI daemon → browser extension  |
14//! | `AiToken`       | 0x04      | AI daemon → browser extension  |
15//! | Cancel          | 0x05      | browser extension → AI daemon  |
16//!
17//! # Payload size guidance
18//!
19//! The NERVE framing layer enforces a hard limit of 1 MiB per frame.
20//! `SearchQuery` context fields (`extract`, `selection`) should contain
21//! bounded, pre-processed excerpts — not raw DOM HTML.  Browser-side
22//! extraction is responsible for keeping these fields small (target < 4 KiB).
23//!
24//! # Versioning
25//!
26//! `SearchQuery` carries a `v` field (currently always `1`).  Implementations
27//! MUST reject payloads with `v != 1` rather than silently treating them as V1.
28
29use serde::{Deserialize, Serialize};
30
31/// The only supported message schema version.
32pub const MESSAGE_VERSION: u8 = 1;
33
34/// Maximum number of search results per response.
35pub const MAX_RESULTS: u32 = 100;
36
37// ─── Error ───────────────────────────────────────────────────────────────────
38
39/// Errors that can occur when encoding or decoding a NERVE message payload.
40#[non_exhaustive]
41#[derive(Debug)]
42pub enum MessageError {
43    /// The JSON payload could not be serialized or deserialized.
44    Json(serde_json::Error),
45    /// The `v` field in the payload does not equal [`MESSAGE_VERSION`].
46    UnsupportedVersion(u8),
47    /// The `query` field is an empty string.
48    EmptyQuery,
49    /// The `max_results` field is outside `1..=MAX_RESULTS`.
50    MaxResultsOutOfRange(u32),
51}
52
53impl std::fmt::Display for MessageError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            MessageError::Json(e) => write!(f, "JSON error: {e}"),
57            MessageError::UnsupportedVersion(v) => {
58                write!(
59                    f,
60                    "unsupported message version: {v} (expected {MESSAGE_VERSION})"
61                )
62            }
63            MessageError::EmptyQuery => write!(f, "query field must not be empty"),
64            MessageError::MaxResultsOutOfRange(n) => {
65                write!(f, "max_results {n} is out of range (1..={MAX_RESULTS})")
66            }
67        }
68    }
69}
70
71impl std::error::Error for MessageError {
72    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
73        match self {
74            MessageError::Json(e) => Some(e),
75            _ => None,
76        }
77    }
78}
79
80impl From<serde_json::Error> for MessageError {
81    fn from(e: serde_json::Error) -> Self {
82        MessageError::Json(e)
83    }
84}
85
86// ─── SearchQuery (0x02) ──────────────────────────────────────────────────────
87
88/// Page context sent alongside a search query.
89///
90/// `url` and `title` are always present.  `selection` is the user's highlighted
91/// text (if any).  `extract` is a bounded text excerpt from the page body
92/// (target: < 4 KiB; browser-side extraction is responsible for truncation).
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct SearchContext {
95    pub url: String,
96    pub title: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub selection: Option<String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub extract: Option<String>,
101}
102
103/// Options controlling how the daemon handles a search query.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct SearchOptions {
106    /// Whether to perform a web search (vs. local-only inference).
107    pub search: bool,
108    /// Maximum number of results to return (1..=`MAX_RESULTS`).
109    pub max_results: u32,
110}
111
112impl Default for SearchOptions {
113    fn default() -> Self {
114        Self {
115            search: true,
116            max_results: 10,
117        }
118    }
119}
120
121/// Payload for `MessageType::SearchQuery` (0x02).
122///
123/// Direction: browser extension → AI daemon.
124///
125/// JSON schema:
126/// ```json
127/// {
128///   "v": 1,
129///   "query": "rust async io",
130///   "context": {
131///     "url": "https://example.com/page",
132///     "title": "Example Page",
133///     "selection": "optional highlighted text",
134///     "extract": "optional page body excerpt"
135///   },
136///   "opts": {
137///     "search": true,
138///     "max_results": 10
139///   }
140/// }
141/// ```
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct SearchQuery {
144    /// Schema version — must be `1`.
145    pub v: u8,
146    /// The user's search query (non-empty).
147    pub query: String,
148    pub context: SearchContext,
149    pub opts: SearchOptions,
150}
151
152impl SearchQuery {
153    /// Validates field constraints after deserialization.
154    ///
155    /// # Errors
156    ///
157    /// | Condition | Error |
158    /// |-----------|-------|
159    /// | `v != 1` | [`MessageError::UnsupportedVersion`] |
160    /// | `query` is empty | [`MessageError::EmptyQuery`] |
161    /// | `max_results == 0` or `> MAX_RESULTS` | [`MessageError::MaxResultsOutOfRange`] |
162    pub fn validate(&self) -> Result<(), MessageError> {
163        if self.v != MESSAGE_VERSION {
164            return Err(MessageError::UnsupportedVersion(self.v));
165        }
166        if self.query.is_empty() {
167            return Err(MessageError::EmptyQuery);
168        }
169        if self.opts.max_results == 0 || self.opts.max_results > MAX_RESULTS {
170            return Err(MessageError::MaxResultsOutOfRange(self.opts.max_results));
171        }
172        Ok(())
173    }
174}
175
176// ─── SearchResult (0x03) ─────────────────────────────────────────────────────
177
178/// A single ranked search result.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct SearchResultItem {
181    pub url: String,
182    pub title: String,
183    pub snippet: String,
184    /// Relevance score (higher is more relevant).
185    pub score: f32,
186}
187
188/// Payload for `MessageType::SearchResult` (0x03).
189///
190/// Direction: AI daemon → browser extension.
191///
192/// The NERVE frame's `request_id` links this response to the originating
193/// `SearchQuery`.  This is a complete (non-streaming) response; the FINAL
194/// flag is set on its frame.
195///
196/// JSON schema:
197/// ```json
198/// {
199///   "results": [
200///     { "url": "…", "title": "…", "snippet": "…", "score": 0.92 }
201///   ],
202///   "took_ms": 34
203/// }
204/// ```
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct SearchResult {
207    pub results: Vec<SearchResultItem>,
208    /// Wall-clock time for the search in milliseconds.
209    pub took_ms: u64,
210}
211
212// ─── AiToken (0x04) ──────────────────────────────────────────────────────────
213
214/// Payload for `MessageType::AiToken` (0x04).
215///
216/// Direction: AI daemon → browser extension.
217///
218/// Tokens are streamed using the NERVE STREAM / FINAL flags:
219///
220/// ```text
221/// AiToken + STREAM
222/// AiToken + STREAM
223/// AiToken + FINAL       ← last token for this request_id
224/// ```
225///
226/// The NERVE `request_id` identifies which inference stream this token belongs
227/// to.  Do not implement a second streaming protocol inside the payload.
228///
229/// JSON schema:
230/// ```json
231/// { "t": "Hello" }
232/// ```
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234pub struct AiToken {
235    /// The token text (UTF-8).  May be empty for the FINAL sentinel.
236    pub t: String,
237}
238
239// ─── Cancel (0x05) ───────────────────────────────────────────────────────────
240//
241// Cancel carries an empty payload.  The target request is identified solely
242// by the `request_id` in the NERVE frame header — no payload struct is needed.
243// Encode with `encode(MessageType::Cancel, FrameFlags::FINAL, request_id, &[])`.
244
245// ─── Codec helpers ───────────────────────────────────────────────────────────
246
247/// Serialize a message struct to a JSON byte vector for use as a NERVE payload.
248///
249/// # Errors
250///
251/// Returns `Err` if `T`'s `Serialize` impl fails; for all types defined in this
252/// crate this is infallible.
253///
254/// # Examples
255///
256/// ```
257/// use nerve_ipc::message::{AiToken, encode_message};
258///
259/// let payload = encode_message(&AiToken { t: "Hello".into() }).unwrap();
260/// assert!(!payload.is_empty());
261/// ```
262pub fn encode_message<T: Serialize>(msg: &T) -> Result<Vec<u8>, serde_json::Error> {
263    serde_json::to_vec(msg)
264}
265
266/// Deserialize a NERVE payload into a message struct.
267///
268/// # Errors
269///
270/// Returns `Err` if `payload` is not valid JSON for type `T`.
271///
272/// # Examples
273///
274/// ```
275/// use nerve_ipc::message::{AiToken, encode_message, decode_message};
276///
277/// let payload = encode_message(&AiToken { t: "Hello".into() }).unwrap();
278/// let token: AiToken = decode_message(&payload).unwrap();
279/// assert_eq!(token.t, "Hello");
280/// ```
281pub fn decode_message<T: for<'de> Deserialize<'de>>(
282    payload: &[u8],
283) -> Result<T, serde_json::Error> {
284    serde_json::from_slice(payload)
285}
286
287/// Deserialize and validate a `SearchQuery` payload.
288///
289/// # Errors
290///
291/// | Condition | Error |
292/// |-----------|-------|
293/// | Invalid JSON | [`MessageError::Json`] |
294/// | `v != 1` | [`MessageError::UnsupportedVersion`] |
295/// | `query` is empty | [`MessageError::EmptyQuery`] |
296/// | `max_results` out of range | [`MessageError::MaxResultsOutOfRange`] |
297pub fn decode_search_query(payload: &[u8]) -> Result<SearchQuery, MessageError> {
298    let q: SearchQuery = serde_json::from_slice(payload)?;
299    q.validate()?;
300    Ok(q)
301}