Skip to content

Commit 25496e5

Browse files
Add configurable casing for exported function and argument names
Adds Builder::function_casing and Builder::argument_casing (with a new Casing enum) to control how command/event accessor names and command argument names are cased in generated bindings. Defaults to camelCase to preserve existing behavior. This lets users with #[tauri::command(rename_all = "snake_case")] emit matching snake_case argument keys, and keep Rust-style naming for accessors. Closes the follow-up to #164. Co-authored-by: James Moynihan <jrmoynihan@users.noreply.github.com>
1 parent 30081b1 commit 25496e5

5 files changed

Lines changed: 296 additions & 6 deletions

File tree

src/builder.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::{any::TypeId, borrow::Cow, collections::BTreeMap, path::Path};
22

3-
use crate::{Commands, EventRegistry, Events, LanguageExt, event::EventRegistryMeta};
3+
use crate::{Casing, Commands, EventRegistry, Events, LanguageExt, event::EventRegistryMeta};
44
use serde::Serialize;
55
use specta::{
66
Type, Types,
@@ -115,6 +115,10 @@ pub struct BuilderConfiguration {
115115
pub dangerously_cast_bigints_to_number: bool,
116116
/// Whether serde serialize/deserialize phase differences should be ignored.
117117
pub disable_serde_phases: bool,
118+
/// Casing applied to generated command and event accessor names.
119+
pub function_casing: Casing,
120+
/// Casing applied to generated command argument names.
121+
pub argument_casing: Casing,
118122
}
119123

120124
impl<R: Runtime> Default for Builder<R> {
@@ -319,6 +323,44 @@ impl<R: Runtime> Builder<R> {
319323
self
320324
}
321325

326+
/// Set the casing convention used for generated command and event accessor names.
327+
///
328+
/// By default Tauri Specta renames Rust `snake_case` names to [`Casing::CamelCase`]
329+
/// (e.g. `hello_world` becomes `commands.helloWorld`). Use this to keep the original
330+
/// Rust naming or pick another convention.
331+
///
332+
/// This does not affect the underlying Tauri command/event string used to invoke the
333+
/// command, only the JavaScript accessor name in the generated bindings.
334+
///
335+
/// ```rust
336+
/// use tauri_specta::{Builder, Casing};
337+
///
338+
/// let mut builder = Builder::<tauri::Wry>::new().function_casing(Casing::SnakeCase);
339+
/// ```
340+
pub fn function_casing(mut self, casing: Casing) -> Self {
341+
self.cfg.function_casing = casing;
342+
self
343+
}
344+
345+
/// Set the casing convention used for generated command argument names.
346+
///
347+
/// By default Tauri Specta renames Rust `snake_case` arguments to [`Casing::CamelCase`]
348+
/// (e.g. `my_name` becomes `myName`), which matches Tauri's default argument handling.
349+
///
350+
/// If your commands use [`#[tauri::command(rename_all = "snake_case")]`](https://docs.rs/tauri/latest/tauri/attr.command.html),
351+
/// set this to [`Casing::SnakeCase`] so the generated argument keys match what Tauri
352+
/// expects when deserializing the invoke payload.
353+
///
354+
/// ```rust
355+
/// use tauri_specta::{Builder, Casing};
356+
///
357+
/// let mut builder = Builder::<tauri::Wry>::new().argument_casing(Casing::SnakeCase);
358+
/// ```
359+
pub fn argument_casing(mut self, casing: Casing) -> Self {
360+
self.cfg.argument_casing = casing;
361+
self
362+
}
363+
322364
/// The Tauri invoke handler to trigger commands registered with the builder.
323365
pub fn invoke_handler(&self) -> impl Fn(Invoke<R>) -> bool + Send + Sync + 'static {
324366
let commands = self.commands.0.clone();

src/casing.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
use std::borrow::Cow;
2+
3+
use heck::{
4+
ToKebabCase, ToLowerCamelCase, ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase,
5+
};
6+
7+
/// The casing convention applied to generated identifiers in the exported bindings.
8+
///
9+
/// By default Tauri Specta renames Rust `snake_case` identifiers to JavaScript-idiomatic
10+
/// [`Casing::CamelCase`] when generating bindings. This enum lets you override that behavior,
11+
/// for example to keep Rust's original `snake_case` naming.
12+
///
13+
/// This is used by [`Builder::function_casing`](crate::Builder::function_casing) and
14+
/// [`Builder::argument_casing`](crate::Builder::argument_casing).
15+
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
16+
#[non_exhaustive]
17+
pub enum Casing {
18+
/// `camelCase`.
19+
///
20+
/// This is the default and matches the JavaScript naming convention.
21+
#[default]
22+
CamelCase,
23+
/// `PascalCase` (also known as `UpperCamelCase`).
24+
PascalCase,
25+
/// `snake_case`.
26+
///
27+
/// This preserves the original Rust naming. Use this for argument names together with
28+
/// [`#[tauri::command(rename_all = "snake_case")]`](https://docs.rs/tauri/latest/tauri/attr.command.html)
29+
/// so the generated argument keys match what Tauri expects.
30+
SnakeCase,
31+
/// `SCREAMING_SNAKE_CASE`.
32+
ScreamingSnakeCase,
33+
/// `kebab-case`.
34+
KebabCase,
35+
}
36+
37+
impl Casing {
38+
/// Apply the casing convention to a given identifier.
39+
pub(crate) fn apply<'a>(&self, ident: &'a str) -> Cow<'a, str> {
40+
match self {
41+
Casing::CamelCase => Cow::Owned(ident.to_lower_camel_case()),
42+
Casing::PascalCase => Cow::Owned(ident.to_upper_camel_case()),
43+
Casing::SnakeCase => Cow::Owned(ident.to_snake_case()),
44+
Casing::ScreamingSnakeCase => Cow::Owned(ident.to_shouty_snake_case()),
45+
Casing::KebabCase => Cow::Owned(ident.to_kebab_case()),
46+
}
47+
}
48+
}
49+
50+
#[cfg(test)]
51+
mod tests {
52+
use super::*;
53+
54+
#[test]
55+
fn applies_each_casing() {
56+
assert_eq!(Casing::CamelCase.apply("my_command_name"), "myCommandName");
57+
assert_eq!(Casing::PascalCase.apply("my_command_name"), "MyCommandName");
58+
assert_eq!(Casing::SnakeCase.apply("my_command_name"), "my_command_name");
59+
assert_eq!(Casing::SnakeCase.apply("myCommandName"), "my_command_name");
60+
assert_eq!(
61+
Casing::ScreamingSnakeCase.apply("my_command_name"),
62+
"MY_COMMAND_NAME"
63+
);
64+
assert_eq!(Casing::KebabCase.apply("my_command_name"), "my-command-name");
65+
}
66+
67+
#[test]
68+
fn default_is_camel_case() {
69+
assert_eq!(Casing::default(), Casing::CamelCase);
70+
}
71+
}

src/lang/js_ts.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use std::{borrow::Cow, path::Path};
22

3-
use heck::ToLowerCamelCase;
43
use specta::{
54
Format, Type, Types,
65
datatype::{
@@ -173,7 +172,7 @@ fn runtime(
173172
.iter()
174173
.map(|(name, dt)| {
175174
Ok((
176-
name.to_lower_camel_case(),
175+
cfg.argument_casing.apply(name).into_owned(),
177176
render_reference_dt_for_phase(
178177
dt,
179178
Phase::Deserialize,
@@ -208,7 +207,7 @@ fn runtime(
208207
.args()
209208
.iter()
210209
.map(|(name, dt)| {
211-
let name = name.to_lower_camel_case();
210+
let name = cfg.argument_casing.apply(name).into_owned();
212211
let value = if let Some(generic) =
213212
channel_generic_type(dt, exporter.types)
214213
{
@@ -507,7 +506,7 @@ fn runtime(
507506

508507
docs.into()
509508
};
510-
s = s.field(command.name().to_lower_camel_case(), field);
509+
s = s.field(cfg.function_casing.apply(command.name()).into_owned(), field);
511510
}
512511

513512
out.push_str("\n/** Commands */");
@@ -593,7 +592,7 @@ fn runtime(
593592
)
594593
.into();
595594
}
596-
s = s.field(name.to_lower_camel_case(), field);
595+
s = s.field(cfg.function_casing.apply(name).into_owned(), field);
597596
}
598597

599598
out.push_str("\n/** Events */");

src/lib.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,31 @@
210210
//! If you would like to opt-in to the dangerous behavior of truncating your integers,
211211
//! you can use [`Builder::dangerously_cast_bigints_to_number`] but we do not recommend it!
212212
//!
213+
//! ## Naming convention (casing)
214+
//!
215+
//! By default Tauri Specta renames your Rust `snake_case` commands, events, and arguments to
216+
//! JavaScript-idiomatic `camelCase` in the generated bindings (e.g. `hello_world(my_name)`
217+
//! becomes `commands.helloWorld(myName)`).
218+
//!
219+
//! You can override this with [`Builder::function_casing`] (for command and event accessor
220+
//! names) and [`Builder::argument_casing`] (for command argument names). See [`Casing`] for the
221+
//! supported conventions.
222+
//!
223+
//! ```rust
224+
//! use tauri_specta::{Builder, Casing};
225+
//!
226+
//! let mut builder = Builder::<tauri::Wry>::new()
227+
//! // Keep the original Rust naming for accessors and arguments.
228+
//! .function_casing(Casing::SnakeCase)
229+
//! .argument_casing(Casing::SnakeCase);
230+
//! ```
231+
//!
232+
//! [`Builder::argument_casing`] is particularly important if your commands use
233+
//! `#[tauri::command(rename_all = "snake_case")]`. Tauri defaults to `camelCase` argument keys,
234+
//! so when you opt into `snake_case` on the Tauri side you must also set
235+
//! `argument_casing(Casing::SnakeCase)` so the generated invoke payload keys match what Tauri
236+
//! expects.
237+
//!
213238
#![cfg_attr(docsrs, feature(doc_cfg))]
214239
#![doc(
215240
// TODO: Tauri Specta logo
@@ -218,13 +243,15 @@
218243
)]
219244

220245
mod builder;
246+
mod casing;
221247
mod commands;
222248
mod event;
223249
mod lang;
224250
mod macros;
225251
mod name;
226252

227253
pub use builder::{Builder, BuilderConfiguration, ErrorHandlingMode};
254+
pub use casing::Casing;
228255
pub use commands::Commands;
229256
pub use event::{Event, Events, TypedEvent};
230257
pub use lang::LanguageExt;

tests/casing.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#![cfg(all(feature = "typescript", feature = "derive"))]
2+
#![allow(missing_docs, clippy::unwrap_used, clippy::panic)]
3+
4+
use std::fs;
5+
6+
use serde::{Deserialize, Serialize};
7+
use specta::Type;
8+
use specta_typescript::Typescript;
9+
use tauri::WebviewWindowBuilder;
10+
use tauri::ipc::{CallbackFn, InvokeBody};
11+
use tauri::test::{INVOKE_KEY, MockRuntime, get_ipc_response, mock_builder, mock_context, noop_assets};
12+
use tauri::webview::InvokeRequest;
13+
use tauri_specta::{Builder, Casing, Event, collect_commands, collect_events};
14+
15+
#[tauri::command]
16+
#[specta::specta]
17+
fn hello_world(my_name: String) -> String {
18+
format!("Hello, {my_name}!")
19+
}
20+
21+
// `rename_all = "snake_case"` makes Tauri expect snake_case argument keys from the frontend.
22+
#[tauri::command(rename_all = "snake_case")]
23+
#[specta::specta]
24+
fn greet_user(user_name: String) -> String {
25+
format!("Hello, {user_name}, from Rust!")
26+
}
27+
28+
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
29+
struct MyDemoEvent(String);
30+
31+
fn export_to_string(builder: &Builder<MockRuntime>) -> String {
32+
let dir = std::env::temp_dir().join(format!(
33+
"tauri_specta_casing_{}_{:?}",
34+
std::process::id(),
35+
std::thread::current().id()
36+
));
37+
fs::create_dir_all(&dir).unwrap();
38+
let path = dir.join("bindings.ts");
39+
builder
40+
.export(Typescript::default(), &path)
41+
.expect("failed to export bindings");
42+
fs::read_to_string(&path).unwrap()
43+
}
44+
45+
fn invoke_request(cmd: &str, body: serde_json::Value) -> InvokeRequest {
46+
InvokeRequest {
47+
cmd: cmd.into(),
48+
callback: CallbackFn(0),
49+
error: CallbackFn(1),
50+
url: if cfg!(any(windows, target_os = "android")) {
51+
"http://tauri.localhost"
52+
} else {
53+
"tauri://localhost"
54+
}
55+
.parse()
56+
.unwrap(),
57+
body: InvokeBody::from(body),
58+
headers: Default::default(),
59+
invoke_key: INVOKE_KEY.to_string(),
60+
}
61+
}
62+
63+
#[test]
64+
fn default_casing_is_camel_case() {
65+
let builder = Builder::<MockRuntime>::new().commands(collect_commands![hello_world]);
66+
let out = export_to_string(&builder);
67+
68+
assert!(
69+
out.contains(
70+
r#"helloWorld: (myName: string) => __TAURI_INVOKE<string>("hello_world", { myName })"#
71+
),
72+
"expected camelCase accessor and argument by default, got:\n{out}"
73+
);
74+
}
75+
76+
#[test]
77+
fn snake_case_function_and_argument_casing() {
78+
let builder = Builder::<MockRuntime>::new()
79+
.commands(collect_commands![hello_world])
80+
.function_casing(Casing::SnakeCase)
81+
.argument_casing(Casing::SnakeCase);
82+
let out = export_to_string(&builder);
83+
84+
assert!(
85+
out.contains(
86+
r#"hello_world: (my_name: string) => __TAURI_INVOKE<string>("hello_world", { my_name })"#
87+
),
88+
"expected snake_case accessor and argument, got:\n{out}"
89+
);
90+
// The underlying Tauri command string must be unchanged.
91+
assert!(out.contains(r#""hello_world""#));
92+
}
93+
94+
#[test]
95+
fn function_casing_applies_to_events() {
96+
let builder = Builder::<MockRuntime>::new()
97+
.events(collect_events![MyDemoEvent])
98+
.function_casing(Casing::SnakeCase);
99+
let out = export_to_string(&builder);
100+
101+
assert!(
102+
out.contains("my_demo_event: makeEvent"),
103+
"expected snake_case event accessor, got:\n{out}"
104+
);
105+
}
106+
107+
// The argument keys emitted by the bindings must match what Tauri actually accepts.
108+
// With `rename_all = "snake_case"`, Tauri expects snake_case keys, so the camelCase keys
109+
// emitted by the default behavior would fail. This proves `argument_casing` fixes that.
110+
#[test]
111+
fn snake_case_arguments_match_tauri_rename_all() {
112+
let ts_builder = Builder::<MockRuntime>::new()
113+
.commands(collect_commands![greet_user])
114+
.argument_casing(Casing::SnakeCase);
115+
let out = export_to_string(&ts_builder);
116+
assert!(
117+
out.contains(r#"__TAURI_INVOKE<string>("greet_user", { user_name })"#),
118+
"expected snake_case argument key, got:\n{out}"
119+
);
120+
121+
let app_builder = Builder::<MockRuntime>::new().commands(collect_commands![greet_user]);
122+
let app = mock_builder()
123+
.invoke_handler(app_builder.invoke_handler())
124+
.build(mock_context(noop_assets()))
125+
.expect("failed to build mock app");
126+
let webview = WebviewWindowBuilder::new(&app, "main", Default::default())
127+
.build()
128+
.expect("failed to build webview");
129+
130+
// snake_case key (what the new bindings emit) succeeds.
131+
let ok = get_ipc_response(
132+
&webview,
133+
invoke_request("greet_user", serde_json::json!({ "user_name": "Cursor" })),
134+
);
135+
assert_eq!(
136+
ok.expect("snake_case invoke should succeed")
137+
.deserialize::<String>()
138+
.unwrap(),
139+
"Hello, Cursor, from Rust!"
140+
);
141+
142+
// camelCase key (what the old default bindings emit) fails against a snake_case command.
143+
let err = get_ipc_response(
144+
&webview,
145+
invoke_request("greet_user", serde_json::json!({ "userName": "Cursor" })),
146+
);
147+
assert!(
148+
err.is_err(),
149+
"camelCase key should not satisfy a `rename_all = \"snake_case\"` command"
150+
);
151+
}

0 commit comments

Comments
 (0)