cuttlefish-abi 0.0.2

Wire types shared by the cuttlefish wasm host and its guest proc-blocks
Documentation
//! The wire format is a compatibility surface: the host and every guest block
//! agree on it, and they are compiled separately. These tests pin the actual
//! JSON, not just round-tripping, because a field rename that still round-trips
//! within one build would silently break every already-compiled block.

use cuttlefish_abi::{
    error_codes, Command, Envelope, Event, JobError, JobStatus, TokenAction, Usage,
};

#[test]
fn infer_command_serializes_to_the_expected_json() {
    let cmd = Command::Infer {
        prompt: "hi".into(),
        max_tokens: 32,
    };
    assert_eq!(
        serde_json::to_string(&cmd).unwrap(),
        r#"{"cmd":"infer","prompt":"hi","max_tokens":32}"#
    );
}

#[test]
fn every_command_round_trips() {
    let cases = vec![
        Command::Infer {
            prompt: "p".into(),
            max_tokens: 1,
        },
        Command::Open {
            path: "/a/b".into(),
        },
        Command::Slice {
            handle: 7,
            offset: 4096,
            len: 1024,
        },
        Command::Emit {
            progress: serde_json::json!({"done": 2}),
        },
        Command::Done {
            result: serde_json::json!({"summary": "s"}),
        },
        Command::Fail {
            code: "bad".into(),
            message: "why".into(),
        },
    ];
    for cmd in cases {
        let json = serde_json::to_string(&cmd).unwrap();
        assert_eq!(
            serde_json::from_str::<Command>(&json).unwrap(),
            cmd,
            "round trip failed for {json}"
        );
    }
}

#[test]
fn every_event_round_trips() {
    let cases = vec![
        Event::InferDone {
            text: "yo".into(),
            tokens_out: 2,
        },
        Event::Opened {
            handle: 3,
            len: 900,
        },
        Event::Sliced {
            text: "abc".into(),
            next_offset: 3,
        },
        Event::Emitted,
    ];
    for ev in cases {
        let json = serde_json::to_string(&ev).unwrap();
        assert_eq!(
            serde_json::from_str::<Event>(&json).unwrap(),
            ev,
            "round trip failed for {json}"
        );
    }
}

#[test]
fn slice_offsets_survive_beyond_u32() {
    // Offsets are u64 precisely so a job can address a file larger than the
    // guest could ever hold. If these narrow to u32, large-file support breaks
    // silently at 4 GiB rather than failing loudly.
    let cmd = Command::Slice {
        handle: 1,
        offset: u64::from(u32::MAX) + 1,
        len: 8,
    };
    let back: Command = serde_json::from_str(&serde_json::to_string(&cmd).unwrap()).unwrap();
    assert_eq!(back, cmd);
}

#[test]
fn envelope_omits_absent_optional_fields() {
    let env = Envelope {
        status: JobStatus::Completed,
        result: Some(serde_json::json!({"summary": "s"})),
        error: None,
        usage: Usage::default(),
    };
    let json = serde_json::to_string(&env).unwrap();
    assert!(
        !json.contains("error"),
        "an absent error must not serialize at all: {json}"
    );
}

#[test]
fn failed_envelope_carries_a_code_and_no_result() {
    let env = Envelope {
        status: JobStatus::Failed,
        result: None,
        error: Some(JobError {
            code: error_codes::CAPABILITY_DENIED.into(),
            message: "read not permitted".into(),
        }),
        usage: Usage::default(),
    };
    let json = serde_json::to_string(&env).unwrap();
    assert!(json.contains(r#""status":"failed""#), "{json}");
    assert!(json.contains(r#""code":"capability_denied""#), "{json}");
    assert!(
        !json.contains(r#""result""#),
        "a failed job must never carry a partial result: {json}"
    );
}

#[test]
fn token_action_maps_to_the_abi_integers() {
    // These integers cross the wasm boundary as a raw i32 return value, so the
    // mapping is part of the ABI and cannot be reordered.
    assert_eq!(TokenAction::Continue.as_i32(), 0);
    assert_eq!(TokenAction::Stop.as_i32(), 1);
    assert_eq!(TokenAction::from_i32(0), TokenAction::Continue);
    assert_eq!(TokenAction::from_i32(1), TokenAction::Stop);
}

#[test]
fn unknown_token_action_values_stop_rather_than_continue() {
    // A guest returning garbage must not be read as "keep generating" — fail
    // closed, the same posture as the capability checks.
    assert_eq!(TokenAction::from_i32(42), TokenAction::Stop);
    assert_eq!(TokenAction::from_i32(-1), TokenAction::Stop);
}

#[test]
fn terminal_statuses_are_exactly_the_finished_ones() {
    // Clients poll until a status is terminal. A terminal status missing from
    // `is_terminal` leaves them polling forever, so this pins the whole set
    // rather than spot-checking one value.
    assert!(JobStatus::Completed.is_terminal());
    assert!(JobStatus::Failed.is_terminal());
    assert!(JobStatus::Cancelled.is_terminal());
    assert!(!JobStatus::Queued.is_terminal());
    assert!(!JobStatus::Running.is_terminal());
}

#[test]
fn job_status_serializes_as_snake_case() {
    // The daemon's HTTP clients match on these strings.
    let json = serde_json::to_string(&JobStatus::Cancelled).unwrap();
    assert_eq!(json, r#""cancelled""#);
}