Skip to main content

braze_sync/cli/
diff.rs

1//! `braze-sync diff` — show drift between local files and Braze.
2//!
3//! v0.1.0 supports Catalog Schema only. The other resource kinds emit a
4//! "not yet implemented" warning.
5//!
6//! Output goes to **stdout** so scripts can `braze-sync diff > drift.txt`
7//! cleanly. Status warnings go to stderr. The formatter is chosen by the
8//! global `--format` flag (default: `table`).
9//!
10//! With `--fail-on-drift`, a non-empty `summary.changed_count()` makes the
11//! command exit with code 2 (`Error::DriftDetected`) so CI pipelines can
12//! gate merges on a clean tree.
13
14use crate::braze::error::BrazeApiError;
15use crate::braze::BrazeClient;
16use crate::config::ResolvedConfig;
17use crate::diff::catalog::diff_schema;
18use crate::diff::{DiffSummary, ResourceDiff};
19use crate::error::Error;
20use crate::format::OutputFormat;
21use crate::fs::catalog_io;
22use crate::resource::{Catalog, ResourceKind};
23use anyhow::Context as _;
24use clap::Args;
25use std::collections::{BTreeMap, BTreeSet};
26use std::path::Path;
27
28use super::{selected_kinds, warn_unimplemented};
29
30#[derive(Args, Debug)]
31pub struct DiffArgs {
32    /// Limit diff to a specific resource kind.
33    #[arg(long, value_enum)]
34    pub resource: Option<ResourceKind>,
35
36    /// When `--resource` is given, optionally restrict to a single named
37    /// resource. Requires `--resource`.
38    #[arg(long, requires = "resource")]
39    pub name: Option<String>,
40
41    /// Exit with code 2 if any drift is detected. Intended for CI gates.
42    #[arg(long)]
43    pub fail_on_drift: bool,
44}
45
46pub async fn run(
47    args: &DiffArgs,
48    resolved: ResolvedConfig,
49    config_dir: &Path,
50    format: OutputFormat,
51) -> anyhow::Result<()> {
52    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
53    let client = BrazeClient::from_resolved(&resolved);
54    let kinds = selected_kinds(args.resource, &resolved.resources);
55
56    let mut summary = DiffSummary::default();
57    for kind in kinds {
58        match kind {
59            ResourceKind::CatalogSchema => {
60                let diffs =
61                    compute_catalog_schema_diffs(&client, &catalogs_root, args.name.as_deref())
62                        .await
63                        .context("computing catalog_schema diff")?;
64                summary.diffs.extend(diffs);
65            }
66            other => {
67                warn_unimplemented(other);
68            }
69        }
70    }
71
72    // Render formatted output to stdout. Formatters return strings ending
73    // with one newline, so a plain `print!` is enough.
74    let formatted = format.formatter().format(&summary);
75    print!("{formatted}");
76
77    if args.fail_on_drift && summary.changed_count() > 0 {
78        return Err(Error::DriftDetected {
79            count: summary.changed_count(),
80        }
81        .into());
82    }
83
84    Ok(())
85}
86
87/// Compute the per-catalog-schema diff between local files and Braze.
88///
89/// `pub(crate)` so [`crate::cli::apply`] can reuse the exact same plan
90/// computation that the diff command displays — apply is "compute the
91/// plan and then execute it", so they MUST agree on what the plan is.
92pub(crate) async fn compute_catalog_schema_diffs(
93    client: &BrazeClient,
94    catalogs_root: &Path,
95    name_filter: Option<&str>,
96) -> anyhow::Result<Vec<ResourceDiff>> {
97    // Local: load all on-disk schemas, then optionally restrict by name.
98    let mut local = catalog_io::load_all_schemas(catalogs_root)?;
99    if let Some(name) = name_filter {
100        local.retain(|c| c.name == name);
101    }
102
103    // Remote: when filtering, hit the cheaper get-by-name endpoint.
104    let remote: Vec<Catalog> = match name_filter {
105        Some(name) => match client.get_catalog(name).await {
106            Ok(c) => vec![c],
107            // NotFound on the filtered get-by-name call means the remote
108            // simply doesn't have it; treat as "no remote" so the local
109            // shows up as Added (a normal diff entry, not an error).
110            Err(BrazeApiError::NotFound { .. }) => Vec::new(),
111            Err(e) => return Err(e.into()),
112        },
113        None => client.list_catalogs().await?,
114    };
115
116    // Index by name and compute diffs over the union, in deterministic
117    // (sorted) order.
118    let local_by_name: BTreeMap<&str, &Catalog> =
119        local.iter().map(|c| (c.name.as_str(), c)).collect();
120    let remote_by_name: BTreeMap<&str, &Catalog> =
121        remote.iter().map(|c| (c.name.as_str(), c)).collect();
122
123    let mut all_names: BTreeSet<&str> = BTreeSet::new();
124    all_names.extend(local_by_name.keys().copied());
125    all_names.extend(remote_by_name.keys().copied());
126
127    let mut diffs = Vec::new();
128    for name in all_names {
129        let l = local_by_name.get(name).copied();
130        let r = remote_by_name.get(name).copied();
131        if let Some(d) = diff_schema(l, r) {
132            diffs.push(ResourceDiff::CatalogSchema(d));
133        }
134    }
135
136    Ok(diffs)
137}