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