Skip to main content

braze_sync/cli/
init.rs

1//! `braze-sync init` — scaffold a new braze-sync workspace.
2
3use crate::config::ConfigFile;
4use anyhow::{bail, Context as _};
5use clap::Args;
6use std::fs;
7use std::io::Write as _;
8use std::path::{Path, PathBuf};
9
10#[derive(Args, Debug)]
11pub struct InitArgs {
12    /// Overwrite an existing `braze-sync.config.yaml`. Directories and
13    /// `.gitignore` are updated idempotently regardless.
14    #[arg(long, conflicts_with = "from_existing")]
15    pub force: bool,
16
17    /// After scaffolding, pull the current state from Braze into the new
18    /// layout. Requires the API key env var from the scaffolded config
19    /// (by default `BRAZE_DEV_API_KEY`) to be set.
20    #[arg(long)]
21    pub from_existing: bool,
22}
23
24pub async fn run(
25    args: &InitArgs,
26    config_path: &Path,
27    env_override: Option<&str>,
28) -> anyhow::Result<()> {
29    let config_dir = config_path
30        .parent()
31        .filter(|p| !p.as_os_str().is_empty())
32        .map(Path::to_path_buf)
33        .unwrap_or_else(|| PathBuf::from("."));
34
35    fs::create_dir_all(&config_dir)
36        .with_context(|| format!("creating config directory {}", config_dir.display()))?;
37
38    // --force and --from-existing are mutually exclusive (clap-enforced)
39    // to prevent silently discarding operator edits.
40    let on_existing = match (args.force, args.from_existing) {
41        (true, _) => OnExisting::Overwrite,
42        (false, true) => OnExisting::Keep,
43        (false, false) => OnExisting::Fail,
44    };
45    let config_written = write_config_file(config_path, on_existing)?;
46    scaffold_resource_dirs(&config_dir)?;
47    let gitignore_updated = update_gitignore(&config_dir)?;
48
49    eprintln!(
50        "✓ config:     {} ({})",
51        config_path.display(),
52        if config_written {
53            "written"
54        } else {
55            "exists, kept"
56        }
57    );
58    eprintln!("✓ directories: ensured");
59    eprintln!(
60        "✓ .gitignore: {}",
61        if gitignore_updated {
62            "updated"
63        } else {
64            "already has entries"
65        }
66    );
67
68    if args.from_existing {
69        if config_written {
70            eprintln!(
71                "⚠ --from-existing is using the freshly-scaffolded config — \
72                 the template's default endpoint will be hit. \
73                 Edit {} first if your Braze instance is elsewhere.",
74                config_path.display()
75            );
76        }
77        eprintln!("✓ --from-existing: loading config and pulling Braze state…");
78        run_from_existing(config_path, &config_dir, env_override).await?;
79    } else {
80        eprintln!();
81        eprintln!("Next steps:");
82        eprintln!("  1. export BRAZE_DEV_API_KEY=<your key>");
83        eprintln!("  2. braze-sync export            # pull current Braze state");
84        eprintln!("  3. braze-sync diff              # preview drift");
85    }
86
87    Ok(())
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum OnExisting {
92    Overwrite,
93    Keep,
94    Fail,
95}
96
97fn write_config_file(config_path: &Path, on_existing: OnExisting) -> anyhow::Result<bool> {
98    if config_path.exists() {
99        match on_existing {
100            OnExisting::Overwrite => {
101                eprintln!("⚠ {} exists; overwriting (--force)", config_path.display());
102            }
103            OnExisting::Keep => return Ok(false),
104            OnExisting::Fail => bail!(
105                "{} already exists; pass --force to overwrite",
106                config_path.display()
107            ),
108        }
109    }
110    fs::write(config_path, CONFIG_TEMPLATE)
111        .with_context(|| format!("writing config to {}", config_path.display()))?;
112    Ok(true)
113}
114
115const SUBDIRS: [&str; 5] = [
116    "catalogs",
117    "content_blocks",
118    "email_templates",
119    "custom_attributes",
120    "tags",
121];
122
123const GITIGNORE_ENTRIES: [&str; 2] = [".env", ".env.*"];
124
125fn scaffold_resource_dirs(config_dir: &Path) -> anyhow::Result<()> {
126    for sub in SUBDIRS {
127        let dir = config_dir.join(sub);
128        fs::create_dir_all(&dir)
129            .with_context(|| format!("creating directory {}", dir.display()))?;
130    }
131    Ok(())
132}
133
134fn update_gitignore(config_dir: &Path) -> anyhow::Result<bool> {
135    let path = config_dir.join(".gitignore");
136
137    let existing = match fs::read_to_string(&path) {
138        Ok(s) => s,
139        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
140        Err(e) => {
141            return Err(e).with_context(|| format!("reading {}", path.display()));
142        }
143    };
144
145    let has_line = |needle: &str| existing.lines().any(|l| l.trim() == needle);
146    let missing: Vec<&str> = GITIGNORE_ENTRIES
147        .iter()
148        .copied()
149        .filter(|e| !has_line(e))
150        .collect();
151    if missing.is_empty() {
152        return Ok(false);
153    }
154
155    let mut f = fs::OpenOptions::new()
156        .create(true)
157        .append(true)
158        .open(&path)
159        .with_context(|| format!("opening {} for append", path.display()))?;
160
161    let prefix = match (existing.is_empty(), existing.ends_with('\n')) {
162        (true, _) => "# braze-sync\n",
163        (false, true) => "\n# braze-sync\n",
164        (false, false) => "\n\n# braze-sync\n",
165    };
166    f.write_all(prefix.as_bytes())?;
167    for entry in missing {
168        writeln!(f, "{entry}")?;
169    }
170    Ok(true)
171}
172
173async fn run_from_existing(
174    config_path: &Path,
175    config_dir: &Path,
176    env_override: Option<&str>,
177) -> anyhow::Result<()> {
178    let cfg = ConfigFile::load(config_path)
179        .with_context(|| format!("loading config from {}", config_path.display()))?;
180    let resolved = cfg
181        .resolve(env_override)
182        .context("resolving environment for --from-existing")?;
183
184    super::export::run(&super::export::ExportArgs::default(), resolved, config_dir).await
185}
186
187const CONFIG_TEMPLATE: &str = r#"# braze-sync configuration (v1 schema, frozen at v1.0).
188
189version: 1
190
191# Environment picked when --env is not passed.
192default_environment: dev
193
194environments:
195  dev:
196    # Braze REST endpoint for your instance. See:
197    # https://www.braze.com/docs/api/basics/#endpoints
198    api_endpoint: https://rest.fra-02.braze.eu
199    # Name of the env var holding the API key — NEVER put the key itself
200    # in this file.
201    api_key_env: BRAZE_DEV_API_KEY
202  # prod:
203  #   api_endpoint: https://rest.fra-02.braze.eu
204  #   api_key_env: BRAZE_PROD_API_KEY
205
206resources:
207  catalog_schema:
208    enabled: true
209    path: catalogs/
210  content_block:
211    enabled: true
212    path: content_blocks/
213  email_template:
214    enabled: true
215    path: email_templates/
216  custom_attribute:
217    enabled: true
218    path: custom_attributes/registry.yaml
219  # Tags are GitOps-only: Braze does not expose a public REST API for
220  # workspace tags, so braze-sync cannot create them. Tracking them as a
221  # registry makes the dependency explicit so `apply` can fail fast (with
222  # an actionable error and a list of tags to create in the dashboard)
223  # instead of mid-pipeline at the first 400 from Braze.
224  tag:
225    enabled: true
226    path: tags/registry.yaml
227
228# Optional name validators enforced by `braze-sync validate`.
229# naming:
230#   catalog_name_pattern: "^[a-z][a-z0-9_]*$"
231#   content_block_name_pattern: "^[a-zA-Z0-9_]+$"
232#   custom_attribute_name_pattern: "^[a-z][a-z0-9_]*$"
233#   tag_name_pattern: "^[a-z][a-z0-9_/-]*$"
234"#;
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn config_template_parses_as_valid_config() {
242        let tmp = tempfile::tempdir().unwrap();
243        let path = tmp.path().join("braze-sync.config.yaml");
244        fs::write(&path, CONFIG_TEMPLATE).unwrap();
245        let cfg = ConfigFile::load(&path).unwrap();
246        assert_eq!(cfg.version, 1);
247        assert_eq!(cfg.default_environment, "dev");
248        assert!(cfg.environments.contains_key("dev"));
249    }
250
251    #[test]
252    fn gitignore_entries_added_on_fresh_file() {
253        let tmp = tempfile::tempdir().unwrap();
254        let updated = update_gitignore(tmp.path()).unwrap();
255        assert!(updated);
256        let content = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
257        assert!(content.contains(".env"));
258        assert!(content.contains(".env.*"));
259    }
260
261    #[test]
262    fn gitignore_idempotent_on_second_run() {
263        let tmp = tempfile::tempdir().unwrap();
264        let first = update_gitignore(tmp.path()).unwrap();
265        assert!(first);
266        let second = update_gitignore(tmp.path()).unwrap();
267        assert!(!second);
268    }
269
270    #[test]
271    fn gitignore_preserves_existing_content() {
272        let tmp = tempfile::tempdir().unwrap();
273        let path = tmp.path().join(".gitignore");
274        fs::write(&path, "target/\ndist/\n").unwrap();
275        update_gitignore(tmp.path()).unwrap();
276        let content = fs::read_to_string(&path).unwrap();
277        assert!(content.contains("target/"));
278        assert!(content.contains("dist/"));
279        assert!(content.contains(".env"));
280    }
281
282    #[test]
283    fn gitignore_skips_already_present_entry() {
284        let tmp = tempfile::tempdir().unwrap();
285        let path = tmp.path().join(".gitignore");
286        fs::write(&path, ".env\n").unwrap();
287        let updated = update_gitignore(tmp.path()).unwrap();
288        assert!(updated);
289        let content = fs::read_to_string(&path).unwrap();
290        let count = content.lines().filter(|l| l.trim() == ".env").count();
291        assert_eq!(count, 1);
292        assert!(content.contains(".env.*"));
293    }
294
295    #[test]
296    fn scaffold_creates_all_four_dirs() {
297        let tmp = tempfile::tempdir().unwrap();
298        scaffold_resource_dirs(tmp.path()).unwrap();
299        for sub in SUBDIRS {
300            assert!(tmp.path().join(sub).is_dir());
301        }
302    }
303
304    #[test]
305    fn scaffold_is_idempotent() {
306        let tmp = tempfile::tempdir().unwrap();
307        scaffold_resource_dirs(tmp.path()).unwrap();
308        scaffold_resource_dirs(tmp.path()).unwrap();
309    }
310
311    #[test]
312    fn write_config_refuses_to_overwrite_without_force() {
313        let tmp = tempfile::tempdir().unwrap();
314        let path = tmp.path().join("braze-sync.config.yaml");
315        fs::write(&path, "version: 1\n# user edits\n").unwrap();
316        let err = write_config_file(&path, OnExisting::Fail).unwrap_err();
317        assert!(err.to_string().contains("--force"));
318        let content = fs::read_to_string(&path).unwrap();
319        assert!(content.contains("user edits"));
320    }
321
322    #[test]
323    fn write_config_overwrites_with_force() {
324        let tmp = tempfile::tempdir().unwrap();
325        let path = tmp.path().join("braze-sync.config.yaml");
326        fs::write(&path, "# old\n").unwrap();
327        let written = write_config_file(&path, OnExisting::Overwrite).unwrap();
328        assert!(written);
329        let content = fs::read_to_string(&path).unwrap();
330        assert!(content.contains("braze-sync configuration"));
331    }
332
333    #[test]
334    fn write_config_keeps_existing_on_keep() {
335        let tmp = tempfile::tempdir().unwrap();
336        let path = tmp.path().join("braze-sync.config.yaml");
337        fs::write(&path, "# operator-tuned\nversion: 1\n").unwrap();
338        let written = write_config_file(&path, OnExisting::Keep).unwrap();
339        assert!(!written);
340        let content = fs::read_to_string(&path).unwrap();
341        assert!(content.contains("operator-tuned"));
342    }
343
344    #[test]
345    fn write_config_writes_fresh_on_keep_when_no_existing() {
346        let tmp = tempfile::tempdir().unwrap();
347        let path = tmp.path().join("braze-sync.config.yaml");
348        let written = write_config_file(&path, OnExisting::Keep).unwrap();
349        assert!(written);
350        assert!(path.exists());
351    }
352}