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