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 report_settings_warnings(&program);
196 print!("{}", program.dump());
197 0
198}
199
200fn emit_command(args: Vec<String>) -> i32 {
201 let options = match cli_options(args, &["--locale", "--fallback-locale"], &[]) {
202 Ok(options) => options,
203 Err(code) => return code,
204 };
205 let file = match options.file("emit requires a file argument") {
206 Ok(file) => file,
207 Err(error) => return usage_error(&error),
208 };
209 let (catalog, locale, program) = match parse_file(&file, options.locale("--locale")) {
210 Ok(parsed) => parsed,
211 Err(error) => return fail(error),
212 };
213 report_settings_warnings(&program);
214 let mut emit_options = EmitOptions::default();
215 emit_options.fallback_locale = options.locale("--fallback-locale");
216 match emitter::emit_with_options(&program, &catalog, &locale, &emit_options) {
217 Ok(output) => {
218 report_fallbacks(&output.fallback_ids);
219 print!("{}", output.text);
220 0
221 }
222 Err(error) => fail(error),
223 }
224}
225
226fn convert_command(args: Vec<String>) -> i32 {
227 let options = match cli_options(args, &["--from", "--to", "--fallback-locale"], &[]) {
228 Ok(options) => options,
229 Err(code) => return code,
230 };
231 let file = match options.file("convert requires a file argument") {
232 Ok(file) => file,
233 Err(error) => return usage_error(&error),
234 };
235 let (Some(from), Some(to)) = (options.locale("--from"), options.locale("--to")) else {
236 return usage_error("convert requires --from and --to locales");
237 };
238 let (catalog, input) = match (catalog(), read_file(&file)) {
239 (Ok(catalog), Ok(input)) => (catalog, input),
240 (Err(error), _) | (_, Err(error)) => return fail(error),
241 };
242 let mut convert_options = ConvertOptions::default();
243 convert_options.fallback_locale = options.locale("--fallback-locale");
244 match convert::convert(&input, &catalog, &from, &to, &convert_options) {
245 Ok(output) => {
246 report_fallbacks(&output.fallback_ids);
247 print!("{}", output.text);
248 0
249 }
250 Err(error) => fail(error),
251 }
252}
253
254fn report_settings_warnings(program: &Program) {
258 for diagnostic in program.settings_diagnostics() {
259 if diagnostic.severity == workshop_rs::settings::DiagnosticSeverity::Warning {
260 eprintln!("workshop-rs-cli: warning: {}", diagnostic.error);
261 }
262 }
263}
264
265fn report_fallbacks(fallback_ids: &[String]) {
268 if fallback_ids.is_empty() {
269 return;
270 }
271 eprintln!(
272 "workshop-rs-cli: note: {} canonical id(s) emitted with a fallback-locale spelling: {}",
273 fallback_ids.len(),
274 fallback_ids.join(", ")
275 );
276}
277
278fn locales_command(args: Vec<String>) -> i32 {
279 let options = match cli_options(args, &[], &[]) {
280 Ok(options) => options,
281 Err(code) => return code,
282 };
283 if let Err(error) = options.paths(0, "") {
284 return usage_error(&error);
285 }
286 let catalog = match catalog() {
287 Ok(catalog) => catalog,
288 Err(error) => return fail(error),
289 };
290 for coverage in catalog.locale_coverage_all() {
291 println!("{} {}/{}", coverage.locale, coverage.mapped, coverage.total);
292 }
293 0
294}
295
296fn version_command(args: Vec<String>) -> i32 {
297 let options = match cli_options(args, &[], &["--json"]) {
298 Ok(options) => options,
299 Err(code) => return code,
300 };
301 if let Err(error) = options.paths(0, "") {
302 return usage_error(&error);
303 }
304 let json = options.has("--json");
305 let catalog = match catalog() {
306 Ok(catalog) => catalog,
307 Err(error) => return fail(error),
308 };
309 let identity = catalog.identity();
310 if json {
311 if let Err(error) = print_json(&identity, "identity") {
312 return fail(error);
313 }
314 } else {
315 println!(
316 "implementation version: {}",
317 identity.implementation_version
318 );
319 println!("catalog version: {}", identity.catalog_version);
320 println!(
321 "catalog digest: {}",
322 identity.catalog_digest.as_deref().unwrap_or("<none>")
323 );
324 for coverage in &identity.locale_coverage {
325 println!(
326 "locale {}: {}/{} mapped",
327 coverage.locale, coverage.mapped, coverage.total
328 );
329 }
330 println!(
331 "target: {} ({})",
332 identity.target.surface, identity.target.game
333 );
334 }
335 0
336}
337
338fn census_command(args: Vec<String>) -> i32 {
339 let json = match args.as_slice() {
340 [] => false,
341 [flag] if flag == "--json" => true,
342 _ => return usage_error("census accepts only the optional --json flag"),
343 };
344 let catalog = match Catalog::builtin() {
345 Ok(catalog) => catalog,
346 Err(error) => return usage_error(&format!("cannot load catalog: {error}")),
347 };
348 let census = match census::Census::builtin(&catalog) {
349 Ok(census) => census,
350 Err(error) => return usage_error(&format!("cannot build census: {error}")),
351 };
352 let report = census.run(&catalog);
353 if let Err(error) = report.validate_against(&catalog) {
354 return usage_error(&format!("invalid census report: {error}"));
355 }
356 if json {
357 match report.to_json() {
358 Ok(text) => println!("{text}"),
359 Err(error) => return usage_error(&format!("cannot serialize census: {error}")),
360 }
361 } else {
362 println!(
363 "census schema {} / conformance schema {}",
364 report.schema_version, report.conformance_schema_version
365 );
366 for result in &report.results {
367 println!("{}: {:?}", result.case_id, result.status);
368 }
369 }
370 if report
371 .results
372 .iter()
373 .any(|result| result.status == conformance::ConformanceStatus::UnexpectedRegression)
374 {
375 1
376 } else {
377 0
378 }
379}
380
381fn corpus_command(args: Vec<String>) -> i32 {
382 let options = match cli_options(args, &[], &["--json"]) {
383 Ok(options) => options,
384 Err(code) => return code,
385 };
386 let manifest = match options.file("corpus requires a manifest file") {
387 Ok(manifest) => manifest,
388 Err(error) => return usage_error(&error),
389 };
390 match corpus::run(&manifest) {
391 Ok(report) => {
392 if options.has("--json") {
393 if let Err(error) = print_json(&report, "corpus report") {
394 return fail(error);
395 }
396 } else {
397 print!("{}", report.human_summary());
398 }
399 i32::from(report.has_unexpected_regression())
400 }
401 Err(error) => fail(format!("corpus: {error}")),
402 }
403}
404
405fn seasonal_diff_command(args: Vec<String>) -> i32 {
406 let options = match cli_options(args, &[], &["--json"]) {
407 Ok(options) => options,
408 Err(code) => return code,
409 };
410 let paths = match options.paths(
411 2,
412 "seasonal-diff requires previous and current capture files",
413 ) {
414 Ok(paths) => paths,
415 Err(error) => return usage_error(&error),
416 };
417 let capture = |path: &Path| -> Result<live_capture::LiveCapture, String> {
418 let text = read_file(path)?;
419 live_capture::LiveCapture::from_json(&text)
420 .map_err(|error| format!("seasonal-diff: {error}"))
421 };
422 let (previous, current) = match (capture(&paths[0]), capture(&paths[1])) {
423 (Ok(previous), Ok(current)) => (previous, current),
424 (Err(error), _) | (_, Err(error)) => return fail(error),
425 };
426 let diff = match previous.diff(¤t) {
427 Ok(diff) => diff,
428 Err(error) => return fail(format!("seasonal-diff: {error}")),
429 };
430 if options.has("--json") {
431 match diff.to_json() {
432 Ok(text) => println!("{text}"),
433 Err(error) => return fail(format!("cannot serialize seasonal diff: {error}")),
434 }
435 } else {
436 print!("{}", diff.human_summary());
437 }
438 0
439}
440
441fn fail(message: impl std::fmt::Display) -> i32 {
443 eprintln!("workshop-rs-cli: {message}");
444 1
445}
446
447fn print_json(value: &impl serde::Serialize, what: &str) -> Result<(), String> {
448 serde_json::to_string_pretty(value)
449 .map(|text| println!("{text}"))
450 .map_err(|error| format!("cannot serialize {what}: {error}"))
451}
452
453fn usage_error(message: &str) -> i32 {
454 eprintln!("workshop-rs-cli: {message}");
455 eprintln!("{USAGE}");
456 2
457}