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
19#[doc(hidden)]
20pub mod census;
21#[doc(hidden)]
22pub mod conformance;
23mod corpus;
24#[doc(hidden)]
25pub mod live_capture;
26
27/// The default locale override for parsing when the input locale is not
28/// specified explicitly.
29const 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/// Parsed command arguments: positionals plus declared `--flag value` options
91/// and standalone `--switch` toggles.
92#[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    /// Exactly `count` positional arguments mapped to paths.
109    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
124/// Consume `args`, collecting declared `--flag value` pairs, declared
125/// `--switch` toggles, and positional arguments.
126fn 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
144/// Unwrap a parsed-CLI result or a command result into a usage/failure exit.
145fn 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
158/// Resolve the parse locale: an explicit override always wins; otherwise
159/// auto-detect with the documented confidence gate.
160fn 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
255/// Report opted-in fallback usage on stderr so the fallback choice is
256/// visible in tooling output (ADR-0001 Decision 7).
257fn 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(&current) {
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
431/// Print a command failure and return exit code 1.
432fn 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}