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