1use crate::config::ConfigFile;
9use crate::error::Error;
10use crate::fs::{
11 catalog_io, content_block_io, custom_attribute_io, email_template_io, tag_io,
12 try_read_resource_dir,
13};
14use crate::resource::ResourceKind;
15use anyhow::anyhow;
16use clap::Args;
17use regex_lite::Regex;
18use std::collections::HashSet;
19use std::path::{Path, PathBuf};
20
21use super::selected_kinds;
22
23#[derive(Args, Debug)]
24pub struct ValidateArgs {
25 #[arg(long, value_enum)]
27 pub resource: Option<ResourceKind>,
28}
29
30#[derive(Debug)]
31struct ValidationIssue {
32 path: PathBuf,
33 message: String,
34}
35
36pub async fn run(args: &ValidateArgs, cfg: &ConfigFile, config_dir: &Path) -> anyhow::Result<()> {
37 let kinds = selected_kinds(args.resource, &cfg.resources);
38
39 let mut issues: Vec<ValidationIssue> = Vec::new();
40
41 for kind in kinds {
42 match kind {
43 ResourceKind::CatalogSchema => {
44 let catalogs_root = config_dir.join(&cfg.resources.catalog_schema.path);
45 let excludes = compile_kind_excludes(cfg, kind)?;
46 validate_catalog_schemas(
47 &catalogs_root,
48 cfg.naming.catalog_name_pattern.as_deref(),
49 &excludes,
50 &mut issues,
51 )?;
52 }
53 ResourceKind::ContentBlock => {
54 let content_blocks_root = config_dir.join(&cfg.resources.content_block.path);
55 let excludes = compile_kind_excludes(cfg, kind)?;
56 validate_content_blocks(
57 &content_blocks_root,
58 cfg.naming.content_block_name_pattern.as_deref(),
59 &excludes,
60 &mut issues,
61 )?;
62 }
63 ResourceKind::EmailTemplate => {
64 let email_templates_root = config_dir.join(&cfg.resources.email_template.path);
65 let excludes = compile_kind_excludes(cfg, kind)?;
66 validate_email_templates(&email_templates_root, &excludes, &mut issues)?;
67 }
68 ResourceKind::CustomAttribute => {
69 let registry_path = config_dir.join(&cfg.resources.custom_attribute.path);
70 let excludes = compile_kind_excludes(cfg, kind)?;
71 validate_custom_attributes(
72 ®istry_path,
73 cfg.naming.custom_attribute_name_pattern.as_deref(),
74 &excludes,
75 &mut issues,
76 )?;
77 }
78 ResourceKind::Tag => {
79 let registry_path = config_dir.join(&cfg.resources.tag.path);
80 let excludes = compile_kind_excludes(cfg, kind)?;
81 validate_tags(
82 cfg,
83 config_dir,
84 ®istry_path,
85 cfg.naming.tag_name_pattern.as_deref(),
86 &excludes,
87 &mut issues,
88 )?;
89 }
90 }
91 }
92
93 if issues.is_empty() {
94 eprintln!("✓ All checks passed.");
95 return Ok(());
96 }
97
98 eprintln!("✗ Validation found {} issue(s):", issues.len());
99 for issue in &issues {
100 eprintln!(" • {}: {}", issue.path.display(), issue.message);
101 }
102
103 Err(Error::Config(format!("{} validation issue(s) found", issues.len())).into())
104}
105
106fn compile_kind_excludes(cfg: &ConfigFile, kind: ResourceKind) -> anyhow::Result<Vec<Regex>> {
107 Ok(crate::config::compile_exclude_patterns(
108 &cfg.resources.for_kind(kind).exclude_patterns,
109 kind.as_str(),
110 )?)
111}
112
113fn open_resource_dir(
117 root: &Path,
118 kind_label: &str,
119 issues: &mut Vec<ValidationIssue>,
120) -> anyhow::Result<Option<std::fs::ReadDir>> {
121 match try_read_resource_dir(root, kind_label) {
122 Ok(rd) => Ok(rd),
123 Err(Error::InvalidFormat { path, message }) => {
124 issues.push(ValidationIssue { path, message });
125 Ok(None)
126 }
127 Err(e) => Err(e.into()),
128 }
129}
130
131fn compile_name_pattern(
136 raw: Option<&str>,
137 config_key: &str,
138) -> anyhow::Result<Option<(String, Regex)>> {
139 match raw {
140 Some(p) => Ok(Some((
141 p.to_string(),
142 Regex::new(p).map_err(|e| anyhow!("invalid {config_key} regex {p:?}: {e}"))?,
143 ))),
144 None => Ok(None),
145 }
146}
147
148fn check_name_pattern(
152 pattern: Option<&(String, Regex)>,
153 name: &str,
154 path: &Path,
155 kind_label: &str,
156 config_key: &str,
157 issues: &mut Vec<ValidationIssue>,
158) {
159 let Some((pattern_str, re)) = pattern else {
160 return;
161 };
162 if !re.is_match(name) {
163 issues.push(ValidationIssue {
164 path: path.to_path_buf(),
165 message: format!(
166 "{kind_label} name '{name}' does not match {config_key} '{pattern_str}'"
167 ),
168 });
169 }
170}
171
172fn validate_catalog_schemas(
173 catalogs_root: &Path,
174 name_pattern: Option<&str>,
175 excludes: &[Regex],
176 issues: &mut Vec<ValidationIssue>,
177) -> anyhow::Result<()> {
178 let Some(read_dir) = open_resource_dir(catalogs_root, "catalogs", issues)? else {
179 return Ok(());
180 };
181
182 let pattern = compile_name_pattern(name_pattern, "catalog_name_pattern")?;
183
184 for entry in read_dir {
185 let entry = entry?;
186 if !entry.file_type()?.is_dir() {
187 tracing::debug!(path = %entry.path().display(), "skipping non-directory entry");
188 continue;
189 }
190 let dir = entry.path();
191 let schema_path = dir.join("schema.yaml");
192 if !schema_path.is_file() {
193 continue;
194 }
195
196 let cat = match catalog_io::read_schema_file(&schema_path) {
197 Ok(c) => c,
198 Err(e) => {
199 issues.push(ValidationIssue {
200 path: schema_path.clone(),
201 message: format!("parse error: {e}"),
202 });
203 continue;
204 }
205 };
206
207 if crate::config::is_excluded(&cat.name, excludes) {
212 continue;
213 }
214
215 let dir_name = entry.file_name().to_string_lossy().into_owned();
219 if cat.name != dir_name {
220 issues.push(ValidationIssue {
221 path: schema_path.clone(),
222 message: format!(
223 "catalog name '{}' does not match its directory '{}'",
224 cat.name, dir_name
225 ),
226 });
227 }
228
229 check_name_pattern(
230 pattern.as_ref(),
231 &cat.name,
232 &schema_path,
233 "catalog",
234 "catalog_name_pattern",
235 issues,
236 );
237 }
238
239 Ok(())
240}
241
242fn validate_content_blocks(
243 content_blocks_root: &Path,
244 name_pattern: Option<&str>,
245 excludes: &[Regex],
246 issues: &mut Vec<ValidationIssue>,
247) -> anyhow::Result<()> {
248 let Some(read_dir) = open_resource_dir(content_blocks_root, "content_blocks", issues)? else {
249 return Ok(());
250 };
251
252 let pattern = compile_name_pattern(name_pattern, "content_block_name_pattern")?;
253
254 for entry in read_dir {
255 let entry = entry?;
256 let path = entry.path();
257 if !entry.file_type()?.is_file() {
258 tracing::debug!(path = %path.display(), "skipping non-file entry");
259 continue;
260 }
261 if path.extension().and_then(|e| e.to_str()) != Some("liquid") {
262 continue;
263 }
264 let stem = path
265 .file_stem()
266 .and_then(|s| s.to_str())
267 .unwrap_or_default()
268 .to_string();
269
270 let cb = match content_block_io::read_content_block_file(&path) {
271 Ok(cb) => cb,
272 Err(e) => {
273 issues.push(ValidationIssue {
274 path: path.clone(),
275 message: format!("parse error: {e}"),
276 });
277 continue;
278 }
279 };
280
281 if crate::config::is_excluded(&cb.name, excludes) {
282 continue;
283 }
284
285 if cb.name != stem {
286 issues.push(ValidationIssue {
287 path: path.clone(),
288 message: format!(
289 "content block name '{}' does not match its file stem '{}'",
290 cb.name, stem
291 ),
292 });
293 }
294
295 check_name_pattern(
296 pattern.as_ref(),
297 &cb.name,
298 &path,
299 "content block",
300 "content_block_name_pattern",
301 issues,
302 );
303 }
304
305 Ok(())
306}
307
308fn validate_email_templates(
309 email_templates_root: &Path,
310 excludes: &[Regex],
311 issues: &mut Vec<ValidationIssue>,
312) -> anyhow::Result<()> {
313 let Some(read_dir) = open_resource_dir(email_templates_root, "email_templates", issues)? else {
314 return Ok(());
315 };
316
317 for entry in read_dir {
318 let entry = entry?;
319 let path = entry.path();
320 if !entry.file_type()?.is_dir() {
321 tracing::debug!(path = %path.display(), "skipping non-directory entry");
322 continue;
323 }
324 let template_yaml_path = path.join("template.yaml");
325 if !template_yaml_path.is_file() {
326 continue;
327 }
328 let dir_name = entry.file_name().to_string_lossy().into_owned();
329
330 let et = match email_template_io::read_email_template_dir(&path) {
331 Ok(et) => et,
332 Err(e) => {
333 issues.push(ValidationIssue {
334 path: template_yaml_path.clone(),
335 message: format!("parse error: {e}"),
336 });
337 continue;
338 }
339 };
340
341 if crate::config::is_excluded(&et.name, excludes) {
342 continue;
343 }
344
345 if et.name != dir_name {
346 issues.push(ValidationIssue {
347 path: template_yaml_path.clone(),
348 message: format!(
349 "email template name '{}' does not match its directory '{}'",
350 et.name, dir_name
351 ),
352 });
353 }
354
355 if et.subject.is_empty() {
356 issues.push(ValidationIssue {
357 path: template_yaml_path.clone(),
358 message: format!("email template '{}' has an empty subject", et.name),
359 });
360 }
361 }
362
363 Ok(())
364}
365
366fn validate_tags(
374 cfg: &ConfigFile,
375 config_dir: &Path,
376 registry_path: &Path,
377 name_pattern: Option<&str>,
378 excludes: &[Regex],
379 issues: &mut Vec<ValidationIssue>,
380) -> anyhow::Result<()> {
381 let registry_opt = match tag_io::load_registry(registry_path) {
382 Ok(r) => r,
383 Err(Error::YamlParse { path, source }) => {
384 issues.push(ValidationIssue {
385 path,
386 message: format!("parse error: {source}"),
387 });
388 return Ok(());
389 }
390 Err(e) => return Err(e.into()),
391 };
392
393 if let Some(registry) = ®istry_opt {
394 let pattern = compile_name_pattern(name_pattern, "tag_name_pattern")?;
395 let mut seen = HashSet::with_capacity(registry.tags.len());
396 for t in ®istry.tags {
397 if crate::config::is_excluded(&t.name, excludes) {
398 continue;
399 }
400 if !seen.insert(t.name.as_str()) {
401 issues.push(ValidationIssue {
402 path: registry_path.to_path_buf(),
403 message: format!("duplicate tag name '{}'", t.name),
404 });
405 }
406 check_name_pattern(
407 pattern.as_ref(),
408 &t.name,
409 registry_path,
410 "tag",
411 "tag_name_pattern",
412 issues,
413 );
414 }
415 }
416
417 if cfg.resources.content_block.enabled {
420 let root = config_dir.join(&cfg.resources.content_block.path);
421 walk_for_tag_refs(&root, "content_block", issues, |p| {
422 if !p.is_file() || p.extension().and_then(|e| e.to_str()) != Some("liquid") {
423 return Ok(None);
424 }
425 let cb = content_block_io::read_content_block_file(p)?;
426 Ok(Some((cb.name, cb.tags)))
427 })?
428 .into_iter()
429 .for_each(|(p, name, tags)| {
430 check_resource_tags(
431 registry_opt.as_ref(),
432 excludes,
433 &p,
434 "content_block",
435 &name,
436 &tags,
437 issues,
438 );
439 });
440 }
441 if cfg.resources.email_template.enabled {
442 let root = config_dir.join(&cfg.resources.email_template.path);
443 walk_for_tag_refs(&root, "email_template", issues, |p| {
444 if !p.is_dir() || !p.join("template.yaml").is_file() {
445 return Ok(None);
446 }
447 let et = email_template_io::read_email_template_dir(p)?;
448 Ok(Some((et.name, et.tags)))
449 })?
450 .into_iter()
451 .for_each(|(p, name, tags)| {
452 check_resource_tags(
453 registry_opt.as_ref(),
454 excludes,
455 &p,
456 "email_template",
457 &name,
458 &tags,
459 issues,
460 );
461 });
462 }
463
464 Ok(())
465}
466
467fn walk_for_tag_refs<F>(
473 root: &Path,
474 kind: &str,
475 issues: &mut Vec<ValidationIssue>,
476 mut read: F,
477) -> anyhow::Result<Vec<(PathBuf, String, Vec<String>)>>
478where
479 F: FnMut(&Path) -> Result<Option<(String, Vec<String>)>, Error>,
480{
481 let mut out = Vec::new();
482 let Some(rd) = try_read_resource_dir(root, kind)? else {
483 return Ok(out);
484 };
485 for entry in rd.flatten() {
486 let p = entry.path();
487 match read(&p) {
488 Ok(Some((name, tags))) => out.push((p, name, tags)),
489 Ok(None) => {}
490 Err(Error::YamlParse { path, source }) => issues.push(ValidationIssue {
491 path,
492 message: format!("parse error: {source}"),
493 }),
494 Err(e) => return Err(e.into()),
495 }
496 }
497 Ok(out)
498}
499
500fn check_resource_tags(
501 registry: Option<&crate::resource::TagRegistry>,
502 excludes: &[Regex],
503 path: &Path,
504 kind: &str,
505 resource_name: &str,
506 tags: &[String],
507 issues: &mut Vec<ValidationIssue>,
508) {
509 for t in tags {
510 if crate::config::is_excluded(t, excludes) {
511 continue;
512 }
513 let in_registry = registry.is_some_and(|r| r.contains(t));
514 if !in_registry {
515 issues.push(ValidationIssue {
516 path: path.to_path_buf(),
517 message: format!(
518 "{kind} '{resource_name}' references tag '{t}' which is not declared in tags/registry.yaml \
519 (apply will fail with HTTP 400 until the tag is created in the Braze dashboard \
520 and added to the registry)"
521 ),
522 });
523 }
524 }
525}
526
527fn validate_custom_attributes(
528 registry_path: &Path,
529 name_pattern: Option<&str>,
530 excludes: &[Regex],
531 issues: &mut Vec<ValidationIssue>,
532) -> anyhow::Result<()> {
533 let registry = match custom_attribute_io::load_registry(registry_path) {
534 Ok(Some(r)) => r,
535 Ok(None) => return Ok(()),
536 Err(Error::YamlParse { path, source }) => {
537 issues.push(ValidationIssue {
538 path,
539 message: format!("parse error: {source}"),
540 });
541 return Ok(());
542 }
543 Err(e) => return Err(e.into()),
544 };
545
546 let pattern = compile_name_pattern(name_pattern, "custom_attribute_name_pattern")?;
547
548 let mut seen = HashSet::with_capacity(registry.attributes.len());
549 for attr in ®istry.attributes {
550 if crate::config::is_excluded(&attr.name, excludes) {
551 continue;
552 }
553 if !seen.insert(attr.name.as_str()) {
554 issues.push(ValidationIssue {
555 path: registry_path.to_path_buf(),
556 message: format!("duplicate custom attribute name '{}'", attr.name),
557 });
558 }
559
560 check_name_pattern(
561 pattern.as_ref(),
562 &attr.name,
563 registry_path,
564 "custom attribute",
565 "custom_attribute_name_pattern",
566 issues,
567 );
568 }
569
570 Ok(())
571}