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