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::convert::{self, ConvertOptions};
13use workshop_rs::detect;
14use workshop_rs::emitter::{self, EmitOptions};
15use workshop_rs::parser;
16
17/// The default locale override for parsing when the input locale is not
18/// specified explicitly.
19const USAGE: &str = "\
20usage: workshop-rs-cli <command> [options]
21
22commands:
23  parse <file> [--locale LOCALE]
24      Parse raw Workshop text into validated Workshop IR and print a
25      deterministic WIR dump. Without --locale the locale is auto-detected.
26  emit <file> [--locale LOCALE] [--fallback-locale LOCALE]
27      Parse and emit localized Workshop text (fail-explicit on missing
28      target-locale mappings; --fallback-locale opts into fallback, which is
29      reported on stderr).
30  convert <file> --from LOCALE --to LOCALE [--fallback-locale LOCALE]
31      Convert raw Workshop text between locales (parse -> canonical
32      semantics -> emit). Missing target-locale mappings fail explicitly
33      unless --fallback-locale is given.
34  locales
35      List the declared locales with per-locale mapping coverage.
36  version [--json]
37      Print the machine-readable catalog identity: implementation version,
38      catalog version and content digest, locale coverage, target evidence,
39      and provenance.
40";
41
42pub fn run(args: Vec<String>) -> i32 {
43    let mut args = args.into_iter();
44    let Some(command) = args.next() else {
45        eprintln!("{USAGE}");
46        return 2;
47    };
48    let rest: Vec<String> = args.collect();
49    match command.as_str() {
50        "parse" => parse_command(rest),
51        "emit" => emit_command(rest),
52        "convert" => convert_command(rest),
53        "locales" => locales_command(rest),
54        "version" => version_command(rest),
55        "help" | "--help" | "-h" => {
56            print!("{USAGE}");
57            0
58        }
59        other => {
60            eprintln!("workshop-rs-cli: unknown command '{other}'");
61            eprintln!("{USAGE}");
62            2
63        }
64    }
65}
66
67/// `--locale LOCALE`, `--fallback-locale LOCALE`, `--from`/`--to LOCALE`,
68/// `--json`, `--file PATH`, and the positional file argument.
69struct ArgParser {
70    args: Vec<String>,
71    position: usize,
72}
73
74impl ArgParser {
75    fn new(args: Vec<String>) -> Self {
76        ArgParser { args, position: 0 }
77    }
78
79    fn next(&mut self) -> Option<&str> {
80        let value = self.args.get(self.position).map(String::as_str);
81        if value.is_some() {
82            self.position += 1;
83        }
84        value
85    }
86
87    fn value_after(&mut self, flag: &str) -> Result<String, String> {
88        self.next()
89            .map(str::to_string)
90            .ok_or_else(|| format!("missing value for {flag}"))
91    }
92
93    fn expect_end(&mut self) -> Result<(), String> {
94        if let Some(extra) = self.next() {
95            return Err(format!("unexpected argument '{extra}'"));
96        }
97        Ok(())
98    }
99}
100
101fn catalog() -> Result<Catalog, String> {
102    Catalog::builtin().map_err(|error| format!("catalog: {error}"))
103}
104
105fn read_file(path: &Path) -> Result<String, String> {
106    std::fs::read_to_string(path)
107        .map_err(|error| format!("cannot read {}: {error}", path.display()))
108}
109
110/// Resolve the parse locale: an explicit override always wins; otherwise
111/// auto-detect with the documented confidence gate.
112fn resolve_parse_locale(
113    input: &str,
114    catalog: &Catalog,
115    explicit: Option<Locale>,
116) -> Result<Locale, String> {
117    detect::resolve_locale(input, catalog, explicit.as_ref()).map_err(|error| error.to_string())
118}
119
120fn parse_command(args: Vec<String>) -> i32 {
121    let mut parser = ArgParser::new(args);
122    let mut file: Option<PathBuf> = None;
123    let mut locale: Option<Locale> = None;
124    loop {
125        match parser.next() {
126            None => break,
127            Some("--locale") => match parser.value_after("--locale") {
128                Ok(value) => locale = Some(Locale::new(&value)),
129                Err(error) => return usage_error(&error),
130            },
131            Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
132            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
133        }
134    }
135    let Some(file) = file else {
136        return usage_error("parse requires a file argument");
137    };
138    let (catalog, input) = match (catalog(), read_file(&file)) {
139        (Ok(catalog), Ok(input)) => (catalog, input),
140        (Err(error), _) | (_, Err(error)) => {
141            eprintln!("workshop-rs-cli: {error}");
142            return 1;
143        }
144    };
145    let locale = match resolve_parse_locale(&input, &catalog, locale) {
146        Ok(locale) => locale,
147        Err(error) => {
148            eprintln!("workshop-rs-cli: {error}");
149            return 1;
150        }
151    };
152    let program = match parser::parse_with_context(&input, &catalog, &locale, &catalog) {
153        Ok(program) => program,
154        Err(error) => {
155            eprintln!("workshop-rs-cli: {error}");
156            return 1;
157        }
158    };
159    if let Err(error) = program.validate() {
160        eprintln!("workshop-rs-cli: WIR validation failed: {error}");
161        return 1;
162    }
163    print!("{}", program.dump());
164    0
165}
166
167fn emit_command(args: Vec<String>) -> i32 {
168    let mut parser = ArgParser::new(args);
169    let mut file: Option<PathBuf> = None;
170    let mut locale: Option<Locale> = None;
171    let mut fallback: Option<Locale> = None;
172    loop {
173        match parser.next() {
174            None => break,
175            Some("--locale") => match parser.value_after("--locale") {
176                Ok(value) => locale = Some(Locale::new(&value)),
177                Err(error) => return usage_error(&error),
178            },
179            Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
180                Ok(value) => fallback = Some(Locale::new(&value)),
181                Err(error) => return usage_error(&error),
182            },
183            Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
184            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
185        }
186    }
187    let Some(file) = file else {
188        return usage_error("emit requires a file argument");
189    };
190    let (catalog, input) = match (catalog(), read_file(&file)) {
191        (Ok(catalog), Ok(input)) => (catalog, input),
192        (Err(error), _) | (_, Err(error)) => {
193            eprintln!("workshop-rs-cli: {error}");
194            return 1;
195        }
196    };
197    let locale = match resolve_parse_locale(&input, &catalog, locale) {
198        Ok(locale) => locale,
199        Err(error) => {
200            eprintln!("workshop-rs-cli: {error}");
201            return 1;
202        }
203    };
204    let program = match parser::parse_with_context(&input, &catalog, &locale, &catalog) {
205        Ok(program) => program,
206        Err(error) => {
207            eprintln!("workshop-rs-cli: {error}");
208            return 1;
209        }
210    };
211    let options = EmitOptions {
212        fallback_locale: fallback,
213    };
214    match emitter::emit_with_options(&program, &catalog, &locale, &options) {
215        Ok(output) => {
216            report_fallbacks(&output.fallback_ids);
217            print!("{}", output.text);
218            0
219        }
220        Err(error) => {
221            eprintln!("workshop-rs-cli: {error}");
222            1
223        }
224    }
225}
226
227fn convert_command(args: Vec<String>) -> i32 {
228    let mut parser = ArgParser::new(args);
229    let mut file: Option<PathBuf> = None;
230    let mut from: Option<Locale> = None;
231    let mut to: Option<Locale> = None;
232    let mut fallback: Option<Locale> = None;
233    loop {
234        match parser.next() {
235            None => break,
236            Some("--from") => match parser.value_after("--from") {
237                Ok(value) => from = Some(Locale::new(&value)),
238                Err(error) => return usage_error(&error),
239            },
240            Some("--to") => match parser.value_after("--to") {
241                Ok(value) => to = Some(Locale::new(&value)),
242                Err(error) => return usage_error(&error),
243            },
244            Some("--fallback-locale") => match parser.value_after("--fallback-locale") {
245                Ok(value) => fallback = Some(Locale::new(&value)),
246                Err(error) => return usage_error(&error),
247            },
248            Some(value) if file.is_none() => file = Some(PathBuf::from(value)),
249            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
250        }
251    }
252    let Some(file) = file else {
253        return usage_error("convert requires a file argument");
254    };
255    let (Some(from), Some(to)) = (from, to) else {
256        return usage_error("convert requires --from and --to locales");
257    };
258    let (catalog, input) = match (catalog(), read_file(&file)) {
259        (Ok(catalog), Ok(input)) => (catalog, input),
260        (Err(error), _) | (_, Err(error)) => {
261            eprintln!("workshop-rs-cli: {error}");
262            return 1;
263        }
264    };
265    let options = ConvertOptions {
266        fallback_locale: fallback,
267    };
268    match convert::convert(&input, &catalog, &from, &to, &options) {
269        Ok(output) => {
270            report_fallbacks(&output.fallback_ids);
271            print!("{}", output.text);
272            0
273        }
274        Err(error) => {
275            eprintln!("workshop-rs-cli: {error}");
276            1
277        }
278    }
279}
280
281/// Report opted-in fallback usage on stderr so the fallback choice is
282/// visible in tooling output (ADR-0001 Decision 7).
283fn report_fallbacks(fallback_ids: &[String]) {
284    if fallback_ids.is_empty() {
285        return;
286    }
287    eprintln!(
288        "workshop-rs-cli: note: {} canonical id(s) emitted with a fallback-locale spelling: {}",
289        fallback_ids.len(),
290        fallback_ids.join(", ")
291    );
292}
293
294fn locales_command(args: Vec<String>) -> i32 {
295    let mut parser = ArgParser::new(args);
296    if let Err(error) = parser.expect_end() {
297        return usage_error(&error);
298    }
299    let catalog = match catalog() {
300        Ok(catalog) => catalog,
301        Err(error) => {
302            eprintln!("workshop-rs-cli: {error}");
303            return 1;
304        }
305    };
306    for coverage in catalog.locale_coverage_all() {
307        println!("{} {}/{}", coverage.locale, coverage.mapped, coverage.total);
308    }
309    0
310}
311
312fn version_command(args: Vec<String>) -> i32 {
313    let mut parser = ArgParser::new(args);
314    let mut json = false;
315    loop {
316        match parser.next() {
317            None => break,
318            Some("--json") => json = true,
319            Some(value) => return usage_error(&format!("unexpected argument '{value}'")),
320        }
321    }
322    let catalog = match catalog() {
323        Ok(catalog) => catalog,
324        Err(error) => {
325            eprintln!("workshop-rs-cli: {error}");
326            return 1;
327        }
328    };
329    let identity = catalog.identity();
330    if json {
331        match serde_json::to_string_pretty(&identity) {
332            Ok(text) => println!("{text}"),
333            Err(error) => {
334                eprintln!("workshop-rs-cli: cannot serialize identity: {error}");
335                return 1;
336            }
337        }
338    } else {
339        println!(
340            "implementation version: {}",
341            identity.implementation_version
342        );
343        println!("catalog version: {}", identity.catalog_version);
344        println!(
345            "catalog digest: {}",
346            identity.catalog_digest.as_deref().unwrap_or("<none>")
347        );
348        for coverage in &identity.locale_coverage {
349            println!(
350                "locale {}: {}/{} mapped",
351                coverage.locale, coverage.mapped, coverage.total
352            );
353        }
354        println!(
355            "target: {} ({})",
356            identity.target.surface, identity.target.game
357        );
358    }
359    0
360}
361
362fn usage_error(message: &str) -> i32 {
363    eprintln!("workshop-rs-cli: {message}");
364    eprintln!("{USAGE}");
365    2
366}