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