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