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