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