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};
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            other => warn_unimplemented(other),
56        }
57    }
58
59    if issues.is_empty() {
60        eprintln!("✓ All checks passed.");
61        return Ok(());
62    }
63
64    eprintln!("✗ Validation found {} issue(s):", issues.len());
65    for issue in &issues {
66        eprintln!("  • {}: {}", issue.path.display(), issue.message);
67    }
68
69    Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
70}
71
72fn validate_catalog_schemas(
73    catalogs_root: &Path,
74    name_pattern: Option<&str>,
75    issues: &mut Vec<ValidationIssue>,
76) -> anyhow::Result<()> {
77    let read_dir = match std::fs::read_dir(catalogs_root) {
78        Ok(rd) => rd,
79        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
80        Err(_) if catalogs_root.is_file() => {
81            issues.push(ValidationIssue {
82                path: catalogs_root.to_path_buf(),
83                message: "expected directory for catalogs root".into(),
84            });
85            return Ok(());
86        }
87        Err(e) => return Err(e.into()),
88    };
89
90    let pattern: Option<(String, Regex)> = match name_pattern {
91        Some(p) => Some((
92            p.to_string(),
93            Regex::new(p).map_err(|e| anyhow!("invalid catalog_name_pattern regex {p:?}: {e}"))?,
94        )),
95        None => None,
96    };
97
98    for entry in read_dir {
99        let entry = entry?;
100        if !entry.file_type()?.is_dir() {
101            tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
102            continue;
103        }
104        let dir = entry.path();
105        let schema_path = dir.join("schema.yaml");
106        if !schema_path.is_file() {
107            continue;
108        }
109
110        let cat = match catalog_io::read_schema_file(&schema_path) {
111            Ok(c) => c,
112            Err(e) => {
113                issues.push(ValidationIssue {
114                    path: schema_path.clone(),
115                    message: format!("parse error: {e}"),
116                });
117                continue;
118            }
119        };
120
121        // load_all_schemas treats dir/name mismatch as a hard error;
122        // here we downgrade to a soft issue so a single run reports
123        // every bad file.
124        let dir_name = entry.file_name().to_string_lossy().into_owned();
125        if cat.name != dir_name {
126            issues.push(ValidationIssue {
127                path: schema_path.clone(),
128                message: format!(
129                    "catalog name '{}' does not match its directory '{}'",
130                    cat.name, dir_name
131                ),
132            });
133        }
134
135        if let Some((pattern_str, re)) = &pattern {
136            if !re.is_match(&cat.name) {
137                issues.push(ValidationIssue {
138                    path: schema_path.clone(),
139                    message: format!(
140                        "catalog name '{}' does not match catalog_name_pattern '{}'",
141                        cat.name, pattern_str
142                    ),
143                });
144            }
145        }
146    }
147
148    Ok(())
149}
150
151fn validate_content_blocks(
152    content_blocks_root: &Path,
153    name_pattern: Option<&str>,
154    issues: &mut Vec<ValidationIssue>,
155) -> anyhow::Result<()> {
156    let read_dir = match std::fs::read_dir(content_blocks_root) {
157        Ok(rd) => rd,
158        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
159        Err(_) if content_blocks_root.is_file() => {
160            issues.push(ValidationIssue {
161                path: content_blocks_root.to_path_buf(),
162                message: "expected directory for the content_blocks root".into(),
163            });
164            return Ok(());
165        }
166        Err(e) => return Err(e.into()),
167    };
168
169    let pattern: Option<(String, Regex)> = match name_pattern {
170        Some(p) => Some((
171            p.to_string(),
172            Regex::new(p)
173                .map_err(|e| anyhow!("invalid content_block_name_pattern regex {p:?}: {e}"))?,
174        )),
175        None => None,
176    };
177
178    for entry in read_dir {
179        let entry = entry?;
180        let path = entry.path();
181        if !entry.file_type()?.is_file() {
182            tracing::debug!(path = %path.display(), "skipping non-file entry");
183            continue;
184        }
185        if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
186            continue;
187        }
188        let stem = path
189            .file_stem()
190            .and_then(|s| s.to_str())
191            .unwrap_or_default()
192            .to_string();
193
194        let cb = match content_block_io::read_content_block_file(&path) {
195            Ok(cb) => cb,
196            Err(e) => {
197                issues.push(ValidationIssue {
198                    path: path.clone(),
199                    message: format!("parse error: {e}"),
200                });
201                continue;
202            }
203        };
204
205        if cb.name != stem {
206            issues.push(ValidationIssue {
207                path: path.clone(),
208                message: format!(
209                    "content block name '{}' does not match its file stem '{}'",
210                    cb.name, stem
211                ),
212            });
213        }
214
215        if let Some((pattern_str, re)) = &pattern {
216            if !re.is_match(&cb.name) {
217                issues.push(ValidationIssue {
218                    path: path.clone(),
219                    message: format!(
220                        "content block name '{}' does not match content_block_name_pattern '{}'",
221                        cb.name, pattern_str
222                    ),
223                });
224            }
225        }
226    }
227
228    Ok(())
229}