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}