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