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