1pub mod apply;
23pub mod diff;
24pub mod export;
25pub mod init;
26pub mod validate;
27
28pub(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 #[arg(long, default_value = "./braze-sync.config.yaml", global = true)]
52 pub config: PathBuf,
53
54 #[arg(long, global = true)]
56 pub env: Option<String>,
57
58 #[arg(short, long, global = true)]
60 pub verbose: bool,
61
62 #[arg(long, global = true)]
64 pub no_color: bool,
65
66 #[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 Init(init::InitArgs),
79 Export(export::ExportArgs),
81 Diff(diff::DiffArgs),
83 Apply(apply::ApplyArgs),
85 Validate(validate::ValidateArgs),
87}
88
89pub async fn run() -> i32 {
92 let cli = match Cli::try_parse() {
93 Ok(c) => c,
94 Err(e) => {
95 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 tracing::warn!("dotenv: {e}");
111 }
112
113 if let Command::Init(args) = &cli.command {
115 return finish(init::run(args, &cli.config, cli.env.as_deref()).await);
116 }
117
118 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 if let Command::Validate(args) = &cli.command {
142 return finish(validate::run(args, &cfg, &config_dir).await);
143 }
144
145 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 finish(dispatch(&cli, resolved, &config_dir).await)
161}
162
163fn 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
196pub(crate) fn selected_kinds(
199 filter: Option<ResourceKind>,
200 resources: &ResourcesConfig,
201) -> Vec<ResourceKind> {
202 match filter {
203 Some(k) => {
204 if !resources.is_enabled(k) {
205 eprintln!("⚠ {}: disabled in config, skipping", k.as_str());
206 vec![]
207 } else {
208 vec![k]
209 }
210 }
211 None => ResourceKind::all()
212 .iter()
213 .copied()
214 .filter(|k| {
215 let enabled = resources.is_enabled(*k);
216 if !enabled {
217 tracing::debug!("{}: disabled in config, skipping", k.as_str());
218 }
219 enabled
220 })
221 .collect(),
222 }
223}
224
225fn init_tracing(verbose: bool, no_color: bool) {
226 let default_level = if verbose { "debug" } else { "warn" };
227 let filter = tracing_subscriber::EnvFilter::try_from_default_env()
228 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
229 let _ = tracing_subscriber::fmt()
230 .with_env_filter(filter)
231 .with_ansi(!no_color)
232 .with_writer(std::io::stderr)
233 .try_init();
234}
235
236fn exit_code_for(err: &anyhow::Error) -> i32 {
239 for cause in err.chain() {
240 if let Some(b) = cause.downcast_ref::<BrazeApiError>() {
241 return match b {
242 BrazeApiError::Unauthorized => 4,
243 BrazeApiError::RateLimitExhausted => 5,
244 _ => 1,
245 };
246 }
247 if let Some(top) = cause.downcast_ref::<Error>() {
248 match top {
249 Error::Api(_) => {}
252 Error::DestructiveBlocked => return 6,
253 Error::DriftDetected { .. } => return 2,
254 Error::Config(_) | Error::MissingEnv(_) => return 3,
255 Error::RateLimitExhausted { .. } => return 5,
256 Error::Io(_)
257 | Error::YamlParse { .. }
258 | Error::CsvParse { .. }
259 | Error::InvalidFormat { .. }
260 | Error::CatalogItemSchemaMismatch { .. }
261 | Error::CustomAttributeCreateNotSupported { .. } => return 1,
262 }
263 }
264 }
265 1
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::resource::ResourceKind;
272
273 #[test]
274 fn parses_export_with_resource_filter() {
275 let cli =
276 Cli::try_parse_from(["braze-sync", "export", "--resource", "catalog_schema"]).unwrap();
277 let Command::Export(args) = cli.command else {
278 panic!("expected Export subcommand");
279 };
280 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
281 assert_eq!(args.name, None);
282 }
283
284 #[test]
285 fn parses_export_with_name_filter() {
286 let cli = Cli::try_parse_from([
287 "braze-sync",
288 "export",
289 "--resource",
290 "catalog_schema",
291 "--name",
292 "cardiology",
293 ])
294 .unwrap();
295 let Command::Export(args) = cli.command else {
296 panic!("expected Export subcommand");
297 };
298 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
299 assert_eq!(args.name.as_deref(), Some("cardiology"));
300 }
301
302 #[test]
303 fn parses_diff_with_fail_on_drift() {
304 let cli = Cli::try_parse_from(["braze-sync", "diff", "--fail-on-drift"]).unwrap();
305 let Command::Diff(args) = cli.command else {
306 panic!("expected Diff subcommand");
307 };
308 assert!(args.fail_on_drift);
309 assert_eq!(args.resource, None);
310 }
311
312 #[test]
313 fn parses_validate_subcommand() {
314 let cli = Cli::try_parse_from(["braze-sync", "validate"]).unwrap();
315 let Command::Validate(args) = cli.command else {
316 panic!("expected Validate subcommand");
317 };
318 assert_eq!(args.resource, None);
319 }
320
321 #[test]
322 fn parses_validate_with_resource_filter() {
323 let cli = Cli::try_parse_from(["braze-sync", "validate", "--resource", "catalog_schema"])
324 .unwrap();
325 let Command::Validate(args) = cli.command else {
326 panic!("expected Validate subcommand");
327 };
328 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
329 }
330
331 #[test]
332 fn parses_diff_with_resource_and_name() {
333 let cli = Cli::try_parse_from([
334 "braze-sync",
335 "diff",
336 "--resource",
337 "catalog_schema",
338 "--name",
339 "cardiology",
340 ])
341 .unwrap();
342 let Command::Diff(args) = cli.command else {
343 panic!("expected Diff subcommand");
344 };
345 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
346 assert_eq!(args.name.as_deref(), Some("cardiology"));
347 assert!(!args.fail_on_drift);
348 }
349
350 #[test]
351 fn name_requires_resource() {
352 let result = Cli::try_parse_from(["braze-sync", "export", "--name", "cardiology"]);
353 assert!(
354 result.is_err(),
355 "expected --name without --resource to error"
356 );
357 }
358
359 #[test]
360 fn config_default_path() {
361 let cli = Cli::try_parse_from(["braze-sync", "export"]).unwrap();
362 assert_eq!(cli.config, PathBuf::from("./braze-sync.config.yaml"));
363 }
364
365 #[test]
366 fn global_flags_position_independent() {
367 let cli = Cli::try_parse_from(["braze-sync", "export", "--config", "/tmp/x.yaml"]).unwrap();
368 assert_eq!(cli.config, PathBuf::from("/tmp/x.yaml"));
369 }
370
371 #[test]
372 fn env_override_parsed() {
373 let cli = Cli::try_parse_from(["braze-sync", "--env", "prod", "export"]).unwrap();
374 assert_eq!(cli.env.as_deref(), Some("prod"));
375 }
376
377 #[test]
378 fn format_value_parsed_as_enum() {
379 let cli = Cli::try_parse_from(["braze-sync", "--format", "json", "export"]).unwrap();
380 assert_eq!(cli.format, Some(OutputFormat::Json));
381 }
382
383 #[test]
384 fn exit_code_for_unauthorized() {
385 let err = anyhow::Error::new(BrazeApiError::Unauthorized);
386 assert_eq!(exit_code_for(&err), 4);
387 }
388
389 #[test]
390 fn exit_code_for_rate_limit_exhausted() {
391 let err = anyhow::Error::new(BrazeApiError::RateLimitExhausted);
392 assert_eq!(exit_code_for(&err), 5);
393 }
394
395 #[test]
396 fn exit_code_for_drift_detected() {
397 let err = anyhow::Error::new(Error::DriftDetected { count: 3 });
398 assert_eq!(exit_code_for(&err), 2);
399 }
400
401 #[test]
402 fn exit_code_for_destructive_blocked() {
403 let err = anyhow::Error::new(Error::DestructiveBlocked);
404 assert_eq!(exit_code_for(&err), 6);
405 }
406
407 #[test]
408 fn exit_code_for_missing_env() {
409 let err = anyhow::Error::new(Error::MissingEnv("X".into()));
410 assert_eq!(exit_code_for(&err), 3);
411 }
412
413 #[test]
414 fn exit_code_for_config_error() {
415 let err = anyhow::Error::new(Error::Config("oops".into()));
416 assert_eq!(exit_code_for(&err), 3);
417 }
418
419 #[test]
420 fn exit_code_for_api_wrapped_unauthorized_unwraps_to_4() {
421 let err = anyhow::Error::new(Error::Api(BrazeApiError::Unauthorized));
424 assert_eq!(exit_code_for(&err), 4);
425 }
426
427 #[test]
428 fn exit_code_for_top_level_rate_limit_exhausted() {
429 let err = anyhow::Error::new(Error::RateLimitExhausted { retries: 3 });
430 assert_eq!(exit_code_for(&err), 5);
431 }
432
433 #[test]
434 fn exit_code_for_other_anyhow_is_one() {
435 let err = anyhow::anyhow!("some random failure");
436 assert_eq!(exit_code_for(&err), 1);
437 }
438}