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 the public
3//! `Program` model, 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::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
24/// The default locale override for parsing when the input locale is not
25/// specified explicitly.
26const 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/// Parsed command arguments: positionals plus declared `--flag value` options
88/// and standalone `--switch` toggles.
89#[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    /// Exactly `count` positional arguments mapped to paths.
106    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
121/// Consume `args`, collecting declared `--flag value` pairs, declared
122/// `--switch` toggles, and positional arguments.
123fn 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
141/// Unwrap a parsed-CLI result or a command result into a usage/failure exit.
142fn 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
155/// Resolve the parse locale: an explicit override always wins; otherwise
156/// auto-detect with the documented confidence gate.
157fn 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
252/// Report opted-in fallback usage on stderr so the fallback choice is
253/// visible in tooling output (ADR-0001 Decision 7).
254fn 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(&current) {
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
428/// Print a command failure and return exit code 1.
429fn 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}