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