nerve-ipc 0.2.0

Binary framing protocol for local IPC over Unix Domain Sockets
Documentation
use nerve_ipc::message::{
    AiToken, MAX_RESULTS, MESSAGE_VERSION, MessageError, SearchContext, SearchOptions, SearchQuery,
    SearchResult, SearchResultItem, decode_message, decode_search_query, encode_message,
};

// ── Helpers ───────────────────────────────────────────────────────────────────

fn minimal_context() -> SearchContext {
    SearchContext {
        url: "https://example.com".into(),
        title: "Example".into(),
        selection: None,
        extract: None,
    }
}

fn minimal_query() -> SearchQuery {
    SearchQuery {
        v: 1,
        query: "rust async io".into(),
        context: minimal_context(),
        opts: SearchOptions::default(),
    }
}

// ── SearchQuery round-trips ───────────────────────────────────────────────────

#[test]
fn search_query_minimal_roundtrip() {
    let q = minimal_query();
    let bytes = encode_message(&q).unwrap();
    let q2: SearchQuery = decode_message(&bytes).unwrap();
    assert_eq!(q, q2);
}

#[test]
fn search_query_full_context_roundtrip() {
    let q = SearchQuery {
        v: 1,
        query: "what is the capital of France?".into(),
        context: SearchContext {
            url: "https://example.com/page".into(),
            title: "Example Page".into(),
            selection: Some("highlighted text here".into()),
            extract: Some("some extracted body content".into()),
        },
        opts: SearchOptions {
            search: true,
            max_results: 20,
        },
    };
    let bytes = encode_message(&q).unwrap();
    let q2: SearchQuery = decode_message(&bytes).unwrap();
    assert_eq!(q, q2);
}

#[test]
fn search_query_selection_optional_absent() {
    let q = SearchQuery {
        v: 1,
        query: "test".into(),
        context: SearchContext {
            url: "https://x.com".into(),
            title: "X".into(),
            selection: None,
            extract: Some("extract present".into()),
        },
        opts: SearchOptions::default(),
    };
    let bytes = encode_message(&q).unwrap();
    let q2: SearchQuery = decode_message(&bytes).unwrap();
    assert!(q2.context.selection.is_none());
    assert_eq!(q2.context.extract.as_deref(), Some("extract present"));
    // selection should be absent from JSON (skip_serializing_if = None)
    let json = String::from_utf8(bytes).unwrap();
    assert!(!json.contains("\"selection\""));
}

#[test]
fn search_query_extract_optional_absent() {
    let q = SearchQuery {
        v: 1,
        query: "test".into(),
        context: SearchContext {
            url: "https://x.com".into(),
            title: "X".into(),
            selection: Some("sel".into()),
            extract: None,
        },
        opts: SearchOptions::default(),
    };
    let bytes = encode_message(&q).unwrap();
    let json = String::from_utf8(bytes.clone()).unwrap();
    assert!(!json.contains("\"extract\""));
    let q2: SearchQuery = decode_message(&bytes).unwrap();
    assert!(q2.context.extract.is_none());
}

#[test]
fn search_query_search_disabled() {
    let q = SearchQuery {
        v: 1,
        query: "local only".into(),
        context: minimal_context(),
        opts: SearchOptions {
            search: false,
            max_results: 5,
        },
    };
    let bytes = encode_message(&q).unwrap();
    let q2: SearchQuery = decode_message(&bytes).unwrap();
    assert!(!q2.opts.search);
}

#[test]
fn search_query_max_results_preserved() {
    for mr in [1u32, 10, 50, MAX_RESULTS] {
        let q = SearchQuery {
            v: 1,
            query: "q".into(),
            context: minimal_context(),
            opts: SearchOptions {
                search: true,
                max_results: mr,
            },
        };
        let bytes = encode_message(&q).unwrap();
        let q2: SearchQuery = decode_message(&bytes).unwrap();
        assert_eq!(q2.opts.max_results, mr);
    }
}

// ── SearchQuery validation ────────────────────────────────────────────────────

#[test]
fn search_query_v1_accepted() {
    let q = minimal_query();
    assert_eq!(q.v, MESSAGE_VERSION);
    q.validate().unwrap();
}

#[test]
fn search_query_unsupported_version_rejected() {
    let mut q = minimal_query();
    q.v = 2;
    match q.validate().unwrap_err() {
        MessageError::UnsupportedVersion(v) => assert_eq!(v, 2),
        e => panic!("expected UnsupportedVersion, got {e:?}"),
    }
}

#[test]
fn search_query_version_zero_rejected() {
    let mut q = minimal_query();
    q.v = 0;
    assert!(matches!(
        q.validate().unwrap_err(),
        MessageError::UnsupportedVersion(0)
    ));
}

#[test]
fn search_query_empty_query_rejected() {
    let mut q = minimal_query();
    q.query = String::new();
    assert!(matches!(
        q.validate().unwrap_err(),
        MessageError::EmptyQuery
    ));
}

#[test]
fn search_query_max_results_zero_rejected() {
    let mut q = minimal_query();
    q.opts.max_results = 0;
    assert!(matches!(
        q.validate().unwrap_err(),
        MessageError::MaxResultsOutOfRange(0)
    ));
}

#[test]
fn search_query_max_results_over_limit_rejected() {
    let mut q = minimal_query();
    q.opts.max_results = MAX_RESULTS + 1;
    assert!(matches!(
        q.validate().unwrap_err(),
        MessageError::MaxResultsOutOfRange(_)
    ));
}

#[test]
fn decode_search_query_rejects_unknown_version() {
    let mut q = minimal_query();
    q.v = 99;
    let bytes = encode_message(&q).unwrap();
    let err = decode_search_query(&bytes).unwrap_err();
    assert!(matches!(err, MessageError::UnsupportedVersion(99)));
}

#[test]
fn decode_search_query_validates_on_decode() {
    let q = minimal_query();
    let bytes = encode_message(&q).unwrap();
    let q2 = decode_search_query(&bytes).unwrap();
    assert_eq!(q, q2);
}

// ── SearchResult round-trips ──────────────────────────────────────────────────

#[test]
fn search_result_empty_roundtrip() {
    let r = SearchResult {
        results: vec![],
        took_ms: 0,
    };
    let bytes = encode_message(&r).unwrap();
    let r2: SearchResult = decode_message(&bytes).unwrap();
    assert_eq!(r, r2);
    assert!(r2.results.is_empty());
}

#[test]
fn search_result_one_item_roundtrip() {
    let r = SearchResult {
        results: vec![SearchResultItem {
            url: "https://result.example.com".into(),
            title: "A Result".into(),
            snippet: "This is a snippet of the result.".into(),
            score: 0.95,
        }],
        took_ms: 12,
    };
    let bytes = encode_message(&r).unwrap();
    let r2: SearchResult = decode_message(&bytes).unwrap();
    assert_eq!(r2.results.len(), 1);
    assert_eq!(r2.results[0].url, "https://result.example.com");
    assert_eq!(r2.took_ms, 12);
}

#[test]
fn search_result_multiple_items_roundtrip() {
    let items: Vec<SearchResultItem> = (0..5)
        .map(|i| SearchResultItem {
            url: format!("https://example.com/{i}"),
            title: format!("Result {i}"),
            snippet: format!("Snippet {i}"),
            score: i as f32 * 0.1,
        })
        .collect();
    let r = SearchResult {
        results: items,
        took_ms: 99,
    };
    let bytes = encode_message(&r).unwrap();
    let r2: SearchResult = decode_message(&bytes).unwrap();
    assert_eq!(r2.results.len(), 5);
    assert_eq!(r2.took_ms, 99);
}

#[test]
fn search_result_score_preserved() {
    let r = SearchResult {
        results: vec![SearchResultItem {
            url: "u".into(),
            title: "t".into(),
            snippet: "s".into(),
            score: 0.123_456_8,
        }],
        took_ms: 1,
    };
    let bytes = encode_message(&r).unwrap();
    let r2: SearchResult = decode_message(&bytes).unwrap();
    // f32 round-trip through JSON loses some precision; check within tolerance.
    assert!((r2.results[0].score - 0.123_456_8f32).abs() < 1e-5);
}

#[test]
fn search_result_took_ms_zero() {
    let r = SearchResult {
        results: vec![],
        took_ms: 0,
    };
    let bytes = encode_message(&r).unwrap();
    let r2: SearchResult = decode_message(&bytes).unwrap();
    assert_eq!(r2.took_ms, 0);
}

// ── AiToken round-trips ───────────────────────────────────────────────────────

#[test]
fn ai_token_normal_roundtrip() {
    let t = AiToken {
        t: "Hello, ".into(),
    };
    let bytes = encode_message(&t).unwrap();
    let t2: AiToken = decode_message(&bytes).unwrap();
    assert_eq!(t, t2);
}

#[test]
fn ai_token_unicode_roundtrip() {
    let t = AiToken {
        t: "こんにちは 🦀 世界".into(),
    };
    let bytes = encode_message(&t).unwrap();
    let t2: AiToken = decode_message(&bytes).unwrap();
    assert_eq!(t2.t, "こんにちは 🦀 世界");
}

#[test]
fn ai_token_empty_roundtrip() {
    // An empty token is valid (e.g. FINAL sentinel frame may carry no text).
    let t = AiToken { t: String::new() };
    let bytes = encode_message(&t).unwrap();
    let t2: AiToken = decode_message(&bytes).unwrap();
    assert_eq!(t2.t, "");
}

// ── Cancel: empty payload ─────────────────────────────────────────────────────

#[test]
fn cancel_uses_empty_payload() {
    use nerve_ipc::codec::encode as frame_encode;
    use nerve_ipc::types::{FrameFlags, MessageType, RequestId};

    // Cancel carries no payload; the request_id in the frame header is
    // the sole identifier of which request to cancel.
    let buf = frame_encode(MessageType::Cancel, FrameFlags::FINAL, RequestId(42), &[]).unwrap();
    let frame = nerve_ipc::decode(&buf).unwrap();
    assert_eq!(frame.header.msg_type, MessageType::Cancel as u8);
    assert!(frame.payload.is_empty());
    assert_eq!(frame.header.request_id, 42);
}

// ── JSON field presence ───────────────────────────────────────────────────────

#[test]
fn search_query_json_has_v_field() {
    let q = minimal_query();
    let json = serde_json::to_string(&q).unwrap();
    assert!(json.contains("\"v\":1"));
}

#[test]
fn search_query_json_has_required_context_fields() {
    let q = minimal_query();
    let json = serde_json::to_string(&q).unwrap();
    assert!(json.contains("\"url\""));
    assert!(json.contains("\"title\""));
}