typespec-rust-emitter 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  {
2
+ "$version": 3,
2
3
  "permissions": {
3
4
  "allow": [
4
5
  "Bash(just *)",
@@ -7,5 +8,5 @@
7
8
  "Bash(cargo check)"
8
9
  ]
9
10
  },
10
- "$version": 3
11
- }
11
+ "context.fileName": "AGENTS.md"
12
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,167 @@
1
+ # TypeSpec Rust Emitter - Agent Guidelines
2
+
3
+ ## Overview
4
+
5
+ TypeSpec emitter that generates idiomatic Rust code (structs, enums, server traits) from TypeSpec specifications.
6
+
7
+ ## Build, Lint, and Test Commands
8
+
9
+ ### Standard Commands
10
+
11
+ ```bash
12
+ # Build TypeScript
13
+ npm run build # or: just build
14
+
15
+ # Run tests
16
+ npm test # or: just test
17
+ node --test 'dist/test/**/*.test.js'
18
+
19
+ # Run a single test file
20
+ node --test 'dist/test/hello.test.js'
21
+
22
+ # Run a specific test
23
+ node --test 'dist/test/hello.test.js' --test-name-pattern="emits model"
24
+
25
+ # Lint
26
+ npm run lint # Check linting
27
+ npm run lint:fix # Auto-fix linting issues
28
+
29
+ # Format code
30
+ npm run format # Format all files
31
+ npm run format:check # Check formatting without changes
32
+
33
+ # Compile TypeSpec to Rust
34
+ just compile # Runs: cd example && tsp compile .
35
+
36
+ # Verify generated Rust compiles
37
+ just check-rust # Runs: cd example/output-rust && cargo check
38
+ ```
39
+
40
+ ### Full Development Cycle
41
+
42
+ After every change, run this sequence:
43
+
44
+ ```bash
45
+ just build && npm test && just compile && just check-rust
46
+ ```
47
+
48
+ ## Project Structure
49
+
50
+ | Path | Purpose |
51
+ | ------------------------------------ | -------------------------------------------------------- |
52
+ | `src/emitter.ts` | Main emitter logic (~1500 lines) - TypeSpec→Rust codegen |
53
+ | `src/lib.tsp` | Decorator declarations (`@rustDerive`, `@rustDerives`) |
54
+ | `src/lib.ts` | Decorator implementations |
55
+ | `src/index.ts` | Public API exports |
56
+ | `src/testing/index.ts` | Test utilities |
57
+ | `test/hello.test.ts` | Unit tests using `emit()` helper |
58
+ | `test/test-host.ts` | Test host setup |
59
+ | `example/lib/` | Demo TypeSpec models & operations |
60
+ | `example/output-rust/src/generated/` | Generated Rust output |
61
+
62
+ ## Code Style
63
+
64
+ ### TypeScript
65
+
66
+ - **Strict mode enabled** - No implicit `any`
67
+ - **No `any` types** - Use `unknown` or proper generics instead
68
+ - **Explicit return types** on public functions
69
+ - **Interfaces over type aliases** for object shapes
70
+
71
+ ### Imports
72
+
73
+ - Named imports from `@typespec/compiler` first, then external packages
74
+ - Group imports: 1) `@typespec/*`, 2) external, 3) internal
75
+ - Use `import type { X }` for type-only imports when appropriate
76
+
77
+ ### Formatting (Prettier)
78
+
79
+ Configuration in `prettierrc.yaml`:
80
+
81
+ - `printWidth: 120`
82
+ - `trailingComma: "all"`
83
+ - `arrowParens: always`
84
+ - `endOfLine: lf`
85
+
86
+ Use `npm run format` before committing.
87
+
88
+ ### Naming Conventions
89
+
90
+ | Element | Convention | Example |
91
+ | --------------------------- | ---------- | -------------------- |
92
+ | TypeScript functions | camelCase | `$rustDerive` |
93
+ | TypeScript types/interfaces | PascalCase | `RustEmitterOptions` |
94
+ | Rust types | PascalCase | `pub struct User` |
95
+ | Rust functions/methods | snake_case | `get_user_by_id` |
96
+ | Internal symbols | camelCase | `rustDeriveKey` |
97
+
98
+ ### Type Mappings (TypeSpec → Rust)
99
+
100
+ | TypeSpec | Rust |
101
+ | ---------------------- | ----------------------- |
102
+ | `string` | `String` |
103
+ | `int32/64` | `i32/i64` |
104
+ | `float32/64` | `f32/f64` |
105
+ | `boolean` | `bool` |
106
+ | `T \| null` | `Option<T>` |
107
+ | `T[]` | `Vec<T>` |
108
+ | `Record<T>` | `HashMap<String, T>` |
109
+ | `@format("uuid")` | `uuid::Uuid` |
110
+ | `@format("date-time")` | `chrono::DateTime<Utc>` |
111
+
112
+ ## Error Handling
113
+
114
+ - Use `context.program.reportDiagnostic()` for errors and warnings
115
+ - Diagnostic codes should use kebab-case: `code: "rust-derive-invalid-target"`
116
+ - Always specify `severity: "error"` or `"warning"`
117
+ - Include `target: context.decoratorTarget` for location info
118
+
119
+ ## Testing Pattern
120
+
121
+ ```typescript
122
+ import { emit } from "./test-host.js";
123
+
124
+ const results = await emit(`model User { name: string; }`);
125
+ const output = results["types.rs"];
126
+ strictEqual(output.includes("pub struct User"), true);
127
+ ```
128
+
129
+ ## Server Trait Generation Rules
130
+
131
+ 1. Generates `Server<Claims>` trait with async methods per operation
132
+ 2. Handler functions: `pub async fn {op}_handler<S, Claims>(...)`
133
+ 3. All handlers use `<S, Claims>` generics (even public routes)
134
+ 4. Protected routes (`@useAuth`) receive `claims: Claims` parameter
135
+ 5. Router splits public/protected routes, merges at end
136
+ 6. Public routes include `Claims: Send + Sync + Clone + 'static` bound
137
+
138
+ ## Architecture
139
+
140
+ 1. `navigateProgram()` walks TypeSpec AST
141
+ 2. Collects models, enums, operations
142
+ 3. Processes decorators
143
+ 4. Generates Rust code via string templates
144
+ 5. Emits to `example/output-rust/src/generated/`
145
+
146
+ ## Linting
147
+
148
+ ESLint config (`eslint.config.js`):
149
+
150
+ - Uses `typescript-eslint` recommended rules
151
+ - Ignores `dist/**` and `.temp/**`
152
+ - Unused variables: warn with `_` prefix exception
153
+
154
+ ## Commit Message Style
155
+
156
+ - Use clear, descriptive titles
157
+ - Focus on "why" rather than "what"
158
+ - Keep concise (1-2 sentences)
159
+ - Example: "Fix handler generics for public routes without auth"
160
+
161
+ ## Checklist Before PR
162
+
163
+ - [ ] Run full cycle: `just build && npm test && just compile && just check-rust`
164
+ - [ ] Add tests for new features
165
+ - [ ] Run `npm run lint` and `npm run format`
166
+ - [ ] Generated Rust must compile with `cargo check`
167
+ - [ ] No TypeScript errors
package/CHANGELOG.md CHANGED
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.0] - 2026-03-31
9
+
10
+ ### Changed
11
+
12
+ - **Error `IntoResponse` now uses dynamic status code from `code` field**: Instead of mapping status codes by error type name, errors now use `StatusCode::from_str(&self.code)` to determine the HTTP status code at runtime
13
+ - This allows defining any error type with custom codes without changing the emitter
14
+ - Falls back to `INTERNAL_SERVER_ERROR` if the code is not a valid HTTP status string
15
+
16
+ ### Example
17
+
18
+ ```rust
19
+ // Error model with any code
20
+ model CustomError {
21
+ code: string; // e.g., "NOT_FOUND", "BAD_REQUEST", "MY_CUSTOM_CODE"
22
+ message: string;
23
+ }
24
+
25
+ impl IntoResponse for CustomError {
26
+ fn into_response(self) -> axum::response::Response {
27
+ (
28
+ StatusCode::from_str(&self.code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
29
+ Json(self),
30
+ )
31
+ .into_response()
32
+ }
33
+ }
34
+ ```
35
+
36
+ ### Fixed
37
+
38
+ - **Clippy warnings**: Enums and newtype structs with pattern validation now use `#[derive(Default)]` with `#[default]` attribute instead of manual `impl Default`, eliminating `clippy::derivable_impls` warnings
39
+
40
+ ### Internal
41
+
42
+ - Added `use std::str::FromStr;` import to generated types.rs
43
+ - Removed unused `getHttpStatusCodeForError()` function
44
+
45
+ ## [0.8.0] - 2026-03-30
46
+
47
+ ### Added
48
+
49
+ - **axum IntoResponse implementation for error types**: Error models (`ApiError`, `NotFoundError`, `ValidationError`, `ConflictError`) now implement `axum::response::IntoResponse`
50
+ - Error types can be returned directly from axum handlers
51
+ - Status code mapping based on error name suffix:
52
+ - `*NotFoundError` → `404 NOT_FOUND`
53
+ - `*ValidationError` → `400 BAD_REQUEST`
54
+ - `*ConflictError` → `409 CONFLICT`
55
+ - Default → `500 INTERNAL_SERVER_ERROR`
56
+
57
+ ### Example
58
+
59
+ ```rust
60
+ // Generated in types.rs
61
+ impl IntoResponse for NotFoundError {
62
+ fn into_response(self) -> axum::response::Response {
63
+ (StatusCode::NOT_FOUND, Json(self)).into_response()
64
+ }
65
+ }
66
+
67
+ // In a handler, error types can be returned directly
68
+ async fn get_item_handler<S>(/* ... */) -> impl IntoResponse {
69
+ // ...
70
+ NotFoundError { code: "NOT_FOUND".to_string(), message: "Item not found".to_string() }
71
+ }
72
+ ```
73
+
8
74
  ## [0.7.0] - 2026-03-30
9
75
 
10
76
  ### Fixed
package/README.md CHANGED
@@ -12,11 +12,11 @@ A TypeSpec emitter that generates idiomatic Rust types and structs from TypeSpec
12
12
  ## Features
13
13
 
14
14
  - **Models**: Converts TypeSpec models to Rust structs with serde derive macros
15
- - **Enums**: Supports both string and integer enums
15
+ - **Enums**: Supports both string and integer enums with `Default` derive
16
16
  - **Unions**: Handles nullable types (`T | null` → `Option<T>`) and string literal unions
17
17
  - **Scalars**: Maps TypeSpec scalars to Rust equivalents with `@format` support
18
18
  - **Inheritance**: Supports model inheritance with `getAllProperties()`
19
- - **Error Models**: Generates `thiserror::Error` derive with `#[error(...)]` attributes
19
+ - **Error Models**: Generates `thiserror::Error` derive with `#[error(...)]` attributes and `IntoResponse` impl for axum
20
20
  - **Pattern Validation**: Supports `@pattern` decorators with `TryFrom<String>` validation
21
21
  - **Custom Derives**: Add arbitrary Rust derive macros via `@rustDerive` decorator (models & enums)
22
22
  - **Custom Attributes**: Add arbitrary Rust attributes via `@rustAttr` decorator (models & enums)
@@ -78,8 +78,9 @@ enum Status {
78
78
  Generates:
79
79
 
80
80
  ```rust
81
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
81
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
82
82
  pub enum Status {
83
+ #[default]
83
84
  #[serde(rename = "active")]
84
85
  Active,
85
86
  #[serde(rename = "inactive")]
@@ -87,12 +88,6 @@ pub enum Status {
87
88
  #[serde(rename = "pending")]
88
89
  Pending,
89
90
  }
90
-
91
- impl Default for Status {
92
- fn default() -> Self {
93
- Status::Active
94
- }
95
- }
96
91
  ```
97
92
 
98
93
  ### Error Model
@@ -116,8 +111,20 @@ pub struct ApiError {
116
111
  #[serde(rename = "message")]
117
112
  pub message: String,
118
113
  }
114
+
115
+ impl IntoResponse for ApiError {
116
+ fn into_response(self) -> axum::response::Response {
117
+ (
118
+ StatusCode::from_str(&self.code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
119
+ Json(self),
120
+ )
121
+ .into_response()
122
+ }
123
+ }
119
124
  ```
120
125
 
126
+ Error codes are parsed dynamically, so any valid HTTP status code string (e.g., `"NOT_FOUND"`, `"BAD_REQUEST"`, `"418 I'M_A_TEAPOT"`) will work.
127
+
121
128
  ### UUID Format
122
129
 
123
130
  ```typespec
@@ -207,9 +214,10 @@ pub struct User {
207
214
  pub name: String,
208
215
  }
209
216
 
210
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, sqlx::Type)]
217
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, sqlx::Type)]
211
218
  #[sqlx(type_name = "study_status")]
212
219
  pub enum StudyStatus {
220
+ #[default]
213
221
  #[serde(rename = "Starting")]
214
222
  Starting,
215
223
  #[serde(rename = "Paused")]
@@ -287,7 +287,7 @@ function getStatusCode(variant) {
287
287
  function getBodyFromResponse(variant, program, anonymousEnums) {
288
288
  if (variant.type.kind === "Model") {
289
289
  const model = variant.type;
290
- for (const [propName, prop] of model.properties) {
290
+ for (const [_propName, prop] of model.properties) {
291
291
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
292
292
  const decorators = prop.decorators;
293
293
  let isBody = false;
@@ -414,9 +414,11 @@ function generateServerTrait(program, namespaceGroups, anonymousEnums) {
414
414
  const parts = [];
415
415
  parts.push(`use super::types::*;
416
416
  use async_trait::async_trait;
417
- use axum::{http::StatusCode, Json};
418
417
  use axum::extract::Path;
419
418
  use axum::Extension;
419
+ use axum::http::StatusCode;
420
+ use axum::response::IntoResponse;
421
+ use axum::Json;
420
422
  use eyre::Result;
421
423
 
422
424
  #[async_trait]
@@ -610,8 +612,7 @@ where
610
612
  const methodImports = Array.from(usedMethods).sort().join(", ");
611
613
  const routerBody = buildRouterBody(publicRoutes, protectedRoutes);
612
614
  const parts = [];
613
- parts.push(`use axum::response::IntoResponse;
614
- use axum::routing::{${methodImports}};
615
+ parts.push(`use axum::routing::{${methodImports}};
615
616
  use axum::Router;
616
617
 
617
618
  `);
@@ -870,17 +871,19 @@ function emitStringLiteralUnion(union) {
870
871
  const parts = [];
871
872
  const name = toPascalCase(union.name ?? "Value");
872
873
  const variants = Array.from(union.variants.values());
873
- parts.push(`#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]\npub enum ${name} {`);
874
- for (const variant of variants) {
875
- const literalType = variant.type;
874
+ const defaultVariant = toRustVariantName(variants[0]?.type ? variants[0].type.value : "");
875
+ parts.push(`#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]\npub enum ${name} {`);
876
+ for (let i = 0; i < variants.length; i++) {
877
+ const literalType = variants[i].type;
876
878
  const variantName = toRustVariantName(literalType.value);
877
879
  const serdeValue = literalType.value;
880
+ if (i === 0) {
881
+ parts.push(` #[default]`);
882
+ }
878
883
  parts.push(` #[serde(rename = "${serdeValue}")]`);
879
884
  parts.push(` ${variantName},`);
880
885
  }
881
886
  parts.push("}");
882
- const defaultVariant = toRustVariantName(variants[0]?.type ? variants[0].type.value : "");
883
- parts.push(`\n\nimpl Default for ${name} {\n fn default() -> Self {\n ${name}::${defaultVariant}\n }\n}`);
884
887
  return parts.join("\n");
885
888
  }
886
889
  function emitModel(model, program, anonymousEnums) {
@@ -940,6 +943,18 @@ ${fields.join("\n")}
940
943
  parts.push(`pub struct ${name}`);
941
944
  parts.push("(());");
942
945
  }
946
+ if (isError && allProps.size > 0) {
947
+ parts.push(`
948
+ impl IntoResponse for ${name} {
949
+ fn into_response(self) -> axum::response::Response {
950
+ (
951
+ StatusCode::from_str(&self.code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
952
+ Json(self),
953
+ )
954
+ .into_response()
955
+ }
956
+ }`);
957
+ }
943
958
  return parts.join("\n");
944
959
  }
945
960
  function getAllProperties(model, _program) {
@@ -967,6 +982,7 @@ function emitEnum(enumType) {
967
982
  "PartialEq",
968
983
  "Eq",
969
984
  "Hash",
985
+ "Default",
970
986
  "serde::Serialize",
971
987
  "serde::Deserialize",
972
988
  ];
@@ -990,9 +1006,12 @@ function emitEnum(enumType) {
990
1006
  parts.push(...attrLines);
991
1007
  }
992
1008
  parts.push(`pub enum ${name} {`);
993
- for (const member of members) {
994
- const variantName = toRustVariantName(member.name);
995
- const serdeValue = member.value ?? member.name;
1009
+ for (let i = 0; i < members.length; i++) {
1010
+ const variantName = toRustVariantName(members[i].name);
1011
+ const serdeValue = members[i].value ?? members[i].name;
1012
+ if (i === 0) {
1013
+ parts.push(` #[default]`);
1014
+ }
996
1015
  parts.push(` #[serde(rename = "${serdeValue}")]`);
997
1016
  parts.push(` ${variantName},`);
998
1017
  }
@@ -1003,15 +1022,16 @@ function emitEnum(enumType) {
1003
1022
  parts.push(...attrLines);
1004
1023
  }
1005
1024
  parts.push(`pub enum ${name} {`);
1006
- for (const member of members) {
1007
- const variantName = toRustVariantName(member.name);
1008
- const enumValue = member.value ?? 0;
1025
+ for (let i = 0; i < members.length; i++) {
1026
+ const variantName = toRustVariantName(members[i].name);
1027
+ const enumValue = members[i].value ?? 0;
1028
+ if (i === 0) {
1029
+ parts.push(` #[default]`);
1030
+ }
1009
1031
  parts.push(` ${variantName} = ${enumValue},`);
1010
1032
  }
1011
1033
  }
1012
1034
  parts.push("}");
1013
- const defaultVariant = toRustVariantName(members[0]?.name ?? "");
1014
- parts.push(`\n\nimpl Default for ${name} {\n fn default() -> Self {\n ${name}::${defaultVariant}\n }\n}`);
1015
1035
  return parts.join("\n");
1016
1036
  }
1017
1037
  function emitUnion(union, program) {
@@ -1046,9 +1066,8 @@ function emitScalar(scalar, program) {
1046
1066
  if (pattern) {
1047
1067
  const rustType = scalarToRust[scalar.name] ?? "String";
1048
1068
  impls.push(`\nimpl TryFrom<String> for ${structName} {\n type Error = String;\n\n fn try_from(value: String) -> Result<Self, Self::Error> {\n let re = regex::Regex::new(r"${pattern}").unwrap();\n if re.is_match(&value) { Ok(Self(value)) } else { Err(format!("Invalid value: {}", value)) }\n }\n}`);
1049
- impls.push(`\nimpl Default for ${structName} {\n fn default() -> Self {\n Self(String::new())\n }\n}`);
1050
1069
  return {
1051
- typeDef: `#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]\npub struct ${structName}(pub ${rustType});`,
1070
+ typeDef: `#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]\npub struct ${structName}(pub ${rustType});`,
1052
1071
  impls,
1053
1072
  };
1054
1073
  }
@@ -1112,15 +1131,17 @@ export async function $onEmit(context, _options) {
1112
1131
  }
1113
1132
  for (const [enumName, anonEnum] of anonymousEnums) {
1114
1133
  const parts = [];
1115
- parts.push(`#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]\npub enum ${enumName} {`);
1116
- for (const literal of anonEnum.variants) {
1134
+ parts.push(`#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]\npub enum ${enumName} {`);
1135
+ for (let i = 0; i < anonEnum.variants.length; i++) {
1136
+ const literal = anonEnum.variants[i];
1117
1137
  const variantName = toRustVariantName(literal.value);
1138
+ if (i === 0) {
1139
+ parts.push(` #[default]`);
1140
+ }
1118
1141
  parts.push(` #[serde(rename = "${literal.value}")]`);
1119
1142
  parts.push(` ${variantName},`);
1120
1143
  }
1121
1144
  parts.push("}");
1122
- const defaultVariant = toRustVariantName(anonEnum.variants[0]?.value ?? "");
1123
- parts.push(`\n\nimpl Default for ${enumName} {\n fn default() -> Self {\n ${enumName}::${defaultVariant}\n }\n}`);
1124
1145
  content.push(parts.join("\n"));
1125
1146
  content.push("");
1126
1147
  }
@@ -1145,7 +1166,13 @@ export async function $onEmit(context, _options) {
1145
1166
  content.push("");
1146
1167
  }
1147
1168
  const namespaceGroups = getAllOperations(context.program);
1148
- const outputContent = "#![allow(unused)]\n\n" + content.join("\n") + "\n";
1169
+ const outputContent = "#![allow(unused)]\n\n" +
1170
+ "use std::str::FromStr;\n" +
1171
+ "use axum::http::StatusCode;\n" +
1172
+ "use axum::response::IntoResponse;\n" +
1173
+ "use axum::Json;\n\n" +
1174
+ content.join("\n") +
1175
+ "\n";
1149
1176
  await emitFile(context.program, {
1150
1177
  path: resolvePath(context.emitterOutputDir, "types.rs"),
1151
1178
  content: outputContent,