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    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
254/// Print settings warnings — accepted members that are close to a declared
255/// spelling and likely misspelled — to stderr so a command-line user sees
256/// them without the emission failing.
257fn 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
265/// Report opted-in fallback usage on stderr so the fallback choice is
266/// visible in tooling output (ADR-0001 Decision 7).
267fn 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(&current) {
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
441/// Print a command failure and return exit code 1.
442fn 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}