1use std::ffi::OsString;
2
3use anyhow::{Error, Result, bail};
4use clap::{Arg, ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand};
5
6#[cfg(feature = "store")]
7use crate::cmd::store::StoreCommand;
8use crate::cmd::{
9 self, build::BuildArgs, doctor::DoctorArgs, flow::FlowCommand, hash::HashArgs,
10 inspect::InspectArgs, new::NewArgs, templates::TemplatesArgs, test::TestArgs,
11 wizard::WizardCliArgs,
12};
13use crate::scaffold::engine::ScaffoldEngine;
14
15#[derive(Parser, Debug)]
16#[command(
17 name = "greentic-component",
18 about = "Toolkit for Greentic component developers",
19 version,
20 arg_required_else_help = true
21)]
22pub struct Cli {
23 #[arg(long = "locale", value_name = "LOCALE", global = true)]
24 locale: Option<String>,
25
26 #[command(subcommand)]
27 command: Commands,
28}
29
30#[derive(Subcommand, Debug)]
31enum Commands {
32 New(Box<NewArgs>),
34 Wizard(Box<WizardCliArgs>),
36 Templates(TemplatesArgs),
38 Doctor(DoctorArgs),
40 Inspect(InspectArgs),
42 Hash(HashArgs),
44 Build(BuildArgs),
46 #[command(
48 long_about = "Invoke a component locally with in-memory state/secrets. \
49See docs/component-developer-guide.md for a walkthrough."
50 )]
51 Test(Box<TestArgs>),
52 #[command(subcommand)]
54 Flow(FlowCommand),
55 #[cfg(feature = "store")]
57 #[command(subcommand)]
58 Store(StoreCommand),
59}
60
61pub fn main() -> Result<()> {
62 let argv: Vec<OsString> = std::env::args_os().collect();
63 cmd::i18n::init(cmd::i18n::cli_locale_from_argv(&argv));
64
65 let mut command = localize_help(Cli::command(), true);
66 let matches = match command.try_get_matches_from_mut(argv) {
67 Ok(matches) => matches,
68 Err(err) => err.exit(),
69 };
70 let cli = Cli::from_arg_matches(&matches).map_err(|err| Error::msg(err.to_string()))?;
71 cmd::i18n::init(cli.locale.clone());
72 let engine = ScaffoldEngine::new();
73 match cli.command {
74 Commands::New(args) => cmd::new::run(*args, &engine),
75 Commands::Wizard(command) => cmd::wizard::run_cli(*command),
76 Commands::Templates(args) => cmd::templates::run(args, &engine),
77 Commands::Doctor(args) => cmd::doctor::run(args).map_err(Error::new),
78 Commands::Inspect(args) => {
79 let result = cmd::inspect::run(&args)?;
80 cmd::inspect::emit_warnings(&result.warnings);
81 if args.strict && !result.warnings.is_empty() {
82 bail!(
83 "component-inspect: {} warning(s) treated as errors (--strict)",
84 result.warnings.len()
85 );
86 }
87 Ok(())
88 }
89 Commands::Hash(args) => cmd::hash::run(args),
90 Commands::Build(args) => cmd::build::run(args),
91 Commands::Test(args) => cmd::test::run(*args),
92 Commands::Flow(flow_cmd) => cmd::flow::run(flow_cmd),
93 #[cfg(feature = "store")]
94 Commands::Store(store_cmd) => cmd::store::run(store_cmd),
95 }
96}
97
98fn localize_help(mut command: clap::Command, is_root: bool) -> clap::Command {
99 if let Some(about) = command.get_about().map(|s| s.to_string()) {
100 command = command.about(cmd::i18n::tr_lit(&about));
101 }
102 if let Some(long_about) = command.get_long_about().map(|s| s.to_string()) {
103 command = command.long_about(cmd::i18n::tr_lit(&long_about));
104 }
105 if let Some(before) = command.get_before_help().map(|s| s.to_string()) {
106 command = command.before_help(cmd::i18n::tr_lit(&before));
107 }
108 if let Some(after) = command.get_after_help().map(|s| s.to_string()) {
109 command = command.after_help(cmd::i18n::tr_lit(&after));
110 }
111
112 command = command
113 .disable_help_subcommand(true)
114 .disable_help_flag(true)
115 .arg(
116 Arg::new("help")
117 .short('h')
118 .long("help")
119 .action(ArgAction::Help)
120 .help(cmd::i18n::tr_lit("Print help")),
121 );
122 if is_root {
123 command = command.disable_version_flag(true).arg(
124 Arg::new("version")
125 .short('V')
126 .long("version")
127 .action(ArgAction::Version)
128 .help(cmd::i18n::tr_lit("Print version")),
129 );
130 }
131
132 let arg_ids = command
133 .get_arguments()
134 .map(|arg| arg.get_id().clone())
135 .collect::<Vec<_>>();
136 for arg_id in arg_ids {
137 command = command.mut_arg(arg_id, |arg| {
138 let mut arg = arg;
139 if let Some(help) = arg.get_help().map(ToString::to_string) {
140 arg = arg.help(cmd::i18n::tr_lit(&help));
141 }
142 if let Some(long_help) = arg.get_long_help().map(ToString::to_string) {
143 arg = arg.long_help(cmd::i18n::tr_lit(&long_help));
144 }
145 arg
146 });
147 }
148
149 let sub_names = command
150 .get_subcommands()
151 .map(|sub| sub.get_name().to_string())
152 .collect::<Vec<_>>();
153 for name in sub_names {
154 command = command.mut_subcommand(name, |sub| localize_help(sub, false));
155 }
156 command
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn parses_new_subcommand() {
165 let cli = Cli::try_parse_from([
166 "greentic-component",
167 "--locale",
168 "nl",
169 "new",
170 "--name",
171 "demo",
172 "--json",
173 ])
174 .expect("expected CLI to parse");
175 assert_eq!(cli.locale.as_deref(), Some("nl"));
176 match cli.command {
177 Commands::New(args) => {
178 assert_eq!(args.name, "demo");
179 assert!(args.json);
180 assert!(!args.no_check);
181 assert!(!args.no_git);
182 assert!(args.operation_names.is_empty());
183 assert_eq!(args.default_operation, None);
184 }
185 _ => panic!("expected new args"),
186 }
187 }
188
189 #[test]
190 fn parses_new_operation_flags() {
191 let cli = Cli::try_parse_from([
192 "greentic-component",
193 "new",
194 "--name",
195 "demo",
196 "--operation",
197 "render,sync-state",
198 "--default-operation",
199 "sync-state",
200 ])
201 .expect("expected CLI to parse");
202 match cli.command {
203 Commands::New(args) => {
204 assert_eq!(args.operation_names, vec!["render", "sync-state"]);
205 assert_eq!(args.default_operation.as_deref(), Some("sync-state"));
206 }
207 _ => panic!("expected new args"),
208 }
209 }
210
211 #[test]
212 fn parses_wizard_command() {
213 let cli = Cli::try_parse_from([
214 "greentic-component",
215 "wizard",
216 "--mode",
217 "doctor",
218 "--execution",
219 "dry-run",
220 "--locale",
221 "ar",
222 ])
223 .expect("expected CLI to parse");
224 assert_eq!(cli.locale.as_deref(), Some("ar"));
225 match cli.command {
226 Commands::Wizard(args) => {
227 assert!(matches!(
228 args.args.mode,
229 crate::cmd::wizard::RunMode::Doctor
230 ));
231 assert!(matches!(
232 args.args.execution,
233 crate::cmd::wizard::ExecutionMode::DryRun
234 ));
235 }
236 _ => panic!("expected wizard args"),
237 }
238 }
239
240 #[test]
241 fn parses_wizard_legacy_new_command() {
242 let cli = Cli::try_parse_from([
243 "greentic-component",
244 "wizard",
245 "new",
246 "wizard-smoke",
247 "--out",
248 "/tmp",
249 ])
250 .expect("expected CLI to parse");
251 match cli.command {
252 Commands::Wizard(args) => match args.command {
253 Some(crate::cmd::wizard::WizardSubcommand::New(new_args)) => {
254 assert_eq!(new_args.name.as_deref(), Some("wizard-smoke"));
255 assert_eq!(new_args.out.as_deref(), Some(std::path::Path::new("/tmp")));
256 }
257 _ => panic!("expected wizard new subcommand"),
258 },
259 _ => panic!("expected wizard args"),
260 }
261 }
262
263 #[test]
264 fn parses_wizard_validate_command_alias() {
265 let cli = Cli::try_parse_from([
266 "greentic-component",
267 "wizard",
268 "validate",
269 "--mode",
270 "create",
271 ])
272 .expect("expected CLI to parse");
273 match cli.command {
274 Commands::Wizard(args) => assert!(matches!(
275 args.command,
276 Some(crate::cmd::wizard::WizardSubcommand::Validate(_))
277 )),
278 _ => panic!("expected wizard args"),
279 }
280 }
281
282 #[test]
283 fn parses_wizard_validate_flag() {
284 let cli = Cli::try_parse_from([
285 "greentic-component",
286 "wizard",
287 "--validate",
288 "--mode",
289 "doctor",
290 ])
291 .expect("expected CLI to parse");
292 match cli.command {
293 Commands::Wizard(args) => {
294 assert!(args.args.validate);
295 assert!(!args.args.apply);
296 assert!(matches!(
297 args.args.mode,
298 crate::cmd::wizard::RunMode::Doctor
299 ));
300 }
301 _ => panic!("expected wizard args"),
302 }
303 }
304
305 #[test]
306 fn parses_wizard_answers_aliases() {
307 let cli = Cli::try_parse_from([
308 "greentic-component",
309 "wizard",
310 "--answers",
311 "in.json",
312 "--emit-answers",
313 "out.json",
314 "--schema-version",
315 "1.2.3",
316 "--migrate",
317 ])
318 .expect("expected CLI to parse");
319 match cli.command {
320 Commands::Wizard(args) => {
321 assert_eq!(
322 args.args.answers.as_deref(),
323 Some(std::path::Path::new("in.json"))
324 );
325 assert_eq!(
326 args.args.emit_answers.as_deref(),
327 Some(std::path::Path::new("out.json"))
328 );
329 assert_eq!(args.args.schema_version.as_deref(), Some("1.2.3"));
330 assert!(args.args.migrate);
331 }
332 _ => panic!("expected wizard args"),
333 }
334 }
335
336 #[cfg(feature = "store")]
337 #[test]
338 fn parses_store_fetch_command() {
339 let cli = Cli::try_parse_from([
340 "greentic-component",
341 "--locale",
342 "nl",
343 "store",
344 "fetch",
345 "--out",
346 "/tmp/out",
347 "file:///tmp/component.wasm",
348 ])
349 .expect("expected CLI to parse");
350 assert_eq!(cli.locale.as_deref(), Some("nl"));
351 match cli.command {
352 Commands::Store(crate::cmd::store::StoreCommand::Fetch(args)) => {
353 assert_eq!(args.out, std::path::PathBuf::from("/tmp/out"));
354 assert_eq!(args.source, "file:///tmp/component.wasm");
355 }
356 _ => panic!("expected store fetch args"),
357 }
358 }
359}