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::{catalog_io, content_block_io, email_template_io};
11use crate::resource::ResourceKind;
12use anyhow::anyhow;
13use clap::Args;
14use regex_lite::Regex;
15use std::path::{Path, PathBuf};
16
17use super::{selected_kinds, warn_unimplemented};
18
19#[derive(Args, Debug)]
20pub struct ValidateArgs {
21    /// Limit validation to a specific resource kind.
22    #[arg(long, value_enum)]
23    pub resource: Option<ResourceKind>,
24}
25
26#[derive(Debug)]
27struct ValidationIssue {
28    path: PathBuf,
29    message: String,
30}
31
32pub async fn run(args: &ValidateArgs, cfg: &ConfigFile, config_dir: &Path) -> anyhow::Result<()> {
33    let kinds = selected_kinds(args.resource, &cfg.resources);
34
35    let mut issues: Vec<ValidationIssue> = Vec::new();
36
37    for kind in kinds {
38        match kind {
39            ResourceKind::CatalogSchema => {
40                let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
41                validate_catalog_schemas(
42                    &catalogs_root,
43                    cfg.naming.catalog_name_pattern.as_deref(),
44                    &mut issues,
45                )?;
46            }
47            ResourceKind::ContentBlock => {
48                let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
49                validate_content_blocks(
50                    &content_blocks_root,
51                    cfg.naming.content_block_name_pattern.as_deref(),
52                    &mut issues,
53                )?;
54            }
55            ResourceKind::EmailTemplate => {
56                let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
57                validate_email_templates(&email_templates_root, &mut issues)?;
58            }
59            other => warn_unimplemented(other),
60        }
61    }
62
63    if issues.is_empty() {
64        eprintln!("✓ All checks passed.");
65        return Ok(());
66    }
67
68    eprintln!("✗ Validation found {} issue(s):", issues.len());
69    for issue in &issues {
70        eprintln!("  • {}: {}", issue.path.display(), issue.message);
71    }
72
73    Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
74}
75
76fn validate_catalog_schemas(
77    catalogs_root: &Path,
78    name_pattern: Option<&str>,
79    issues: &mut Vec<ValidationIssue>,
80) -> anyhow::Result<()> {
81    let read_dir = match std::fs::read_dir(catalogs_root) {
82        Ok(rd) => rd,
83        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
84        Err(_) if catalogs_root.is_file() => {
85            issues.push(ValidationIssue {
86                path: catalogs_root.to_path_buf(),
87                message: "expected directory for catalogs root".into(),
88            });
89            return Ok(());
90        }
91        Err(e) => return Err(e.into()),
92    };
93
94    let pattern: Option<(String, Regex)> = match name_pattern {
95        Some(p) => Some((
96            p.to_string(),
97            Regex::new(p).map_err(|e| anyhow!("invalid catalog_name_pattern regex {p:?}: {e}"))?,
98        )),
99        None => None,
100    };
101
102    for entry in read_dir {
103        let entry = entry?;
104        if !entry.file_type()?.is_dir() {
105            tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
106            continue;
107        }
108        let dir = entry.path();
109        let schema_path = dir.join("schema.yaml");
110        if !schema_path.is_file() {
111            continue;
112        }
113
114        let cat = match catalog_io::read_schema_file(&schema_path) {
115            Ok(c) => c,
116            Err(e) => {
117                issues.push(ValidationIssue {
118                    path: schema_path.clone(),
119                    message: format!("parse error: {e}"),
120                });
121                continue;
122            }
123        };
124
125        // load_all_schemas treats dir/name mismatch as a hard error;
126        // here we downgrade to a soft issue so a single run reports
127        // every bad file.
128        let dir_name = entry.file_name().to_string_lossy().into_owned();
129        if cat.name != dir_name {
130            issues.push(ValidationIssue {
131                path: schema_path.clone(),
132                message: format!(
133                    "catalog name '{}' does not match its directory '{}'",
134                    cat.name, dir_name
135                ),
136            });
137        }
138
139        if let Some((pattern_str, re)) = &pattern {
140            if !re.is_match(&cat.name) {
141                issues.push(ValidationIssue {
142                    path: schema_path.clone(),
143                    message: format!(
144                        "catalog name '{}' does not match catalog_name_pattern '{}'",
145                        cat.name, pattern_str
146                    ),
147                });
148            }
149        }
150    }
151
152    Ok(())
153}
154
155fn validate_content_blocks(
156    content_blocks_root: &Path,
157    name_pattern: Option<&str>,
158    issues: &mut Vec<ValidationIssue>,
159) -> anyhow::Result<()> {
160    let read_dir = match std::fs::read_dir(content_blocks_root) {
161        Ok(rd) => rd,
162        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
163        Err(_) if content_blocks_root.is_file() => {
164            issues.push(ValidationIssue {
165                path: content_blocks_root.to_path_buf(),
166                message: "expected directory for the content_blocks root".into(),
167            });
168            return Ok(());
169        }
170        Err(e) => return Err(e.into()),
171    };
172
173    let pattern: Option<(String, Regex)> = match name_pattern {
174        Some(p) => Some((
175            p.to_string(),
176            Regex::new(p)
177                .map_err(|e| anyhow!("invalid content_block_name_pattern regex {p:?}: {e}"))?,
178        )),
179        None => None,
180    };
181
182    for entry in read_dir {
183        let entry = entry?;
184        let path = entry.path();
185        if !entry.file_type()?.is_file() {
186            tracing::debug!(path = %path.display(), "skipping non-file entry");
187            continue;
188        }
189        if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
190            continue;
191        }
192        let stem = path
193            .file_stem()
194            .and_then(|s| s.to_str())
195            .unwrap_or_default()
196            .to_string();
197
198        let cb = match content_block_io::read_content_block_file(&path) {
199            Ok(cb) => cb,
200            Err(e) => {
201                issues.push(ValidationIssue {
202                    path: path.clone(),
203                    message: format!("parse error: {e}"),
204                });
205                continue;
206            }
207        };
208
209        if cb.name != stem {
210            issues.push(ValidationIssue {
211                path: path.clone(),
212                message: format!(
213                    "content block name '{}' does not match its file stem '{}'",
214                    cb.name, stem
215                ),
216            });
217        }
218
219        if let Some((pattern_str, re)) = &pattern {
220            if !re.is_match(&cb.name) {
221                issues.push(ValidationIssue {
222                    path: path.clone(),
223                    message: format!(
224                        "content block name '{}' does not match content_block_name_pattern '{}'",
225                        cb.name, pattern_str
226                    ),
227                });
228            }
229        }
230    }
231
232    Ok(())
233}
234
235fn validate_email_templates(
236    email_templates_root: &Path,
237    issues: &mut Vec<ValidationIssue>,
238) -> anyhow::Result<()> {
239    let read_dir = match std::fs::read_dir(email_templates_root) {
240        Ok(rd) => rd,
241        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
242        Err(_) if email_templates_root.is_file() => {
243            issues.push(ValidationIssue {
244                path: email_templates_root.to_path_buf(),
245                message: "expected directory for the email_templates root".into(),
246            });
247            return Ok(());
248        }
249        Err(e) => return Err(e.into()),
250    };
251
252    for entry in read_dir {
253        let entry = entry?;
254        let path = entry.path();
255        if !entry.file_type()?.is_dir() {
256            tracing::debug!(path = %path.display(), "skipping non-directory entry");
257            continue;
258        }
259        let template_yaml_path = path.join("template.yaml");
260        if !template_yaml_path.is_file() {
261            continue;
262        }
263        let dir_name = entry.file_name().to_string_lossy().into_owned();
264
265        let et = match email_template_io::read_email_template_dir(&path) {
266            Ok(et) => et,
267            Err(e) => {
268                issues.push(ValidationIssue {
269                    path: template_yaml_path.clone(),
270                    message: format!("parse error: {e}"),
271                });
272                continue;
273            }
274        };
275
276        if et.name != dir_name {
277            issues.push(ValidationIssue {
278                path: template_yaml_path.clone(),
279                message: format!(
280                    "email template name '{}' does not match its directory '{}'",
281                    et.name, dir_name
282                ),
283            });
284        }
285
286        if et.subject.is_empty() {
287            issues.push(ValidationIssue {
288                path: template_yaml_path.clone(),
289                message: format!("email template '{}' has an empty subject", et.name),
290            });
291        }
292    }
293
294    Ok(())
295}