Skip to main content

braze_sync/cli/
mod.rs

1//! CLI dispatch entry point.
2//!
3//! Two-stage error model so the frozen exit codes from
4//! IMPLEMENTATION.md §7.1 are deterministic regardless of which step
5//! fails:
6//!
7//! 1. **Config stage** ([`load_and_resolve_config`]) — parse the YAML,
8//!    validate it, resolve the api-key environment variable. Any failure
9//!    here, including a missing or unreadable config file, exits with
10//!    code **3** (config / argument error).
11//! 2. **Dispatch stage** ([`dispatch`]) — run the requested subcommand.
12//!    Errors are mapped through [`exit_code_for`], which walks the
13//!    `anyhow::Error` chain and downcasts to the typed errors that carry
14//!    semantic meaning ([`BrazeApiError`], [`Error`]).
15//!
16//! `exit_code_for` deliberately walks the entire chain so an error wrapped
17//! as `Error::Api(BrazeApiError::Unauthorized)` and a bare
18//! `BrazeApiError::Unauthorized` map to the same exit code (4). This
19//! matters because `?` from braze API methods produces the latter while
20//! some library helpers might produce the former in the future.
21
22pub mod apply;
23pub mod diff;
24pub mod export;
25pub mod validate;
26
27use crate::braze::error::BrazeApiError;
28use crate::config::{ConfigFile, ResolvedConfig, ResourcesConfig};
29use crate::error::Error;
30use crate::format::OutputFormat;
31use crate::resource::ResourceKind;
32use anyhow::Context as _;
33use clap::{Parser, Subcommand};
34use std::path::{Path, PathBuf};
35
36#[derive(Parser, Debug)]
37#[command(
38    name = "braze-sync",
39    version,
40    about = "GitOps CLI for managing Braze configuration as code"
41)]
42pub struct Cli {
43    /// Path to the braze-sync config file
44    #[arg(long, default_value = "./braze-sync.config.yaml", global = true)]
45    pub config: PathBuf,
46
47    /// Target environment (defaults to `default_environment` in the config)
48    #[arg(long, global = true)]
49    pub env: Option<String>,
50
51    /// Verbose tracing output (sets log level to debug)
52    #[arg(short, long, global = true)]
53    pub verbose: bool,
54
55    /// Disable colored output
56    #[arg(long, global = true)]
57    pub no_color: bool,
58
59    /// Output format. `table` for humans, `json` for CI consumption.
60    /// Used by diff/apply; export and validate ignore this in v0.1.0.
61    #[arg(long, global = true, value_enum)]
62    pub format: Option<OutputFormat>,
63
64    #[command(subcommand)]
65    pub command: Command,
66}
67
68#[derive(Subcommand, Debug)]
69pub enum Command {
70    /// Pull state from Braze into local files
71    Export(export::ExportArgs),
72    /// Show drift between local files and Braze
73    Diff(diff::DiffArgs),
74    /// Apply local intent to Braze (dry-run by default)
75    Apply(apply::ApplyArgs),
76    /// Validate local files (no Braze API access required)
77    Validate(validate::ValidateArgs),
78}
79
80/// Top-level CLI entry point. Returns the process exit code per
81/// IMPLEMENTATION.md §7.1.
82pub async fn run() -> i32 {
83    let cli = match Cli::try_parse() {
84        Ok(c) => c,
85        Err(e) => {
86            // clap prints help/version to stdout and parse errors to stderr.
87            e.print().ok();
88            return match e.kind() {
89                clap::error::ErrorKind::DisplayHelp
90                | clap::error::ErrorKind::DisplayVersion
91                | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => 0,
92                _ => 3,
93            };
94        }
95    };
96
97    init_tracing(cli.verbose, cli.no_color);
98    if let Err(e) = crate::config::load_dotenv() {
99        // dotenv failures are non-fatal — config resolution will surface
100        // any actually missing vars with a clearer error.
101        tracing::warn!("dotenv: {e}");
102    }
103
104    // Stage 1: parse + structurally validate the config file. No env
105    // access yet, so a missing BRAZE_*_API_KEY does NOT fail here.
106    // Failure → exit 3 (config / argument error per §7.1).
107    let cfg = match ConfigFile::load(&cli.config)
108        .with_context(|| format!("failed to load config from {}", cli.config.display()))
109    {
110        Ok(c) => c,
111        Err(e) => {
112            eprintln!("error: {e:#}");
113            return 3;
114        }
115    };
116    let config_dir = cli
117        .config
118        .parent()
119        .map(Path::to_path_buf)
120        .unwrap_or_else(|| PathBuf::from("."));
121
122    // Validate is the only command that does NOT need an api key — its
123    // entire job is local file checking. Dispatch it directly from the
124    // parsed ConfigFile so a CI on a fork PR (no secrets) can still run
125    // it as a pre-merge check. All other commands fall through to the
126    // env-resolution stage below.
127    if let Command::Validate(args) = &cli.command {
128        return finish(validate::run(args, &cfg, &config_dir).await);
129    }
130
131    // Stage 2: resolve the environment (api_key from env var, etc.).
132    // Failure here is also exit 3 — typically a missing
133    // BRAZE_*_API_KEY env var.
134    let resolved = match cfg
135        .resolve(cli.env.as_deref())
136        .context("failed to resolve environment from config")
137    {
138        Ok(r) => r,
139        Err(e) => {
140            eprintln!("error: {e:#}");
141            return 3;
142        }
143    };
144
145    // Stage 3: dispatch the env-resolved command.
146    finish(dispatch(&cli, resolved, &config_dir).await)
147}
148
149/// Map a command result to an exit code, printing the error chain on
150/// failure. Used by both the validate (no-resolve) and dispatch
151/// (env-resolved) branches of `run`.
152fn finish(result: anyhow::Result<()>) -> i32 {
153    match result {
154        Ok(()) => 0,
155        Err(e) => {
156            eprintln!("error: {e:#}");
157            exit_code_for(&e)
158        }
159    }
160}
161
162async fn dispatch(cli: &Cli, resolved: ResolvedConfig, config_dir: &Path) -> anyhow::Result<()> {
163    match &cli.command {
164        Command::Export(args) => export::run(args, resolved, config_dir).await,
165        Command::Diff(args) => {
166            let format = cli.format.unwrap_or_default();
167            diff::run(args, resolved, config_dir, format).await
168        }
169        Command::Apply(args) => {
170            let format = cli.format.unwrap_or_default();
171            apply::run(args, resolved, config_dir, format).await
172        }
173        Command::Validate(_) => {
174            unreachable!("validate is dispatched in cli::run before env resolution")
175        }
176    }
177}
178
179/// Expand an optional resource filter to the list of kinds to process,
180/// excluding any kinds disabled in the config.
181pub(crate) fn selected_kinds(
182    filter: Option<ResourceKind>,
183    resources: &ResourcesConfig,
184) -> Vec<ResourceKind> {
185    match filter {
186        Some(k) => {
187            if !resources.is_enabled(k) {
188                eprintln!("⚠ {}: disabled in config, skipping", k.as_str());
189                vec![]
190            } else {
191                vec![k]
192            }
193        }
194        None => ResourceKind::all()
195            .iter()
196            .copied()
197            .filter(|k| {
198                let enabled = resources.is_enabled(*k);
199                if !enabled {
200                    tracing::debug!("{}: disabled in config, skipping", k.as_str());
201                }
202                enabled
203            })
204            .collect(),
205    }
206}
207
208/// Warn on stderr about a resource kind not yet implemented.
209pub(crate) fn warn_unimplemented(kind: ResourceKind) {
210    eprintln!("⚠ {}: not yet implemented in this binary", kind.as_str());
211}
212
213fn init_tracing(verbose: bool, no_color: bool) {
214    let default_level = if verbose { "debug" } else { "warn" };
215    let filter = tracing_subscriber::EnvFilter::try_from_default_env()
216        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
217    let _ = tracing_subscriber::fmt()
218        .with_env_filter(filter)
219        .with_ansi(!no_color)
220        .with_writer(std::io::stderr)
221        .try_init();
222}
223
224/// Map a stage-2 error to a §7.1 exit code by walking the
225/// `anyhow::Error` chain.
226fn exit_code_for(err: &anyhow::Error) -> i32 {
227    for cause in err.chain() {
228        if let Some(b) = cause.downcast_ref::<BrazeApiError>() {
229            return match b {
230                BrazeApiError::Unauthorized => 4,
231                BrazeApiError::RateLimitExhausted => 5,
232                _ => 1,
233            };
234        }
235        if let Some(top) = cause.downcast_ref::<Error>() {
236            match top {
237                // Walk into the chain — the wrapped BrazeApiError is the
238                // next entry.
239                Error::Api(_) => {}
240                Error::DestructiveBlocked => return 6,
241                Error::DriftDetected { .. } => return 2,
242                Error::Config(_) | Error::MissingEnv(_) => return 3,
243                Error::RateLimitExhausted { .. } => return 5,
244                Error::Io(_)
245                | Error::YamlParse { .. }
246                | Error::CsvParse { .. }
247                | Error::InvalidFormat { .. }
248                | Error::CatalogItemSchemaMismatch { .. }
249                | Error::CustomAttributeCreateNotSupported { .. } => return 1,
250            }
251        }
252    }
253    1
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::resource::ResourceKind;
260
261    #[test]
262    fn parses_export_with_resource_filter() {
263        let cli =
264            Cli::try_parse_from(["braze-sync", "export", "--resource", "catalog_schema"]).unwrap();
265        let Command::Export(args) = cli.command else {
266            panic!("expected Export subcommand");
267        };
268        assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
269        assert_eq!(args.name, None);
270    }
271
272    #[test]
273    fn parses_export_with_name_filter() {
274        let cli = Cli::try_parse_from([
275            "braze-sync",
276            "export",
277            "--resource",
278            "catalog_schema",
279            "--name",
280            "cardiology",
281        ])
282        .unwrap();
283        let Command::Export(args) = cli.command else {
284            panic!("expected Export subcommand");
285        };
286        assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
287        assert_eq!(args.name.as_deref(), Some("cardiology"));
288    }
289
290    #[test]
291    fn parses_diff_with_fail_on_drift() {
292        let cli = Cli::try_parse_from(["braze-sync", "diff", "--fail-on-drift"]).unwrap();
293        let Command::Diff(args) = cli.command else {
294            panic!("expected Diff subcommand");
295        };
296        assert!(args.fail_on_drift);
297        assert_eq!(args.resource, None);
298    }
299
300    #[test]
301    fn parses_validate_subcommand() {
302        let cli = Cli::try_parse_from(["braze-sync", "validate"]).unwrap();
303        let Command::Validate(args) = cli.command else {
304            panic!("expected Validate subcommand");
305        };
306        assert_eq!(args.resource, None);
307    }
308
309    #[test]
310    fn parses_validate_with_resource_filter() {
311        let cli = Cli::try_parse_from(["braze-sync", "validate", "--resource", "catalog_schema"])
312            .unwrap();
313        let Command::Validate(args) = cli.command else {
314            panic!("expected Validate subcommand");
315        };
316        assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
317    }
318
319    #[test]
320    fn parses_diff_with_resource_and_name() {
321        let cli = Cli::try_parse_from([
322            "braze-sync",
323            "diff",
324            "--resource",
325            "catalog_schema",
326            "--name",
327            "cardiology",
328        ])
329        .unwrap();
330        let Command::Diff(args) = cli.command else {
331            panic!("expected Diff subcommand");
332        };
333        assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
334        assert_eq!(args.name.as_deref(), Some("cardiology"));
335        assert!(!args.fail_on_drift);
336    }
337
338    #[test]
339    fn name_requires_resource() {
340        let result = Cli::try_parse_from(["braze-sync", "export", "--name", "cardiology"]);
341        assert!(
342            result.is_err(),
343            "expected --name without --resource to error"
344        );
345    }
346
347    #[test]
348    fn config_default_path() {
349        let cli = Cli::try_parse_from(["braze-sync", "export"]).unwrap();
350        assert_eq!(cli.config, PathBuf::from("./braze-sync.config.yaml"));
351    }
352
353    #[test]
354    fn global_flags_position_independent() {
355        let cli = Cli::try_parse_from(["braze-sync", "export", "--config", "/tmp/x.yaml"]).unwrap();
356        assert_eq!(cli.config, PathBuf::from("/tmp/x.yaml"));
357    }
358
359    #[test]
360    fn env_override_parsed() {
361        let cli = Cli::try_parse_from(["braze-sync", "--env", "prod", "export"]).unwrap();
362        assert_eq!(cli.env.as_deref(), Some("prod"));
363    }
364
365    #[test]
366    fn format_value_parsed_as_enum() {
367        let cli = Cli::try_parse_from(["braze-sync", "--format", "json", "export"]).unwrap();
368        assert_eq!(cli.format, Some(OutputFormat::Json));
369    }
370
371    #[test]
372    fn exit_code_for_unauthorized() {
373        let err = anyhow::Error::new(BrazeApiError::Unauthorized);
374        assert_eq!(exit_code_for(&err), 4);
375    }
376
377    #[test]
378    fn exit_code_for_rate_limit_exhausted() {
379        let err = anyhow::Error::new(BrazeApiError::RateLimitExhausted);
380        assert_eq!(exit_code_for(&err), 5);
381    }
382
383    #[test]
384    fn exit_code_for_drift_detected() {
385        let err = anyhow::Error::new(Error::DriftDetected { count: 3 });
386        assert_eq!(exit_code_for(&err), 2);
387    }
388
389    #[test]
390    fn exit_code_for_destructive_blocked() {
391        let err = anyhow::Error::new(Error::DestructiveBlocked);
392        assert_eq!(exit_code_for(&err), 6);
393    }
394
395    #[test]
396    fn exit_code_for_missing_env() {
397        let err = anyhow::Error::new(Error::MissingEnv("X".into()));
398        assert_eq!(exit_code_for(&err), 3);
399    }
400
401    #[test]
402    fn exit_code_for_config_error() {
403        let err = anyhow::Error::new(Error::Config("oops".into()));
404        assert_eq!(exit_code_for(&err), 3);
405    }
406
407    #[test]
408    fn exit_code_for_api_wrapped_unauthorized_unwraps_to_4() {
409        // Error::Api(BrazeApiError::Unauthorized) — chain walk must reach
410        // the inner BrazeApiError on the second iteration.
411        let err = anyhow::Error::new(Error::Api(BrazeApiError::Unauthorized));
412        assert_eq!(exit_code_for(&err), 4);
413    }
414
415    #[test]
416    fn exit_code_for_top_level_rate_limit_exhausted() {
417        let err = anyhow::Error::new(Error::RateLimitExhausted { retries: 3 });
418        assert_eq!(exit_code_for(&err), 5);
419    }
420
421    #[test]
422    fn exit_code_for_other_anyhow_is_one() {
423        let err = anyhow::anyhow!("some random failure");
424        assert_eq!(exit_code_for(&err), 1);
425    }
426}