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                validate_catalog_schemas(
45                    &catalogs_root,
46                    cfg.naming.catalog_name_pattern.as_deref(),
47                    &mut issues,
48                )?;
49            }
50            ResourceKind::ContentBlock => {
51                let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
52                validate_content_blocks(
53                    &content_blocks_root,
54                    cfg.naming.content_block_name_pattern.as_deref(),
55                    &mut issues,
56                )?;
57            }
58            ResourceKind::CatalogItems => {
59                let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
60                validate_catalog_items(&catalogs_root, &mut issues)?;
61            }
62            ResourceKind::EmailTemplate => {
63                let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
64                validate_email_templates(&email_templates_root, &mut issues)?;
65            }
66            ResourceKind::CustomAttribute => {
67                let registry_path = config_dir.join(&cfg.resources.custom_attribute.path);
68                validate_custom_attributes(
69                    &registry_path,
70                    cfg.naming.custom_attribute_name_pattern.as_deref(),
71                    &mut issues,
72                )?;
73            }
74        }
75    }
76
77    if issues.is_empty() {
78        eprintln!("✓ All checks passed.");
79        return Ok(());
80    }
81
82    eprintln!("✗ Validation found {} issue(s):", issues.len());
83    for issue in &issues {
84        eprintln!("  • {}: {}", issue.path.display(), issue.message);
85    }
86
87    Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
88}
89
90/// Try to open a resource root directory. Returns `None` (and pushes an
91/// issue) when the path is missing or is a file — callers should return
92/// `Ok(())` in that case.
93fn open_resource_dir(
94    root: &Path,
95    kind_label: &str,
96    issues: &mut Vec<ValidationIssue>,
97) -> anyhow::Result<Option<std::fs::ReadDir>> {
98    match try_read_resource_dir(root, kind_label) {
99        Ok(rd) => Ok(rd),
100        Err(Error::InvalidFormat { path, message }) => {
101            issues.push(ValidationIssue { path, message });
102            Ok(None)
103        }
104        Err(e) => Err(e.into()),
105    }
106}
107
108/// Compile an optional naming-pattern regex, returning the raw string
109/// alongside the compiled `Regex` so error messages can reference the
110/// original pattern.  `config_key` names the config field for the error
111/// message (e.g. `"catalog_name_pattern"`).
112fn compile_name_pattern(
113    raw: Option<&str>,
114    config_key: &str,
115) -> anyhow::Result<Option<(String, Regex)>> {
116    match raw {
117        Some(p) => Ok(Some((
118            p.to_string(),
119            Regex::new(p).map_err(|e| anyhow!("invalid {config_key} regex {p:?}: {e}"))?,
120        ))),
121        None => Ok(None),
122    }
123}
124
125/// Check `name` against the compiled pattern and push a uniform
126/// "does not match <config_key>" issue when it fails. `kind_label` is
127/// the human-readable resource noun for the message (e.g. `"catalog"`).
128fn check_name_pattern(
129    pattern: Option<&(String, Regex)>,
130    name: &str,
131    path: &Path,
132    kind_label: &str,
133    config_key: &str,
134    issues: &mut Vec<ValidationIssue>,
135) {
136    let Some((pattern_str, re)) = pattern else {
137        return;
138    };
139    if !re.is_match(name) {
140        issues.push(ValidationIssue {
141            path: path.to_path_buf(),
142            message: format!(
143                "{kind_label} name '{name}' does not match {config_key} '{pattern_str}'"
144            ),
145        });
146    }
147}
148
149fn validate_catalog_schemas(
150    catalogs_root: &Path,
151    name_pattern: Option<&str>,
152    issues: &mut Vec<ValidationIssue>,
153) -> anyhow::Result<()> {
154    let Some(read_dir) = open_resource_dir(catalogs_root, "catalogs", issues)? else {
155        return Ok(());
156    };
157
158    let pattern = compile_name_pattern(name_pattern, "catalog_name_pattern")?;
159
160    for entry in read_dir {
161        let entry = entry?;
162        if !entry.file_type()?.is_dir() {
163            tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
164            continue;
165        }
166        let dir = entry.path();
167        let schema_path = dir.join("schema.yaml");
168        if !schema_path.is_file() {
169            continue;
170        }
171
172        let cat = match catalog_io::read_schema_file(&schema_path) {
173            Ok(c) => c,
174            Err(e) => {
175                issues.push(ValidationIssue {
176                    path: schema_path.clone(),
177                    message: format!("parse error: {e}"),
178                });
179                continue;
180            }
181        };
182
183        // load_all_schemas treats dir/name mismatch as a hard error;
184        // here we downgrade to a soft issue so a single run reports
185        // every bad file.
186        let dir_name = entry.file_name().to_string_lossy().into_owned();
187        if cat.name != dir_name {
188            issues.push(ValidationIssue {
189                path: schema_path.clone(),
190                message: format!(
191                    "catalog name '{}' does not match its directory '{}'",
192                    cat.name, dir_name
193                ),
194            });
195        }
196
197        check_name_pattern(
198            pattern.as_ref(),
199            &cat.name,
200            &schema_path,
201            "catalog",
202            "catalog_name_pattern",
203            issues,
204        );
205    }
206
207    Ok(())
208}
209
210fn validate_content_blocks(
211    content_blocks_root: &Path,
212    name_pattern: Option<&str>,
213    issues: &mut Vec<ValidationIssue>,
214) -> anyhow::Result<()> {
215    let Some(read_dir) = open_resource_dir(content_blocks_root, "content_blocks", issues)? else {
216        return Ok(());
217    };
218
219    let pattern = compile_name_pattern(name_pattern, "content_block_name_pattern")?;
220
221    for entry in read_dir {
222        let entry = entry?;
223        let path = entry.path();
224        if !entry.file_type()?.is_file() {
225            tracing::debug!(path = %path.display(), "skipping non-file entry");
226            continue;
227        }
228        if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
229            continue;
230        }
231        let stem = path
232            .file_stem()
233            .and_then(|s| s.to_str())
234            .unwrap_or_default()
235            .to_string();
236
237        let cb = match content_block_io::read_content_block_file(&path) {
238            Ok(cb) => cb,
239            Err(e) => {
240                issues.push(ValidationIssue {
241                    path: path.clone(),
242                    message: format!("parse error: {e}"),
243                });
244                continue;
245            }
246        };
247
248        if cb.name != stem {
249            issues.push(ValidationIssue {
250                path: path.clone(),
251                message: format!(
252                    "content block name '{}' does not match its file stem '{}'",
253                    cb.name, stem
254                ),
255            });
256        }
257
258        check_name_pattern(
259            pattern.as_ref(),
260            &cb.name,
261            &path,
262            "content block",
263            "content_block_name_pattern",
264            issues,
265        );
266    }
267
268    Ok(())
269}
270
271fn validate_email_templates(
272    email_templates_root: &Path,
273    issues: &mut Vec<ValidationIssue>,
274) -> anyhow::Result<()> {
275    let Some(read_dir) = open_resource_dir(email_templates_root, "email_templates", issues)? else {
276        return Ok(());
277    };
278
279    for entry in read_dir {
280        let entry = entry?;
281        let path = entry.path();
282        if !entry.file_type()?.is_dir() {
283            tracing::debug!(path = %path.display(), "skipping non-directory entry");
284            continue;
285        }
286        let template_yaml_path = path.join("template.yaml");
287        if !template_yaml_path.is_file() {
288            continue;
289        }
290        let dir_name = entry.file_name().to_string_lossy().into_owned();
291
292        let et = match email_template_io::read_email_template_dir(&path) {
293            Ok(et) => et,
294            Err(e) => {
295                issues.push(ValidationIssue {
296                    path: template_yaml_path.clone(),
297                    message: format!("parse error: {e}"),
298                });
299                continue;
300            }
301        };
302
303        if et.name != dir_name {
304            issues.push(ValidationIssue {
305                path: template_yaml_path.clone(),
306                message: format!(
307                    "email template name '{}' does not match its directory '{}'",
308                    et.name, dir_name
309                ),
310            });
311        }
312
313        if et.subject.is_empty() {
314            issues.push(ValidationIssue {
315                path: template_yaml_path.clone(),
316                message: format!("email template '{}' has an empty subject", et.name),
317            });
318        }
319    }
320
321    Ok(())
322}
323
324fn validate_catalog_items(
325    catalogs_root: &Path,
326    issues: &mut Vec<ValidationIssue>,
327) -> anyhow::Result<()> {
328    let Some(read_dir) = open_resource_dir(catalogs_root, "catalogs", issues)? else {
329        return Ok(());
330    };
331
332    for entry in read_dir {
333        let entry = entry?;
334        if !entry.file_type()?.is_dir() {
335            continue;
336        }
337        let dir = entry.path();
338        let items_path = dir.join(catalog_io::ITEMS_FILE_NAME);
339        if !items_path.is_file() {
340            continue;
341        }
342
343        // Read only the CSV header — avoids materializing all rows.
344        let (catalog_name, csv_columns) = match catalog_io::read_item_csv_columns(&items_path) {
345            Ok(result) => result,
346            Err(e) => {
347                issues.push(ValidationIssue {
348                    path: items_path.clone(),
349                    message: format!("parse error: {e}"),
350                });
351                continue;
352            }
353        };
354
355        // Cross-check CSV header columns against sibling schema.yaml, if present.
356        let schema_path = dir.join("schema.yaml");
357        if schema_path.is_file() {
358            let schema = match catalog_io::read_schema_file(&schema_path) {
359                Ok(s) => s,
360                Err(e) => {
361                    issues.push(ValidationIssue {
362                        path: schema_path.clone(),
363                        message: format!("cannot parse schema.yaml: {e}"),
364                    });
365                    continue;
366                }
367            };
368            let schema_field_names: std::collections::BTreeSet<&str> =
369                schema.fields.iter().map(|f| f.name.as_str()).collect();
370            let csv_field_names: std::collections::BTreeSet<&str> =
371                csv_columns.iter().map(String::as_str).collect();
372
373            for col in &csv_field_names {
374                if !schema_field_names.contains(col) {
375                    issues.push(ValidationIssue {
376                        path: items_path.clone(),
377                        message: format!(
378                            "CSV column '{}' is not in schema for catalog '{}'",
379                            col, catalog_name
380                        ),
381                    });
382                }
383            }
384            for field in &schema_field_names {
385                if !csv_field_names.contains(field) {
386                    issues.push(ValidationIssue {
387                        path: items_path.clone(),
388                        message: format!(
389                            "schema field '{}' is missing from CSV for catalog '{}'",
390                            field, catalog_name
391                        ),
392                    });
393                }
394            }
395        }
396    }
397
398    Ok(())
399}
400
401fn validate_custom_attributes(
402    registry_path: &Path,
403    name_pattern: Option<&str>,
404    issues: &mut Vec<ValidationIssue>,
405) -> anyhow::Result<()> {
406    let registry = match custom_attribute_io::load_registry(registry_path) {
407        Ok(Some(r)) => r,
408        Ok(None) => return Ok(()),
409        Err(Error::YamlParse { path, source }) => {
410            issues.push(ValidationIssue {
411                path,
412                message: format!("parse error: {source}"),
413            });
414            return Ok(());
415        }
416        Err(e) => return Err(e.into()),
417    };
418
419    let pattern = compile_name_pattern(name_pattern, "custom_attribute_name_pattern")?;
420
421    let mut seen = HashSet::with_capacity(registry.attributes.len());
422    for attr in &registry.attributes {
423        if !seen.insert(attr.name.as_str()) {
424            issues.push(ValidationIssue {
425                path: registry_path.to_path_buf(),
426                message: format!("duplicate custom attribute name '{}'", attr.name),
427            });
428        }
429
430        check_name_pattern(
431            pattern.as_ref(),
432            &attr.name,
433            registry_path,
434            "custom attribute",
435            "custom_attribute_name_pattern",
436            issues,
437        );
438    }
439
440    Ok(())
441}