Ashiedu's picture
Sync unified workbench
0490201 verified
# 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]`:
```toml
ts-rs = { version = "*", features = ["serde-compat", "no-serde-warnings"] }
```
Add to `kansas/Cargo.toml` `[dependencies]`:
```toml
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:
```rust
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`):
```rust
#[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`):
```rust
#[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`:**
```rust
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub touchpad: Option<[f32; 2]>,
```
---
## New File: `kansas/src/ipc/tests.rs`
```rust
#[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`:
```rust
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:
```typescript
// 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:
```yaml
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:
```yaml
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
```bash
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
```bash
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)