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
214pub(crate) fn warn_unimplemented(kind: ResourceKind) {
216 eprintln!("⚠ {}: not yet implemented in this binary", kind.as_str());
217}
218
219fn init_tracing(verbose: bool, no_color: bool) {
220 let default_level = if verbose { "debug" } else { "warn" };
221 let filter = tracing_subscriber::EnvFilter::try_from_default_env()
222 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
223 let _ = tracing_subscriber::fmt()
224 .with_env_filter(filter)
225 .with_ansi(!no_color)
226 .with_writer(std::io::stderr)
227 .try_init();
228}
229
230fn exit_code_for(err: &anyhow::Error) -> i32 {
233 for cause in err.chain() {
234 if let Some(b) = cause.downcast_ref::<BrazeApiError>() {
235 return match b {
236 BrazeApiError::Unauthorized => 4,
237 BrazeApiError::RateLimitExhausted => 5,
238 _ => 1,
239 };
240 }
241 if let Some(top) = cause.downcast_ref::<Error>() {
242 match top {
243 Error::Api(_) => {}
246 Error::DestructiveBlocked => return 6,
247 Error::DriftDetected { .. } => return 2,
248 Error::Config(_) | Error::MissingEnv(_) => return 3,
249 Error::RateLimitExhausted { .. } => return 5,
250 Error::Io(_)
251 | Error::YamlParse { .. }
252 | Error::CsvParse { .. }
253 | Error::InvalidFormat { .. }
254 | Error::CatalogItemSchemaMismatch { .. }
255 | Error::CustomAttributeCreateNotSupported { .. } => return 1,
256 }
257 }
258 }
259 1
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::resource::ResourceKind;
266
267 #[test]
268 fn parses_export_with_resource_filter() {
269 let cli =
270 Cli::try_parse_from(["braze-sync", "export", "--resource", "catalog_schema"]).unwrap();
271 let Command::Export(args) = cli.command else {
272 panic!("expected Export subcommand");
273 };
274 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
275 assert_eq!(args.name, None);
276 }
277
278 #[test]
279 fn parses_export_with_name_filter() {
280 let cli = Cli::try_parse_from([
281 "braze-sync",
282 "export",
283 "--resource",
284 "catalog_schema",
285 "--name",
286 "cardiology",
287 ])
288 .unwrap();
289 let Command::Export(args) = cli.command else {
290 panic!("expected Export subcommand");
291 };
292 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
293 assert_eq!(args.name.as_deref(), Some("cardiology"));
294 }
295
296 #[test]
297 fn parses_diff_with_fail_on_drift() {
298 let cli = Cli::try_parse_from(["braze-sync", "diff", "--fail-on-drift"]).unwrap();
299 let Command::Diff(args) = cli.command else {
300 panic!("expected Diff subcommand");
301 };
302 assert!(args.fail_on_drift);
303 assert_eq!(args.resource, None);
304 }
305
306 #[test]
307 fn parses_validate_subcommand() {
308 let cli = Cli::try_parse_from(["braze-sync", "validate"]).unwrap();
309 let Command::Validate(args) = cli.command else {
310 panic!("expected Validate subcommand");
311 };
312 assert_eq!(args.resource, None);
313 }
314
315 #[test]
316 fn parses_validate_with_resource_filter() {
317 let cli = Cli::try_parse_from(["braze-sync", "validate", "--resource", "catalog_schema"])
318 .unwrap();
319 let Command::Validate(args) = cli.command else {
320 panic!("expected Validate subcommand");
321 };
322 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
323 }
324
325 #[test]
326 fn parses_diff_with_resource_and_name() {
327 let cli = Cli::try_parse_from([
328 "braze-sync",
329 "diff",
330 "--resource",
331 "catalog_schema",
332 "--name",
333 "cardiology",
334 ])
335 .unwrap();
336 let Command::Diff(args) = cli.command else {
337 panic!("expected Diff subcommand");
338 };
339 assert_eq!(args.resource, Some(ResourceKind::CatalogSchema));
340 assert_eq!(args.name.as_deref(), Some("cardiology"));
341 assert!(!args.fail_on_drift);
342 }
343
344 #[test]
345 fn name_requires_resource() {
346 let result = Cli::try_parse_from(["braze-sync", "export", "--name", "cardiology"]);
347 assert!(
348 result.is_err(),
349 "expected --name without --resource to error"
350 );
351 }
352
353 #[test]
354 fn config_default_path() {
355 let cli = Cli::try_parse_from(["braze-sync", "export"]).unwrap();
356 assert_eq!(cli.config, PathBuf::from("./braze-sync.config.yaml"));
357 }
358
359 #[test]
360 fn global_flags_position_independent() {
361 let cli = Cli::try_parse_from(["braze-sync", "export", "--config", "/tmp/x.yaml"]).unwrap();
362 assert_eq!(cli.config, PathBuf::from("/tmp/x.yaml"));
363 }
364
365 #[test]
366 fn env_override_parsed() {
367 let cli = Cli::try_parse_from(["braze-sync", "--env", "prod", "export"]).unwrap();
368 assert_eq!(cli.env.as_deref(), Some("prod"));
369 }
370
371 #[test]
372 fn format_value_parsed_as_enum() {
373 let cli = Cli::try_parse_from(["braze-sync", "--format", "json", "export"]).unwrap();
374 assert_eq!(cli.format, Some(OutputFormat::Json));
375 }
376
377 #[test]
378 fn exit_code_for_unauthorized() {
379 let err = anyhow::Error::new(BrazeApiError::Unauthorized);
380 assert_eq!(exit_code_for(&err), 4);
381 }
382
383 #[test]
384 fn exit_code_for_rate_limit_exhausted() {
385 let err = anyhow::Error::new(BrazeApiError::RateLimitExhausted);
386 assert_eq!(exit_code_for(&err), 5);
387 }
388
389 #[test]
390 fn exit_code_for_drift_detected() {
391 let err = anyhow::Error::new(Error::DriftDetected { count: 3 });
392 assert_eq!(exit_code_for(&err), 2);
393 }
394
395 #[test]
396 fn exit_code_for_destructive_blocked() {
397 let err = anyhow::Error::new(Error::DestructiveBlocked);
398 assert_eq!(exit_code_for(&err), 6);
399 }
400
401 #[test]
402 fn exit_code_for_missing_env() {
403 let err = anyhow::Error::new(Error::MissingEnv("X".into()));
404 assert_eq!(exit_code_for(&err), 3);
405 }
406
407 #[test]
408 fn exit_code_for_config_error() {
409 let err = anyhow::Error::new(Error::Config("oops".into()));
410 assert_eq!(exit_code_for(&err), 3);
411 }
412
413 #[test]
414 fn exit_code_for_api_wrapped_unauthorized_unwraps_to_4() {
415 let err = anyhow::Error::new(Error::Api(BrazeApiError::Unauthorized));
418 assert_eq!(exit_code_for(&err), 4);
419 }
420
421 #[test]
422 fn exit_code_for_top_level_rate_limit_exhausted() {
423 let err = anyhow::Error::new(Error::RateLimitExhausted { retries: 3 });
424 assert_eq!(exit_code_for(&err), 5);
425 }
426
427 #[test]
428 fn exit_code_for_other_anyhow_is_one() {
429 let err = anyhow::anyhow!("some random failure");
430 assert_eq!(exit_code_for(&err), 1);
431 }
432}