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