1use 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 #[arg(long, conflicts_with = "from_existing")]
15 pub force: bool,
16
17 #[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 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}