Skip to main content

braze_sync/cli/
validate.rs

1//! `braze-sync validate` — local-only structural and naming checks.
2//!
3//! Runs without a Braze API key so CI on fork PRs (where the secret
4//! isn't available) can still gate merges. Issues are collected across
5//! the whole run and reported at the end so a single pass surfaces
6//! every problem.
7
8use crate::config::ConfigFile;
9use crate::error::Error;
10use crate::fs::{
11    catalog_io, content_block_io, custom_attribute_io, email_template_io, tag_io,
12    try_read_resource_dir,
13};
14use crate::resource::ResourceKind;
15use anyhow::anyhow;
16use clap::Args;
17use regex_lite::Regex;
18use std::collections::HashSet;
19use std::path::{Path, PathBuf};
20
21use super::selected_kinds;
22
23#[derive(Args, Debug)]
24pub struct ValidateArgs {
25    /// Limit validation to a specific resource kind.
26    #[arg(long, value_enum)]
27    pub resource: Option<ResourceKind>,
28}
29
30#[derive(Debug)]
31struct ValidationIssue {
32    path: PathBuf,
33    message: String,
34}
35
36pub async fn run(args: &ValidateArgs, cfg: &ConfigFile, config_dir: &Path) -> anyhow::Result<()> {
37    let kinds = selected_kinds(args.resource, &cfg.resources);
38
39    let mut issues: Vec<ValidationIssue> = Vec::new();
40
41    for kind in kinds {
42        match kind {
43            ResourceKind::CatalogSchema => {
44                let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
45                let excludes = compile_kind_excludes(cfg, kind)?;
46                validate_catalog_schemas(
47                    &catalogs_root,
48                    cfg.naming.catalog_name_pattern.as_deref(),
49                    &excludes,
50                    &mut issues,
51                )?;
52            }
53            ResourceKind::ContentBlock => {
54                let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
55                let excludes = compile_kind_excludes(cfg, kind)?;
56                validate_content_blocks(
57                    &content_blocks_root,
58                    cfg.naming.content_block_name_pattern.as_deref(),
59                    &excludes,
60                    &mut issues,
61                )?;
62            }
63            ResourceKind::EmailTemplate => {
64                let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
65                let excludes = compile_kind_excludes(cfg, kind)?;
66                validate_email_templates(&email_templates_root, &excludes, &mut issues)?;
67            }
68            ResourceKind::CustomAttribute => {
69                let registry_path = config_dir.join(&cfg.resources.custom_attribute.path);
70                let excludes = compile_kind_excludes(cfg, kind)?;
71                validate_custom_attributes(
72                    &registry_path,
73                    cfg.naming.custom_attribute_name_pattern.as_deref(),
74                    &excludes,
75                    &mut issues,
76                )?;
77            }
78            ResourceKind::Tag => {
79                let registry_path = config_dir.join(&cfg.resources.tag.path);
80                let excludes = compile_kind_excludes(cfg, kind)?;
81                validate_tags(
82                    cfg,
83                    config_dir,
84                    &registry_path,
85                    cfg.naming.tag_name_pattern.as_deref(),
86                    &excludes,
87                    &mut issues,
88                )?;
89            }
90        }
91    }
92
93    if issues.is_empty() {
94        eprintln!("✓ All checks passed.");
95        return Ok(());
96    }
97
98    eprintln!("✗ Validation found {} issue(s):", issues.len());
99    for issue in &issues {
100        eprintln!("  • {}: {}", issue.path.display(), issue.message);
101    }
102
103    Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
104}
105
106fn compile_kind_excludes(cfg: &ConfigFile, kind: ResourceKind) -> anyhow::Result<Vec<Regex>> {
107    Ok(crate::config::compile_exclude_patterns(
108        &cfg.resources.for_kind(kind).exclude_patterns,
109        kind.as_str(),
110    )?)
111}
112
113/// Try to open a resource root directory. Returns `None` (and pushes an
114/// issue) when the path is missing or is a file — callers should return
115/// `Ok(())` in that case.
116fn open_resource_dir(
117    root: &Path,
118    kind_label: &str,
119    issues: &mut Vec<ValidationIssue>,
120) -> anyhow::Result<Option<std::fs::ReadDir>> {
121    match try_read_resource_dir(root, kind_label) {
122        Ok(rd) => Ok(rd),
123        Err(Error::InvalidFormat { path, message }) => {
124            issues.push(ValidationIssue { path, message });
125            Ok(None)
126        }
127        Err(e) => Err(e.into()),
128    }
129}
130
131/// Compile an optional naming-pattern regex, returning the raw string
132/// alongside the compiled `Regex` so error messages can reference the
133/// original pattern.  `config_key` names the config field for the error
134/// message (e.g. `"catalog_name_pattern"`).
135fn compile_name_pattern(
136    raw: Option<&str>,
137    config_key: &str,
138) -> anyhow::Result<Option<(String, Regex)>> {
139    match raw {
140        Some(p) => Ok(Some((
141            p.to_string(),
142            Regex::new(p).map_err(|e| anyhow!("invalid {config_key} regex {p:?}: {e}"))?,
143        ))),
144        None => Ok(None),
145    }
146}
147
148/// Check `name` against the compiled pattern and push a uniform
149/// "does not match <config_key>" issue when it fails. `kind_label` is
150/// the human-readable resource noun for the message (e.g. `"catalog"`).
151fn check_name_pattern(
152    pattern: Option<&(String, Regex)>,
153    name: &str,
154    path: &Path,
155    kind_label: &str,
156    config_key: &str,
157    issues: &mut Vec<ValidationIssue>,
158) {
159    let Some((pattern_str, re)) = pattern else {
160        return;
161    };
162    if !re.is_match(name) {
163        issues.push(ValidationIssue {
164            path: path.to_path_buf(),
165            message: format!(
166                "{kind_label} name '{name}' does not match {config_key} '{pattern_str}'"
167            ),
168        });
169    }
170}
171
172fn validate_catalog_schemas(
173    catalogs_root: &Path,
174    name_pattern: Option<&str>,
175    excludes: &[Regex],
176    issues: &mut Vec<ValidationIssue>,
177) -> anyhow::Result<()> {
178    let Some(read_dir) = open_resource_dir(catalogs_root, "catalogs", issues)? else {
179        return Ok(());
180    };
181
182    let pattern = compile_name_pattern(name_pattern, "catalog_name_pattern")?;
183
184    for entry in read_dir {
185        let entry = entry?;
186        if !entry.file_type()?.is_dir() {
187            tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
188            continue;
189        }
190        let dir = entry.path();
191        let schema_path = dir.join("schema.yaml");
192        if !schema_path.is_file() {
193            continue;
194        }
195
196        let cat = match catalog_io::read_schema_file(&schema_path) {
197            Ok(c) => c,
198            Err(e) => {
199                issues.push(ValidationIssue {
200                    path: schema_path.clone(),
201                    message: format!("parse error: {e}"),
202                });
203                continue;
204            }
205        };
206
207        // Exclude is checked AFTER parse so a malformed file still
208        // surfaces — excludes mean "don't enforce rules on this name",
209        // not "silence this file". A broken YAML is a workspace problem
210        // regardless of whether the resource is managed out of band.
211        if crate::config::is_excluded(&cat.name, excludes) {
212            continue;
213        }
214
215        // load_all_schemas treats dir/name mismatch as a hard error;
216        // here we downgrade to a soft issue so a single run reports
217        // every bad file.
218        let dir_name = entry.file_name().to_string_lossy().into_owned();
219        if cat.name != dir_name {
220            issues.push(ValidationIssue {
221                path: schema_path.clone(),
222                message: format!(
223                    "catalog name '{}' does not match its directory '{}'",
224                    cat.name, dir_name
225                ),
226            });
227        }
228
229        check_name_pattern(
230            pattern.as_ref(),
231            &cat.name,
232            &schema_path,
233            "catalog",
234            "catalog_name_pattern",
235            issues,
236        );
237    }
238
239    Ok(())
240}
241
242fn validate_content_blocks(
243    content_blocks_root: &Path,
244    name_pattern: Option<&str>,
245    excludes: &[Regex],
246    issues: &mut Vec<ValidationIssue>,
247) -> anyhow::Result<()> {
248    let Some(read_dir) = open_resource_dir(content_blocks_root, "content_blocks", issues)? else {
249        return Ok(());
250    };
251
252    let pattern = compile_name_pattern(name_pattern, "content_block_name_pattern")?;
253
254    for entry in read_dir {
255        let entry = entry?;
256        let path = entry.path();
257        if !entry.file_type()?.is_file() {
258            tracing::debug!(path = %path.display(), "skipping non-file entry");
259            continue;
260        }
261        if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
262            continue;
263        }
264        let stem = path
265            .file_stem()
266            .and_then(|s| s.to_str())
267            .unwrap_or_default()
268            .to_string();
269
270        let cb = match content_block_io::read_content_block_file(&path) {
271            Ok(cb) => cb,
272            Err(e) => {
273                issues.push(ValidationIssue {
274                    path: path.clone(),
275                    message: format!("parse error: {e}"),
276                });
277                continue;
278            }
279        };
280
281        if crate::config::is_excluded(&cb.name, excludes) {
282            continue;
283        }
284
285        if cb.name != stem {
286            issues.push(ValidationIssue {
287                path: path.clone(),
288                message: format!(
289                    "content block name '{}' does not match its file stem '{}'",
290                    cb.name, stem
291                ),
292            });
293        }
294
295        check_name_pattern(
296            pattern.as_ref(),
297            &cb.name,
298            &path,
299            "content block",
300            "content_block_name_pattern",
301            issues,
302        );
303    }
304
305    Ok(())
306}
307
308fn validate_email_templates(
309    email_templates_root: &Path,
310    excludes: &[Regex],
311    issues: &mut Vec<ValidationIssue>,
312) -> anyhow::Result<()> {
313    let Some(read_dir) = open_resource_dir(email_templates_root, "email_templates", issues)? else {
314        return Ok(());
315    };
316
317    for entry in read_dir {
318        let entry = entry?;
319        let path = entry.path();
320        if !entry.file_type()?.is_dir() {
321            tracing::debug!(path = %path.display(), "skipping non-directory entry");
322            continue;
323        }
324        let template_yaml_path = path.join("template.yaml");
325        if !template_yaml_path.is_file() {
326            continue;
327        }
328        let dir_name = entry.file_name().to_string_lossy().into_owned();
329
330        let et = match email_template_io::read_email_template_dir(&path) {
331            Ok(et) => et,
332            Err(e) => {
333                issues.push(ValidationIssue {
334                    path: template_yaml_path.clone(),
335                    message: format!("parse error: {e}"),
336                });
337                continue;
338            }
339        };
340
341        if crate::config::is_excluded(&et.name, excludes) {
342            continue;
343        }
344
345        if et.name != dir_name {
346            issues.push(ValidationIssue {
347                path: template_yaml_path.clone(),
348                message: format!(
349                    "email template name '{}' does not match its directory '{}'",
350                    et.name, dir_name
351                ),
352            });
353        }
354
355        if et.subject.is_empty() {
356            issues.push(ValidationIssue {
357                path: template_yaml_path.clone(),
358                message: format!("email template '{}' has an empty subject", et.name),
359            });
360        }
361    }
362
363    Ok(())
364}
365
366/// Tag-resource validation. Two checks:
367///   1. Structural — the registry parses, has no duplicate names, and
368///      every name matches the configured naming pattern.
369///   2. Cross-reference — every tag referenced by a content_block or
370///      email_template frontmatter exists in the registry. **This is the
371///      check that prevents the "tag not found in Braze" apply failure
372///      from happening in CI.**
373fn validate_tags(
374    cfg: &ConfigFile,
375    config_dir: &Path,
376    registry_path: &Path,
377    name_pattern: Option<&str>,
378    excludes: &[Regex],
379    issues: &mut Vec<ValidationIssue>,
380) -> anyhow::Result<()> {
381    let registry_opt = match tag_io::load_registry(registry_path) {
382        Ok(r) => r,
383        Err(Error::YamlParse { path, source }) => {
384            issues.push(ValidationIssue {
385                path,
386                message: format!("parse error: {source}"),
387            });
388            return Ok(());
389        }
390        Err(e) => return Err(e.into()),
391    };
392
393    if let Some(registry) = &registry_opt {
394        let pattern = compile_name_pattern(name_pattern, "tag_name_pattern")?;
395        let mut seen = HashSet::with_capacity(registry.tags.len());
396        for t in &registry.tags {
397            if crate::config::is_excluded(&t.name, excludes) {
398                continue;
399            }
400            if !seen.insert(t.name.as_str()) {
401                issues.push(ValidationIssue {
402                    path: registry_path.to_path_buf(),
403                    message: format!("duplicate tag name '{}'", t.name),
404                });
405            }
406            check_name_pattern(
407                pattern.as_ref(),
408                &t.name,
409                registry_path,
410                "tag",
411                "tag_name_pattern",
412                issues,
413            );
414        }
415    }
416
417    // Resources matching their own kind's exclude_patterns are still
418    // checked: the apply path doesn't skip them, so neither does this.
419    if cfg.resources.content_block.enabled {
420        let root = config_dir.join(&cfg.resources.content_block.path);
421        walk_for_tag_refs(&root, "content_block", issues, |p| {
422            if !p.is_file() || p.extension().and_then(|e| e.to_str()) != Some("liquid") {
423                return Ok(None);
424            }
425            let cb = content_block_io::read_content_block_file(p)?;
426            Ok(Some((cb.name, cb.tags)))
427        })?
428        .into_iter()
429        .for_each(|(p, name, tags)| {
430            check_resource_tags(
431                registry_opt.as_ref(),
432                excludes,
433                &p,
434                "content_block",
435                &name,
436                &tags,
437                issues,
438            );
439        });
440    }
441    if cfg.resources.email_template.enabled {
442        let root = config_dir.join(&cfg.resources.email_template.path);
443        walk_for_tag_refs(&root, "email_template", issues, |p| {
444            if !p.is_dir() || !p.join("template.yaml").is_file() {
445                return Ok(None);
446            }
447            let et = email_template_io::read_email_template_dir(p)?;
448            Ok(Some((et.name, et.tags)))
449        })?
450        .into_iter()
451        .for_each(|(p, name, tags)| {
452            check_resource_tags(
453                registry_opt.as_ref(),
454                excludes,
455                &p,
456                "email_template",
457                &name,
458                &tags,
459                issues,
460            );
461        });
462    }
463
464    Ok(())
465}
466
467/// Walk a resource directory, skipping non-resource entries. The reader
468/// returns `Ok(None)` for entries that aren't a resource of the kind,
469/// `Ok(Some((name, tags)))` for valid ones, and `Err` for parse failures
470/// (folded into `issues` so a single malformed file doesn't hide the
471/// rest from `validate --resource tag`).
472fn walk_for_tag_refs<F>(
473    root: &Path,
474    kind: &str,
475    issues: &mut Vec<ValidationIssue>,
476    mut read: F,
477) -> anyhow::Result<Vec<(PathBuf, String, Vec<String>)>>
478where
479    F: FnMut(&Path) -> Result<Option<(String, Vec<String>)>, Error>,
480{
481    let mut out = Vec::new();
482    let Some(rd) = try_read_resource_dir(root, kind)? else {
483        return Ok(out);
484    };
485    for entry in rd.flatten() {
486        let p = entry.path();
487        match read(&p) {
488            Ok(Some((name, tags))) => out.push((p, name, tags)),
489            Ok(None) => {}
490            Err(Error::YamlParse { path, source }) => issues.push(ValidationIssue {
491                path,
492                message: format!("parse error: {source}"),
493            }),
494            Err(e) => return Err(e.into()),
495        }
496    }
497    Ok(out)
498}
499
500fn check_resource_tags(
501    registry: Option<&crate::resource::TagRegistry>,
502    excludes: &[Regex],
503    path: &Path,
504    kind: &str,
505    resource_name: &str,
506    tags: &[String],
507    issues: &mut Vec<ValidationIssue>,
508) {
509    for t in tags {
510        if crate::config::is_excluded(t, excludes) {
511            continue;
512        }
513        let in_registry = registry.is_some_and(|r| r.contains(t));
514        if !in_registry {
515            issues.push(ValidationIssue {
516                path: path.to_path_buf(),
517                message: format!(
518                    "{kind} '{resource_name}' references tag '{t}' which is not declared in tags/registry.yaml \
519                     (apply will fail with HTTP 400 until the tag is created in the Braze dashboard \
520                     and added to the registry)"
521                ),
522            });
523        }
524    }
525}
526
527fn validate_custom_attributes(
528    registry_path: &Path,
529    name_pattern: Option<&str>,
530    excludes: &[Regex],
531    issues: &mut Vec<ValidationIssue>,
532) -> anyhow::Result<()> {
533    let registry = match custom_attribute_io::load_registry(registry_path) {
534        Ok(Some(r)) => r,
535        Ok(None) => return Ok(()),
536        Err(Error::YamlParse { path, source }) => {
537            issues.push(ValidationIssue {
538                path,
539                message: format!("parse error: {source}"),
540            });
541            return Ok(());
542        }
543        Err(e) => return Err(e.into()),
544    };
545
546    let pattern = compile_name_pattern(name_pattern, "custom_attribute_name_pattern")?;
547
548    let mut seen = HashSet::with_capacity(registry.attributes.len());
549    for attr in &registry.attributes {
550        if crate::config::is_excluded(&attr.name, excludes) {
551            continue;
552        }
553        if !seen.insert(attr.name.as_str()) {
554            issues.push(ValidationIssue {
555                path: registry_path.to_path_buf(),
556                message: format!("duplicate custom attribute name '{}'", attr.name),
557            });
558        }
559
560        check_name_pattern(
561            pattern.as_ref(),
562            &attr.name,
563            registry_path,
564            "custom attribute",
565            "custom_attribute_name_pattern",
566            issues,
567        );
568    }
569
570    Ok(())
571}