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