Skip to main content

greentic_bundle/cli/
mod.rs

1use std::ffi::OsString;
2
3use anyhow::Result;
4use clap::{Arg, ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand};
5
6pub mod access;
7pub mod add;
8pub mod build;
9pub mod doctor;
10pub mod export;
11pub mod init;
12pub mod inspect;
13pub mod remove;
14pub mod unbundle;
15pub mod wizard;
16
17#[derive(Debug, Parser)]
18#[command(
19    name = "greentic-bundle",
20    about = "cli.root.about",
21    long_about = "cli.root.long_about",
22    version,
23    arg_required_else_help = true
24)]
25pub struct Cli {
26    #[arg(
27        long = "locale",
28        value_name = "LOCALE",
29        global = true,
30        help = "cli.option.locale"
31    )]
32    locale: Option<String>,
33
34    #[arg(
35        long = "offline",
36        global = true,
37        default_value_t = false,
38        help = "cli.option.offline"
39    )]
40    offline: bool,
41
42    #[command(subcommand)]
43    command: Commands,
44}
45
46#[derive(Debug, Subcommand)]
47enum Commands {
48    #[command(about = "cli.wizard.about")]
49    Wizard(wizard::WizardArgs),
50    #[command(about = "cli.doctor.about")]
51    Doctor(doctor::DoctorArgs),
52    #[command(about = "cli.build.about", long_about = "cli.build.long_about")]
53    Build(build::BuildArgs),
54    #[command(about = "cli.export.about", long_about = "cli.export.long_about")]
55    Export(export::ExportArgs),
56    #[command(about = "cli.inspect.about")]
57    Inspect(inspect::InspectArgs),
58    #[command(about = "cli.unbundle.about")]
59    Unbundle(unbundle::UnbundleArgs),
60    #[command(about = "cli.add.about")]
61    Add(add::AddArgs),
62    #[command(about = "cli.remove.about")]
63    Remove(remove::RemoveArgs),
64    #[command(about = "cli.access.about")]
65    Access(access::AccessArgs),
66    #[command(about = "cli.init.about")]
67    Init(init::InitArgs),
68}
69
70pub fn run() -> Result<()> {
71    let argv: Vec<OsString> = std::env::args_os().collect();
72    crate::i18n::init(crate::i18n::cli_locale_from_argv(&argv));
73
74    let mut command = localized_command(true);
75    let matches = match command.try_get_matches_from_mut(argv) {
76        Ok(matches) => matches,
77        Err(err) => err.exit(),
78    };
79    let cli = Cli::from_arg_matches(&matches)?;
80    crate::i18n::init(cli.locale.clone());
81    crate::runtime::set_offline(cli.offline);
82    cli.dispatch()
83}
84
85pub fn localized_command(is_root: bool) -> clap::Command {
86    localize_help(Cli::command(), is_root)
87}
88
89impl Cli {
90    fn dispatch(self) -> Result<()> {
91        match self.command {
92            Commands::Wizard(args) => wizard::run(args),
93            Commands::Doctor(args) => doctor::run(args),
94            Commands::Build(args) => build::run(args),
95            Commands::Export(args) => export::run(args),
96            Commands::Inspect(args) => inspect::run(args),
97            Commands::Unbundle(args) => unbundle::run(args),
98            Commands::Add(args) => add::run(args),
99            Commands::Remove(args) => remove::run(args),
100            Commands::Access(args) => access::run(args),
101            Commands::Init(args) => init::run(args),
102        }
103    }
104}
105
106fn localize_help(mut command: clap::Command, is_root: bool) -> clap::Command {
107    if let Some(about) = command.get_about().map(|s| s.to_string()) {
108        command = command.about(crate::i18n::tr(&about));
109    }
110    if let Some(long_about) = command.get_long_about().map(|s| s.to_string()) {
111        command = command.long_about(crate::i18n::tr(&long_about));
112    }
113    if let Some(before) = command.get_before_help().map(|s| s.to_string()) {
114        command = command.before_help(crate::i18n::tr(&before));
115    }
116    if let Some(after) = command.get_after_help().map(|s| s.to_string()) {
117        command = command.after_help(crate::i18n::tr(&after));
118    }
119
120    command = command
121        .disable_help_subcommand(true)
122        .disable_help_flag(true)
123        .arg(
124            Arg::new("help")
125                .short('h')
126                .long("help")
127                .action(ArgAction::Help)
128                .help(crate::i18n::tr("cli.help.flag")),
129        );
130    if is_root {
131        command = command.disable_version_flag(true).arg(
132            Arg::new("version")
133                .short('V')
134                .long("version")
135                .action(ArgAction::Version)
136                .help(crate::i18n::tr("cli.version.flag")),
137        );
138    }
139
140    let arg_ids = command
141        .get_arguments()
142        .map(|arg| arg.get_id().clone())
143        .collect::<Vec<_>>();
144    for arg_id in arg_ids {
145        command = command.mut_arg(arg_id, |arg| {
146            let mut arg = arg;
147            if let Some(help) = arg.get_help().map(ToString::to_string) {
148                arg = arg.help(crate::i18n::tr(&help));
149            }
150            if let Some(long_help) = arg.get_long_help().map(ToString::to_string) {
151                arg = arg.long_help(crate::i18n::tr(&long_help));
152            }
153            arg
154        });
155    }
156
157    let sub_names = command
158        .get_subcommands()
159        .map(|sub| sub.get_name().to_string())
160        .collect::<Vec<_>>();
161    for name in sub_names {
162        command = command.mut_subcommand(name, |sub| localize_help(sub, false));
163    }
164    command
165}
166
167#[cfg(test)]
168mod tests {
169    use clap::Parser;
170
171    use super::{Cli, Commands};
172
173    #[test]
174    fn parses_global_locale_and_wizard_flags() {
175        let cli = Cli::try_parse_from([
176            "greentic-bundle",
177            "--locale",
178            "en-US",
179            "wizard",
180            "run",
181            "--schema",
182            "--answers",
183            "answers.json",
184            "--emit-answers",
185            "out.json",
186            "--schema-version",
187            "1.2.3",
188            "--migrate",
189            "--dry-run",
190        ])
191        .expect("cli parses");
192
193        assert_eq!(cli.locale.as_deref(), Some("en-US"));
194        match cli.command {
195            Commands::Wizard(args) => {
196                assert!(args.schema);
197                match args.command.expect("wizard subcommand") {
198                    super::wizard::WizardCommand::Run(run) => {
199                        assert_eq!(
200                            run.answers.as_deref(),
201                            Some(std::path::Path::new("answers.json"))
202                        );
203                        assert_eq!(
204                            run.emit_answers.as_deref(),
205                            Some(std::path::Path::new("out.json"))
206                        );
207                        assert_eq!(run.schema_version.as_deref(), Some("1.2.3"));
208                        assert!(run.migrate);
209                        assert!(run.dry_run);
210                    }
211                    _ => panic!("expected run"),
212                }
213            }
214            _ => panic!("expected wizard"),
215        }
216    }
217
218    #[test]
219    fn parses_access_allow_execute_flag() {
220        let cli = Cli::try_parse_from([
221            "greentic-bundle",
222            "access",
223            "allow",
224            "tenant-a",
225            "--execute",
226        ])
227        .expect("cli parses");
228
229        match cli.command {
230            Commands::Access(args) => match args.command {
231                super::access::AccessCommand::Allow(allow) => {
232                    assert_eq!(allow.subject, "tenant-a");
233                    assert!(allow.execute);
234                    assert!(!allow.dry_run);
235                }
236                _ => panic!("expected access allow"),
237            },
238            _ => panic!("expected access"),
239        }
240    }
241
242    #[test]
243    fn parses_build_export_doctor_and_inspect_flags() {
244        let build = Cli::try_parse_from([
245            "greentic-bundle",
246            "build",
247            "--root",
248            "bundle",
249            "--output",
250            "out.gtbundle",
251            "--dry-run",
252        ])
253        .expect("build parses");
254        match build.command {
255            Commands::Build(args) => {
256                assert_eq!(args.root, std::path::PathBuf::from("bundle"));
257                assert_eq!(args.output, Some(std::path::PathBuf::from("out.gtbundle")));
258                assert!(args.dry_run);
259            }
260            _ => panic!("expected build"),
261        }
262
263        let doctor = Cli::try_parse_from([
264            "greentic-bundle",
265            "doctor",
266            "--artifact",
267            "demo.gtbundle",
268            "--json",
269        ])
270        .expect("doctor parses");
271        match doctor.command {
272            Commands::Doctor(args) => {
273                assert_eq!(
274                    args.artifact,
275                    Some(std::path::PathBuf::from("demo.gtbundle"))
276                );
277                assert!(args.json);
278            }
279            _ => panic!("expected doctor"),
280        }
281
282        let export = Cli::try_parse_from([
283            "greentic-bundle",
284            "export",
285            "--build-dir",
286            "state/build/demo/normalized",
287            "--output",
288            "demo.gtbundle",
289            "--dry-run",
290        ])
291        .expect("export parses");
292        match export.command {
293            Commands::Export(args) => {
294                assert_eq!(
295                    args.build_dir,
296                    std::path::PathBuf::from("state/build/demo/normalized")
297                );
298                assert_eq!(args.output, std::path::PathBuf::from("demo.gtbundle"));
299                assert!(args.dry_run);
300            }
301            _ => panic!("expected export"),
302        }
303
304        let inspect = Cli::try_parse_from(["greentic-bundle", "inspect", "bundle", "--json"])
305            .expect("inspect parses");
306        match inspect.command {
307            Commands::Inspect(args) => {
308                assert_eq!(args.target, Some(std::path::PathBuf::from("bundle")));
309                assert!(args.json);
310            }
311            _ => panic!("expected inspect"),
312        }
313    }
314
315    #[test]
316    fn command_defaults_use_current_directory() {
317        assert_eq!(
318            super::build::BuildArgs::default().root,
319            std::path::PathBuf::from(".")
320        );
321        assert_eq!(
322            super::doctor::DoctorArgs::default().root,
323            std::path::PathBuf::from(".")
324        );
325        assert_eq!(
326            super::inspect::InspectArgs::default().root,
327            std::path::PathBuf::from(".")
328        );
329    }
330}