Spaces:
Runtime error
Runtime error
| # 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) | |