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}