Spaces:
Runtime error
A newer version of the Gradio SDK is available: 6.14.0
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-rsto auto-generatesrc/ipc/generated.tsfrom the Rust types, write serde round-trip tests, and audit that every type usesrename_all = "camelCase". The hand-writtensrc/ipc/types.tsfrom 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 --workspacepasses
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-compattellsts-rsto 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.rsderivesTSin addition to its existing derives -
cargo test -p kansas -- export_ts_bindings --nocaptureruns without error and writessrc/ipc/generated.ts -
src/ipc/generated.tscontains a TypeScript interface or type for every Rust type intypes.rs - Generated interfaces use
camelCasefield names (verified byserde-compatfeature) -
src/ipc/types.tsupdated to re-export everything from./generatedβ no duplicate definitions
Serde tests
-
cargo test -p kansas -- ipc_typesruns 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::TriggerResistanceadjacent-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
structintypes.rshas#[serde(rename_all = "camelCase")] - Every
enumintypes.rshas#[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 aftersrc/ipc/types.tsis 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_bindingstest) will fail if the committed file doesn't match whatts-rswould 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)