nerve-ipc 0.2.0

Binary framing protocol for local IPC over Unix Domain Sockets
Documentation
//! V1 message-level schema for NERVE.
//!
//! Each NERVE frame carries a binary header (see `frame.rs`) followed by a
//! JSON payload defined here.  JSON is used in V1 for debuggability and for
//! easy implementation from JavaScript.
//!
//! # Message directions
//!
//! | `MessageType`   | Wire byte | Direction                      |
//! |-----------------|-----------|-------------------------------|
//! | Ping            | 0x01      | bidirectional                  |
//! | `SearchQuery`   | 0x02      | browser extension → AI daemon  |
//! | `SearchResult`  | 0x03      | AI daemon → browser extension  |
//! | `AiToken`       | 0x04      | AI daemon → browser extension  |
//! | Cancel          | 0x05      | browser extension → AI daemon  |
//!
//! # Payload size guidance
//!
//! The NERVE framing layer enforces a hard limit of 1 MiB per frame.
//! `SearchQuery` context fields (`extract`, `selection`) should contain
//! bounded, pre-processed excerpts — not raw DOM HTML.  Browser-side
//! extraction is responsible for keeping these fields small (target < 4 KiB).
//!
//! # Versioning
//!
//! `SearchQuery` carries a `v` field (currently always `1`).  Implementations
//! MUST reject payloads with `v != 1` rather than silently treating them as V1.

use serde::{Deserialize, Serialize};

/// The only supported message schema version.
pub const MESSAGE_VERSION: u8 = 1;

/// Maximum number of search results per response.
pub const MAX_RESULTS: u32 = 100;

// ─── Error ───────────────────────────────────────────────────────────────────

/// Errors that can occur when encoding or decoding a NERVE message payload.
#[non_exhaustive]
#[derive(Debug)]
pub enum MessageError {
    /// The JSON payload could not be serialized or deserialized.
    Json(serde_json::Error),
    /// The `v` field in the payload does not equal [`MESSAGE_VERSION`].
    UnsupportedVersion(u8),
    /// The `query` field is an empty string.
    EmptyQuery,
    /// The `max_results` field is outside `1..=MAX_RESULTS`.
    MaxResultsOutOfRange(u32),
}

impl std::fmt::Display for MessageError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MessageError::Json(e) => write!(f, "JSON error: {e}"),
            MessageError::UnsupportedVersion(v) => {
                write!(
                    f,
                    "unsupported message version: {v} (expected {MESSAGE_VERSION})"
                )
            }
            MessageError::EmptyQuery => write!(f, "query field must not be empty"),
            MessageError::MaxResultsOutOfRange(n) => {
                write!(f, "max_results {n} is out of range (1..={MAX_RESULTS})")
            }
        }
    }
}

impl std::error::Error for MessageError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            MessageError::Json(e) => Some(e),
            _ => None,
        }
    }
}

impl From<serde_json::Error> for MessageError {
    fn from(e: serde_json::Error) -> Self {
        MessageError::Json(e)
    }
}

// ─── SearchQuery (0x02) ──────────────────────────────────────────────────────

/// Page context sent alongside a search query.
///
/// `url` and `title` are always present.  `selection` is the user's highlighted
/// text (if any).  `extract` is a bounded text excerpt from the page body
/// (target: < 4 KiB; browser-side extraction is responsible for truncation).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchContext {
    pub url: String,
    pub title: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub selection: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extract: Option<String>,
}

/// Options controlling how the daemon handles a search query.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchOptions {
    /// Whether to perform a web search (vs. local-only inference).
    pub search: bool,
    /// Maximum number of results to return (1..=`MAX_RESULTS`).
    pub max_results: u32,
}

impl Default for SearchOptions {
    fn default() -> Self {
        Self {
            search: true,
            max_results: 10,
        }
    }
}

/// Payload for `MessageType::SearchQuery` (0x02).
///
/// Direction: browser extension → AI daemon.
///
/// JSON schema:
/// ```json
/// {
///   "v": 1,
///   "query": "rust async io",
///   "context": {
///     "url": "https://example.com/page",
///     "title": "Example Page",
///     "selection": "optional highlighted text",
///     "extract": "optional page body excerpt"
///   },
///   "opts": {
///     "search": true,
///     "max_results": 10
///   }
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchQuery {
    /// Schema version — must be `1`.
    pub v: u8,
    /// The user's search query (non-empty).
    pub query: String,
    pub context: SearchContext,
    pub opts: SearchOptions,
}

impl SearchQuery {
    /// Validates field constraints after deserialization.
    ///
    /// # Errors
    ///
    /// | Condition | Error |
    /// |-----------|-------|
    /// | `v != 1` | [`MessageError::UnsupportedVersion`] |
    /// | `query` is empty | [`MessageError::EmptyQuery`] |
    /// | `max_results == 0` or `> MAX_RESULTS` | [`MessageError::MaxResultsOutOfRange`] |
    pub fn validate(&self) -> Result<(), MessageError> {
        if self.v != MESSAGE_VERSION {
            return Err(MessageError::UnsupportedVersion(self.v));
        }
        if self.query.is_empty() {
            return Err(MessageError::EmptyQuery);
        }
        if self.opts.max_results == 0 || self.opts.max_results > MAX_RESULTS {
            return Err(MessageError::MaxResultsOutOfRange(self.opts.max_results));
        }
        Ok(())
    }
}

// ─── SearchResult (0x03) ─────────────────────────────────────────────────────

/// A single ranked search result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchResultItem {
    pub url: String,
    pub title: String,
    pub snippet: String,
    /// Relevance score (higher is more relevant).
    pub score: f32,
}

/// Payload for `MessageType::SearchResult` (0x03).
///
/// Direction: AI daemon → browser extension.
///
/// The NERVE frame's `request_id` links this response to the originating
/// `SearchQuery`.  This is a complete (non-streaming) response; the FINAL
/// flag is set on its frame.
///
/// JSON schema:
/// ```json
/// {
///   "results": [
///     { "url": "…", "title": "…", "snippet": "…", "score": 0.92 }
///   ],
///   "took_ms": 34
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchResult {
    pub results: Vec<SearchResultItem>,
    /// Wall-clock time for the search in milliseconds.
    pub took_ms: u64,
}

// ─── AiToken (0x04) ──────────────────────────────────────────────────────────

/// Payload for `MessageType::AiToken` (0x04).
///
/// Direction: AI daemon → browser extension.
///
/// Tokens are streamed using the NERVE STREAM / FINAL flags:
///
/// ```text
/// AiToken + STREAM
/// AiToken + STREAM
/// AiToken + FINAL       ← last token for this request_id
/// ```
///
/// The NERVE `request_id` identifies which inference stream this token belongs
/// to.  Do not implement a second streaming protocol inside the payload.
///
/// JSON schema:
/// ```json
/// { "t": "Hello" }
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AiToken {
    /// The token text (UTF-8).  May be empty for the FINAL sentinel.
    pub t: String,
}

// ─── Cancel (0x05) ───────────────────────────────────────────────────────────
//
// Cancel carries an empty payload.  The target request is identified solely
// by the `request_id` in the NERVE frame header — no payload struct is needed.
// Encode with `encode(MessageType::Cancel, FrameFlags::FINAL, request_id, &[])`.

// ─── Codec helpers ───────────────────────────────────────────────────────────

/// Serialize a message struct to a JSON byte vector for use as a NERVE payload.
///
/// # Errors
///
/// Returns `Err` if `T`'s `Serialize` impl fails; for all types defined in this
/// crate this is infallible.
///
/// # Examples
///
/// ```
/// use nerve_ipc::message::{AiToken, encode_message};
///
/// let payload = encode_message(&AiToken { t: "Hello".into() }).unwrap();
/// assert!(!payload.is_empty());
/// ```
pub fn encode_message<T: Serialize>(msg: &T) -> Result<Vec<u8>, serde_json::Error> {
    serde_json::to_vec(msg)
}

/// Deserialize a NERVE payload into a message struct.
///
/// # Errors
///
/// Returns `Err` if `payload` is not valid JSON for type `T`.
///
/// # Examples
///
/// ```
/// use nerve_ipc::message::{AiToken, encode_message, decode_message};
///
/// let payload = encode_message(&AiToken { t: "Hello".into() }).unwrap();
/// let token: AiToken = decode_message(&payload).unwrap();
/// assert_eq!(token.t, "Hello");
/// ```
pub fn decode_message<T: for<'de> Deserialize<'de>>(
    payload: &[u8],
) -> Result<T, serde_json::Error> {
    serde_json::from_slice(payload)
}

/// Deserialize and validate a `SearchQuery` payload.
///
/// # Errors
///
/// | Condition | Error |
/// |-----------|-------|
/// | Invalid JSON | [`MessageError::Json`] |
/// | `v != 1` | [`MessageError::UnsupportedVersion`] |
/// | `query` is empty | [`MessageError::EmptyQuery`] |
/// | `max_results` out of range | [`MessageError::MaxResultsOutOfRange`] |
pub fn decode_search_query(payload: &[u8]) -> Result<SearchQuery, MessageError> {
    let q: SearchQuery = serde_json::from_slice(payload)?;
    q.validate()?;
    Ok(q)
}