Skip to main content

greentic_component/
cli.rs

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    /// Scaffold a new Greentic component project
33    New(Box<NewArgs>),
34    /// Component wizard helpers
35    Wizard(Box<WizardCliArgs>),
36    /// List available component templates
37    Templates(TemplatesArgs),
38    /// Run component doctor checks
39    Doctor(DoctorArgs),
40    /// Inspect manifests and describe payloads
41    Inspect(InspectArgs),
42    /// Recompute manifest hashes
43    Hash(HashArgs),
44    /// Build component wasm + update config flows
45    Build(BuildArgs),
46    /// Invoke a component locally with an in-memory state/secrets harness
47    #[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    /// Flow utilities (config flow regeneration)
53    #[command(subcommand)]
54    Flow(FlowCommand),
55    /// Interact with the component store
56    #[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}