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 WIR,
3//! 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::path::{Path, PathBuf};
10
11use workshop_rs::catalog::{Catalog, Locale};
12use workshop_rs::census;
13use workshop_rs::convert::{self, ConvertOptions};
14use workshop_rs::detect;
15use workshop_rs::emitter::{self, EmitOptions};
16use workshop_rs::live_capture;
17use workshop_rs::parser;
18
19mod corpus;
20
21/// The default locale override for parsing when the input locale is not
22/// specified explicitly.
23const USAGE: &str = "\
24usage: workshop-rs-cli <command> [options]
25
26commands:
27  parse <file> [--locale LOCALE]
28      Parse raw Workshop text into validated Workshop IR and print a
29      deterministic WIR 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 evidence,
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 #18 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/// `--locale LOCALE`, `--fallback-locale LOCALE`, `--from`/`--to LOCALE`,
85/// `--json`, `--file PATH`, and the positional file argument.
86struct ArgParser {
87    args: Vec<String>,
88    position: usize,
89}
90
91impl ArgParser {
92    fn new(args: Vec<String>) -> Self {
93        ArgParser { args, position: 0 }
94    }
95
96    fn next(&mut self) -> Option<&str> {
97        let value = self.args.get(self.position).map(String::as_str);
98        if value.is_some() {
99            self.position += 1;
100        }
101        value
102    }
103
104    fn value_after(&mut self, flag: &str) -> Result<String, String> {
105        self.next()
106            .map(str::to_string)
107            .ok_or_else(|| format!("missing value for {flag}"))
108    }
109
110    fn expect_end(&mut self) -> Result<(), String> {
111        if let Some(extra) = self.next() {
112            return Err(format!("unexpected argument '{extra}'"));
113        }
114        Ok(())
115    }
116}
117
118fn catalog() -> Result<Catalog, String> {
119    Catalog::builtin().map_err(|error| format!("catalog: {error}"))
120}
121
122fn read_file(path: &Path) -> Result<String, String> {
123    std::fs::read_to_string(path)
124        .map_err(|error| format!("cannot read {}: {error}", path.display()))
125}
126
127/// Resolve the parse locale: an explicit override always wins; otherwise
128/// auto-detect with the documented confidence gate.
129fn resolve_parse_locale(
130    input: &str,
131    catalog: &Catalog,
132    explicit: Option<Locale>,
133) -> Result<Locale, String> {
134    detect::resolve_locale(input, catalog, explicit.as_ref()).map_err(|error| error.to_string())
135}
136
137fn parse_command(args: Vec<String>) -> i32 {
138    let mut parser = ArgParser::new(args);
139    let mut file: Option<PathBuf> = None;
140    let mut locale: Option<Locale> = None;
141    loop {
142        match parser.next() {
143            None => break,
144            Some("--locale") => match parser.value_after("--locale") {
145                Ok(value) => locale = Some(Locale::new(&value)),
146                Err(error) => return usage_error(&error),
147            },
148            Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
149            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
150        }
151    }
152    let Some(file) = file else {
153        return usage_error("parse requires a file argument");
154    };
155    let (catalog, input) = match (catalog(), read_file(&file)) {
156        (Ok(catalog), Ok(input)) => (catalog, input),
157        (Err(error), _) | (_, Err(error)) => {
158            eprintln!("workshop-rs-cli: {error}");
159            return 1;
160        }
161    };
162    let locale = match resolve_parse_locale(&input, &catalog, locale) {
163        Ok(locale) => locale,
164        Err(error) => {
165            eprintln!("workshop-rs-cli: {error}");
166            return 1;
167        }
168    };
169    let program = match parser::parse_with_context(&input, &catalog, &locale, &catalog) {
170        Ok(program) => program,
171        Err(error) => {
172            eprintln!("workshop-rs-cli: {error}");
173            return 1;
174        }
175    };
176    if let Err(error) = program.validate() {
177        eprintln!("workshop-rs-cli: WIR validation failed: {error}");
178        return 1;
179    }
180    print!("{}", program.dump());
181    0
182}
183
184fn emit_command(args: Vec<String>) -> i32 {
185    let mut parser = ArgParser::new(args);
186    let mut file: Option<PathBuf> = None;
187    let mut locale: Option<Locale> = None;
188    let mut fallback: Option<Locale> = None;
189    loop {
190        match parser.next() {
191            None => break,
192            Some("--locale") => match parser.value_after("--locale") {
193                Ok(value) => locale = Some(Locale::new(&value)),
194                Err(error) => return usage_error(&error),
195            },
196            Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
197                Ok(value) => fallback = Some(Locale::new(&value)),
198                Err(error) => return usage_error(&error),
199            },
200            Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
201            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
202        }
203    }
204    let Some(file) = file else {
205        return usage_error("emit requires a file argument");
206    };
207    let (catalog, input) = match (catalog(), read_file(&file)) {
208        (Ok(catalog), Ok(input)) => (catalog, input),
209        (Err(error), _) | (_, Err(error)) => {
210            eprintln!("workshop-rs-cli: {error}");
211            return 1;
212        }
213    };
214    let locale = match resolve_parse_locale(&input, &catalog, locale) {
215        Ok(locale) => locale,
216        Err(error) => {
217            eprintln!("workshop-rs-cli: {error}");
218            return 1;
219        }
220    };
221    let program = match parser::parse_with_context(&input, &catalog, &locale, &catalog) {
222        Ok(program) => program,
223        Err(error) => {
224            eprintln!("workshop-rs-cli: {error}");
225            return 1;
226        }
227    };
228    let options = EmitOptions {
229        fallback_locale: fallback,
230    };
231    match emitter::emit_with_options(&program, &catalog, &locale, &options) {
232        Ok(output) => {
233            report_fallbacks(&output.fallback_ids);
234            print!("{}", output.text);
235            0
236        }
237        Err(error) => {
238            eprintln!("workshop-rs-cli: {error}");
239            1
240        }
241    }
242}
243
244fn convert_command(args: Vec<String>) -> i32 {
245    let mut parser = ArgParser::new(args);
246    let mut file: Option<PathBuf> = None;
247    let mut from: Option<Locale> = None;
248    let mut to: Option<Locale> = None;
249    let mut fallback: Option<Locale> = None;
250    loop {
251        match parser.next() {
252            None => break,
253            Some("--from") => match parser.value_after("--from") {
254                Ok(value) => from = Some(Locale::new(&value)),
255                Err(error) => return usage_error(&error),
256            },
257            Some("--to") => match parser.value_after("--to") {
258                Ok(value) => to = Some(Locale::new(&value)),
259                Err(error) => return usage_error(&error),
260            },
261            Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
262                Ok(value) => fallback = Some(Locale::new(&value)),
263                Err(error) => return usage_error(&error),
264            },
265            Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
266            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
267        }
268    }
269    let Some(file) = file else {
270        return usage_error("convert requires a file argument");
271    };
272    let (Some(from), Some(to)) = (from, to) else {
273        return usage_error("convert requires --from and --to locales");
274    };
275    let (catalog, input) = match (catalog(), read_file(&file)) {
276        (Ok(catalog), Ok(input)) => (catalog, input),
277        (Err(error), _) | (_, Err(error)) => {
278            eprintln!("workshop-rs-cli: {error}");
279            return 1;
280        }
281    };
282    let options = ConvertOptions {
283        fallback_locale: fallback,
284    };
285    match convert::convert(&input, &catalog, &from, &to, &options) {
286        Ok(output) => {
287            report_fallbacks(&output.fallback_ids);
288            print!("{}", output.text);
289            0
290        }
291        Err(error) => {
292            eprintln!("workshop-rs-cli: {error}");
293            1
294        }
295    }
296}
297
298/// Report opted-in fallback usage on stderr so the fallback choice is
299/// visible in tooling output (ADR-0001 Decision 7).
300fn report_fallbacks(fallback_ids: &[String]) {
301    if fallback_ids.is_empty() {
302        return;
303    }
304    eprintln!(
305        "workshop-rs-cli: note: {} canonical id(s) emitted with a fallback-locale spelling: {}",
306        fallback_ids.len(),
307        fallback_ids.join(", ")
308    );
309}
310
311fn locales_command(args: Vec<String>) -> i32 {
312    let mut parser = ArgParser::new(args);
313    if let Err(error) = parser.expect_end() {
314        return usage_error(&error);
315    }
316    let catalog = match catalog() {
317        Ok(catalog) => catalog,
318        Err(error) => {
319            eprintln!("workshop-rs-cli: {error}");
320            return 1;
321        }
322    };
323    for coverage in catalog.locale_coverage_all() {
324        println!("{} {}/{}", coverage.locale, coverage.mapped, coverage.total);
325    }
326    0
327}
328
329fn version_command(args: Vec<String>) -> i32 {
330    let mut parser = ArgParser::new(args);
331    let mut json = false;
332    loop {
333        match parser.next() {
334            None => break,
335            Some("--json") => json = true,
336            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
337        }
338    }
339    let catalog = match catalog() {
340        Ok(catalog) => catalog,
341        Err(error) => {
342            eprintln!("workshop-rs-cli: {error}");
343            return 1;
344        }
345    };
346    let identity = catalog.identity();
347    if json {
348        match serde_json::to_string_pretty(&identity) {
349            Ok(text) => println!("{text}"),
350            Err(error) => {
351                eprintln!("workshop-rs-cli: cannot serialize identity: {error}");
352                return 1;
353            }
354        }
355    } else {
356        println!(
357            "implementation version: {}",
358            identity.implementation_version
359        );
360        println!("catalog version: {}", identity.catalog_version);
361        println!(
362            "catalog digest: {}",
363            identity.catalog_digest.as_deref().unwrap_or("<none>")
364        );
365        for coverage in &identity.locale_coverage {
366            println!(
367                "locale {}: {}/{} mapped",
368                coverage.locale, coverage.mapped, coverage.total
369            );
370        }
371        println!(
372            "target: {} ({})",
373            identity.target.surface, identity.target.game
374        );
375    }
376    0
377}
378
379fn census_command(args: Vec<String>) -> i32 {
380    let json = match args.as_slice() {
381        [] => false,
382        [flag] if flag == "--json" => true,
383        _ => return usage_error("census accepts only the optional --json flag"),
384    };
385    let catalog = match Catalog::builtin() {
386        Ok(catalog) => catalog,
387        Err(error) => return usage_error(&format!("cannot load catalog: {error}")),
388    };
389    let census = match census::Census::builtin(&catalog) {
390        Ok(census) => census,
391        Err(error) => return usage_error(&format!("cannot build census: {error}")),
392    };
393    let report = census.run(&catalog);
394    if let Err(error) = report.validate_against(&catalog) {
395        return usage_error(&format!("invalid census report: {error}"));
396    }
397    if json {
398        match report.to_json() {
399            Ok(text) => println!("{text}"),
400            Err(error) => return usage_error(&format!("cannot serialize census: {error}")),
401        }
402    } else {
403        println!(
404            "census schema {} / conformance schema {}",
405            report.schema_version, report.conformance_schema_version
406        );
407        for result in &report.results {
408            println!("{}: {:?}", result.case_id, result.status);
409        }
410    }
411    if report.results.iter().any(|result| {
412        result.status == workshop_rs::conformance::ConformanceStatus::UnexpectedRegression
413    }) {
414        1
415    } else {
416        0
417    }
418}
419
420fn corpus_command(args: Vec<String>) -> i32 {
421    let mut parser = ArgParser::new(args);
422    let mut manifest: Option<PathBuf> = None;
423    let mut json = false;
424    loop {
425        match parser.next() {
426            None => break,
427            Some("--json") => json = true,
428            Some(value) if manifest.is_none() => manifest = Some(PathBuf::from(value)),
429            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
430        }
431    }
432    let Some(manifest) = manifest else {
433        return usage_error("corpus requires a manifest file");
434    };
435    match corpus::run(&manifest) {
436        Ok(report) => {
437            if json {
438                match serde_json::to_string_pretty(&report) {
439                    Ok(text) => println!("{text}"),
440                    Err(error) => {
441                        eprintln!("workshop-rs-cli: cannot serialize corpus report: {error}");
442                        return 1;
443                    }
444                }
445            } else {
446                print!("{}", report.human_summary());
447            }
448            if report.has_unexpected_regression() {
449                1
450            } else {
451                0
452            }
453        }
454        Err(error) => {
455            eprintln!("workshop-rs-cli: corpus: {error}");
456            1
457        }
458    }
459}
460
461fn seasonal_diff_command(args: Vec<String>) -> i32 {
462    let mut paths = Vec::new();
463    let mut json = false;
464    for argument in args {
465        if argument == "--json" {
466            json = true;
467        } else if paths.len() < 2 {
468            paths.push(PathBuf::from(argument));
469        } else {
470            return usage_error("seasonal-diff accepts two capture files and --json");
471        }
472    }
473    if paths.len() != 2 {
474        return usage_error("seasonal-diff requires previous and current capture files");
475    }
476    let previous = match read_file(&paths[0]) {
477        Ok(text) => text,
478        Err(error) => {
479            eprintln!("workshop-rs-cli: {error}");
480            return 1;
481        }
482    };
483    let current = match read_file(&paths[1]) {
484        Ok(text) => text,
485        Err(error) => {
486            eprintln!("workshop-rs-cli: {error}");
487            return 1;
488        }
489    };
490    let previous = match live_capture::LiveCapture::from_json(&previous) {
491        Ok(capture) => capture,
492        Err(error) => {
493            eprintln!("workshop-rs-cli: seasonal-diff: {error}");
494            return 1;
495        }
496    };
497    let current = match live_capture::LiveCapture::from_json(&current) {
498        Ok(capture) => capture,
499        Err(error) => {
500            eprintln!("workshop-rs-cli: seasonal-diff: {error}");
501            return 1;
502        }
503    };
504    let diff = match previous.diff(&current) {
505        Ok(diff) => diff,
506        Err(error) => {
507            eprintln!("workshop-rs-cli: seasonal-diff: {error}");
508            return 1;
509        }
510    };
511    if json {
512        match diff.to_json() {
513            Ok(text) => println!("{text}"),
514            Err(error) => {
515                eprintln!("workshop-rs-cli: cannot serialize seasonal diff: {error}");
516                return 1;
517            }
518        }
519    } else {
520        print!("{}", diff.human_summary());
521    }
522    0
523}
524
525fn usage_error(message: &str) -> i32 {
526    eprintln!("workshop-rs-cli: {message}");
527    eprintln!("{USAGE}");
528    2
529}