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 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
220pub(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
260fn 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 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 { .. } => return 1,
284 }
285 }
286 }
287 1
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crate::resource::ResourceKind;
294
295 #[test]
296 fn parses_export_with_resource_filter() {
297 let cli =
298 Cli::try_parse_from(["braze-sync", "export", "--resource", "catalog_schema"]).unwrap();
299 let Command::Export(args) = cli.command else {
300 panic!("expected Export subcommand");
301 };
302 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
303 assert_eq!(args.name, None);
304 }
305
306 #[test]
307 fn parses_export_with_name_filter() {
308 let cli = Cli::try_parse_from([
309 "braze-sync",
310 "export",
311 "--resource",
312 "catalog_schema",
313 "--name",
314 "cardiology",
315 ])
316 .unwrap();
317 let Command::Export(args) = cli.command else {
318 panic!("expected Export subcommand");
319 };
320 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
321 assert_eq!(args.name.as_deref(), Some("cardiology"));
322 }
323
324 #[test]
325 fn parses_diff_with_fail_on_drift() {
326 let cli = Cli::try_parse_from(["braze-sync", "diff", "--fail-on-drift"]).unwrap();
327 let Command::Diff(args) = cli.command else {
328 panic!("expected Diff subcommand");
329 };
330 assert!(args.fail_on_drift);
331 assert_eq!(args.resource, None);
332 }
333
334 #[test]
335 fn parses_validate_subcommand() {
336 let cli = Cli::try_parse_from(["braze-sync", "validate"]).unwrap();
337 let Command::Validate(args) = cli.command else {
338 panic!("expected Validate subcommand");
339 };
340 assert_eq!(args.resource, None);
341 }
342
343 #[test]
344 fn parses_validate_with_resource_filter() {
345 let cli = Cli::try_parse_from(["braze-sync", "validate", "--resource", "catalog_schema"])
346 .unwrap();
347 let Command::Validate(args) = cli.command else {
348 panic!("expected Validate subcommand");
349 };
350 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
351 }
352
353 #[test]
354 fn parses_diff_with_resource_and_name() {
355 let cli = Cli::try_parse_from([
356 "braze-sync",
357 "diff",
358 "--resource",
359 "catalog_schema",
360 "--name",
361 "cardiology",
362 ])
363 .unwrap();
364 let Command::Diff(args) = cli.command else {
365 panic!("expected Diff subcommand");
366 };
367 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
368 assert_eq!(args.name.as_deref(), Some("cardiology"));
369 assert!(!args.fail_on_drift);
370 }
371
372 #[test]
373 fn name_requires_resource() {
374 let result = Cli::try_parse_from(["braze-sync", "export", "--name", "cardiology"]);
375 assert!(
376 result.is_err(),
377 "expected --name without --resource to error"
378 );
379 }
380
381 #[test]
382 fn config_default_path() {
383 let cli = Cli::try_parse_from(["braze-sync", "export"]).unwrap();
384 assert_eq!(cli.config, PathBuf::from("./braze-sync.config.yaml"));
385 }
386
387 #[test]
388 fn global_flags_position_independent() {
389 let cli = Cli::try_parse_from(["braze-sync", "export", "--config", "/tmp/x.yaml"]).unwrap();
390 assert_eq!(cli.config, PathBuf::from("/tmp/x.yaml"));
391 }
392
393 #[test]
394 fn env_override_parsed() {
395 let cli = Cli::try_parse_from(["braze-sync", "--env", "prod", "export"]).unwrap();
396 assert_eq!(cli.env.as_deref(), Some("prod"));
397 }
398
399 #[test]
400 fn format_value_parsed_as_enum() {
401 let cli = Cli::try_parse_from(["braze-sync", "--format", "json", "export"]).unwrap();
402 assert_eq!(cli.format, Some(OutputFormat::Json));
403 }
404
405 #[test]
406 fn exit_code_for_unauthorized() {
407 let err = anyhow::Error::new(BrazeApiError::Unauthorized);
408 assert_eq!(exit_code_for(&err), 4);
409 }
410
411 #[test]
412 fn exit_code_for_rate_limit_exhausted() {
413 let err = anyhow::Error::new(BrazeApiError::RateLimitExhausted);
414 assert_eq!(exit_code_for(&err), 5);
415 }
416
417 #[test]
418 fn exit_code_for_drift_detected() {
419 let err = anyhow::Error::new(Error::DriftDetected { count: 3 });
420 assert_eq!(exit_code_for(&err), 2);
421 }
422
423 #[test]
424 fn exit_code_for_destructive_blocked() {
425 let err = anyhow::Error::new(Error::DestructiveBlocked);
426 assert_eq!(exit_code_for(&err), 6);
427 }
428
429 #[test]
430 fn exit_code_for_missing_env() {
431 let err = anyhow::Error::new(Error::MissingEnv("X".into()));
432 assert_eq!(exit_code_for(&err), 3);
433 }
434
435 #[test]
436 fn exit_code_for_config_error() {
437 let err = anyhow::Error::new(Error::Config("oops".into()));
438 assert_eq!(exit_code_for(&err), 3);
439 }
440
441 #[test]
442 fn exit_code_for_api_wrapped_unauthorized_unwraps_to_4() {
443 let err = anyhow::Error::new(Error::Api(BrazeApiError::Unauthorized));
446 assert_eq!(exit_code_for(&err), 4);
447 }
448
449 #[test]
450 fn exit_code_for_top_level_rate_limit_exhausted() {
451 let err = anyhow::Error::new(Error::RateLimitExhausted { retries: 3 });
452 assert_eq!(exit_code_for(&err), 5);
453 }
454
455 #[test]
456 fn exit_code_for_other_anyhow_is_one() {
457 let err = anyhow::anyhow!("some random failure");
458 assert_eq!(exit_code_for(&err), 1);
459 }
460}