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