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 evidence,
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 #18 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 options = EmitOptions {
233        fallback_locale: fallback,
234    };
235    match emitter::emit_with_options(&program, &catalog, &locale, &options) {
236        Ok(output) => {
237            report_fallbacks(&output.fallback_ids);
238            print!("{}", output.text);
239            0
240        }
241        Err(error) => {
242            eprintln!("workshop-rs-cli: {error}");
243            1
244        }
245    }
246}
247
248fn convert_command(args: Vec<String>) -> i32 {
249    let mut parser = ArgParser::new(args);
250    let mut file: Option<PathBuf> = None;
251    let mut from: Option<Locale> = None;
252    let mut to: Option<Locale> = None;
253    let mut fallback: Option<Locale> = None;
254    loop {
255        match parser.next() {
256            None => break,
257            Some("--from") => match parser.value_after("--from") {
258                Ok(value) => from = Some(Locale::new(&value)),
259                Err(error) => return usage_error(&error),
260            },
261            Some("--to") => match parser.value_after("--to") {
262                Ok(value) => to = Some(Locale::new(&value)),
263                Err(error) => return usage_error(&error),
264            },
265            Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
266                Ok(value) => fallback = Some(Locale::new(&value)),
267                Err(error) => return usage_error(&error),
268            },
269            Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
270            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
271        }
272    }
273    let Some(file) = file else {
274        return usage_error("convert requires a file argument");
275    };
276    let (Some(from), Some(to)) = (from, to) else {
277        return usage_error("convert requires --from and --to locales");
278    };
279    let (catalog, input) = match (catalog(), read_file(&file)) {
280        (Ok(catalog), Ok(input)) => (catalog, input),
281        (Err(error), _) | (_, Err(error)) => {
282            eprintln!("workshop-rs-cli: {error}");
283            return 1;
284        }
285    };
286    let options = ConvertOptions {
287        fallback_locale: fallback,
288    };
289    match convert::convert(&input, &catalog, &from, &to, &options) {
290        Ok(output) => {
291            report_fallbacks(&output.fallback_ids);
292            print!("{}", output.text);
293            0
294        }
295        Err(error) => {
296            eprintln!("workshop-rs-cli: {error}");
297            1
298        }
299    }
300}
301
302/// Report opted-in fallback usage on stderr so the fallback choice is
303/// visible in tooling output (ADR-0001 Decision 7).
304fn report_fallbacks(fallback_ids: &[String]) {
305    if fallback_ids.is_empty() {
306        return;
307    }
308    eprintln!(
309        "workshop-rs-cli: note: {} canonical id(s) emitted with a fallback-locale spelling: {}",
310        fallback_ids.len(),
311        fallback_ids.join(", ")
312    );
313}
314
315fn locales_command(args: Vec<String>) -> i32 {
316    let mut parser = ArgParser::new(args);
317    if let Err(error) = parser.expect_end() {
318        return usage_error(&error);
319    }
320    let catalog = match catalog() {
321        Ok(catalog) => catalog,
322        Err(error) => {
323            eprintln!("workshop-rs-cli: {error}");
324            return 1;
325        }
326    };
327    for coverage in catalog.locale_coverage_all() {
328        println!("{} {}/{}", coverage.locale, coverage.mapped, coverage.total);
329    }
330    0
331}
332
333fn version_command(args: Vec<String>) -> i32 {
334    let mut parser = ArgParser::new(args);
335    let mut json = false;
336    loop {
337        match parser.next() {
338            None => break,
339            Some("--json") => json = true,
340            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
341        }
342    }
343    let catalog = match catalog() {
344        Ok(catalog) => catalog,
345        Err(error) => {
346            eprintln!("workshop-rs-cli: {error}");
347            return 1;
348        }
349    };
350    let identity = catalog.identity();
351    if json {
352        match serde_json::to_string_pretty(&identity) {
353            Ok(text) => println!("{text}"),
354            Err(error) => {
355                eprintln!("workshop-rs-cli: cannot serialize identity: {error}");
356                return 1;
357            }
358        }
359    } else {
360        println!(
361            "implementation version: {}",
362            identity.implementation_version
363        );
364        println!("catalog version: {}", identity.catalog_version);
365        println!(
366            "catalog digest: {}",
367            identity.catalog_digest.as_deref().unwrap_or("<none>")
368        );
369        for coverage in &identity.locale_coverage {
370            println!(
371                "locale {}: {}/{} mapped",
372                coverage.locale, coverage.mapped, coverage.total
373            );
374        }
375        println!(
376            "target: {} ({})",
377            identity.target.surface, identity.target.game
378        );
379    }
380    0
381}
382
383fn census_command(args: Vec<String>) -> i32 {
384    let json = match args.as_slice() {
385        [] => false,
386        [flag] if flag == "--json" => true,
387        _ => return usage_error("census accepts only the optional --json flag"),
388    };
389    let catalog = match Catalog::builtin() {
390        Ok(catalog) => catalog,
391        Err(error) => return usage_error(&format!("cannot load catalog: {error}")),
392    };
393    let census = match census::Census::builtin(&catalog) {
394        Ok(census) => census,
395        Err(error) => return usage_error(&format!("cannot build census: {error}")),
396    };
397    let report = census.run(&catalog);
398    if let Err(error) = report.validate_against(&catalog) {
399        return usage_error(&format!("invalid census report: {error}"));
400    }
401    if json {
402        match report.to_json() {
403            Ok(text) => println!("{text}"),
404            Err(error) => return usage_error(&format!("cannot serialize census: {error}")),
405        }
406    } else {
407        println!(
408            "census schema {} / conformance schema {}",
409            report.schema_version, report.conformance_schema_version
410        );
411        for result in &report.results {
412            println!("{}: {:?}", result.case_id, result.status);
413        }
414    }
415    if report
416        .results
417        .iter()
418        .any(|result| result.status == conformance::ConformanceStatus::UnexpectedRegression)
419    {
420        1
421    } else {
422        0
423    }
424}
425
426fn corpus_command(args: Vec<String>) -> i32 {
427    let mut parser = ArgParser::new(args);
428    let mut manifest: Option<PathBuf> = None;
429    let mut json = false;
430    loop {
431        match parser.next() {
432            None => break,
433            Some("--json") => json = true,
434            Some(value) if manifest.is_none() => manifest = Some(PathBuf::from(value)),
435            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
436        }
437    }
438    let Some(manifest) = manifest else {
439        return usage_error("corpus requires a manifest file");
440    };
441    match corpus::run(&manifest) {
442        Ok(report) => {
443            if json {
444                match serde_json::to_string_pretty(&report) {
445                    Ok(text) => println!("{text}"),
446                    Err(error) => {
447                        eprintln!("workshop-rs-cli: cannot serialize corpus report: {error}");
448                        return 1;
449                    }
450                }
451            } else {
452                print!("{}", report.human_summary());
453            }
454            if report.has_unexpected_regression() {
455                1
456            } else {
457                0
458            }
459        }
460        Err(error) => {
461            eprintln!("workshop-rs-cli: corpus: {error}");
462            1
463        }
464    }
465}
466
467fn seasonal_diff_command(args: Vec<String>) -> i32 {
468    let mut paths = Vec::new();
469    let mut json = false;
470    for argument in args {
471        if argument == "--json" {
472            json = true;
473        } else if paths.len() < 2 {
474            paths.push(PathBuf::from(argument));
475        } else {
476            return usage_error("seasonal-diff accepts two capture files and --json");
477        }
478    }
479    if paths.len() != 2 {
480        return usage_error("seasonal-diff requires previous and current capture files");
481    }
482    let previous = match read_file(&paths[0]) {
483        Ok(text) => text,
484        Err(error) => {
485            eprintln!("workshop-rs-cli: {error}");
486            return 1;
487        }
488    };
489    let current = match read_file(&paths[1]) {
490        Ok(text) => text,
491        Err(error) => {
492            eprintln!("workshop-rs-cli: {error}");
493            return 1;
494        }
495    };
496    let previous = match live_capture::LiveCapture::from_json(&previous) {
497        Ok(capture) => capture,
498        Err(error) => {
499            eprintln!("workshop-rs-cli: seasonal-diff: {error}");
500            return 1;
501        }
502    };
503    let current = match live_capture::LiveCapture::from_json(&current) {
504        Ok(capture) => capture,
505        Err(error) => {
506            eprintln!("workshop-rs-cli: seasonal-diff: {error}");
507            return 1;
508        }
509    };
510    let diff = match previous.diff(&current) {
511        Ok(diff) => diff,
512        Err(error) => {
513            eprintln!("workshop-rs-cli: seasonal-diff: {error}");
514            return 1;
515        }
516    };
517    if json {
518        match diff.to_json() {
519            Ok(text) => println!("{text}"),
520            Err(error) => {
521                eprintln!("workshop-rs-cli: cannot serialize seasonal diff: {error}");
522                return 1;
523            }
524        }
525    } else {
526        print!("{}", diff.human_summary());
527    }
528    0
529}
530
531fn usage_error(message: &str) -> i32 {
532    eprintln!("workshop-rs-cli: {message}");
533    eprintln!("{USAGE}");
534    2
535}