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