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