1use std::path::{Path, PathBuf};
10
11use workshop_rs::catalog::{Catalog, Locale};
12use workshop_rs::convert::{self, ConvertOptions};
13use workshop_rs::detect;
14use workshop_rs::emitter::{self, EmitOptions};
15use workshop_rs::parser;
16
17const USAGE: &str = "\
20usage: workshop-rs-cli <command> [options]
21
22commands:
23 parse <file> [--locale LOCALE]
24 Parse raw Workshop text into validated Workshop IR and print a
25 deterministic WIR dump. Without --locale the locale is auto-detected.
26 emit <file> [--locale LOCALE] [--fallback-locale LOCALE]
27 Parse and emit localized Workshop text (fail-explicit on missing
28 target-locale mappings; --fallback-locale opts into fallback, which is
29 reported on stderr).
30 convert <file> --from LOCALE --to LOCALE [--fallback-locale LOCALE]
31 Convert raw Workshop text between locales (parse -> canonical
32 semantics -> emit). Missing target-locale mappings fail explicitly
33 unless --fallback-locale is given.
34 locales
35 List the declared locales with per-locale mapping coverage.
36 version [--json]
37 Print the machine-readable catalog identity: implementation version,
38 catalog version and content digest, locale coverage, target evidence,
39 and provenance.
40";
41
42pub fn run(args: Vec<String>) -> i32 {
43 let mut args = args.into_iter();
44 let Some(command) = args.next() else {
45 eprintln!("{USAGE}");
46 return 2;
47 };
48 let rest: Vec<String> = args.collect();
49 match command.as_str() {
50 "parse" => parse_command(rest),
51 "emit" => emit_command(rest),
52 "convert" => convert_command(rest),
53 "locales" => locales_command(rest),
54 "version" => version_command(rest),
55 "help" | "--help" | "-h" => {
56 print!("{USAGE}");
57 0
58 }
59 other => {
60 eprintln!("workshop-rs-cli: unknown command '{other}'");
61 eprintln!("{USAGE}");
62 2
63 }
64 }
65}
66
67struct ArgParser {
70 args: Vec<String>,
71 position: usize,
72}
73
74impl ArgParser {
75 fn new(args: Vec<String>) -> Self {
76 ArgParser { args, position: 0 }
77 }
78
79 fn next(&mut self) -> Option<&str> {
80 let value = self.args.get(self.position).map(String::as_str);
81 if value.is_some() {
82 self.position += 1;
83 }
84 value
85 }
86
87 fn value_after(&mut self, flag: &str) -> Result<String, String> {
88 self.next()
89 .map(str::to_string)
90 .ok_or_else(|| format!("missing value for {flag}"))
91 }
92
93 fn expect_end(&mut self) -> Result<(), String> {
94 if let Some(extra) = self.next() {
95 return Err(format!("unexpected argument '{extra}'"));
96 }
97 Ok(())
98 }
99}
100
101fn catalog() -> Result<Catalog, String> {
102 Catalog::builtin().map_err(|error| format!("catalog: {error}"))
103}
104
105fn read_file(path: &Path) -> Result<String, String> {
106 std::fs::read_to_string(path)
107 .map_err(|error| format!("cannot read {}: {error}", path.display()))
108}
109
110fn resolve_parse_locale(
113 input: &str,
114 catalog: &Catalog,
115 explicit: Option<Locale>,
116) -> Result<Locale, String> {
117 detect::resolve_locale(input, catalog, explicit.as_ref()).map_err(|error| error.to_string())
118}
119
120fn parse_command(args: Vec<String>) -> i32 {
121 let mut parser = ArgParser::new(args);
122 let mut file: Option<PathBuf> = None;
123 let mut locale: Option<Locale> = None;
124 loop {
125 match parser.next() {
126 None => break,
127 Some("--locale") => match parser.value_after("--locale") {
128 Ok(value) => locale = Some(Locale::new(&value)),
129 Err(error) => return usage_error(&error),
130 },
131 Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
132 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
133 }
134 }
135 let Some(file) = file else {
136 return usage_error("parse requires a file argument");
137 };
138 let (catalog, input) = match (catalog(), read_file(&file)) {
139 (Ok(catalog), Ok(input)) => (catalog, input),
140 (Err(error), _) | (_, Err(error)) => {
141 eprintln!("workshop-rs-cli: {error}");
142 return 1;
143 }
144 };
145 let locale = match resolve_parse_locale(&input, &catalog, locale) {
146 Ok(locale) => locale,
147 Err(error) => {
148 eprintln!("workshop-rs-cli: {error}");
149 return 1;
150 }
151 };
152 let program = match parser::parse_with_context(&input, &catalog, &locale, &catalog) {
153 Ok(program) => program,
154 Err(error) => {
155 eprintln!("workshop-rs-cli: {error}");
156 return 1;
157 }
158 };
159 if let Err(error) = program.validate() {
160 eprintln!("workshop-rs-cli: WIR validation failed: {error}");
161 return 1;
162 }
163 print!("{}", program.dump());
164 0
165}
166
167fn emit_command(args: Vec<String>) -> i32 {
168 let mut parser = ArgParser::new(args);
169 let mut file: Option<PathBuf> = None;
170 let mut locale: Option<Locale> = None;
171 let mut fallback: Option<Locale> = None;
172 loop {
173 match parser.next() {
174 None => break,
175 Some("--locale") => match parser.value_after("--locale") {
176 Ok(value) => locale = Some(Locale::new(&value)),
177 Err(error) => return usage_error(&error),
178 },
179 Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
180 Ok(value) => fallback = Some(Locale::new(&value)),
181 Err(error) => return usage_error(&error),
182 },
183 Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
184 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
185 }
186 }
187 let Some(file) = file else {
188 return usage_error("emit requires a file argument");
189 };
190 let (catalog, input) = match (catalog(), read_file(&file)) {
191 (Ok(catalog), Ok(input)) => (catalog, input),
192 (Err(error), _) | (_, Err(error)) => {
193 eprintln!("workshop-rs-cli: {error}");
194 return 1;
195 }
196 };
197 let locale = match resolve_parse_locale(&input, &catalog, locale) {
198 Ok(locale) => locale,
199 Err(error) => {
200 eprintln!("workshop-rs-cli: {error}");
201 return 1;
202 }
203 };
204 let program = match parser::parse_with_context(&input, &catalog, &locale, &catalog) {
205 Ok(program) => program,
206 Err(error) => {
207 eprintln!("workshop-rs-cli: {error}");
208 return 1;
209 }
210 };
211 let options = EmitOptions {
212 fallback_locale: fallback,
213 };
214 match emitter::emit_with_options(&program, &catalog, &locale, &options) {
215 Ok(output) => {
216 report_fallbacks(&output.fallback_ids);
217 print!("{}", output.text);
218 0
219 }
220 Err(error) => {
221 eprintln!("workshop-rs-cli: {error}");
222 1
223 }
224 }
225}
226
227fn convert_command(args: Vec<String>) -> i32 {
228 let mut parser = ArgParser::new(args);
229 let mut file: Option<PathBuf> = None;
230 let mut from: Option<Locale> = None;
231 let mut to: Option<Locale> = None;
232 let mut fallback: Option<Locale> = None;
233 loop {
234 match parser.next() {
235 None => break,
236 Some("--from") => match parser.value_after("--from") {
237 Ok(value) => from = Some(Locale::new(&value)),
238 Err(error) => return usage_error(&error),
239 },
240 Some("--to") => match parser.value_after("--to") {
241 Ok(value) => to = Some(Locale::new(&value)),
242 Err(error) => return usage_error(&error),
243 },
244 Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
245 Ok(value) => fallback = Some(Locale::new(&value)),
246 Err(error) => return usage_error(&error),
247 },
248 Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
249 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
250 }
251 }
252 let Some(file) = file else {
253 return usage_error("convert requires a file argument");
254 };
255 let (Some(from), Some(to)) = (from, to) else {
256 return usage_error("convert requires --from and --to locales");
257 };
258 let (catalog, input) = match (catalog(), read_file(&file)) {
259 (Ok(catalog), Ok(input)) => (catalog, input),
260 (Err(error), _) | (_, Err(error)) => {
261 eprintln!("workshop-rs-cli: {error}");
262 return 1;
263 }
264 };
265 let options = ConvertOptions {
266 fallback_locale: fallback,
267 };
268 match convert::convert(&input, &catalog, &from, &to, &options) {
269 Ok(output) => {
270 report_fallbacks(&output.fallback_ids);
271 print!("{}", output.text);
272 0
273 }
274 Err(error) => {
275 eprintln!("workshop-rs-cli: {error}");
276 1
277 }
278 }
279}
280
281fn report_fallbacks(fallback_ids: &[String]) {
284 if fallback_ids.is_empty() {
285 return;
286 }
287 eprintln!(
288 "workshop-rs-cli: note: {} canonical id(s) emitted with a fallback-locale spelling: {}",
289 fallback_ids.len(),
290 fallback_ids.join(", ")
291 );
292}
293
294fn locales_command(args: Vec<String>) -> i32 {
295 let mut parser = ArgParser::new(args);
296 if let Err(error) = parser.expect_end() {
297 return usage_error(&error);
298 }
299 let catalog = match catalog() {
300 Ok(catalog) => catalog,
301 Err(error) => {
302 eprintln!("workshop-rs-cli: {error}");
303 return 1;
304 }
305 };
306 for coverage in catalog.locale_coverage_all() {
307 println!("{} {}/{}", coverage.locale, coverage.mapped, coverage.total);
308 }
309 0
310}
311
312fn version_command(args: Vec<String>) -> i32 {
313 let mut parser = ArgParser::new(args);
314 let mut json = false;
315 loop {
316 match parser.next() {
317 None => break,
318 Some("--json") => json = true,
319 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
320 }
321 }
322 let catalog = match catalog() {
323 Ok(catalog) => catalog,
324 Err(error) => {
325 eprintln!("workshop-rs-cli: {error}");
326 return 1;
327 }
328 };
329 let identity = catalog.identity();
330 if json {
331 match serde_json::to_string_pretty(&identity) {
332 Ok(text) => println!("{text}"),
333 Err(error) => {
334 eprintln!("workshop-rs-cli: cannot serialize identity: {error}");
335 return 1;
336 }
337 }
338 } else {
339 println!(
340 "implementation version: {}",
341 identity.implementation_version
342 );
343 println!("catalog version: {}", identity.catalog_version);
344 println!(
345 "catalog digest: {}",
346 identity.catalog_digest.as_deref().unwrap_or("<none>")
347 );
348 for coverage in &identity.locale_coverage {
349 println!(
350 "locale {}: {}/{} mapped",
351 coverage.locale, coverage.mapped, coverage.total
352 );
353 }
354 println!(
355 "target: {} ({})",
356 identity.target.surface, identity.target.game
357 );
358 }
359 0
360}
361
362fn usage_error(message: &str) -> i32 {
363 eprintln!("workshop-rs-cli: {message}");
364 eprintln!("{USAGE}");
365 2
366}