Skip to main content

workshop_rs_cli/
lib.rs

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