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
17#[doc(hidden)]
18pub mod census;
19#[doc(hidden)]
20pub mod conformance;
21mod corpus;
22#[doc(hidden)]
23pub mod live_capture;
24
25const USAGE: &str = "\
28usage: workshop-rs-cli <command> [options]
29
30commands:
31 parse <file> [--locale LOCALE]
32 Parse raw Workshop text into the validated public Program model and
33 print a deterministic debug dump. Without --locale the locale is auto-detected.
34 emit <file> [--locale LOCALE] [--fallback-locale LOCALE]
35 Parse and emit localized Workshop text (fail-explicit on missing
36 target-locale mappings; --fallback-locale opts into fallback, which is
37 reported on stderr).
38 convert <file> --from LOCALE --to LOCALE [--fallback-locale LOCALE]
39 Convert raw Workshop text between locales (parse -> canonical
40 semantics -> emit). Missing target-locale mappings fail explicitly
41 unless --fallback-locale is given.
42 locales
43 List the declared locales with per-locale mapping coverage.
44 version [--json]
45 Print the machine-readable catalog identity: implementation version,
46 catalog version and content digest, locale coverage, target source,
47 and provenance.
48 census [--json]
49 Run the deterministic offline Workshop feature census. Unexpected
50 regressions exit with status 1; known gaps remain visible.
51 corpus <manifest> [--json]
52 Run an offline provenance-linked real-project corpus manifest and print
53 its conformance report. Known gaps remain visible and do not count
54 as matches; unexpected regressions return exit code 1.
55 seasonal-diff <previous.json> <current.json> [--json]
56 Validate two provenance-rich live-client capture documents and emit a
57 structured offline drift report. This command never captures a client.
58";
59
60pub fn run(args: Vec<String>) -> i32 {
61 let mut args = args.into_iter();
62 let Some(command) = args.next() else {
63 eprintln!("{USAGE}");
64 return 2;
65 };
66 let rest: Vec<String> = args.collect();
67 match command.as_str() {
68 "parse" => parse_command(rest),
69 "emit" => emit_command(rest),
70 "convert" => convert_command(rest),
71 "locales" => locales_command(rest),
72 "version" => version_command(rest),
73 "census" => census_command(rest),
74 "corpus" => corpus_command(rest),
75 "seasonal-diff" => seasonal_diff_command(rest),
76 "help" | "--help" | "-h" => {
77 print!("{USAGE}");
78 0
79 }
80 other => {
81 eprintln!("workshop-rs-cli: unknown command '{other}'");
82 eprintln!("{USAGE}");
83 2
84 }
85 }
86}
87
88struct ArgParser {
91 args: Vec<String>,
92 position: usize,
93}
94
95impl ArgParser {
96 fn new(args: Vec<String>) -> Self {
97 ArgParser { args, position: 0 }
98 }
99
100 fn next(&mut self) -> Option<&str> {
101 let value = self.args.get(self.position).map(String::as_str);
102 if value.is_some() {
103 self.position += 1;
104 }
105 value
106 }
107
108 fn value_after(&mut self, flag: &str) -> Result<String, String> {
109 self.next()
110 .map(str::to_string)
111 .ok_or_else(|| format!("missing value for {flag}"))
112 }
113
114 fn expect_end(&mut self) -> Result<(), String> {
115 if let Some(extra) = self.next() {
116 return Err(format!("unexpected argument '{extra}'"));
117 }
118 Ok(())
119 }
120}
121
122fn catalog() -> Result<Catalog, String> {
123 Catalog::builtin().map_err(|error| format!("catalog: {error}"))
124}
125
126fn read_file(path: &Path) -> Result<String, String> {
127 std::fs::read_to_string(path)
128 .map_err(|error| format!("cannot read {}: {error}", path.display()))
129}
130
131fn resolve_parse_locale(
134 input: &str,
135 catalog: &Catalog,
136 explicit: Option<Locale>,
137) -> Result<Locale, String> {
138 detect::resolve_locale(input, catalog, explicit.as_ref()).map_err(|error| error.to_string())
139}
140
141fn parse_command(args: Vec<String>) -> i32 {
142 let mut parser = ArgParser::new(args);
143 let mut file: Option<PathBuf> = None;
144 let mut locale: Option<Locale> = None;
145 loop {
146 match parser.next() {
147 None => break,
148 Some("--locale") => match parser.value_after("--locale") {
149 Ok(value) => locale = Some(Locale::new(&value)),
150 Err(error) => return usage_error(&error),
151 },
152 Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
153 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
154 }
155 }
156 let Some(file) = file else {
157 return usage_error("parse requires a file argument");
158 };
159 let (catalog, input) = match (catalog(), read_file(&file)) {
160 (Ok(catalog), Ok(input)) => (catalog, input),
161 (Err(error), _) | (_, Err(error)) => {
162 eprintln!("workshop-rs-cli: {error}");
163 return 1;
164 }
165 };
166 let locale = match resolve_parse_locale(&input, &catalog, locale) {
167 Ok(locale) => locale,
168 Err(error) => {
169 eprintln!("workshop-rs-cli: {error}");
170 return 1;
171 }
172 };
173 let program = match parser::parse_with_context(&input, &catalog, &locale, &catalog) {
174 Ok(program) => program,
175 Err(error) => {
176 eprintln!("workshop-rs-cli: {error}");
177 return 1;
178 }
179 };
180 if let Err(error) = program.validate() {
181 eprintln!("workshop-rs-cli: WIR validation failed: {error}");
182 return 1;
183 }
184 print!("{}", program.dump());
185 0
186}
187
188fn emit_command(args: Vec<String>) -> i32 {
189 let mut parser = ArgParser::new(args);
190 let mut file: Option<PathBuf> = None;
191 let mut locale: Option<Locale> = None;
192 let mut fallback: Option<Locale> = None;
193 loop {
194 match parser.next() {
195 None => break,
196 Some("--locale") => match parser.value_after("--locale") {
197 Ok(value) => locale = Some(Locale::new(&value)),
198 Err(error) => return usage_error(&error),
199 },
200 Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
201 Ok(value) => fallback = Some(Locale::new(&value)),
202 Err(error) => return usage_error(&error),
203 },
204 Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
205 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
206 }
207 }
208 let Some(file) = file else {
209 return usage_error("emit requires a file argument");
210 };
211 let (catalog, input) = match (catalog(), read_file(&file)) {
212 (Ok(catalog), Ok(input)) => (catalog, input),
213 (Err(error), _) | (_, Err(error)) => {
214 eprintln!("workshop-rs-cli: {error}");
215 return 1;
216 }
217 };
218 let locale = match resolve_parse_locale(&input, &catalog, locale) {
219 Ok(locale) => locale,
220 Err(error) => {
221 eprintln!("workshop-rs-cli: {error}");
222 return 1;
223 }
224 };
225 let program = match parser::parse_with_context(&input, &catalog, &locale, &catalog) {
226 Ok(program) => program,
227 Err(error) => {
228 eprintln!("workshop-rs-cli: {error}");
229 return 1;
230 }
231 };
232 let mut options = EmitOptions::default();
233 options.fallback_locale = fallback;
234 match emitter::emit_with_options(&program, &catalog, &locale, &options) {
235 Ok(output) => {
236 report_fallbacks(&output.fallback_ids);
237 print!("{}", output.text);
238 0
239 }
240 Err(error) => {
241 eprintln!("workshop-rs-cli: {error}");
242 1
243 }
244 }
245}
246
247fn convert_command(args: Vec<String>) -> i32 {
248 let mut parser = ArgParser::new(args);
249 let mut file: Option<PathBuf> = None;
250 let mut from: Option<Locale> = None;
251 let mut to: Option<Locale> = None;
252 let mut fallback: Option<Locale> = None;
253 loop {
254 match parser.next() {
255 None => break,
256 Some("--from") => match parser.value_after("--from") {
257 Ok(value) => from = Some(Locale::new(&value)),
258 Err(error) => return usage_error(&error),
259 },
260 Some("--to") => match parser.value_after("--to") {
261 Ok(value) => to = Some(Locale::new(&value)),
262 Err(error) => return usage_error(&error),
263 },
264 Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
265 Ok(value) => fallback = Some(Locale::new(&value)),
266 Err(error) => return usage_error(&error),
267 },
268 Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
269 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
270 }
271 }
272 let Some(file) = file else {
273 return usage_error("convert requires a file argument");
274 };
275 let (Some(from), Some(to)) = (from, to) else {
276 return usage_error("convert requires --from and --to locales");
277 };
278 let (catalog, input) = match (catalog(), read_file(&file)) {
279 (Ok(catalog), Ok(input)) => (catalog, input),
280 (Err(error), _) | (_, Err(error)) => {
281 eprintln!("workshop-rs-cli: {error}");
282 return 1;
283 }
284 };
285 let mut options = ConvertOptions::default();
286 options.fallback_locale = fallback;
287 match convert::convert(&input, &catalog, &from, &to, &options) {
288 Ok(output) => {
289 report_fallbacks(&output.fallback_ids);
290 print!("{}", output.text);
291 0
292 }
293 Err(error) => {
294 eprintln!("workshop-rs-cli: {error}");
295 1
296 }
297 }
298}
299
300fn report_fallbacks(fallback_ids: &[String]) {
303 if fallback_ids.is_empty() {
304 return;
305 }
306 eprintln!(
307 "workshop-rs-cli: note: {} canonical id(s) emitted with a fallback-locale spelling: {}",
308 fallback_ids.len(),
309 fallback_ids.join(", ")
310 );
311}
312
313fn locales_command(args: Vec<String>) -> i32 {
314 let mut parser = ArgParser::new(args);
315 if let Err(error) = parser.expect_end() {
316 return usage_error(&error);
317 }
318 let catalog = match catalog() {
319 Ok(catalog) => catalog,
320 Err(error) => {
321 eprintln!("workshop-rs-cli: {error}");
322 return 1;
323 }
324 };
325 for coverage in catalog.locale_coverage_all() {
326 println!("{} {}/{}", coverage.locale, coverage.mapped, coverage.total);
327 }
328 0
329}
330
331fn version_command(args: Vec<String>) -> i32 {
332 let mut parser = ArgParser::new(args);
333 let mut json = false;
334 loop {
335 match parser.next() {
336 None => break,
337 Some("--json") => json = true,
338 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
339 }
340 }
341 let catalog = match catalog() {
342 Ok(catalog) => catalog,
343 Err(error) => {
344 eprintln!("workshop-rs-cli: {error}");
345 return 1;
346 }
347 };
348 let identity = catalog.identity();
349 if json {
350 match serde_json::to_string_pretty(&identity) {
351 Ok(text) => println!("{text}"),
352 Err(error) => {
353 eprintln!("workshop-rs-cli: cannot serialize identity: {error}");
354 return 1;
355 }
356 }
357 } else {
358 println!(
359 "implementation version: {}",
360 identity.implementation_version
361 );
362 println!("catalog version: {}", identity.catalog_version);
363 println!(
364 "catalog digest: {}",
365 identity.catalog_digest.as_deref().unwrap_or("<none>")
366 );
367 for coverage in &identity.locale_coverage {
368 println!(
369 "locale {}: {}/{} mapped",
370 coverage.locale, coverage.mapped, coverage.total
371 );
372 }
373 println!(
374 "target: {} ({})",
375 identity.target.surface, identity.target.game
376 );
377 }
378 0
379}
380
381fn census_command(args: Vec<String>) -> i32 {
382 let json = match args.as_slice() {
383 [] => false,
384 [flag] if flag == "--json" => true,
385 _ => return usage_error("census accepts only the optional --json flag"),
386 };
387 let catalog = match Catalog::builtin() {
388 Ok(catalog) => catalog,
389 Err(error) => return usage_error(&format!("cannot load catalog: {error}")),
390 };
391 let census = match census::Census::builtin(&catalog) {
392 Ok(census) => census,
393 Err(error) => return usage_error(&format!("cannot build census: {error}")),
394 };
395 let report = census.run(&catalog);
396 if let Err(error) = report.validate_against(&catalog) {
397 return usage_error(&format!("invalid census report: {error}"));
398 }
399 if json {
400 match report.to_json() {
401 Ok(text) => println!("{text}"),
402 Err(error) => return usage_error(&format!("cannot serialize census: {error}")),
403 }
404 } else {
405 println!(
406 "census schema {} / conformance schema {}",
407 report.schema_version, report.conformance_schema_version
408 );
409 for result in &report.results {
410 println!("{}: {:?}", result.case_id, result.status);
411 }
412 }
413 if report
414 .results
415 .iter()
416 .any(|result| result.status == conformance::ConformanceStatus::UnexpectedRegression)
417 {
418 1
419 } else {
420 0
421 }
422}
423
424fn corpus_command(args: Vec<String>) -> i32 {
425 let mut parser = ArgParser::new(args);
426 let mut manifest: Option<PathBuf> = None;
427 let mut json = false;
428 loop {
429 match parser.next() {
430 None => break,
431 Some("--json") => json = true,
432 Some(value) if manifest.is_none() => manifest = Some(PathBuf::from(value)),
433 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
434 }
435 }
436 let Some(manifest) = manifest else {
437 return usage_error("corpus requires a manifest file");
438 };
439 match corpus::run(&manifest) {
440 Ok(report) => {
441 if json {
442 match serde_json::to_string_pretty(&report) {
443 Ok(text) => println!("{text}"),
444 Err(error) => {
445 eprintln!("workshop-rs-cli: cannot serialize corpus report: {error}");
446 return 1;
447 }
448 }
449 } else {
450 print!("{}", report.human_summary());
451 }
452 if report.has_unexpected_regression() {
453 1
454 } else {
455 0
456 }
457 }
458 Err(error) => {
459 eprintln!("workshop-rs-cli: corpus: {error}");
460 1
461 }
462 }
463}
464
465fn seasonal_diff_command(args: Vec<String>) -> i32 {
466 let mut paths = Vec::new();
467 let mut json = false;
468 for argument in args {
469 if argument == "--json" {
470 json = true;
471 } else if paths.len() < 2 {
472 paths.push(PathBuf::from(argument));
473 } else {
474 return usage_error("seasonal-diff accepts two capture files and --json");
475 }
476 }
477 if paths.len() != 2 {
478 return usage_error("seasonal-diff requires previous and current capture files");
479 }
480 let previous = match read_file(&paths[0]) {
481 Ok(text) => text,
482 Err(error) => {
483 eprintln!("workshop-rs-cli: {error}");
484 return 1;
485 }
486 };
487 let current = match read_file(&paths[1]) {
488 Ok(text) => text,
489 Err(error) => {
490 eprintln!("workshop-rs-cli: {error}");
491 return 1;
492 }
493 };
494 let previous = match live_capture::LiveCapture::from_json(&previous) {
495 Ok(capture) => capture,
496 Err(error) => {
497 eprintln!("workshop-rs-cli: seasonal-diff: {error}");
498 return 1;
499 }
500 };
501 let current = match live_capture::LiveCapture::from_json(¤t) {
502 Ok(capture) => capture,
503 Err(error) => {
504 eprintln!("workshop-rs-cli: seasonal-diff: {error}");
505 return 1;
506 }
507 };
508 let diff = match previous.diff(¤t) {
509 Ok(diff) => diff,
510 Err(error) => {
511 eprintln!("workshop-rs-cli: seasonal-diff: {error}");
512 return 1;
513 }
514 };
515 if json {
516 match diff.to_json() {
517 Ok(text) => println!("{text}"),
518 Err(error) => {
519 eprintln!("workshop-rs-cli: cannot serialize seasonal diff: {error}");
520 return 1;
521 }
522 }
523 } else {
524 print!("{}", diff.human_summary());
525 }
526 0
527}
528
529fn usage_error(message: &str) -> i32 {
530 eprintln!("workshop-rs-cli: {message}");
531 eprintln!("{USAGE}");
532 2
533}