Ashiedu's picture
Sync unified workbench
0490201 verified

A newer version of the Gradio SDK is available: 6.14.0

Upgrade

T-010: Serde Type Hardening β€” ts-rs Auto-Generation, Round-Trip Tests, camelCase Audit

Type: Task
Phase: 0 β€” Foundation
Autonomy: agent:autonomous β€” Mechanical additions to existing types, fully specified.
Stack: stack:rust
Version: v0.1
Iteration: iter-1
Effort: S (half-day)


⚠️ Agent Scope: T-002 wrote all IPC types by hand and manually mirrored them in TypeScript. T-010 eliminates the hand-maintenance burden: add ts-rs to auto-generate src/ipc/generated.ts from the Rust types, write serde round-trip tests, and audit that every type uses rename_all = "camelCase". The hand-written src/ipc/types.ts from T-002 becomes a compatibility re-export β€” it does not disappear, it delegates to generated types.


Context

The single most common source of runtime bugs in Tauri apps is a Rust struct field named some_field serializing as "some_field" while TypeScript code reads .someField β€” a mismatch that produces silent undefined at runtime, not a compile error on either side. T-002 addressed this with serde(rename_all = "camelCase") on the Rust side and careful hand-written TypeScript interfaces. T-010 replaces "careful hand-written" with "automatically verified" β€” ts-rs generates the TypeScript interfaces from the same derive attributes that drive serialization, so they are guaranteed to stay in sync.


Prerequisites

  • T-002 merged β€” all types exist in kansas/src/ipc/types.rs
  • cargo check --workspace passes

New Workspace Dependencies

Add to root Cargo.toml [workspace.dependencies]:

ts-rs = { version = "*", features = ["serde-compat", "no-serde-warnings"] }

Add to kansas/Cargo.toml [dependencies]:

ts-rs = { workspace = true }

serde-compat tells ts-rs to respect #[serde(rename_all)], #[serde(tag)], and #[serde(content)] attributes when generating TypeScript β€” essential since all our types use these attributes.


Acceptance Criteria

Auto-generation

  • Every type in kansas/src/ipc/types.rs derives TS in addition to its existing derives
  • cargo test -p kansas -- export_ts_bindings --nocapture runs without error and writes src/ipc/generated.ts
  • src/ipc/generated.ts contains a TypeScript interface or type for every Rust type in types.rs
  • Generated interfaces use camelCase field names (verified by serde-compat feature)
  • src/ipc/types.ts updated to re-export everything from ./generated β€” no duplicate definitions

Serde tests

  • cargo test -p kansas -- ipc_types runs the round-trip test suite and passes
  • Every struct type has at least one round-trip test: serialize β†’ JSON string β†’ deserialize β†’ assert_eq!
  • Every enum variant is exercised in at least one test
  • HapticKind::TriggerResistance adjacent-tagged round-trip is tested (most complex variant)
  • All JSON snapshots use camelCase keys β€” no snake_case keys in any test assertion

camelCase audit

  • Every struct in types.rs has #[serde(rename_all = "camelCase")]
  • Every enum in types.rs has #[serde(rename_all = "camelCase")]
  • No struct field or enum variant uses #[serde(rename = "...")] with a snake_case value

Build

  • cargo check --workspace β€” zero errors
  • cargo clippy --workspace -- -D warnings β€” zero warnings
  • pnpm typecheck β€” zero errors after src/ipc/types.ts is updated to re-export

Changes to kansas/src/ipc/types.rs

Add use ts_rs::TS; at the top, then add TS to every derive. Example for three representative types:

use ts_rs::TS;

// ─── before (T-002) ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransportSetCmd { /* ... */ }

// ─── after (T-010) ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct TransportSetCmd { /* ... */ }

Apply TS + #[ts(export)] to every public type. The #[ts(export)] attribute tells ts-rs to include this type in the export bundle. Types that are only used internally (not crossing IPC) do not need it, but all types in ipc/types.rs do.

Types that need special ts annotations

ts-rs handles most serde attributes automatically via serde-compat, but these three patterns need explicit attention:

serde_json::Value fields (used in ModelInferCmd):

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ModelInferCmd {
    pub model: ModelId,
    #[ts(type = "unknown")]   // serde_json::Value β†’ TypeScript unknown
    pub input: serde_json::Value,
}

Fixed-size arrays (StyleBlendSetCmd):

#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct StyleBlendSetCmd {
    pub weights: [f32; 6],  // ts-rs generates: weights: [number, number, number, number, number, number]
}

Option<[f32; 2]> in ControllerStateEvent:

#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub touchpad: Option<[f32; 2]>,

New File: kansas/src/ipc/tests.rs

#[cfg(test)]
mod tests {
    use serde_json::{from_str, to_string};
    use crate::ipc::types::*;

    // ── Helper ────────────────────────────────────────────────────────────────

    fn roundtrip<T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug>(
        value: &T,
    ) {
        let json = to_string(value).expect("serialize failed");
        let restored: T = from_str(&json).expect("deserialize failed");
        assert_eq!(value, &restored, "round-trip mismatch\nJSON: {json}");
    }

    fn assert_key<T: serde::Serialize>(value: &T, expected_key: &str) {
        let json = to_string(value).expect("serialize failed");
        assert!(
            json.contains(&format!("\"{expected_key}\"")),
            "Expected key \"{expected_key}\" in JSON: {json}"
        );
    }

    // ── Transport ─────────────────────────────────────────────────────────────

    #[test]
    fn transport_state_camel_case() {
        let s = TransportState::Playing;
        let json = to_string(&s).unwrap();
        assert_eq!(json, r#"{"kind":"playing"}"#);
    }

    #[test]
    fn transport_set_cmd_roundtrip() {
        let cmd = TransportSetCmd {
            state: Some(TransportState::Playing),
            bpm: Some(128.0),
            time_sig_numerator: Some(4),
            time_sig_denominator: Some(4),
        };
        roundtrip(&cmd);
        assert_key(&cmd, "timeSigNumerator"); // not "time_sig_numerator"
    }

    #[test]
    fn transport_set_cmd_partial() {
        // Only bpm set β€” state/time_sig fields must be absent (skip_serializing_if)
        let cmd = TransportSetCmd { bpm: Some(140.0), ..Default::default() };
        let json = to_string(&cmd).unwrap();
        assert!(!json.contains("state"), "Absent optional should not appear: {json}");
        assert!(json.contains("\"bpm\""), "bpm must appear: {json}");
    }

    #[test]
    fn transport_tick_event_roundtrip() {
        let evt = TransportTickEvent {
            bar: 5, beat: 3, tick: 47,
            bpm: 128.0, elapsed_ms: 12500, is_downbeat: false,
        };
        roundtrip(&evt);
        assert_key(&evt, "elapsedMs");
        assert_key(&evt, "isDownbeat");
    }

    // ── Controllers ───────────────────────────────────────────────────────────

    #[test]
    fn controller_id_camel_case() {
        let id = ControllerId::DualSense;
        assert_eq!(to_string(&id).unwrap(), r#"{"kind":"dualSense"}"#);

        let id = ControllerId::JoyConLeft;
        assert_eq!(to_string(&id).unwrap(), r#"{"kind":"joyConLeft"}"#);
    }

    #[test]
    fn haptic_beat_pulse_roundtrip() {
        let cmd = HapticCmd {
            controller: ControllerId::DualSense,
            kind: HapticKind::BeatPulse { intensity: 0.8 },
        };
        roundtrip(&cmd);
        let json = to_string(&cmd).unwrap();
        // Adjacent tag: { "type": "beatPulse", "data": { "intensity": 0.8 } }
        assert!(json.contains(r#""type":"beatPulse""#), "Adjacent tag wrong: {json}");
        assert!(json.contains(r#""data""#), "Adjacent content wrong: {json}");
    }

    #[test]
    fn haptic_trigger_resistance_roundtrip() {
        let cmd = HapticCmd {
            controller: ControllerId::DualSense,
            kind: HapticKind::TriggerResistance {
                trigger: TriggerSide::Right,
                force: 0.7,
                start_pos: 0.3,
            },
        };
        roundtrip(&cmd);
        let json = to_string(&cmd).unwrap();
        assert!(json.contains(r#""triggerResistance""#), "Variant name wrong: {json}");
        assert!(json.contains(r#""startPos""#), "Field camelCase wrong: {json}");
    }

    #[test]
    fn haptic_chunk_boundary_unit_variant() {
        // Unit variant with adjacent tagging should produce { "type": "chunkBoundary", "data": {} }
        let kind = HapticKind::ChunkBoundary;
        let json = to_string(&kind).unwrap();
        assert!(json.contains(r#""chunkBoundary""#), "Unit variant wrong: {json}");
    }

    // ── Models ────────────────────────────────────────────────────────────────

    #[test]
    fn model_id_roundtrip() {
        let id = ModelId::PerfRnnStep;
        assert_eq!(to_string(&id).unwrap(), r#"{"kind":"perfRnnStep"}"#);
    }

    #[test]
    fn model_infer_cmd_roundtrip() {
        let cmd = ModelInferCmd {
            model: ModelId::MusicVaeEncoder,
            input: serde_json::json!({ "latentDim": 512, "temperature": 0.5 }),
        };
        roundtrip(&cmd);
    }

    // ── Skeleton Pose ─────────────────────────────────────────────────────────

    #[test]
    fn skeleton_pose_event_roundtrip() {
        let evt = SkeletonPoseEvent {
            landmarks: vec![PoseLandmark { x: 0.5, y: 0.5, z: 0.0, visibility: 0.99 }; 33],
            timestamp_ms: 1234567,
            detected: true,
        };
        roundtrip(&evt);
        assert_key(&evt, "timestampMs");
    }

    // ── Style blend ───────────────────────────────────────────────────────────

    #[test]
    fn style_blend_fixed_array() {
        let cmd = StyleBlendSetCmd { weights: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] };
        roundtrip(&cmd);
        let json = to_string(&cmd).unwrap();
        // Verify array serializes as JSON array, not object
        assert!(json.contains("[0.1"), "weights must be a JSON array: {json}");
    }

    // ── Generation source (nested enum) ───────────────────────────────────────

    #[test]
    fn generation_source_perf_rnn_roundtrip() {
        let src = GenerationSource::PerfRnn { channel: RnnChannel::Lead };
        let json = to_string(&src).unwrap();
        assert!(json.contains(r#""kind":"perfRnn""#), "Wrong kind: {json}");
        assert!(json.contains(r#""lead""#), "Wrong channel: {json}");
        let restored: GenerationSource = from_str(&json).unwrap();
        assert_eq!(
            to_string(&src).unwrap(),
            to_string(&restored).unwrap()
        );
    }

    // ── ts-rs export ──────────────────────────────────────────────────────────

    #[test]
    fn export_ts_bindings() {
        // Regenerates src/ipc/generated.ts from all #[ts(export)] types.
        // Run this test whenever types.rs changes.
        // CI runs this via: cargo test -p kansas -- export_ts_bindings
        TransportSetCmd::export_all_to("../src/ipc/").expect("ts-rs export failed");
    }
}

Add the module declaration to kansas/src/ipc/mod.rs:

pub mod types;
pub mod error;
#[cfg(test)]
mod tests;

Update src/ipc/types.ts

After export_ts_bindings generates src/ipc/generated.ts, update types.ts to re-export from it:

// src/ipc/types.ts
// Auto-generated types live in generated.ts (produced by: cargo test -p kansas -- export_ts_bindings)
// This file re-exports them for backwards compatibility with existing imports.
// DO NOT edit interfaces here β€” edit the Rust types in kansas/src/ipc/types.rs instead.

export * from './generated';

// ── Helpers that are not auto-generated ──────────────────────────────────────
// (parseIpcError stays here β€” it's a function, not a type)

export type IpcErrorKind =
  | 'notImplemented' | 'invalidParam' | 'deviceNotFound'
  | 'modelNotLoaded' | 'audioError' | 'internal';

export interface IpcError { kind: IpcErrorKind; message: string; }

export function parseIpcError(raw: unknown): IpcError {
  if (typeof raw === 'string') {
    try { return JSON.parse(raw) as IpcError; } catch { /* fall through */ }
  }
  return { kind: 'internal', message: String(raw) };
}

Add task check:types to Taskfile

T-007 owns the Taskfile β€” add one line to the check target body:

check:
  cmds:
    - cargo check --workspace
    - pnpm typecheck
    - cargo test -p kansas -- export_ts_bindings  # regenerates src/ipc/generated.ts
    - echo "[check] βœ“ Type checks passed"

And add a standalone target:

check:types:
  desc: "Regenerate TypeScript bindings from Rust types (ts-rs)"
  cmd: cargo test -p kansas -- export_ts_bindings --nocapture

Implementation Notes

Why ts-rs over manual TypeScript mirrors

Manual mirror maintenance fails silently. A developer renames elapsed_ms to elapsed_micros in Rust, forgets to update TypeScript, and the frontend reads undefined for months until someone bisects a production bug. ts-rs generates TypeScript at test time β€” cargo test fails if the generated file is out of sync with what's committed.

Generated file committed to git

src/ipc/generated.ts is committed. This means:

  • PRs that change Rust types will diff the generated TS β€” reviewers see both sides in one PR
  • The CI check (export_ts_bindings test) will fail if the committed file doesn't match what ts-rs would generate

Add a CI step note in T-008: after this task merges, add cargo test -p kansas -- export_ts_bindings to the CI check job to enforce this.

serde_json::Value β†’ unknown

ts-rs cannot infer a TypeScript type for serde_json::Value without the #[ts(type = "unknown")] annotation. Without it, the generate step panics. The annotation is applied in ModelInferCmd and ModelInferResult.


Testing

cargo test -p kansas -- ipc_types --nocapture   # all round-trip tests
cargo test -p kansas -- export_ts_bindings      # regenerates generated.ts
pnpm typecheck                                  # verifies generated.ts is valid TS

GitHub CLI

gh issue create \
  --title "T-010: Serde type hardening β€” ts-rs auto-generation, round-trip tests, camelCase audit" \
  --label "type:task,stack:rust,agent:autonomous,priority:high,status:ready,day:1" \
  --body-file T-010.md

Parent: GENESIS
Blocks: T-008 (add export_ts_bindings step to CI check job), T-002b (Conductor Agent tools use generated types)
Blocked By: T-002
Version: v0.1 Β· Iteration: iter-1 Β· Effort: S (half-day)