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 evidence,
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 #18 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 options = EmitOptions {
233 fallback_locale: fallback,
234 };
235 match emitter::emit_with_options(&program, &catalog, &locale, &options) {
236 Ok(output) => {
237 report_fallbacks(&output.fallback_ids);
238 print!("{}", output.text);
239 0
240 }
241 Err(error) => {
242 eprintln!("workshop-rs-cli: {error}");
243 1
244 }
245 }
246}
247
248fn convert_command(args: Vec<String>) -> i32 {
249 let mut parser = ArgParser::new(args);
250 let mut file: Option<PathBuf> = None;
251 let mut from: Option<Locale> = None;
252 let mut to: Option<Locale> = None;
253 let mut fallback: Option<Locale> = None;
254 loop {
255 match parser.next() {
256 None => break,
257 Some("--from") => match parser.value_after("--from") {
258 Ok(value) => from = Some(Locale::new(&value)),
259 Err(error) => return usage_error(&error),
260 },
261 Some("--to") => match parser.value_after("--to") {
262 Ok(value) => to = Some(Locale::new(&value)),
263 Err(error) => return usage_error(&error),
264 },
265 Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
266 Ok(value) => fallback = Some(Locale::new(&value)),
267 Err(error) => return usage_error(&error),
268 },
269 Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
270 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
271 }
272 }
273 let Some(file) = file else {
274 return usage_error("convert requires a file argument");
275 };
276 let (Some(from), Some(to)) = (from, to) else {
277 return usage_error("convert requires --from and --to locales");
278 };
279 let (catalog, input) = match (catalog(), read_file(&file)) {
280 (Ok(catalog), Ok(input)) => (catalog, input),
281 (Err(error), _) | (_, Err(error)) => {
282 eprintln!("workshop-rs-cli: {error}");
283 return 1;
284 }
285 };
286 let options = ConvertOptions {
287 fallback_locale: fallback,
288 };
289 match convert::convert(&input, &catalog, &from, &to, &options) {
290 Ok(output) => {
291 report_fallbacks(&output.fallback_ids);
292 print!("{}", output.text);
293 0
294 }
295 Err(error) => {
296 eprintln!("workshop-rs-cli: {error}");
297 1
298 }
299 }
300}
301
302fn report_fallbacks(fallback_ids: &[String]) {
305 if fallback_ids.is_empty() {
306 return;
307 }
308 eprintln!(
309 "workshop-rs-cli: note: {} canonical id(s) emitted with a fallback-locale spelling: {}",
310 fallback_ids.len(),
311 fallback_ids.join(", ")
312 );
313}
314
315fn locales_command(args: Vec<String>) -> i32 {
316 let mut parser = ArgParser::new(args);
317 if let Err(error) = parser.expect_end() {
318 return usage_error(&error);
319 }
320 let catalog = match catalog() {
321 Ok(catalog) => catalog,
322 Err(error) => {
323 eprintln!("workshop-rs-cli: {error}");
324 return 1;
325 }
326 };
327 for coverage in catalog.locale_coverage_all() {
328 println!("{} {}/{}", coverage.locale, coverage.mapped, coverage.total);
329 }
330 0
331}
332
333fn version_command(args: Vec<String>) -> i32 {
334 let mut parser = ArgParser::new(args);
335 let mut json = false;
336 loop {
337 match parser.next() {
338 None => break,
339 Some("--json") => json = true,
340 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
341 }
342 }
343 let catalog = match catalog() {
344 Ok(catalog) => catalog,
345 Err(error) => {
346 eprintln!("workshop-rs-cli: {error}");
347 return 1;
348 }
349 };
350 let identity = catalog.identity();
351 if json {
352 match serde_json::to_string_pretty(&identity) {
353 Ok(text) => println!("{text}"),
354 Err(error) => {
355 eprintln!("workshop-rs-cli: cannot serialize identity: {error}");
356 return 1;
357 }
358 }
359 } else {
360 println!(
361 "implementation version: {}",
362 identity.implementation_version
363 );
364 println!("catalog version: {}", identity.catalog_version);
365 println!(
366 "catalog digest: {}",
367 identity.catalog_digest.as_deref().unwrap_or("<none>")
368 );
369 for coverage in &identity.locale_coverage {
370 println!(
371 "locale {}: {}/{} mapped",
372 coverage.locale, coverage.mapped, coverage.total
373 );
374 }
375 println!(
376 "target: {} ({})",
377 identity.target.surface, identity.target.game
378 );
379 }
380 0
381}
382
383fn census_command(args: Vec<String>) -> i32 {
384 let json = match args.as_slice() {
385 [] => false,
386 [flag] if flag == "--json" => true,
387 _ => return usage_error("census accepts only the optional --json flag"),
388 };
389 let catalog = match Catalog::builtin() {
390 Ok(catalog) => catalog,
391 Err(error) => return usage_error(&format!("cannot load catalog: {error}")),
392 };
393 let census = match census::Census::builtin(&catalog) {
394 Ok(census) => census,
395 Err(error) => return usage_error(&format!("cannot build census: {error}")),
396 };
397 let report = census.run(&catalog);
398 if let Err(error) = report.validate_against(&catalog) {
399 return usage_error(&format!("invalid census report: {error}"));
400 }
401 if json {
402 match report.to_json() {
403 Ok(text) => println!("{text}"),
404 Err(error) => return usage_error(&format!("cannot serialize census: {error}")),
405 }
406 } else {
407 println!(
408 "census schema {} / conformance schema {}",
409 report.schema_version, report.conformance_schema_version
410 );
411 for result in &report.results {
412 println!("{}: {:?}", result.case_id, result.status);
413 }
414 }
415 if report
416 .results
417 .iter()
418 .any(|result| result.status == conformance::ConformanceStatus::UnexpectedRegression)
419 {
420 1
421 } else {
422 0
423 }
424}
425
426fn corpus_command(args: Vec<String>) -> i32 {
427 let mut parser = ArgParser::new(args);
428 let mut manifest: Option<PathBuf> = None;
429 let mut json = false;
430 loop {
431 match parser.next() {
432 None => break,
433 Some("--json") => json = true,
434 Some(value) if manifest.is_none() => manifest = Some(PathBuf::from(value)),
435 Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
436 }
437 }
438 let Some(manifest) = manifest else {
439 return usage_error("corpus requires a manifest file");
440 };
441 match corpus::run(&manifest) {
442 Ok(report) => {
443 if json {
444 match serde_json::to_string_pretty(&report) {
445 Ok(text) => println!("{text}"),
446 Err(error) => {
447 eprintln!("workshop-rs-cli: cannot serialize corpus report: {error}");
448 return 1;
449 }
450 }
451 } else {
452 print!("{}", report.human_summary());
453 }
454 if report.has_unexpected_regression() {
455 1
456 } else {
457 0
458 }
459 }
460 Err(error) => {
461 eprintln!("workshop-rs-cli: corpus: {error}");
462 1
463 }
464 }
465}
466
467fn seasonal_diff_command(args: Vec<String>) -> i32 {
468 let mut paths = Vec::new();
469 let mut json = false;
470 for argument in args {
471 if argument == "--json" {
472 json = true;
473 } else if paths.len() < 2 {
474 paths.push(PathBuf::from(argument));
475 } else {
476 return usage_error("seasonal-diff accepts two capture files and --json");
477 }
478 }
479 if paths.len() != 2 {
480 return usage_error("seasonal-diff requires previous and current capture files");
481 }
482 let previous = match read_file(&paths[0]) {
483 Ok(text) => text,
484 Err(error) => {
485 eprintln!("workshop-rs-cli: {error}");
486 return 1;
487 }
488 };
489 let current = match read_file(&paths[1]) {
490 Ok(text) => text,
491 Err(error) => {
492 eprintln!("workshop-rs-cli: {error}");
493 return 1;
494 }
495 };
496 let previous = match live_capture::LiveCapture::from_json(&previous) {
497 Ok(capture) => capture,
498 Err(error) => {
499 eprintln!("workshop-rs-cli: seasonal-diff: {error}");
500 return 1;
501 }
502 };
503 let current = match live_capture::LiveCapture::from_json(¤t) {
504 Ok(capture) => capture,
505 Err(error) => {
506 eprintln!("workshop-rs-cli: seasonal-diff: {error}");
507 return 1;
508 }
509 };
510 let diff = match previous.diff(¤t) {
511 Ok(diff) => diff,
512 Err(error) => {
513 eprintln!("workshop-rs-cli: seasonal-diff: {error}");
514 return 1;
515 }
516 };
517 if json {
518 match diff.to_json() {
519 Ok(text) => println!("{text}"),
520 Err(error) => {
521 eprintln!("workshop-rs-cli: cannot serialize seasonal diff: {error}");
522 return 1;
523 }
524 }
525 } else {
526 print!("{}", diff.human_summary());
527 }
528 0
529}
530
531fn usage_error(message: &str) -> i32 {
532 eprintln!("workshop-rs-cli: {message}");
533 eprintln!("{USAGE}");
534 2
535}