braze_sync/cli/
validate.rs1use 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 #[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 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}