1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
mod cli;
mod config;
mod generator;
mod migrator;
mod parser;
mod roblox;
mod utils;
mod validator;
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::Path;
use utils::validation;
#[derive(Parser)]
#[command(name = "roblox-slang")]
#[command(version)]
#[command(about = "Type-safe internationalization for Roblox experiences")]
#[command(
long_about = "Roblox Slang is a CLI tool that generates type-safe Luau code from translation files.\n\
Write translations in JSON/YAML, generate type-safe code with autocomplete support.\n\n\
For more information, visit: https://github.com/mathtechstudio/roblox-slang"
)]
#[command(author = "Iqbal Fauzi <iqbalfauzien@proton.me>")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize a new Roblox Slang project
///
/// Creates a new project with default configuration file and translation directory.
/// Use --with-overrides to also create an overrides.yaml template for A/B testing.
Init {
/// Create example overrides.yaml file for translation overrides
#[arg(long, help = "Create overrides.yaml template")]
with_overrides: bool,
},
/// Build translations and generate Luau code
///
/// Parses translation files (JSON/YAML) and generates type-safe Luau code.
/// Outputs: Translations.lua, type definitions, and CSV for Roblox Cloud.
Build {
/// Watch for file changes and rebuild automatically
#[arg(short, long, help = "Enable watch mode (auto-rebuild on changes)")]
watch: bool,
},
/// Import translations from a Roblox CSV file
///
/// Converts Roblox Cloud CSV format to JSON translation files.
/// Useful for migrating existing translations or syncing with Roblox Cloud.
Import {
/// Path to the CSV file to import
#[arg(value_name = "CSV_FILE", help = "Path to Roblox CSV file")]
csv_file: String,
},
/// Validate translations for errors and inconsistencies
///
/// Checks for missing translations, unused keys, conflicts, and coverage.
/// Use --all to run all checks at once.
Validate {
/// Check for missing translations across locales
#[arg(long, help = "Check for missing translations")]
missing: bool,
/// Check for unused translation keys in source code
#[arg(long, help = "Check for unused keys")]
unused: bool,
/// Check for duplicate keys or conflicts
#[arg(long, help = "Check for conflicts")]
conflicts: bool,
/// Show translation coverage report per locale
#[arg(long, help = "Show coverage report")]
coverage: bool,
/// Source directory to scan for unused keys
#[arg(long, value_name = "DIR", help = "Source directory to scan")]
source: Option<String>,
/// Run all validation checks
#[arg(long, help = "Run all checks")]
all: bool,
},
/// Migrate translations from another format
///
/// Converts translations from other formats (custom-json, gettext) to Roblox Slang format.
/// Supports key transformation strategies for compatibility.
Migrate {
/// Format to migrate from
#[arg(
long,
value_name = "FORMAT",
help = "Source format (custom-json, gettext)"
)]
from: String,
/// Input file path
#[arg(long, value_name = "FILE", help = "Input file path")]
input: String,
/// Output file path
#[arg(long, value_name = "FILE", help = "Output file path")]
output: String,
/// Key transformation strategy
#[arg(
long,
value_name = "TRANSFORM",
help = "Key transformation (snake-to-camel, upper-to-lower, dot-to-nested, none)"
)]
transform: Option<String>,
},
/// Upload local translations to Roblox Cloud Localization Table
///
/// Reads local translation files, validates them, and uploads to Roblox Cloud.
/// Requires API key via ROBLOX_CLOUD_API_KEY environment variable or config file.
///
/// Examples:
/// roblox-slang upload --table-id abc123
/// roblox-slang upload --dry-run
/// roblox-slang upload --skip-validation
Upload {
/// Roblox Cloud Localization Table ID (or set in config: cloud.table_id)
#[arg(
long,
value_name = "TABLE_ID",
help = "Localization table ID (or use config: cloud.table_id)"
)]
table_id: Option<String>,
/// Preview changes without uploading to cloud
#[arg(long, help = "Preview changes without uploading (shows statistics)")]
dry_run: bool,
/// Skip pre-upload validation checks
#[arg(long, help = "Skip validation before upload (not recommended)")]
skip_validation: bool,
},
/// Download translations from Roblox Cloud Localization Table
///
/// Fetches translations from Roblox Cloud and writes them to local JSON files.
/// Creates one file per locale in the input directory.
/// Requires API key via ROBLOX_CLOUD_API_KEY environment variable or config file.
///
/// Examples:
/// roblox-slang download --table-id abc123
/// roblox-slang download --dry-run
Download {
/// Roblox Cloud Localization Table ID (or set in config: cloud.table_id)
#[arg(
long,
value_name = "TABLE_ID",
help = "Localization table ID (or use config: cloud.table_id)"
)]
table_id: Option<String>,
/// Preview changes without writing files to disk
#[arg(
long,
help = "Preview changes without writing files (shows statistics)"
)]
dry_run: bool,
},
/// Synchronize translations bidirectionally between local and cloud
///
/// Compares local and cloud translations, then applies the specified merge strategy.
/// Strategies: overwrite (local→cloud), merge (union, cloud wins), skip-conflicts (safe only).
/// Requires API key via ROBLOX_CLOUD_API_KEY environment variable or config file.
///
/// Examples:
/// roblox-slang sync --strategy merge
/// roblox-slang sync --table-id abc123 --strategy overwrite
/// roblox-slang sync --dry-run
Sync {
/// Roblox Cloud Localization Table ID (or set in config: cloud.table_id)
#[arg(
long,
value_name = "TABLE_ID",
help = "Localization table ID (or use config: cloud.table_id)"
)]
table_id: Option<String>,
/// Merge strategy: overwrite (local→cloud), merge (union), skip-conflicts (safe only)
#[arg(
long,
value_name = "STRATEGY",
help = "overwrite | merge | skip-conflicts (or use config: cloud.strategy)"
)]
strategy: Option<String>,
/// Preview changes without syncing (shows what would change)
#[arg(long, help = "Preview changes without syncing (shows statistics)")]
dry_run: bool,
},
}
fn main() -> Result<()> {
// Initialize logger
env_logger::init();
let cli = Cli::parse();
// Use tokio runtime for async commands
let runtime = tokio::runtime::Runtime::new()?;
match cli.command {
Commands::Init { with_overrides } => {
cli::init(with_overrides)?;
}
Commands::Build { watch } => {
let config_path = Path::new("slang-roblox.yaml");
if watch {
cli::watch(config_path)?;
} else {
cli::build(config_path)?;
}
}
Commands::Import { csv_file } => {
let csv_path = Path::new(&csv_file);
// Validate file path
validation::validate_safe_path(csv_path)?;
validation::validate_file_exists(csv_path, "CSV file")?;
let config_path = Path::new("slang-roblox.yaml");
cli::import_csv(csv_path, config_path)?;
}
Commands::Validate {
missing,
unused,
conflicts,
coverage,
source,
all,
} => {
let config_path = Path::new("slang-roblox.yaml");
// If --all is specified, enable all checks
let check_missing = all || missing;
let check_unused = all || unused;
let check_conflicts = all || conflicts;
let show_coverage = all || coverage;
let source_dir = if let Some(ref s) = source {
let path = Path::new(s.as_str());
validation::validate_safe_path(path)?;
validation::validate_directory_exists(path, "source directory")?;
Some(path)
} else {
None
};
cli::validate(
config_path,
check_missing,
check_unused,
check_conflicts,
show_coverage,
source_dir,
)?;
}
Commands::Migrate {
from,
input,
output,
transform,
} => {
let input_path = Path::new(&input);
let output_path = Path::new(&output);
// Validate file paths
validation::validate_safe_path(input_path)?;
validation::validate_file_exists(input_path, "input file")?;
validation::validate_safe_path(output_path)?;
let transform_str = transform.as_deref();
cli::migrate(&from, input_path, output_path, transform_str)?;
}
Commands::Upload {
table_id,
dry_run,
skip_validation,
} => {
runtime.block_on(cli::upload(table_id, dry_run, skip_validation))?;
}
Commands::Download { table_id, dry_run } => {
runtime.block_on(cli::download(table_id, dry_run))?;
}
Commands::Sync {
table_id,
strategy,
dry_run,
} => {
runtime.block_on(cli::sync(table_id, strategy, dry_run))?;
}
}
Ok(())
}