pub mod apply;
pub mod diff;
pub mod export;
pub mod init;
pub mod validate;
pub(crate) const FETCH_CONCURRENCY: usize = 8;
use crate::braze::error::BrazeApiError;
use crate::config::{ConfigFile, ResolvedConfig, ResourcesConfig};
use crate::error::Error;
use crate::format::OutputFormat;
use crate::resource::ResourceKind;
use anyhow::Context as _;
use clap::{Parser, Subcommand};
use std::path::{Path, PathBuf};
#[derive(Parser, Debug)]
#[command(
name = "braze-sync",
version,
about = "GitOps CLI for managing Braze configuration as code"
)]
pub struct Cli {
#[arg(long, default_value = "./braze-sync.config.yaml", global = true)]
pub config: PathBuf,
#[arg(long, global = true)]
pub env: Option<String>,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(long, global = true)]
pub no_color: bool,
#[arg(long, global = true, value_enum)]
pub format: Option<OutputFormat>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Init(init::InitArgs),
Export(export::ExportArgs),
Diff(diff::DiffArgs),
Apply(apply::ApplyArgs),
Validate(validate::ValidateArgs),
}
pub async fn run() -> i32 {
let cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => {
e.print().ok();
return match e.kind() {
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayVersion
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => 0,
_ => 3,
};
}
};
init_tracing(cli.verbose, cli.no_color);
if let Err(e) = crate::config::load_dotenv() {
tracing::warn!("dotenv: {e}");
}
if let Command::Init(args) = &cli.command {
return finish(init::run(args, &cli.config, cli.env.as_deref()).await);
}
let cfg = match ConfigFile::load(&cli.config)
.with_context(|| format!("failed to load config from {}", cli.config.display()))
{
Ok(c) => c,
Err(e) => {
eprintln!("error: {e:#}");
return 3;
}
};
let config_dir = cli
.config
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
if let Command::Validate(args) = &cli.command {
return finish(validate::run(args, &cfg, &config_dir).await);
}
let resolved = match cfg
.resolve(cli.env.as_deref())
.context("failed to resolve environment from config")
{
Ok(r) => r,
Err(e) => {
eprintln!("error: {e:#}");
return 3;
}
};
finish(dispatch(&cli, resolved, &config_dir).await)
}
fn finish(result: anyhow::Result<()>) -> i32 {
match result {
Ok(()) => 0,
Err(e) => {
eprintln!("error: {e:#}");
exit_code_for(&e)
}
}
}
async fn dispatch(cli: &Cli, resolved: ResolvedConfig, config_dir: &Path) -> anyhow::Result<()> {
match &cli.command {
Command::Export(args) => export::run(args, resolved, config_dir).await,
Command::Diff(args) => {
let format = cli.format.unwrap_or_default();
diff::run(args, resolved, config_dir, format).await
}
Command::Apply(args) => {
let format = cli.format.unwrap_or_default();
apply::run(args, resolved, config_dir, format).await
}
Command::Validate(_) => {
unreachable!("validate is dispatched in cli::run before env resolution")
}
Command::Init(_) => {
unreachable!("init is dispatched in cli::run before config load")
}
}
}
pub(crate) fn warn_if_name_excluded(
kind: ResourceKind,
name: Option<&str>,
excludes: &[regex_lite::Regex],
) -> bool {
let Some(name) = name else {
return false;
};
if crate::config::is_excluded(name, excludes) {
eprintln!(
"⚠ {}: '{}' matches exclude_patterns; skipping",
kind.as_str(),
name
);
return true;
}
false
}
pub(crate) fn selected_kinds(
filter: Option<ResourceKind>,
resources: &ResourcesConfig,
) -> Vec<ResourceKind> {
match filter {
Some(k) => {
if !resources.is_enabled(k) {
eprintln!("⚠ {}: disabled in config, skipping", k.as_str());
vec![]
} else {
vec![k]
}
}
None => ResourceKind::all()
.iter()
.copied()
.filter(|k| {
let enabled = resources.is_enabled(*k);
if !enabled {
tracing::debug!("{}: disabled in config, skipping", k.as_str());
}
enabled
})
.collect(),
}
}
fn init_tracing(verbose: bool, no_color: bool) {
let default_level = if verbose { "debug" } else { "warn" };
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(!no_color)
.with_writer(std::io::stderr)
.try_init();
}
fn exit_code_for(err: &anyhow::Error) -> i32 {
for cause in err.chain() {
if let Some(b) = cause.downcast_ref::<BrazeApiError>() {
return match b {
BrazeApiError::Unauthorized => 4,
BrazeApiError::RateLimitExhausted => 5,
_ => 1,
};
}
if let Some(top) = cause.downcast_ref::<Error>() {
match top {
Error::Api(_) => {}
Error::DestructiveBlocked => return 6,
Error::DriftDetected { .. } => return 2,
Error::Config(_) | Error::MissingEnv(_) => return 3,
Error::RateLimitExhausted { .. } => return 5,
Error::Io(_)
| Error::YamlParse { .. }
| Error::CsvParse { .. }
| Error::InvalidFormat { .. } => return 1,
}
}
}
1
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resource::ResourceKind;
#[test]
fn parses_export_with_resource_filter() {
let cli =
Cli::try_parse_from(["braze-sync", "export", "--resource", "catalog_schema"]).unwrap();
let Command::Export(args) = cli.command else {
panic!("expected Export subcommand");
};
assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
assert_eq!(args.name, None);
}
#[test]
fn parses_export_with_name_filter() {
let cli = Cli::try_parse_from([
"braze-sync",
"export",
"--resource",
"catalog_schema",
"--name",
"cardiology",
])
.unwrap();
let Command::Export(args) = cli.command else {
panic!("expected Export subcommand");
};
assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
assert_eq!(args.name.as_deref(), Some("cardiology"));
}
#[test]
fn parses_diff_with_fail_on_drift() {
let cli = Cli::try_parse_from(["braze-sync", "diff", "--fail-on-drift"]).unwrap();
let Command::Diff(args) = cli.command else {
panic!("expected Diff subcommand");
};
assert!(args.fail_on_drift);
assert_eq!(args.resource, None);
}
#[test]
fn parses_validate_subcommand() {
let cli = Cli::try_parse_from(["braze-sync", "validate"]).unwrap();
let Command::Validate(args) = cli.command else {
panic!("expected Validate subcommand");
};
assert_eq!(args.resource, None);
}
#[test]
fn parses_validate_with_resource_filter() {
let cli = Cli::try_parse_from(["braze-sync", "validate", "--resource", "catalog_schema"])
.unwrap();
let Command::Validate(args) = cli.command else {
panic!("expected Validate subcommand");
};
assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
}
#[test]
fn parses_diff_with_resource_and_name() {
let cli = Cli::try_parse_from([
"braze-sync",
"diff",
"--resource",
"catalog_schema",
"--name",
"cardiology",
])
.unwrap();
let Command::Diff(args) = cli.command else {
panic!("expected Diff subcommand");
};
assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
assert_eq!(args.name.as_deref(), Some("cardiology"));
assert!(!args.fail_on_drift);
}
#[test]
fn name_requires_resource() {
let result = Cli::try_parse_from(["braze-sync", "export", "--name", "cardiology"]);
assert!(
result.is_err(),
"expected --name without --resource to error"
);
}
#[test]
fn config_default_path() {
let cli = Cli::try_parse_from(["braze-sync", "export"]).unwrap();
assert_eq!(cli.config, PathBuf::from("./braze-sync.config.yaml"));
}
#[test]
fn global_flags_position_independent() {
let cli = Cli::try_parse_from(["braze-sync", "export", "--config", "/tmp/x.yaml"]).unwrap();
assert_eq!(cli.config, PathBuf::from("/tmp/x.yaml"));
}
#[test]
fn env_override_parsed() {
let cli = Cli::try_parse_from(["braze-sync", "--env", "prod", "export"]).unwrap();
assert_eq!(cli.env.as_deref(), Some("prod"));
}
#[test]
fn format_value_parsed_as_enum() {
let cli = Cli::try_parse_from(["braze-sync", "--format", "json", "export"]).unwrap();
assert_eq!(cli.format, Some(OutputFormat::Json));
}
#[test]
fn exit_code_for_unauthorized() {
let err = anyhow::Error::new(BrazeApiError::Unauthorized);
assert_eq!(exit_code_for(&err), 4);
}
#[test]
fn exit_code_for_rate_limit_exhausted() {
let err = anyhow::Error::new(BrazeApiError::RateLimitExhausted);
assert_eq!(exit_code_for(&err), 5);
}
#[test]
fn exit_code_for_drift_detected() {
let err = anyhow::Error::new(Error::DriftDetected { count: 3 });
assert_eq!(exit_code_for(&err), 2);
}
#[test]
fn exit_code_for_destructive_blocked() {
let err = anyhow::Error::new(Error::DestructiveBlocked);
assert_eq!(exit_code_for(&err), 6);
}
#[test]
fn exit_code_for_missing_env() {
let err = anyhow::Error::new(Error::MissingEnv("X".into()));
assert_eq!(exit_code_for(&err), 3);
}
#[test]
fn exit_code_for_config_error() {
let err = anyhow::Error::new(Error::Config("oops".into()));
assert_eq!(exit_code_for(&err), 3);
}
#[test]
fn exit_code_for_api_wrapped_unauthorized_unwraps_to_4() {
let err = anyhow::Error::new(Error::Api(BrazeApiError::Unauthorized));
assert_eq!(exit_code_for(&err), 4);
}
#[test]
fn exit_code_for_top_level_rate_limit_exhausted() {
let err = anyhow::Error::new(Error::RateLimitExhausted { retries: 3 });
assert_eq!(exit_code_for(&err), 5);
}
#[test]
fn exit_code_for_other_anyhow_is_one() {
let err = anyhow::anyhow!("some random failure");
assert_eq!(exit_code_for(&err), 1);
}
}