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::validator_for(&schema_json)
245                .map_err(|e| anyhow::anyhow!("schema load error: {e}"))?;
246            let mut errors = compiled.iter_errors(&config_json).peekable();
247            if errors.peek().is_none() {
248                println!("{}", crate::i18n::tr("cli.ext.validate.ok"));
249            } else {
250                for e in errors {
251                    eprintln!("{}: {e}", e.instance_path());
252                }
253                return Err(anyhow::anyhow!(crate::i18n::tr("cli.ext.validate.failed")));
254            }
255        }
256        ext::ExtCommand::Render {
257            extension_id,
258            recipe_id,
259            config,
260            session,
261            out,
262            json,
263        } => {
264            use ext_helpers::{extension_error_code, fail_json, read_input};
265
266            // Can't multiplex a single stdin between two --config and --session reads.
267            if config == "-" && session == "-" {
268                return Err(fail_json(
269                    json,
270                    "invalid-args",
271                    "only one of --config and --session may read from stdin",
272                ));
273            }
274            if json && out.is_none() {
275                return Err(fail_json(json, "invalid-args", "--json requires --out"));
276            }
277
278            let config_json = read_input(&config)
279                .map_err(|e| fail_json(json, "invalid-config", &e.to_string()))?;
280            let session_json = read_input(&session)
281                .map_err(|e| fail_json(json, "invalid-session", &e.to_string()))?;
282
283            let result = invoke_recipe(
284                &registry,
285                &extension_id,
286                &recipe_id,
287                &config_json,
288                &session_json,
289            );
290
291            match (result, out) {
292                (Ok(art), Some(path)) => {
293                    fs::write(&path, &art.bytes)?;
294                    if json {
295                        let summary = serde_json::json!({
296                            "status": "ok",
297                            "filename": art.filename,
298                            "sha256": art.sha256,
299                            "bytesLen": art.bytes.len(),
300                        });
301                        println!("{summary}");
302                    } else {
303                        let path_str = path.display().to_string();
304                        println!(
305                            "{}",
306                            crate::i18n::trf(
307                                "cli.ext.render.wrote",
308                                &[("file", path_str.as_str()), ("sha256", art.sha256.as_str()),],
309                            )
310                        );
311                    }
312                }
313                (Ok(art), None) => {
314                    std::io::stdout().write_all(&art.bytes)?;
315                }
316                (Err(e), _) => {
317                    return Err(fail_json(json, extension_error_code(&e), &e.to_string()));
318                }
319            }
320        }
321        ext::ExtCommand::InstallDir => {
322            println!("{}", install_dir.display());
323        }
324    }
325    Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330    use clap::Parser;
331
332    use super::{Cli, Commands};
333
334    #[test]
335    fn parses_global_locale_and_wizard_flags() {
336        let cli = Cli::try_parse_from([
337            "greentic-bundle",
338            "--locale",
339            "en-US",
340            "wizard",
341            "run",
342            "--schema",
343            "--answers",
344            "answers.json",
345            "--emit-answers",
346            "out.json",
347            "--schema-version",
348            "1.2.3",
349            "--migrate",
350            "--dry-run",
351        ])
352        .expect("cli parses");
353
354        assert_eq!(cli.locale.as_deref(), Some("en-US"));
355        match cli.command {
356            Commands::Wizard(args) => {
357                assert!(args.schema);
358                match args.command.expect("wizard subcommand") {
359                    super::wizard::WizardCommand::Run(run) => {
360                        assert_eq!(
361                            run.answers.as_deref(),
362                            Some(std::path::Path::new("answers.json"))
363                        );
364                        assert_eq!(
365                            run.emit_answers.as_deref(),
366                            Some(std::path::Path::new("out.json"))
367                        );
368                        assert_eq!(run.schema_version.as_deref(), Some("1.2.3"));
369                        assert!(run.migrate);
370                        assert!(run.dry_run);
371                    }
372                    _ => panic!("expected run"),
373                }
374            }
375            _ => panic!("expected wizard"),
376        }
377    }
378
379    #[test]
380    fn parses_access_allow_execute_flag() {
381        let cli = Cli::try_parse_from([
382            "greentic-bundle",
383            "access",
384            "allow",
385            "tenant-a",
386            "--execute",
387        ])
388        .expect("cli parses");
389
390        match cli.command {
391            Commands::Access(args) => match args.command {
392                super::access::AccessCommand::Allow(allow) => {
393                    assert_eq!(allow.subject, "tenant-a");
394                    assert!(allow.execute);
395                    assert!(!allow.dry_run);
396                }
397                _ => panic!("expected access allow"),
398            },
399            _ => panic!("expected access"),
400        }
401    }
402
403    #[test]
404    fn parses_build_export_doctor_and_inspect_flags() {
405        let build = Cli::try_parse_from([
406            "greentic-bundle",
407            "build",
408            "--root",
409            "bundle",
410            "--output",
411            "out.gtbundle",
412            "--dry-run",
413        ])
414        .expect("build parses");
415        match build.command {
416            Commands::Build(args) => {
417                assert_eq!(args.root, std::path::PathBuf::from("bundle"));
418                assert_eq!(args.output, Some(std::path::PathBuf::from("out.gtbundle")));
419                assert!(args.dry_run);
420            }
421            _ => panic!("expected build"),
422        }
423
424        let doctor = Cli::try_parse_from([
425            "greentic-bundle",
426            "doctor",
427            "--artifact",
428            "demo.gtbundle",
429            "--json",
430        ])
431        .expect("doctor parses");
432        match doctor.command {
433            Commands::Doctor(args) => {
434                assert_eq!(
435                    args.artifact,
436                    Some(std::path::PathBuf::from("demo.gtbundle"))
437                );
438                assert!(args.json);
439            }
440            _ => panic!("expected doctor"),
441        }
442
443        let export = Cli::try_parse_from([
444            "greentic-bundle",
445            "export",
446            "--build-dir",
447            "state/build/demo/normalized",
448            "--output",
449            "demo.gtbundle",
450            "--dry-run",
451        ])
452        .expect("export parses");
453        match export.command {
454            Commands::Export(args) => {
455                assert_eq!(
456                    args.build_dir,
457                    std::path::PathBuf::from("state/build/demo/normalized")
458                );
459                assert_eq!(args.output, std::path::PathBuf::from("demo.gtbundle"));
460                assert!(args.dry_run);
461            }
462            _ => panic!("expected export"),
463        }
464
465        let inspect = Cli::try_parse_from(["greentic-bundle", "inspect", "bundle", "--json"])
466            .expect("inspect parses");
467        match inspect.command {
468            Commands::Inspect(args) => {
469                assert_eq!(args.target, Some(std::path::PathBuf::from("bundle")));
470                assert!(args.json);
471            }
472            _ => panic!("expected inspect"),
473        }
474    }
475
476    #[test]
477    fn command_defaults_use_current_directory() {
478        assert_eq!(
479            super::build::BuildArgs::default().root,
480            std::path::PathBuf::from(".")
481        );
482        assert_eq!(
483            super::doctor::DoctorArgs::default().root,
484            std::path::PathBuf::from(".")
485        );
486        assert_eq!(
487            super::inspect::InspectArgs::default().root,
488            std::path::PathBuf::from(".")
489        );
490    }
491}