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;
11#[cfg(feature = "extensions")]
12pub mod ext;
13#[cfg(feature = "extensions")]
14mod ext_helpers;
15pub mod info;
16pub mod init;
17pub mod inspect;
18pub mod remove;
19pub mod unbundle;
20pub mod wizard;
21
22#[derive(Debug, Parser)]
23#[command(
24    name = "greentic-bundle",
25    about = "cli.root.about",
26    long_about = "cli.root.long_about",
27    version,
28    arg_required_else_help = true
29)]
30pub struct Cli {
31    #[arg(
32        long = "locale",
33        value_name = "LOCALE",
34        global = true,
35        help = "cli.option.locale"
36    )]
37    locale: Option<String>,
38
39    #[arg(
40        long = "offline",
41        global = true,
42        default_value_t = false,
43        help = "cli.option.offline"
44    )]
45    offline: bool,
46
47    #[command(subcommand)]
48    command: Commands,
49}
50
51#[derive(Debug, Subcommand)]
52enum Commands {
53    #[command(about = "cli.wizard.about")]
54    Wizard(wizard::WizardArgs),
55    #[command(about = "cli.doctor.about")]
56    Doctor(doctor::DoctorArgs),
57    #[command(about = "cli.build.about", long_about = "cli.build.long_about")]
58    Build(build::BuildArgs),
59    #[command(about = "cli.export.about", long_about = "cli.export.long_about")]
60    Export(export::ExportArgs),
61    #[command(about = "cli.inspect.about")]
62    Inspect(inspect::InspectArgs),
63    #[command(about = "cli.info.about")]
64    Info(info::InfoArgs),
65    #[command(about = "cli.unbundle.about")]
66    Unbundle(unbundle::UnbundleArgs),
67    #[command(about = "cli.add.about")]
68    Add(add::AddArgs),
69    #[command(about = "cli.remove.about")]
70    Remove(remove::RemoveArgs),
71    #[command(about = "cli.access.about")]
72    Access(access::AccessArgs),
73    #[command(about = "cli.init.about")]
74    Init(init::InitArgs),
75    #[cfg(feature = "extensions")]
76    #[command(about = "cli.ext.about")]
77    Ext(ext::ExtArgs),
78}
79
80pub fn run() -> Result<()> {
81    let argv: Vec<OsString> = std::env::args_os().collect();
82    crate::i18n::init(crate::i18n::cli_locale_from_argv(&argv));
83
84    let mut command = localized_command(true);
85    let matches = match command.try_get_matches_from_mut(argv) {
86        Ok(matches) => matches,
87        Err(err) => err.exit(),
88    };
89    let cli = Cli::from_arg_matches(&matches)?;
90    crate::i18n::init(cli.locale.clone());
91    crate::runtime::set_offline(cli.offline);
92    cli.dispatch()
93}
94
95pub fn localized_command(is_root: bool) -> clap::Command {
96    localize_help(Cli::command(), is_root)
97}
98
99impl Cli {
100    fn dispatch(self) -> Result<()> {
101        match self.command {
102            Commands::Wizard(args) => wizard::run(args),
103            Commands::Doctor(args) => doctor::run(args),
104            Commands::Build(args) => build::run(args),
105            Commands::Export(args) => export::run(args),
106            Commands::Inspect(args) => inspect::run(args),
107            Commands::Info(args) => info::run(args),
108            Commands::Unbundle(args) => unbundle::run(args),
109            Commands::Add(args) => add::run(args),
110            Commands::Remove(args) => remove::run(args),
111            Commands::Access(args) => access::run(args),
112            Commands::Init(args) => init::run(args),
113            #[cfg(feature = "extensions")]
114            Commands::Ext(args) => run_ext(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(feature = "extensions")]
181#[allow(clippy::too_many_lines)]
182fn run_ext(args: ext::ExtArgs) -> anyhow::Result<()> {
183    use std::fs;
184    use std::io::Write;
185    use std::path::PathBuf;
186
187    use crate::ext::dispatcher::invoke_recipe;
188    use crate::ext::loader::load_from_dir;
189    use crate::ext::registry::ExtensionRegistry;
190
191    let install_dir = args
192        .extension_dir
193        .clone()
194        .unwrap_or_else(|| PathBuf::from("state").join("ext"));
195
196    let mut registry = ExtensionRegistry::new();
197    let discovered = load_from_dir(&install_dir)?;
198    registry.register_discovered(discovered)?;
199
200    match args.command {
201        ext::ExtCommand::List => {
202            for e in registry.list() {
203                println!(
204                    "{ext} {ver} recipe={recipe} kind={kind:?}",
205                    ext = e.extension_id,
206                    ver = e.extension_version,
207                    recipe = e.recipe.id,
208                    kind = e.execution,
209                );
210            }
211        }
212        ext::ExtCommand::Info { extension_id } => {
213            let mut any = false;
214            for e in registry.list().filter(|e| e.extension_id == extension_id) {
215                any = true;
216                println!(
217                    "{ext} {ver}\n  recipe: {rid} — {display}\n  schema: {schema}\n  capabilities: {caps}",
218                    ext = e.extension_id,
219                    ver = e.extension_version,
220                    rid = e.recipe.id,
221                    display = e.recipe.display_name,
222                    schema = e.recipe.config_schema,
223                    caps = e.recipe.supported_capabilities.join(", "),
224                );
225            }
226            if !any {
227                return Err(anyhow::anyhow!(crate::i18n::trf(
228                    "cli.ext.info.not_found",
229                    &[("id", extension_id.as_str())]
230                )));
231            }
232        }
233        ext::ExtCommand::Validate {
234            extension_id,
235            recipe_id,
236            config,
237        } => {
238            let entry = registry.resolve(&extension_id, &recipe_id)?;
239            let schema_path = entry.descriptor_root.join(&entry.recipe.config_schema);
240            let schema_raw = fs::read_to_string(&schema_path)?;
241            let schema_json: serde_json::Value = serde_json::from_str(&schema_raw)?;
242            let config_raw = fs::read_to_string(&config)?;
243            let config_json: serde_json::Value = serde_json::from_str(&config_raw)?;
244            let compiled = jsonschema::JSONSchema::compile(&schema_json)
245                .map_err(|e| anyhow::anyhow!("schema load error: {e}"))?;
246            match compiled.validate(&config_json) {
247                Ok(()) => {
248                    println!("{}", crate::i18n::tr("cli.ext.validate.ok"));
249                }
250                Err(errs) => {
251                    for e in errs {
252                        eprintln!("{}: {e}", e.instance_path);
253                    }
254                    return Err(anyhow::anyhow!(crate::i18n::tr("cli.ext.validate.failed")));
255                }
256            }
257        }
258        ext::ExtCommand::Render {
259            extension_id,
260            recipe_id,
261            config,
262            session,
263            out,
264            json,
265        } => {
266            use ext_helpers::{extension_error_code, fail_json, read_input};
267
268            // Can't multiplex a single stdin between two --config and --session reads.
269            if config == "-" && session == "-" {
270                return Err(fail_json(
271                    json,
272                    "invalid-args",
273                    "only one of --config and --session may read from stdin",
274                ));
275            }
276            if json && out.is_none() {
277                return Err(fail_json(json, "invalid-args", "--json requires --out"));
278            }
279
280            let config_json = read_input(&config)
281                .map_err(|e| fail_json(json, "invalid-config", &e.to_string()))?;
282            let session_json = read_input(&session)
283                .map_err(|e| fail_json(json, "invalid-session", &e.to_string()))?;
284
285            let result = invoke_recipe(
286                &registry,
287                &extension_id,
288                &recipe_id,
289                &config_json,
290                &session_json,
291            );
292
293            match (result, out) {
294                (Ok(art), Some(path)) => {
295                    fs::write(&path, &art.bytes)?;
296                    if json {
297                        let summary = serde_json::json!({
298                            "status": "ok",
299                            "filename": art.filename,
300                            "sha256": art.sha256,
301                            "bytesLen": art.bytes.len(),
302                        });
303                        println!("{summary}");
304                    } else {
305                        let path_str = path.display().to_string();
306                        println!(
307                            "{}",
308                            crate::i18n::trf(
309                                "cli.ext.render.wrote",
310                                &[("file", path_str.as_str()), ("sha256", art.sha256.as_str()),],
311                            )
312                        );
313                    }
314                }
315                (Ok(art), None) => {
316                    std::io::stdout().write_all(&art.bytes)?;
317                }
318                (Err(e), _) => {
319                    return Err(fail_json(json, extension_error_code(&e), &e.to_string()));
320                }
321            }
322        }
323        ext::ExtCommand::InstallDir => {
324            println!("{}", install_dir.display());
325        }
326    }
327    Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332    use clap::Parser;
333
334    use super::{Cli, Commands};
335
336    #[test]
337    fn parses_global_locale_and_wizard_flags() {
338        let cli = Cli::try_parse_from([
339            "greentic-bundle",
340            "--locale",
341            "en-US",
342            "wizard",
343            "run",
344            "--schema",
345            "--answers",
346            "answers.json",
347            "--emit-answers",
348            "out.json",
349            "--schema-version",
350            "1.2.3",
351            "--migrate",
352            "--dry-run",
353        ])
354        .expect("cli parses");
355
356        assert_eq!(cli.locale.as_deref(), Some("en-US"));
357        match cli.command {
358            Commands::Wizard(args) => {
359                assert!(args.schema);
360                match args.command.expect("wizard subcommand") {
361                    super::wizard::WizardCommand::Run(run) => {
362                        assert_eq!(
363                            run.answers.as_deref(),
364                            Some(std::path::Path::new("answers.json"))
365                        );
366                        assert_eq!(
367                            run.emit_answers.as_deref(),
368                            Some(std::path::Path::new("out.json"))
369                        );
370                        assert_eq!(run.schema_version.as_deref(), Some("1.2.3"));
371                        assert!(run.migrate);
372                        assert!(run.dry_run);
373                    }
374                    _ => panic!("expected run"),
375                }
376            }
377            _ => panic!("expected wizard"),
378        }
379    }
380
381    #[test]
382    fn parses_access_allow_execute_flag() {
383        let cli = Cli::try_parse_from([
384            "greentic-bundle",
385            "access",
386            "allow",
387            "tenant-a",
388            "--execute",
389        ])
390        .expect("cli parses");
391
392        match cli.command {
393            Commands::Access(args) => match args.command {
394                super::access::AccessCommand::Allow(allow) => {
395                    assert_eq!(allow.subject, "tenant-a");
396                    assert!(allow.execute);
397                    assert!(!allow.dry_run);
398                }
399                _ => panic!("expected access allow"),
400            },
401            _ => panic!("expected access"),
402        }
403    }
404
405    #[test]
406    fn parses_build_export_doctor_and_inspect_flags() {
407        let build = Cli::try_parse_from([
408            "greentic-bundle",
409            "build",
410            "--root",
411            "bundle",
412            "--output",
413            "out.gtbundle",
414            "--dry-run",
415        ])
416        .expect("build parses");
417        match build.command {
418            Commands::Build(args) => {
419                assert_eq!(args.root, std::path::PathBuf::from("bundle"));
420                assert_eq!(args.output, Some(std::path::PathBuf::from("out.gtbundle")));
421                assert!(args.dry_run);
422            }
423            _ => panic!("expected build"),
424        }
425
426        let doctor = Cli::try_parse_from([
427            "greentic-bundle",
428            "doctor",
429            "--artifact",
430            "demo.gtbundle",
431            "--json",
432        ])
433        .expect("doctor parses");
434        match doctor.command {
435            Commands::Doctor(args) => {
436                assert_eq!(
437                    args.artifact,
438                    Some(std::path::PathBuf::from("demo.gtbundle"))
439                );
440                assert!(args.json);
441            }
442            _ => panic!("expected doctor"),
443        }
444
445        let export = Cli::try_parse_from([
446            "greentic-bundle",
447            "export",
448            "--build-dir",
449            "state/build/demo/normalized",
450            "--output",
451            "demo.gtbundle",
452            "--dry-run",
453        ])
454        .expect("export parses");
455        match export.command {
456            Commands::Export(args) => {
457                assert_eq!(
458                    args.build_dir,
459                    std::path::PathBuf::from("state/build/demo/normalized")
460                );
461                assert_eq!(args.output, std::path::PathBuf::from("demo.gtbundle"));
462                assert!(args.dry_run);
463            }
464            _ => panic!("expected export"),
465        }
466
467        let inspect = Cli::try_parse_from(["greentic-bundle", "inspect", "bundle", "--json"])
468            .expect("inspect parses");
469        match inspect.command {
470            Commands::Inspect(args) => {
471                assert_eq!(args.target, Some(std::path::PathBuf::from("bundle")));
472                assert!(args.json);
473            }
474            _ => panic!("expected inspect"),
475        }
476    }
477
478    #[test]
479    fn command_defaults_use_current_directory() {
480        assert_eq!(
481            super::build::BuildArgs::default().root,
482            std::path::PathBuf::from(".")
483        );
484        assert_eq!(
485            super::doctor::DoctorArgs::default().root,
486            std::path::PathBuf::from(".")
487        );
488        assert_eq!(
489            super::inspect::InspectArgs::default().root,
490            std::path::PathBuf::from(".")
491        );
492    }
493}