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