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 = "\
24usage: workshop-rs-cli <command> [options]
25
26commands:
27 parse <file> [--locale LOCALE]
28 Parse raw Workshop text into the validated public Program model and
29 print a deterministic debug 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 source,
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 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
84#[derive(Default)]
85struct CliOptions {
86 positional: Vec<String>,
87 values: HashMap<String, String>,
88 switches: HashSet<String>,
89}
90
91impl CliOptions {
92 fn locale(&self, flag: &str) -> Option<Locale> {
93 self.values.get(flag).map(|value| Locale::new(value))
94 }
95
96 fn has(&self, switch: &str) -> bool {
97 self.switches.contains(switch)
98 }
99
100 fn paths(&self, count: usize, missing: &str) -> Result<Vec<PathBuf>, String> {
101 if self.positional.len() < count {
102 return Err(missing.to_string());
103 }
104 if let Some(extra) = self.positional.get(count) {
105 return Err(format!("unexpected argument '{extra}'"));
106 }
107 Ok(self.positional.iter().map(PathBuf::from).collect())
108 }
109
110 fn file(&self, missing: &str) -> Result<PathBuf, String> {
111 Ok(self.paths(1, missing)?.remove(0))
112 }
113}
114
115fn parse_cli(args: Vec<String>, flags: &[&str], switches: &[&str]) -> Result<CliOptions, String> {
116 let mut options = CliOptions::default();
117 let mut args = args.into_iter();
118 while let Some(argument) = args.next() {
119 if switches.contains(&argument.as_str()) {
120 options.switches.insert(argument);
121 } else if flags.contains(&argument.as_str()) {
122 let value = args
123 .next()
124 .ok_or_else(|| format!("missing value for {argument}"))?;
125 options.values.insert(argument, value);
126 } else {
127 options.positional.push(argument);
128 }
129 }
130 Ok(options)
131}
132
133fn cli_options(args: Vec<String>, flags: &[&str], switches: &[&str]) -> Result<CliOptions, i32> {
134 parse_cli(args, flags, switches).map_err(|error| usage_error(&error))
135}
136
137fn catalog() -> Result<Catalog, String> {
138 Catalog::builtin().map_err(|error| format!("catalog: {error}"))
139}
140
141fn read_file(path: &Path) -> Result<String, String> {
142 std::fs::read_to_string(path)
143 .map_err(|error| format!("cannot read {}: {error}", path.display()))
144}
145
146fn resolve_parse_locale(
147 input: &str,
148 catalog: &Catalog,
149 explicit: Option<Locale>,
150) -> Result<Locale, String> {
151 detect::resolve_locale(input, catalog, explicit.as_ref()).map_err(|error| error.to_string())
152}
153
154fn parse_file(
155 file: &Path,
156 explicit_locale: Option<Locale>,
157) -> Result<(Catalog, Locale, Program), String> {
158 let (catalog, input) = match (catalog(), read_file(file)) {
159 (Ok(catalog), Ok(input)) => (catalog, input),
160 (Err(error), _) | (_, Err(error)) => return Err(error),
161 };
162 let locale = resolve_parse_locale(&input, &catalog, explicit_locale)?;
163 let program = parser::parse_with_context(&input, &catalog, &locale, &catalog)
164 .map_err(|error| error.to_string())?;
165 validate::validate_canonical_ids(&program, &catalog)
166 .map_err(|error| format!("canonical identity validation failed: {error}"))?;
167 Ok((catalog, locale, program))
168}
169
170fn parse_command(args: Vec<String>) -> i32 {
171 let options = match cli_options(args, &["--locale"], &[]) {
172 Ok(options) => options,
173 Err(code) => return code,
174 };
175 let file = match options.file("parse requires a file argument") {
176 Ok(file) => file,
177 Err(error) => return usage_error(&error),
178 };
179 let (_, _, program) = match parse_file(&file, options.locale("--locale")) {
180 Ok(parsed) => parsed,
181 Err(error) => return fail(error),
182 };
183 if let Err(error) = program.validate() {
184 return fail(format!("WIR validation failed: {error}"));
185 }
186 report_settings_warnings(&program);
187 print!("{}", program.dump());
188 0
189}
190
191fn emit_command(args: Vec<String>) -> i32 {
192 let options = match cli_options(args, &["--locale", "--fallback-locale"], &[]) {
193 Ok(options) => options,
194 Err(code) => return code,
195 };
196 let file = match options.file("emit requires a file argument") {
197 Ok(file) => file,
198 Err(error) => return usage_error(&error),
199 };
200 let (catalog, locale, program) = match parse_file(&file, options.locale("--locale")) {
201 Ok(parsed) => parsed,
202 Err(error) => return fail(error),
203 };
204 report_settings_warnings(&program);
205 let mut emit_options = EmitOptions::default();
206 emit_options.fallback_locale = options.locale("--fallback-locale");
207 match emitter::emit_with_options(&program, &catalog, &locale, &emit_options) {
208 Ok(output) => {
209 report_fallbacks(&output.fallback_ids);
210 print!("{}", output.text);
211 0
212 }
213 Err(error) => fail(error),
214 }
215}
216
217fn convert_command(args: Vec<String>) -> i32 {
218 let options = match cli_options(args, &["--from", "--to", "--fallback-locale"], &[]) {
219 Ok(options) => options,
220 Err(code) => return code,
221 };
222 let file = match options.file("convert requires a file argument") {
223 Ok(file) => file,
224 Err(error) => return usage_error(&error),
225 };
226 let (Some(from), Some(to)) = (options.locale("--from"), options.locale("--to")) else {
227 return usage_error("convert requires --from and --to locales");
228 };
229 let (catalog, input) = match (catalog(), read_file(&file)) {
230 (Ok(catalog), Ok(input)) => (catalog, input),
231 (Err(error), _) | (_, Err(error)) => return fail(error),
232 };
233 let program = parser::parse_with_context(&input, &catalog, &from, &catalog)
234 .map_err(|error| error.to_string())
235 .and_then(|program| {
236 validate::validate_canonical_ids(&program, &catalog)
237 .map_err(|error| format!("canonical identity validation failed: {error}"))?;
238 Ok(program)
239 });
240 let program = match program {
241 Ok(program) => program,
242 Err(error) => return fail(error),
243 };
244 let mut emit_options = EmitOptions::default();
245 emit_options.fallback_locale = options.locale("--fallback-locale");
246 match emitter::emit_with_options(&program, &catalog, &to, &emit_options) {
247 Ok(output) => {
248 report_fallbacks(&output.fallback_ids);
249 print!("{}", output.text);
250 0
251 }
252 Err(error) => fail(error),
253 }
254}
255
256fn report_settings_warnings(program: &Program) {
257 for diagnostic in program.settings_diagnostics() {
258 if diagnostic.severity == workshop_rs::settings::DiagnosticSeverity::Warning {
259 eprintln!("workshop-rs-cli: warning: {}", diagnostic.error);
260 }
261 }
262}
263
264fn report_fallbacks(fallback_ids: &[String]) {
265 if fallback_ids.is_empty() {
266 return;
267 }
268 eprintln!(
269 "workshop-rs-cli: note: {} canonical id(s) emitted with a fallback-locale spelling: {}",
270 fallback_ids.len(),
271 fallback_ids.join(", ")
272 );
273}
274
275fn locales_command(args: Vec<String>) -> i32 {
276 let options = match cli_options(args, &[], &[]) {
277 Ok(options) => options,
278 Err(code) => return code,
279 };
280 if let Err(error) = options.paths(0, "") {
281 return usage_error(&error);
282 }
283 let catalog = match catalog() {
284 Ok(catalog) => catalog,
285 Err(error) => return fail(error),
286 };
287 for coverage in catalog.locale_coverage_all() {
288 println!("{} {}/{}", coverage.locale, coverage.mapped, coverage.total);
289 }
290 0
291}
292
293fn version_command(args: Vec<String>) -> i32 {
294 let options = match cli_options(args, &[], &["--json"]) {
295 Ok(options) => options,
296 Err(code) => return code,
297 };
298 if let Err(error) = options.paths(0, "") {
299 return usage_error(&error);
300 }
301 let json = options.has("--json");
302 let catalog = match catalog() {
303 Ok(catalog) => catalog,
304 Err(error) => return fail(error),
305 };
306 let identity = catalog.identity();
307 if json {
308 if let Err(error) = print_json(&identity, "identity") {
309 return fail(error);
310 }
311 } else {
312 println!(
313 "implementation version: {}",
314 identity.implementation_version
315 );
316 println!("catalog version: {}", identity.catalog_version);
317 println!(
318 "catalog digest: {}",
319 identity.catalog_digest.as_deref().unwrap_or("<none>")
320 );
321 for coverage in &identity.locale_coverage {
322 println!(
323 "locale {}: {}/{} mapped",
324 coverage.locale, coverage.mapped, coverage.total
325 );
326 }
327 println!(
328 "target: {} ({})",
329 identity.target.surface, identity.target.game
330 );
331 }
332 0
333}
334
335fn census_command(args: Vec<String>) -> i32 {
336 let json = match args.as_slice() {
337 [] => false,
338 [flag] if flag == "--json" => true,
339 _ => return usage_error("census accepts only the optional --json flag"),
340 };
341 let catalog = match Catalog::builtin() {
342 Ok(catalog) => catalog,
343 Err(error) => return fail(format!("cannot load catalog: {error}")),
344 };
345 let census = match census::Census::builtin(&catalog) {
346 Ok(census) => census,
347 Err(error) => return fail(format!("cannot build census: {error}")),
348 };
349 let report = census.run(&catalog);
350 if let Err(error) = report.validate_against(&catalog) {
351 return fail(format!("invalid census report: {error}"));
352 }
353 if json {
354 match report.to_json() {
355 Ok(text) => println!("{text}"),
356 Err(error) => return fail(format!("cannot serialize census: {error}")),
357 }
358 } else {
359 println!(
360 "census schema {} / conformance schema {}",
361 report.schema_version, report.conformance_schema_version
362 );
363 for result in &report.results {
364 println!("{}: {:?}", result.case_id, result.status);
365 }
366 }
367 if report
368 .results
369 .iter()
370 .any(|result| result.status == conformance::ConformanceStatus::UnexpectedRegression)
371 {
372 1
373 } else {
374 0
375 }
376}
377
378fn corpus_command(args: Vec<String>) -> i32 {
379 let options = match cli_options(args, &[], &["--json"]) {
380 Ok(options) => options,
381 Err(code) => return code,
382 };
383 let manifest = match options.file("corpus requires a manifest file") {
384 Ok(manifest) => manifest,
385 Err(error) => return usage_error(&error),
386 };
387 match corpus::run(&manifest) {
388 Ok(report) => {
389 if options.has("--json") {
390 if let Err(error) = print_json(&report, "corpus report") {
391 return fail(error);
392 }
393 } else {
394 print!("{}", report.human_summary());
395 }
396 i32::from(report.has_unexpected_regression())
397 }
398 Err(error) => fail(format!("corpus: {error}")),
399 }
400}
401
402fn seasonal_diff_command(args: Vec<String>) -> i32 {
403 let options = match cli_options(args, &[], &["--json"]) {
404 Ok(options) => options,
405 Err(code) => return code,
406 };
407 let paths = match options.paths(
408 2,
409 "seasonal-diff requires previous and current capture files",
410 ) {
411 Ok(paths) => paths,
412 Err(error) => return usage_error(&error),
413 };
414 let capture = |path: &Path| -> Result<live_capture::LiveCapture, String> {
415 let text = read_file(path)?;
416 live_capture::LiveCapture::from_json(&text)
417 .map_err(|error| format!("seasonal-diff: {error}"))
418 };
419 let (previous, current) = match (capture(&paths[0]), capture(&paths[1])) {
420 (Ok(previous), Ok(current)) => (previous, current),
421 (Err(error), _) | (_, Err(error)) => return fail(error),
422 };
423 let diff = match previous.diff(¤t) {
424 Ok(diff) => diff,
425 Err(error) => return fail(format!("seasonal-diff: {error}")),
426 };
427 if options.has("--json") {
428 match diff.to_json() {
429 Ok(text) => println!("{text}"),
430 Err(error) => return fail(format!("cannot serialize seasonal diff: {error}")),
431 }
432 } else {
433 print!("{}", diff.human_summary());
434 }
435 0
436}
437
438fn fail(message: impl std::fmt::Display) -> i32 {
439 eprintln!("workshop-rs-cli: {message}");
440 1
441}
442
443fn print_json(value: &impl serde::Serialize, what: &str) -> Result<(), String> {
444 serde_json::to_string_pretty(value)
445 .map(|text| println!("{text}"))
446 .map_err(|error| format!("cannot serialize {what}: {error}"))
447}
448
449fn usage_error(message: &str) -> i32 {
450 eprintln!("workshop-rs-cli: {message}");
451 eprintln!("{USAGE}");
452 2
453}