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, try_read_resource_dir,
12};
13use crate::resource::ResourceKind;
14use anyhow::anyhow;
15use clap::Args;
16use regex_lite::Regex;
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20use super::selected_kinds;
21
22#[derive(Args, Debug)]
23pub struct ValidateArgs {
24    /// Limit validation to a specific resource kind.
25    #[arg(long, value_enum)]
26    pub resource: Option<ResourceKind>,
27}
28
29#[derive(Debug)]
30struct ValidationIssue {
31    path: PathBuf,
32    message: String,
33}
34
35pub async fn run(args: &ValidateArgs, cfg: &ConfigFile, config_dir: &Path) -> anyhow::Result<()> {
36    let kinds = selected_kinds(args.resource, &cfg.resources);
37
38    let mut issues: Vec<ValidationIssue> = Vec::new();
39
40    for kind in kinds {
41        match kind {
42            ResourceKind::CatalogSchema => {
43                let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
44                let excludes = compile_kind_excludes(cfg, kind)?;
45                validate_catalog_schemas(
46                    &catalogs_root,
47                    cfg.naming.catalog_name_pattern.as_deref(),
48                    &excludes,
49                    &mut issues,
50                )?;
51            }
52            ResourceKind::ContentBlock => {
53                let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
54                let excludes = compile_kind_excludes(cfg, kind)?;
55                validate_content_blocks(
56                    &content_blocks_root,
57                    cfg.naming.content_block_name_pattern.as_deref(),
58                    &excludes,
59                    &mut issues,
60                )?;
61            }
62            ResourceKind::EmailTemplate => {
63                let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
64                let excludes = compile_kind_excludes(cfg, kind)?;
65                validate_email_templates(&email_templates_root, &excludes, &mut issues)?;
66            }
67            ResourceKind::CustomAttribute => {
68                let registry_path = config_dir.join(&cfg.resources.custom_attribute.path);
69                let excludes = compile_kind_excludes(cfg, kind)?;
70                validate_custom_attributes(
71                    &registry_path,
72                    cfg.naming.custom_attribute_name_pattern.as_deref(),
73                    &excludes,
74                    &mut issues,
75                )?;
76            }
77        }
78    }
79
80    if issues.is_empty() {
81        eprintln!("✓ All checks passed.");
82        return Ok(());
83    }
84
85    eprintln!("✗ Validation found {} issue(s):", issues.len());
86    for issue in &issues {
87        eprintln!("  • {}: {}", issue.path.display(), issue.message);
88    }
89
90    Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
91}
92
93fn compile_kind_excludes(cfg: &ConfigFile, kind: ResourceKind) -> anyhow::Result<Vec<Regex>> {
94    Ok(crate::config::compile_exclude_patterns(
95        &cfg.resources.for_kind(kind).exclude_patterns,
96        kind.as_str(),
97    )?)
98}
99
100/// Try to open a resource root directory. Returns `None` (and pushes an
101/// issue) when the path is missing or is a file — callers should return
102/// `Ok(())` in that case.
103fn open_resource_dir(
104    root: &Path,
105    kind_label: &str,
106    issues: &mut Vec<ValidationIssue>,
107) -> anyhow::Result<Option<std::fs::ReadDir>> {
108    match try_read_resource_dir(root, kind_label) {
109        Ok(rd) => Ok(rd),
110        Err(Error::InvalidFormat { path, message }) => {
111            issues.push(ValidationIssue { path, message });
112            Ok(None)
113        }
114        Err(e) => Err(e.into()),
115    }
116}
117
118/// Compile an optional naming-pattern regex, returning the raw string
119/// alongside the compiled `Regex` so error messages can reference the
120/// original pattern.  `config_key` names the config field for the error
121/// message (e.g. `"catalog_name_pattern"`).
122fn compile_name_pattern(
123    raw: Option<&str>,
124    config_key: &str,
125) -> anyhow::Result<Option<(String, Regex)>> {
126    match raw {
127        Some(p) => Ok(Some((
128            p.to_string(),
129            Regex::new(p).map_err(|e| anyhow!("invalid {config_key} regex {p:?}: {e}"))?,
130        ))),
131        None => Ok(None),
132    }
133}
134
135/// Check `name` against the compiled pattern and push a uniform
136/// "does not match <config_key>" issue when it fails. `kind_label` is
137/// the human-readable resource noun for the message (e.g. `"catalog"`).
138fn check_name_pattern(
139    pattern: Option<&(String, Regex)>,
140    name: &str,
141    path: &Path,
142    kind_label: &str,
143    config_key: &str,
144    issues: &mut Vec<ValidationIssue>,
145) {
146    let Some((pattern_str, re)) = pattern else {
147        return;
148    };
149    if !re.is_match(name) {
150        issues.push(ValidationIssue {
151            path: path.to_path_buf(),
152            message: format!(
153                "{kind_label} name '{name}' does not match {config_key} '{pattern_str}'"
154            ),
155        });
156    }
157}
158
159fn validate_catalog_schemas(
160    catalogs_root: &Path,
161    name_pattern: Option<&str>,
162    excludes: &[Regex],
163    issues: &mut Vec<ValidationIssue>,
164) -> anyhow::Result<()> {
165    let Some(read_dir) = open_resource_dir(catalogs_root, "catalogs", issues)? else {
166        return Ok(());
167    };
168
169    let pattern = compile_name_pattern(name_pattern, "catalog_name_pattern")?;
170
171    for entry in read_dir {
172        let entry = entry?;
173        if !entry.file_type()?.is_dir() {
174            tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
175            continue;
176        }
177        let dir = entry.path();
178        let schema_path = dir.join("schema.yaml");
179        if !schema_path.is_file() {
180            continue;
181        }
182
183        let cat = match catalog_io::read_schema_file(&schema_path) {
184            Ok(c) => c,
185            Err(e) => {
186                issues.push(ValidationIssue {
187                    path: schema_path.clone(),
188                    message: format!("parse error: {e}"),
189                });
190                continue;
191            }
192        };
193
194        // Exclude is checked AFTER parse so a malformed file still
195        // surfaces — excludes mean "don't enforce rules on this name",
196        // not "silence this file". A broken YAML is a workspace problem
197        // regardless of whether the resource is managed out of band.
198        if crate::config::is_excluded(&cat.name, excludes) {
199            continue;
200        }
201
202        // load_all_schemas treats dir/name mismatch as a hard error;
203        // here we downgrade to a soft issue so a single run reports
204        // every bad file.
205        let dir_name = entry.file_name().to_string_lossy().into_owned();
206        if cat.name != dir_name {
207            issues.push(ValidationIssue {
208                path: schema_path.clone(),
209                message: format!(
210                    "catalog name '{}' does not match its directory '{}'",
211                    cat.name, dir_name
212                ),
213            });
214        }
215
216        check_name_pattern(
217            pattern.as_ref(),
218            &cat.name,
219            &schema_path,
220            "catalog",
221            "catalog_name_pattern",
222            issues,
223        );
224    }
225
226    Ok(())
227}
228
229fn validate_content_blocks(
230    content_blocks_root: &Path,
231    name_pattern: Option<&str>,
232    excludes: &[Regex],
233    issues: &mut Vec<ValidationIssue>,
234) -> anyhow::Result<()> {
235    let Some(read_dir) = open_resource_dir(content_blocks_root, "content_blocks", issues)? else {
236        return Ok(());
237    };
238
239    let pattern = compile_name_pattern(name_pattern, "content_block_name_pattern")?;
240
241    for entry in read_dir {
242        let entry = entry?;
243        let path = entry.path();
244        if !entry.file_type()?.is_file() {
245            tracing::debug!(path = %path.display(), "skipping non-file entry");
246            continue;
247        }
248        if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
249            continue;
250        }
251        let stem = path
252            .file_stem()
253            .and_then(|s| s.to_str())
254            .unwrap_or_default()
255            .to_string();
256
257        let cb = match content_block_io::read_content_block_file(&path) {
258            Ok(cb) => cb,
259            Err(e) => {
260                issues.push(ValidationIssue {
261                    path: path.clone(),
262                    message: format!("parse error: {e}"),
263                });
264                continue;
265            }
266        };
267
268        if crate::config::is_excluded(&cb.name, excludes) {
269            continue;
270        }
271
272        if cb.name != stem {
273            issues.push(ValidationIssue {
274                path: path.clone(),
275                message: format!(
276                    "content block name '{}' does not match its file stem '{}'",
277                    cb.name, stem
278                ),
279            });
280        }
281
282        check_name_pattern(
283            pattern.as_ref(),
284            &cb.name,
285            &path,
286            "content block",
287            "content_block_name_pattern",
288            issues,
289        );
290    }
291
292    Ok(())
293}
294
295fn validate_email_templates(
296    email_templates_root: &Path,
297    excludes: &[Regex],
298    issues: &mut Vec<ValidationIssue>,
299) -> anyhow::Result<()> {
300    let Some(read_dir) = open_resource_dir(email_templates_root, "email_templates", issues)? else {
301        return Ok(());
302    };
303
304    for entry in read_dir {
305        let entry = entry?;
306        let path = entry.path();
307        if !entry.file_type()?.is_dir() {
308            tracing::debug!(path = %path.display(), "skipping non-directory entry");
309            continue;
310        }
311        let template_yaml_path = path.join("template.yaml");
312        if !template_yaml_path.is_file() {
313            continue;
314        }
315        let dir_name = entry.file_name().to_string_lossy().into_owned();
316
317        let et = match email_template_io::read_email_template_dir(&path) {
318            Ok(et) => et,
319            Err(e) => {
320                issues.push(ValidationIssue {
321                    path: template_yaml_path.clone(),
322                    message: format!("parse error: {e}"),
323                });
324                continue;
325            }
326        };
327
328        if crate::config::is_excluded(&et.name, excludes) {
329            continue;
330        }
331
332        if et.name != dir_name {
333            issues.push(ValidationIssue {
334                path: template_yaml_path.clone(),
335                message: format!(
336                    "email template name '{}' does not match its directory '{}'",
337                    et.name, dir_name
338                ),
339            });
340        }
341
342        if et.subject.is_empty() {
343            issues.push(ValidationIssue {
344                path: template_yaml_path.clone(),
345                message: format!("email template '{}' has an empty subject", et.name),
346            });
347        }
348    }
349
350    Ok(())
351}
352
353fn validate_custom_attributes(
354    registry_path: &Path,
355    name_pattern: Option<&str>,
356    excludes: &[Regex],
357    issues: &mut Vec<ValidationIssue>,
358) -> anyhow::Result<()> {
359    let registry = match custom_attribute_io::load_registry(registry_path) {
360        Ok(Some(r)) => r,
361        Ok(None) => return Ok(()),
362        Err(Error::YamlParse { path, source }) => {
363            issues.push(ValidationIssue {
364                path,
365                message: format!("parse error: {source}"),
366            });
367            return Ok(());
368        }
369        Err(e) => return Err(e.into()),
370    };
371
372    let pattern = compile_name_pattern(name_pattern, "custom_attribute_name_pattern")?;
373
374    let mut seen = HashSet::with_capacity(registry.attributes.len());
375    for attr in &registry.attributes {
376        if crate::config::is_excluded(&attr.name, excludes) {
377            continue;
378        }
379        if !seen.insert(attr.name.as_str()) {
380            issues.push(ValidationIssue {
381                path: registry_path.to_path_buf(),
382                message: format!("duplicate custom attribute name '{}'", attr.name),
383            });
384        }
385
386        check_name_pattern(
387            pattern.as_ref(),
388            &attr.name,
389            registry_path,
390            "custom attribute",
391            "custom_attribute_name_pattern",
392            issues,
393        );
394    }
395
396    Ok(())
397}