1pub mod apply;
22pub mod diff;
23pub mod export;
24pub mod init;
25pub mod templatize;
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 Templatize(templatize::TemplatizeArgs),
90}
91
92pub async fn run() -> i32 {
94 let cli = match Cli::try_parse() {
95 Ok(c) => c,
96 Err(e) => {
97 e.print().ok();
99 return match e.kind() {
100 clap::error::ErrorKind::DisplayHelp
101 | clap::error::ErrorKind::DisplayVersion
102 | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => 0,
103 _ => 3,
104 };
105 }
106 };
107
108 init_tracing(cli.verbose, cli.no_color);
109 if let Err(e) = crate::config::load_dotenv() {
110 tracing::warn!("dotenv: {e}");
113 }
114
115 if let Command::Init(args) = &cli.command {
117 return finish(init::run(args, &cli.config, cli.env.as_deref()).await);
118 }
119
120 let cfg = match ConfigFile::load(&cli.config)
124 .with_context(|| format!("failed to load config from {}", cli.config.display()))
125 {
126 Ok(c) => c,
127 Err(e) => {
128 eprintln!("error: {e:#}");
129 return 3;
130 }
131 };
132 let config_dir = cli
133 .config
134 .parent()
135 .map(Path::to_path_buf)
136 .unwrap_or_else(|| PathBuf::from("."));
137
138 if let Command::Validate(args) = &cli.command {
144 return finish(validate::run(args, &cfg, &config_dir).await);
145 }
146
147 if let Command::Templatize(args) = &cli.command {
151 return finish(templatize::run(args, &cfg, &config_dir).await);
152 }
153
154 let resolved = match cfg
158 .resolve(cli.env.as_deref())
159 .context("failed to resolve environment from config")
160 {
161 Ok(r) => r,
162 Err(e) => {
163 eprintln!("error: {e:#}");
164 return 3;
165 }
166 };
167
168 finish(dispatch(&cli, resolved, &config_dir).await)
170}
171
172fn finish(result: anyhow::Result<()>) -> i32 {
176 match result {
177 Ok(()) => 0,
178 Err(e) => {
179 eprintln!("error: {e:#}");
180 exit_code_for(&e)
181 }
182 }
183}
184
185async fn dispatch(cli: &Cli, resolved: ResolvedConfig, config_dir: &Path) -> anyhow::Result<()> {
186 match &cli.command {
187 Command::Export(args) => export::run(args, resolved, config_dir).await,
188 Command::Diff(args) => {
189 let format = cli.format.unwrap_or_default();
190 diff::run(args, resolved, config_dir, format).await
191 }
192 Command::Apply(args) => {
193 let format = cli.format.unwrap_or_default();
194 apply::run(args, resolved, config_dir, format).await
195 }
196 Command::Validate(_) => {
197 unreachable!("validate is dispatched in cli::run before env resolution")
198 }
199 Command::Templatize(_) => {
200 unreachable!("templatize is dispatched in cli::run before env resolution")
201 }
202 Command::Init(_) => {
203 unreachable!("init is dispatched in cli::run before config load")
204 }
205 }
206}
207
208pub(crate) fn warn_if_name_excluded(
214 kind: ResourceKind,
215 name: Option<&str>,
216 excludes: &[regex_lite::Regex],
217) -> bool {
218 let Some(name) = name else {
219 return false;
220 };
221 if crate::config::is_excluded(name, excludes) {
222 eprintln!(
223 "⚠ {}: '{}' matches exclude_patterns; skipping",
224 kind.as_str(),
225 name
226 );
227 return true;
228 }
229 false
230}
231
232pub(crate) fn selected_kinds(
235 filter: Option<ResourceKind>,
236 resources: &ResourcesConfig,
237) -> Vec<ResourceKind> {
238 match filter {
239 Some(k) => {
240 if !resources.is_enabled(k) {
241 eprintln!("⚠ {}: disabled in config, skipping", k.as_str());
242 vec![]
243 } else {
244 vec![k]
245 }
246 }
247 None => ResourceKind::all()
248 .iter()
249 .copied()
250 .filter(|k| {
251 let enabled = resources.is_enabled(*k);
252 if !enabled {
253 tracing::debug!("{}: disabled in config, skipping", k.as_str());
254 }
255 enabled
256 })
257 .collect(),
258 }
259}
260
261fn init_tracing(verbose: bool, no_color: bool) {
262 let default_level = if verbose { "debug" } else { "warn" };
263 let filter = tracing_subscriber::EnvFilter::try_from_default_env()
264 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
265 let _ = tracing_subscriber::fmt()
266 .with_env_filter(filter)
267 .with_ansi(!no_color)
268 .with_writer(std::io::stderr)
269 .try_init();
270}
271
272fn exit_code_for(err: &anyhow::Error) -> i32 {
275 for cause in err.chain() {
276 if let Some(b) = cause.downcast_ref::<BrazeApiError>() {
277 return match b {
278 BrazeApiError::Unauthorized => 4,
279 BrazeApiError::RateLimitExhausted => 5,
280 _ => 1,
281 };
282 }
283 if let Some(top) = cause.downcast_ref::<Error>() {
284 match top {
285 Error::Api(_) => {}
288 Error::DestructiveBlocked => return 6,
289 Error::PlanDrift => return 7,
290 Error::DriftDetected { .. } => return 2,
291 Error::Config(_) | Error::MissingEnv(_) => return 3,
292 Error::RateLimitExhausted { .. } => return 5,
293 Error::Io(_)
294 | Error::YamlParse { .. }
295 | Error::CsvParse { .. }
296 | Error::InvalidFormat { .. } => return 1,
297 }
298 }
299 }
300 1
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::resource::ResourceKind;
307
308 #[test]
309 fn parses_export_with_resource_filter() {
310 let cli =
311 Cli::try_parse_from(["braze-sync", "export", "--resource", "catalog_schema"]).unwrap();
312 let Command::Export(args) = cli.command else {
313 panic!("expected Export subcommand");
314 };
315 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
316 assert_eq!(args.name, None);
317 }
318
319 #[test]
320 fn parses_export_with_name_filter() {
321 let cli = Cli::try_parse_from([
322 "braze-sync",
323 "export",
324 "--resource",
325 "catalog_schema",
326 "--name",
327 "cardiology",
328 ])
329 .unwrap();
330 let Command::Export(args) = cli.command else {
331 panic!("expected Export subcommand");
332 };
333 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
334 assert_eq!(args.name.as_deref(), Some("cardiology"));
335 }
336
337 #[test]
338 fn parses_diff_with_fail_on_drift() {
339 let cli = Cli::try_parse_from(["braze-sync", "diff", "--fail-on-drift"]).unwrap();
340 let Command::Diff(args) = cli.command else {
341 panic!("expected Diff subcommand");
342 };
343 assert!(args.fail_on_drift);
344 assert_eq!(args.resource, None);
345 }
346
347 #[test]
348 fn parses_validate_subcommand() {
349 let cli = Cli::try_parse_from(["braze-sync", "validate"]).unwrap();
350 let Command::Validate(args) = cli.command else {
351 panic!("expected Validate subcommand");
352 };
353 assert_eq!(args.resource, None);
354 }
355
356 #[test]
357 fn parses_validate_with_resource_filter() {
358 let cli = Cli::try_parse_from(["braze-sync", "validate", "--resource", "catalog_schema"])
359 .unwrap();
360 let Command::Validate(args) = cli.command else {
361 panic!("expected Validate subcommand");
362 };
363 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
364 }
365
366 #[test]
367 fn parses_diff_with_resource_and_name() {
368 let cli = Cli::try_parse_from([
369 "braze-sync",
370 "diff",
371 "--resource",
372 "catalog_schema",
373 "--name",
374 "cardiology",
375 ])
376 .unwrap();
377 let Command::Diff(args) = cli.command else {
378 panic!("expected Diff subcommand");
379 };
380 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
381 assert_eq!(args.name.as_deref(), Some("cardiology"));
382 assert!(!args.fail_on_drift);
383 }
384
385 #[test]
386 fn name_requires_resource() {
387 let result = Cli::try_parse_from(["braze-sync", "export", "--name", "cardiology"]);
388 assert!(
389 result.is_err(),
390 "expected --name without --resource to error"
391 );
392 }
393
394 #[test]
395 fn config_default_path() {
396 let cli = Cli::try_parse_from(["braze-sync", "export"]).unwrap();
397 assert_eq!(cli.config, PathBuf::from("./braze-sync.config.yaml"));
398 }
399
400 #[test]
401 fn global_flags_position_independent() {
402 let cli = Cli::try_parse_from(["braze-sync", "export", "--config", "/tmp/x.yaml"]).unwrap();
403 assert_eq!(cli.config, PathBuf::from("/tmp/x.yaml"));
404 }
405
406 #[test]
407 fn env_override_parsed() {
408 let cli = Cli::try_parse_from(["braze-sync", "--env", "prod", "export"]).unwrap();
409 assert_eq!(cli.env.as_deref(), Some("prod"));
410 }
411
412 #[test]
413 fn format_value_parsed_as_enum() {
414 let cli = Cli::try_parse_from(["braze-sync", "--format", "json", "export"]).unwrap();
415 assert_eq!(cli.format, Some(OutputFormat::Json));
416 }
417
418 #[test]
419 fn exit_code_for_unauthorized() {
420 let err = anyhow::Error::new(BrazeApiError::Unauthorized);
421 assert_eq!(exit_code_for(&err), 4);
422 }
423
424 #[test]
425 fn exit_code_for_rate_limit_exhausted() {
426 let err = anyhow::Error::new(BrazeApiError::RateLimitExhausted);
427 assert_eq!(exit_code_for(&err), 5);
428 }
429
430 #[test]
431 fn exit_code_for_drift_detected() {
432 let err = anyhow::Error::new(Error::DriftDetected { count: 3 });
433 assert_eq!(exit_code_for(&err), 2);
434 }
435
436 #[test]
437 fn exit_code_for_destructive_blocked() {
438 let err = anyhow::Error::new(Error::DestructiveBlocked);
439 assert_eq!(exit_code_for(&err), 6);
440 }
441
442 #[test]
443 fn exit_code_for_missing_env() {
444 let err = anyhow::Error::new(Error::MissingEnv("X".into()));
445 assert_eq!(exit_code_for(&err), 3);
446 }
447
448 #[test]
449 fn exit_code_for_config_error() {
450 let err = anyhow::Error::new(Error::Config("oops".into()));
451 assert_eq!(exit_code_for(&err), 3);
452 }
453
454 #[test]
455 fn exit_code_for_api_wrapped_unauthorized_unwraps_to_4() {
456 let err = anyhow::Error::new(Error::Api(BrazeApiError::Unauthorized));
459 assert_eq!(exit_code_for(&err), 4);
460 }
461
462 #[test]
463 fn exit_code_for_top_level_rate_limit_exhausted() {
464 let err = anyhow::Error::new(Error::RateLimitExhausted { retries: 3 });
465 assert_eq!(exit_code_for(&err), 5);
466 }
467
468 #[test]
469 fn exit_code_for_other_anyhow_is_one() {
470 let err = anyhow::anyhow!("some random failure");
471 assert_eq!(exit_code_for(&err), 1);
472 }
473}